diff --git a/package.json b/package.json index d1c6fe3..5dce1c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.5.86", + "version": "1.5.87", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index 8d5b146..b0c8860 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -16,6 +16,10 @@ export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); export const REQUEST_RETRIES = 3; export const CHUNK_SIZE = 512 * 1024; +export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB) +export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout +export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment + export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]); export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]); @@ -78,6 +82,8 @@ export function defaultSettings(): AppSettings { autoSkipExtracted: false, confirmDeleteSelection: true, totalDownloadedAllTime: 0, - bandwidthSchedules: [] + bandwidthSchedules: [], + columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], + extractCpuPriority: "high" }; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 88c2a4f..a507461 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -19,7 +19,7 @@ import { StartConflictResolutionResult, UiSnapshot } from "../shared/types"; -import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS } from "./constants"; +import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE } from "./constants"; import { cleanupCancelledPackageArtifactsAsync } from "./cleanup"; import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid"; import { collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates } from "./extractor"; @@ -267,6 +267,14 @@ function isExtractedLabel(statusText: string): boolean { return /^entpackt\b/i.test(String(statusText || "").trim()); } +function formatExtractDone(elapsedMs: number): string { + if (elapsedMs < 1000) return "Entpackt - Done (<1s)"; + const secs = elapsedMs / 1000; + return secs < 100 + ? `Entpackt - Done (${secs.toFixed(1)}s)` + : `Entpackt - Done (${Math.round(secs)}s)`; +} + function providerLabel(provider: DownloadItem["provider"]): string { if (provider === "realdebrid") { return "Real-Debrid"; @@ -2432,12 +2440,89 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } + public resetItems(itemIds: string[]): void { + const affectedPackageIds = new Set(); + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + + affectedPackageIds.add(item.packageId); + + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "cancel"; + active.abortController.abort("cancel"); + } + + const targetPath = String(item.targetPath || "").trim(); + if (targetPath) { + try { fs.rmSync(targetPath, { force: true }); } catch { /* ignore */ } + this.releaseTargetPath(itemId); + } + + this.dropItemContribution(itemId); + this.runOutcomes.delete(itemId); + this.runItemIds.delete(itemId); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + + item.status = "queued"; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.attempts = 0; + item.retries = 0; + item.lastError = ""; + item.resumable = true; + item.targetPath = ""; + item.provider = null; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + + // Reset parent package status if it was completed/failed (now has queued items again) + for (const pkgId of affectedPackageIds) { + const pkg = this.session.packages[pkgId]; + if (pkg && (pkg.status === "completed" || pkg.status === "failed" || pkg.status === "cancelled")) { + pkg.status = "queued"; + pkg.cancelled = false; + pkg.updatedAt = nowMs(); + } + } + + logger.info(`${itemIds.length} Item(s) zurückgesetzt`); + this.persistSoon(); + this.emitState(true); + } + public setPackagePriority(packageId: string, priority: PackagePriority): void { const pkg = this.session.packages[packageId]; if (!pkg) return; if (priority !== "high" && priority !== "normal" && priority !== "low") return; pkg.priority = priority; pkg.updatedAt = nowMs(); + + // Move high-priority packages to the top of packageOrder + if (priority === "high") { + const order = this.session.packageOrder; + const idx = order.indexOf(packageId); + if (idx > 0) { + order.splice(idx, 1); + // Insert after last existing high-priority package + let insertAt = 0; + for (let i = 0; i < order.length; i++) { + const p = this.session.packages[order[i]]; + if (p && p.priority === "high") { + insertAt = i + 1; + } else { + break; + } + } + order.splice(insertAt, 0, packageId); + } + } + this.persistSoon(); this.emitState(); } @@ -4601,7 +4686,22 @@ export class DownloadManager extends EventEmitter { } await fs.promises.mkdir(path.dirname(effectiveTargetPath), { recursive: true }); - const stream = fs.createWriteStream(effectiveTargetPath, { flags: writeMode }); + + // Sparse file pre-allocation (Windows only, new files with known size) + let preAllocated = false; + if (writeMode === "w" && item.totalBytes && item.totalBytes > 0 && process.platform === "win32") { + try { + const fd = await fs.promises.open(effectiveTargetPath, "w"); + await fd.truncate(item.totalBytes); + await fd.close(); + preAllocated = true; + } catch { /* best-effort */ } + } + + const stream = fs.createWriteStream(effectiveTargetPath, { + flags: preAllocated ? "r+" : writeMode, + start: preAllocated ? 0 : undefined + }); let written = writeMode === "a" ? existingBytes : 0; let windowBytes = 0; let windowStarted = nowMs(); @@ -4694,6 +4794,28 @@ export class DownloadManager extends EventEmitter { active.abortController.signal.addEventListener("abort", onAbort, { once: true }); }); + // Write-buffer with 4KB NTFS alignment (JDownloader-style) + const writeBuf = Buffer.allocUnsafe(WRITE_BUFFER_SIZE); + let writeBufPos = 0; + let lastFlushAt = nowMs(); + + const alignedFlush = async (final = false): Promise => { + if (writeBufPos === 0) return; + let toWrite = writeBufPos; + if (!final && toWrite > ALLOCATION_UNIT_SIZE) { + toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE); + } + const slice = Buffer.from(writeBuf.subarray(0, toWrite)); + if (!stream.write(slice)) { + await waitDrain(); + } + if (toWrite < writeBufPos) { + writeBuf.copy(writeBuf, 0, toWrite, writeBufPos); + } + writeBufPos -= toWrite; + lastFlushAt = nowMs(); + }; + let bodyError: unknown = null; try { const body = response.body; @@ -4814,9 +4936,24 @@ export class DownloadManager extends EventEmitter { if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } - if (!stream.write(buffer)) { - await waitDrain(); + + // Buffer incoming data for aligned writes + let srcOffset = 0; + while (srcOffset < buffer.length) { + const space = WRITE_BUFFER_SIZE - writeBufPos; + const toCopy = Math.min(space, buffer.length - srcOffset); + buffer.copy(writeBuf, writeBufPos, srcOffset, srcOffset + toCopy); + writeBufPos += toCopy; + srcOffset += toCopy; + if (writeBufPos >= Math.floor(WRITE_BUFFER_SIZE * 0.80)) { + await alignedFlush(false); + } } + // Time-based flush + if (writeBufPos > 0 && nowMs() - lastFlushAt >= WRITE_FLUSH_TIMEOUT_MS) { + await alignedFlush(false); + } + written += buffer.length; windowBytes += buffer.length; this.session.totalDownloadedBytes += buffer.length; @@ -4868,6 +5005,14 @@ export class DownloadManager extends EventEmitter { bodyError = error; throw error; } finally { + // Flush remaining buffered data before closing stream + try { + await alignedFlush(true); + } catch (flushError) { + if (!bodyError) { + bodyError = flushError; + } + } try { await new Promise((resolve, reject) => { if (stream.closed || stream.destroyed) { @@ -4920,6 +5065,14 @@ export class DownloadManager extends EventEmitter { throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); } + // Truncate pre-allocated files to actual bytes written to prevent zero-padded tail + if (preAllocated && item.totalBytes && written < item.totalBytes) { + try { + await fs.promises.truncate(effectiveTargetPath, written); + } catch { /* best-effort */ } + logger.warn(`Pre-alloc underflow: erwartet=${item.totalBytes}, erhalten=${written} für ${item.fileName}`); + } + item.downloadedBytes = written; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; item.speedBps = 0; @@ -5434,6 +5587,7 @@ export class DownloadManager extends EventEmitter { // Track multiple active archives for parallel hybrid extraction const activeHybridArchiveMap = new Map(); + const hybridArchiveStartTimes = new Map(); let hybridLastEmitAt = 0; // Mark hybrid items as pending, others as waiting for parts @@ -5470,19 +5624,23 @@ export class DownloadManager extends EventEmitter { packageId, hybridMode: true, maxParallel: this.settings.maxParallelExtract || 2, + extractCpuPriority: this.settings.extractCpuPriority, onProgress: (progress) => { if (progress.phase === "done") { // Mark all remaining active archives as done - for (const [, archItems] of activeHybridArchiveMap) { + for (const [archName, archItems] of activeHybridArchiveMap) { const doneAt = nowMs(); + const startedAt = hybridArchiveStartTimes.get(archName) || doneAt; + const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archItems) { if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = "Entpackt - Done"; + entry.fullStatus = doneLabel; entry.updatedAt = doneAt; } } } activeHybridArchiveMap.clear(); + hybridArchiveStartTimes.clear(); return; } @@ -5490,19 +5648,23 @@ export class DownloadManager extends EventEmitter { // Resolve items for this archive if not yet tracked if (!activeHybridArchiveMap.has(progress.archiveName)) { activeHybridArchiveMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName)); + hybridArchiveStartTimes.set(progress.archiveName, nowMs()); } const archItems = activeHybridArchiveMap.get(progress.archiveName)!; // If archive is at 100%, mark its items as done and remove from active if (Number(progress.archivePercent ?? 0) >= 100) { const doneAt = nowMs(); + const startedAt = hybridArchiveStartTimes.get(progress.archiveName) || doneAt; + const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archItems) { if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = "Entpackt - Done"; + entry.fullStatus = doneLabel; entry.updatedAt = doneAt; } } activeHybridArchiveMap.delete(progress.archiveName); + hybridArchiveStartTimes.delete(progress.archiveName); } else { // Update this archive's items with current progress const archive = ` · ${progress.archiveName}`; @@ -5704,6 +5866,7 @@ export class DownloadManager extends EventEmitter { try { // Track multiple active archives for parallel extraction const activeArchiveItemsMap = new Map(); + const archiveStartTimes = new Map(); const result = await extractPackageArchives({ packageDir: pkg.outputDir, @@ -5716,19 +5879,23 @@ export class DownloadManager extends EventEmitter { signal: extractAbortController.signal, packageId, maxParallel: this.settings.maxParallelExtract || 2, + extractCpuPriority: this.settings.extractCpuPriority, onProgress: (progress) => { if (progress.phase === "done") { // Mark all remaining active archives as done - for (const [, items] of activeArchiveItemsMap) { + for (const [archName, items] of activeArchiveItemsMap) { const doneAt = nowMs(); + const startedAt = archiveStartTimes.get(archName) || doneAt; + const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of items) { if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = "Entpackt - Done"; + entry.fullStatus = doneLabel; entry.updatedAt = doneAt; } } } activeArchiveItemsMap.clear(); + archiveStartTimes.clear(); emitExtractStatus("Entpacken 100%", true); return; } @@ -5737,19 +5904,23 @@ export class DownloadManager extends EventEmitter { // Resolve items for this archive if not yet tracked if (!activeArchiveItemsMap.has(progress.archiveName)) { activeArchiveItemsMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName)); + archiveStartTimes.set(progress.archiveName, nowMs()); } const archiveItems = activeArchiveItemsMap.get(progress.archiveName)!; // If archive is at 100%, mark its items as done and remove from active if (Number(progress.archivePercent ?? 0) >= 100) { const doneAt = nowMs(); + const startedAt = archiveStartTimes.get(progress.archiveName) || doneAt; + const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archiveItems) { if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = "Entpackt - Done"; + entry.fullStatus = doneLabel; entry.updatedAt = doneAt; } } activeArchiveItemsMap.delete(progress.archiveName); + archiveStartTimes.delete(progress.archiveName); } else { // Update this archive's items with current progress const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; @@ -5799,9 +5970,13 @@ export class DownloadManager extends EventEmitter { logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); if (result.failed > 0) { const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); + const failAt = nowMs(); for (const entry of completedItems) { - entry.fullStatus = `Entpack-Fehler: ${reason}`; - entry.updatedAt = nowMs(); + // Preserve per-archive "Entpackt - Done (X.Xs)" labels for successfully extracted archives + if (!isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = `Entpack-Fehler: ${reason}`; + } + entry.updatedAt = failAt; } pkg.status = "failed"; } else { @@ -5821,9 +5996,13 @@ export class DownloadManager extends EventEmitter { finalStatusText = "Entpackt (keine Archive)"; } + const finalAt = nowMs(); for (const entry of completedItems) { - entry.fullStatus = finalStatusText; - entry.updatedAt = nowMs(); + // Preserve per-archive duration labels (e.g. "Entpackt - Done (5.3s)") + if (!isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = finalStatusText; + } + entry.updatedAt = finalAt; } pkg.status = "completed"; } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 721b13d..a851a9e 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -72,6 +72,7 @@ const EXTRACTOR_RETRY_AFTER_MS = 30_000; const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000; const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80; +let currentExtractCpuPriority: string | undefined; export interface ExtractOptions { packageDir: string; @@ -88,6 +89,7 @@ export interface ExtractOptions { packageId?: string; hybridMode?: boolean; maxParallel?: number; + extractCpuPriority?: string; } export interface ExtractProgressUpdate { @@ -566,15 +568,30 @@ function shouldUseExtractorPerformanceFlags(): boolean { return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no"; } -function extractCpuBudgetPercent(): number { +function extractCpuBudgetFromPriority(priority?: string): number { + switch (priority) { + case "low": return 25; + case "middle": return 50; + default: return 80; + } +} + +function extractOsPriority(priority?: string): number { + switch (priority) { + case "high": return os.constants.priority.PRIORITY_BELOW_NORMAL; + default: return os.constants.priority.PRIORITY_LOW; + } +} + +function extractCpuBudgetPercent(priority?: string): number { const envValue = Number(process.env.RD_EXTRACT_CPU_BUDGET_PERCENT ?? NaN); if (Number.isFinite(envValue) && envValue >= 40 && envValue <= 95) { return Math.floor(envValue); } - return DEFAULT_EXTRACT_CPU_BUDGET_PERCENT; + return extractCpuBudgetFromPriority(priority); } -function extractorThreadSwitch(hybridMode = false): string { +function extractorThreadSwitch(hybridMode = false, priority?: string): string { if (hybridMode) { // 2 threads during hybrid extraction (download + extract simultaneously). // JDownloader 2 uses in-process 7-Zip-JBinding which naturally limits throughput @@ -586,13 +603,13 @@ function extractorThreadSwitch(hybridMode = false): string { return `-mt${Math.floor(envValue)}`; } const cpuCount = Math.max(1, os.cpus().length || 1); - const budgetPercent = extractCpuBudgetPercent(); + const budgetPercent = extractCpuBudgetPercent(priority); const budgetedThreads = Math.floor((cpuCount * budgetPercent) / 100); const threadCount = Math.max(1, Math.min(16, Math.max(1, budgetedThreads))); return `-mt${threadCount}`; } -function lowerExtractProcessPriority(childPid: number | undefined): void { +function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?: string): void { if (process.platform !== "win32") { return; } @@ -601,9 +618,9 @@ function lowerExtractProcessPriority(childPid: number | undefined): void { return; } try { - // IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction - // doesn't starve other processes. I/O priority stays Normal (like JDownloader 2). - os.setPriority(pid, os.constants.priority.PRIORITY_LOW); + // Lowers CPU scheduling priority so extraction doesn't starve other processes. + // high → BELOW_NORMAL, middle/low → IDLE. I/O priority stays Normal (like JDownloader 2). + os.setPriority(pid, extractOsPriority(cpuPriority)); } catch { // ignore: priority lowering is best-effort } @@ -673,7 +690,7 @@ function runExtractCommand( let settled = false; let output = ""; const child = spawn(command, args, { windowsHide: true }); - lowerExtractProcessPriority(child.pid); + lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); let timeoutId: NodeJS.Timeout | null = null; let timedOutByWatchdog = false; let abortedBySignal = false; @@ -995,7 +1012,7 @@ function runJvmExtractCommand( let stderrBuffer = ""; const child = spawn(layout.javaCommand, args, { windowsHide: true }); - lowerExtractProcessPriority(child.pid); + lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); const flushLines = (rawChunk: string, fromStdErr = false): void => { if (!rawChunk) { @@ -1174,7 +1191,7 @@ export function buildExternalExtractArgs( // On Windows (the target platform) this is less of a concern than on shared Unix systems. const pass = password ? `-p${password}` : "-p-"; const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags() - ? ["-idc", extractorThreadSwitch(hybridMode)] + ? ["-idc", extractorThreadSwitch(hybridMode, currentExtractCpuPriority)] : []; return ["x", overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`]; } @@ -1824,7 +1841,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (options.signal?.aborted) { throw new Error("aborted:extract"); } - const allCandidates = await findArchiveCandidates(options.packageDir); const candidates = options.onlyArchives ? allCandidates.filter((archivePath) => { @@ -1972,6 +1988,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ ? (attempt: number, total: number) => { emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); } : undefined; try { + // Set module-level priority before each extract call (race-safe: spawn is synchronous) + currentExtractCpuPriority = options.extractCpuPriority; const ext = path.extname(archivePath).toLowerCase(); if (ext === ".zip") { const preferExternal = await shouldPreferExternalZip(archivePath); diff --git a/src/main/storage.ts b/src/main/storage.ts index 07776c0..82ed5bb 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, SessionState } from "../shared/types"; +import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; @@ -12,6 +12,8 @@ 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"]); +const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); +const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); const VALID_DOWNLOAD_STATUSES = new Set([ "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" ]); @@ -65,6 +67,29 @@ function normalizeAbsoluteDir(value: unknown, fallback: string): string { return path.resolve(text); } +const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"]; +const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]); + +function normalizeColumnOrder(raw: unknown): string[] { + if (!Array.isArray(raw) || raw.length === 0) { + return [...DEFAULT_COLUMN_ORDER]; + } + const valid = ALL_VALID_COLUMNS; + const seen = new Set(); + const result: string[] = []; + for (const col of raw) { + if (typeof col === "string" && valid.has(col) && !seen.has(col)) { + seen.add(col); + result.push(col); + } + } + // "name" is mandatory — ensure it's always present + if (!seen.has("name")) { + result.unshift("name"); + } + return result; +} + export function normalizeSettings(settings: AppSettings): AppSettings { const defaults = defaultSettings(); const normalized: AppSettings = { @@ -112,7 +137,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings { confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, - bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules) + bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules), + columnOrder: normalizeColumnOrder(settings.columnOrder), + extractCpuPriority: settings.extractCpuPriority }; if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { @@ -142,6 +169,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings { if (!VALID_SPEED_MODES.has(normalized.speedLimitMode)) { normalized.speedLimitMode = defaults.speedLimitMode; } + if (!VALID_EXTRACT_CPU_PRIORITIES.has(normalized.extractCpuPriority)) { + normalized.extractCpuPriority = defaults.extractCpuPriority; + } return normalized; } @@ -274,6 +304,7 @@ function normalizeLoadedSession(raw: unknown): SessionState { .filter((value) => value.length > 0), cancelled: Boolean(pkg.cancelled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), + priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 71afd9d..69f3771 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -15,6 +15,7 @@ import type { UpdateCheckResult, UpdateInstallProgress } from "../shared/types"; +import { reorderPackageOrderByDrop, sortPackageOrderByName } from "./package-order"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; @@ -72,7 +73,8 @@ const emptySnapshot = (): UiSnapshot => ({ maxParallel: 4, maxParallelExtract: 2, retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", collapseNewPackages: true, autoSkipExtracted: false, confirmDeleteSelection: true, - bandwidthSchedules: [], totalDownloadedAllTime: 0 + bandwidthSchedules: [], totalDownloadedAllTime: 0, + columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"] }, session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, @@ -93,6 +95,16 @@ const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" }; +function formatDateTime(ts: number): string { + const d = new Date(ts); + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yyyy = d.getFullYear(); + const hh = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return `${dd}.${mm}.${yyyy} - ${hh}:${min}`; +} + function extractHoster(url: string): string { try { const host = new URL(url).hostname.replace(/^www\./, ""); @@ -230,7 +242,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp ctx.font = "13px 'Manrope', sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten fur Statistiken", width / 2, height / 2); + ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten für Statistiken", width / 2, height / 2); return; } @@ -276,13 +288,17 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp }, [drawChart]); useEffect(() => { + // Only record samples while the session is running and not paused + if (!running || paused) return; + const now = Date.now(); - const totalSpeed = Object.values(items) - .filter((item) => item.status === "downloading") - .reduce((sum, item) => sum + (item.speedBps || 0), 0); + const activeItems = Object.values(items).filter((item) => item.status === "downloading"); + if (activeItems.length === 0) return; + + const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0); const history = speedHistoryRef.current; - history.push({ time: now, speed: paused ? 0 : totalSpeed }); + history.push({ time: now, speed: totalSpeed }); const cutoff = now - 60000; let trimIndex = 0; @@ -294,7 +310,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp } lastUpdateRef.current = now; - }, [items, paused]); + }, [items, paused, running]); useEffect(() => { const handleResize = () => { @@ -327,29 +343,6 @@ function createScheduleId(): string { return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } -export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { - const fromIndex = order.indexOf(draggedPackageId); - const toIndex = order.indexOf(targetPackageId); - if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { - return order; - } - const next = [...order]; - const [dragged] = next.splice(fromIndex, 1); - const insertIndex = Math.max(0, Math.min(next.length, toIndex)); - next.splice(insertIndex, 0, dragged); - return next; -} - -export function sortPackageOrderByName(order: string[], packages: Record, descending: boolean): string[] { - const sorted = [...order]; - sorted.sort((a, b) => { - const nameA = (packages[a]?.name ?? "").toLowerCase(); - const nameB = (packages[b]?.name ?? "").toLowerCase(); - const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" }); - return descending ? -cmp : cmp; - }); - return sorted; -} function sortPackageOrderBySize(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; @@ -401,6 +394,20 @@ function computePackageProgress(pkg: PackageEntry | undefined, items: Record = { + name: { label: "Name", width: "1fr", sortable: "name" }, + size: { label: "Geladen / Größe", width: "160px", sortable: "size" }, + progress: { label: "Fortschritt", width: "80px", sortable: "progress" }, + hoster: { label: "Hoster", width: "110px", sortable: "hoster" }, + account: { label: "Service", width: "110px" }, + prio: { label: "Priorität", width: "70px" }, + status: { label: "Status", width: "160px" }, + speed: { label: "Geschwindigkeit", width: "90px" }, + added: { label: "Hinzugefügt am", width: "155px" }, +}; + function sameStringArray(a: string[], b: string[]): boolean { if (a.length !== b.length) { return false; @@ -511,6 +518,12 @@ export function App(): ReactElement { const [linkPopup, setLinkPopup] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set; dontAsk: boolean } | null>(null); + const [columnOrder, setColumnOrder] = useState(() => DEFAULT_COLUMN_ORDER); + const [dragColId, setDragColId] = useState(null); + const [dropTargetCol, setDropTargetCol] = useState(null); + const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null); + const colHeaderCtxRef = useRef(null); + const colHeaderBarRef = useRef(null); const [historyEntries, setHistoryEntries] = useState([]); const historyEntriesRef = useRef([]); const [historyCollapsed, setHistoryCollapsed] = useState>({}); @@ -537,6 +550,16 @@ export function App(): ReactElement { useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]); + // Sync column order from settings (value-based comparison to avoid reference issues) + const columnOrderJson = JSON.stringify(snapshot.settings.columnOrder); + useEffect(() => { + const order = snapshot.settings.columnOrder; + if (order && order.length > 0) { + setColumnOrder(order); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnOrderJson]); + const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; useEffect(() => { @@ -615,6 +638,9 @@ export function App(): ReactElement { return; } setSnapshot(state); + if (state.settings.columnOrder?.length > 0) { + setColumnOrder(state.settings.columnOrder); + } setSettingsDraft(state.settings); settingsDirtyRef.current = false; setSettingsDirty(false); @@ -654,6 +680,9 @@ export function App(): ReactElement { if (latestStateRef.current) { const next = latestStateRef.current; setSnapshot(next); + if (next.settings.columnOrder?.length > 0) { + setColumnOrder(next.settings.columnOrder); + } if (!settingsDirtyRef.current) { setSettingsDraft(next.settings); } @@ -706,6 +735,7 @@ export function App(): ReactElement { const deferredDownloadSearch = useDeferredValue(downloadSearch); const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase(); const downloadSearchActive = downloadSearchQuery.length > 0; + const gridTemplate = useMemo(() => columnOrder.map((col) => COLUMN_DEFS[col]?.width ?? "100px").join(" "), [columnOrder]); const totalPackageCount = snapshot.session.packageOrder.length; const shouldLimitPackageRendering = downloadsTabActive && snapshot.session.running @@ -1188,18 +1218,10 @@ export function App(): ReactElement { const onExportQueue = async (): Promise => { await performQuickAction(async () => { - 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.style.display = "none"; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 60_000); - showToast("Queue exportiert"); + const result = await window.rd.exportQueue(); + if (result.saved) { + showToast("Queue exportiert"); + } }, (error) => { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); }); @@ -1670,6 +1692,32 @@ export function App(): ReactElement { } }, [contextMenu]); + useEffect(() => { + if (!colHeaderCtx) return; + const close = (e: MouseEvent): void => { + // Don't close if click is inside the menu or on the header bar (re-position instead) + if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return; + if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return; + setColHeaderCtx(null); + }; + window.addEventListener("mousedown", close); + return () => { + window.removeEventListener("mousedown", close); + }; + }, [colHeaderCtx]); + + useLayoutEffect(() => { + if (!colHeaderCtx || !colHeaderCtxRef.current) return; + const el = colHeaderCtxRef.current; + const rect = el.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + el.style.top = `${Math.max(0, colHeaderCtx.y - rect.height)}px`; + } + if (rect.right > window.innerWidth) { + el.style.left = `${Math.max(0, colHeaderCtx.x - rect.width)}px`; + } + }, [colHeaderCtx]); + useEffect(() => { if (!historyCtxMenu) return; const close = (): void => setHistoryCtxMenu(null); @@ -2150,65 +2198,47 @@ export function App(): ReactElement { {snapshot.session.reconnectReason && ({snapshot.session.reconnectReason})} )} -
- - - -
-
- {(["name", "size", "progress", "hoster"] as PkgSortColumn[]).flatMap((col) => { - const labels: Record = { name: "Name", progress: "Fortschritt", size: "Geladen / Größe", hoster: "Hoster" }; - const isActive = downloadsSortColumn === col; - return [ + {/* Action buttons moved to footer */} +
{ e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}> + {columnOrder.map((col) => { + const def = COLUMN_DEFS[col]; + if (!def) return null; + const sortCol = def.sortable; + const isActive = sortCol ? downloadsSortColumn === sortCol : false; + return ( { + className={`pkg-col pkg-col-${col}${sortCol ? " sortable" : ""}${isActive ? " sort-active" : ""}${dragColId === col ? " pkg-col-dragging" : ""}${dropTargetCol === col ? " pkg-col-drop-target" : ""}`} + draggable + onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; setDragColId(col); }} + onDragOver={(e) => { if (dragColId && dragColId !== col) { e.preventDefault(); setDropTargetCol(col); } }} + onDragLeave={() => { if (dropTargetCol === col) setDropTargetCol(null); }} + onDrop={(e) => { + e.preventDefault(); + setDropTargetCol(null); + if (!dragColId || dragColId === col) return; + const newOrder = [...columnOrder]; + const fromIdx = newOrder.indexOf(dragColId); + const toIdx = newOrder.indexOf(col); + if (fromIdx < 0 || toIdx < 0) return; + newOrder.splice(fromIdx, 1); + newOrder.splice(toIdx, 0, dragColId); + setColumnOrder(newOrder); + setDragColId(null); + void window.rd.updateSettings({ columnOrder: newOrder }); + }} + onDragEnd={() => { setDragColId(null); setDropTargetCol(null); }} + onClick={sortCol ? () => { const nextDesc = isActive ? !downloadsSortDescending : false; - setDownloadsSortColumn(col); + setDownloadsSortColumn(sortCol); setDownloadsSortDescending(nextDesc); const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; let sorted: string[]; - if (col === "progress") { + if (sortCol === "progress") { sorted = sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); - } else if (col === "size") { + } else if (sortCol === "size") { sorted = sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); - } else if (col === "hoster") { + } else if (sortCol === "hoster") { sorted = sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); } else { sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDesc); @@ -2222,16 +2252,12 @@ export function App(): ReactElement { packageOrderRef.current = serverPackageOrderRef.current; showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); - }} + } : undefined} > - {labels[col]} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""} - , - ]; + {def.label} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""} + + ); })} - Service - Priorität - Status - Geschwindigkeit
{totalPackageCount === 0 &&
Noch keine Pakete in der Queue.
} {totalPackageCount > 0 && packages.length === 0 &&
Keine Pakete passend zur Suche.
} @@ -2253,6 +2279,8 @@ export function App(): ReactElement { editingName={editingName} collapsed={collapsedPackages[pkg.id] ?? false} selectedIds={selectedIds} + columnOrder={columnOrder} + gridTemplate={gridTemplate} onSelect={onSelectId} onSelectMouseDown={onSelectMouseDown} onSelectMouseEnter={onSelectMouseEnter} @@ -2321,28 +2349,38 @@ export function App(): ReactElement { }} >
{ if (e.ctrlKey) return; setHistoryCollapsed((prev) => ({ ...prev, [entry.id]: !collapsed })); }} style={{ cursor: "pointer" }}> -
-
- -

{entry.name}

-
- {(() => { - const pct = entry.totalBytes > 0 ? Math.min(100, Math.round((entry.downloadedBytes / entry.totalBytes) * 100)) : 0; - const label = `${humanSize(entry.downloadedBytes)} / ${humanSize(entry.totalBytes)}`; - return entry.totalBytes > 0 ? ( - - - {label} - {label} - - ) : "-"; - })()} - {entry.status === "completed" ? "100%" : "-"} - - - {entry.provider ? providerLabels[entry.provider] : "-"} - - {entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"} - - +
+ {columnOrder.map((col) => { + switch (col) { + case "name": return ( +
+ +

{entry.name}

+
+ ); + case "size": return ( + {(() => { + const pct = entry.totalBytes > 0 ? Math.min(100, Math.round((entry.downloadedBytes / entry.totalBytes) * 100)) : 0; + const label = `${humanSize(entry.downloadedBytes)} / ${humanSize(entry.totalBytes)}`; + return entry.totalBytes > 0 ? ( + + + {label} + {label} + + ) : ""; + })()} + ); + case "progress": return {entry.status === "completed" ? "100%" : ""}; + case "hoster": return ; + case "account": return {entry.provider ? providerLabels[entry.provider] : ""}; + case "prio": return ; + case "status": return {entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}; + case "speed": return ; + case "added": return {formatDateTime(entry.completedAt)}; + default: return null; + } + })}
@@ -2360,11 +2398,11 @@ export function App(): ReactElement { Dauer {entry.durationSeconds >= 3600 ? `${Math.floor(entry.durationSeconds / 3600)}h ${Math.floor((entry.durationSeconds % 3600) / 60)}min` : entry.durationSeconds >= 60 ? `${Math.floor(entry.durationSeconds / 60)}min ${entry.durationSeconds % 60}s` : `${entry.durationSeconds}s`} Durchschnitt - {entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : "-"} + {entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : ""} Provider - {entry.provider ? providerLabels[entry.provider] : "-"} + {entry.provider ? providerLabels[entry.provider] : ""} Zielordner - {entry.outputDir || "-"} + {entry.outputDir || ""} Status {entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}
@@ -2554,6 +2592,11 @@ export function App(): ReactElement {
setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} />
+
@@ -2739,6 +2782,31 @@ export function App(): ReactElement { Hoster: {configuredProviders.length} {snapshot.speedText} {snapshot.etaText} + + {totalPackageCount > 0 && ( + + )} + {totalPackageCount > 0 && ( + + )} + {snapshot.clipboardActive && ( + + )} {updateInstallProgress && ( @@ -2806,6 +2874,15 @@ export function App(): ReactElement { setContextMenu(null); }}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.packages[id]).length})` : ""} )} + {contextMenu.itemId && ( + + )} {hasPackages && !multi && (() => { const pkg = snapshot.session.packages[contextMenu.packageId]; const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || []; @@ -2847,6 +2924,46 @@ export function App(): ReactElement {
); })()} + {colHeaderCtx && ( +
e.stopPropagation()}> + {ALL_COLUMN_KEYS.map((col) => { + const def = COLUMN_DEFS[col]; + if (!def) return null; + const isVisible = columnOrder.includes(col); + const isRequired = col === "name"; + return ( + + ); + })} +
+ )} {historyCtxMenu && (() => { const multi = selectedHistoryIds.size > 1; const contextEntry = historyEntries.find(e => e.id === historyCtxMenu.entryId); @@ -2929,6 +3046,8 @@ interface PackageCardProps { editingName: string; collapsed: boolean; selectedIds: Set; + columnOrder: string[]; + gridTemplate: string; onSelect: (id: string, ctrlKey: boolean) => void; onSelectMouseDown: (id: string, e: React.MouseEvent) => void; onSelectMouseEnter: (id: string) => void; @@ -2947,7 +3066,7 @@ interface PackageCardProps { onDragEnd: () => void; } -const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, selectedIds, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { +const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, 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; @@ -2997,53 +3116,77 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs if (tag === "BUTTON" || tag === "INPUT" || tag === "SELECT") return; onToggleCollapse(pkg.id); }} style={{ cursor: "pointer" }}> -
-
- - onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> - {isEditing ? ( - onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus /> - ) : ( -

{ e.stopPropagation(); onStartEdit(pkg.id, pkg.name); }} title="Klicken zum Umbenennen">{pkg.name}

- )} -
- {(() => { - const totalBytes = items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); - const dlBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); - const pct = totalBytes > 0 ? Math.min(100, Math.round((dlBytes / totalBytes) * 100)) : 0; - const label = `${humanSize(dlBytes)} / ${humanSize(totalBytes)}`; - return totalBytes > 0 ? ( - - - {label} - {label} - - ) : "-"; - })()} - - - - {combinedProgress}% - {combinedProgress}% - - - { - const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; - return hosters.join(", "); - })()}>{(() => { - const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; - return hosters.length > 0 ? hosters.join(", ") : "-"; - })()} - { - const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; - return providers.map((p) => providerLabels[p!] || p).join(", "); - })()}>{(() => { - const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; - return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : "-"; - })()} - {pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : "-"} - [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}] - {packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : "-"} +
+ {columnOrder.map((col) => { + switch (col) { + case "name": return ( +
+ + onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> + {isEditing ? ( + onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus /> + ) : ( +

{ e.stopPropagation(); onStartEdit(pkg.id, pkg.name); }} title="Klicken zum Umbenennen">{pkg.name}

+ )} +
+ ); + case "size": return ( + {(() => { + const totalBytes = items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); + const dlBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + const pct = totalBytes > 0 ? Math.min(100, Math.round((dlBytes / totalBytes) * 100)) : 0; + const label = `${humanSize(dlBytes)} / ${humanSize(totalBytes)}`; + return totalBytes > 0 ? ( + + + {label} + {label} + + ) : ""; + })()} + ); + case "progress": return ( + + + + {combinedProgress}% + {combinedProgress}% + + + ); + case "hoster": return ( + { + const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; + return hosters.join(", "); + })()}>{(() => { + const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; + return hosters.length > 0 ? hosters.join(", ") : ""; + })()} + ); + case "account": return ( + { + const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; + return providers.map((p) => providerLabels[p!] || p).join(", "); + })()}>{(() => { + const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; + return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : ""; + })()} + ); + case "prio": return ( + {pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""} + ); + case "status": return ( + [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}] + ); + case "speed": return ( + {packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""} + ); + case "added": return ( + {formatDateTime(pkg.createdAt)} + ); + default: return null; + } + })}
@@ -3051,40 +3194,54 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs {extracting &&
}
{!collapsed && items.map((item) => ( -
{ e.stopPropagation(); onSelect(item.id, e.ctrlKey); }} onMouseDown={(e) => { e.stopPropagation(); onSelectMouseDown(item.id, e); }} onMouseEnter={() => onSelectMouseEnter(item.id)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, item.id, e.clientX, e.clientY); }}> - - {item.onlineStatus && } - {item.fileName} - - {(() => { - const total = item.totalBytes || item.downloadedBytes || 0; - const dl = item.downloadedBytes || 0; - const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0; - const label = `${humanSize(dl)} / ${humanSize(total)}`; - return total > 0 ? ( - - - {label} - {label} - - ) : "-"; - })()} - - {item.totalBytes > 0 ? ( - - - {item.progressPercent}% - {item.progressPercent}% - - ) : "-"} - - {extractHoster(item.url) || "-"} - {item.provider ? providerLabels[item.provider] : "-"} - - 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}> - {item.fullStatus} - - {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : "-"} +
{ e.stopPropagation(); onSelect(item.id, e.ctrlKey); }} onMouseDown={(e) => { e.stopPropagation(); onSelectMouseDown(item.id, e); }} onMouseEnter={() => onSelectMouseEnter(item.id)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, item.id, e.clientX, e.clientY); }}> + {columnOrder.map((col) => { + switch (col) { + case "name": return ( + + {item.onlineStatus && } + {item.fileName} + + ); + case "size": return ( + {(() => { + const total = item.totalBytes || item.downloadedBytes || 0; + const dl = item.downloadedBytes || 0; + const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0; + const label = `${humanSize(dl)} / ${humanSize(total)}`; + return total > 0 ? ( + + + {label} + {label} + + ) : ""; + })()} + ); + case "progress": return ( + + {item.totalBytes > 0 ? ( + + + {item.progressPercent}% + {item.progressPercent}% + + ) : ""} + + ); + case "hoster": return {extractHoster(item.url) || ""}; + case "account": return {item.provider ? providerLabels[item.provider] : ""}; + case "prio": return ; + case "status": return ( + 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}> + {item.fullStatus} + + ); + case "speed": return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}; + case "added": return {formatDateTime(item.createdAt)}; + default: return null; + } + })}
))} @@ -3104,7 +3261,9 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs || prev.isLast !== next.isLast || prev.isEditing !== next.isEditing || prev.collapsed !== next.collapsed - || prev.selectedIds !== next.selectedIds) { + || prev.selectedIds !== next.selectedIds + || prev.columnOrder !== next.columnOrder + || prev.gridTemplate !== next.gridTemplate) { return false; } if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { diff --git a/src/shared/types.ts b/src/shared/types.ts index eb224a9..d4c8013 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -18,6 +18,7 @@ export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "allde export type DebridFallbackProvider = DebridProvider | "none"; export type AppTheme = "dark" | "light"; export type PackagePriority = "high" | "normal" | "low"; +export type ExtractCpuPriority = "high" | "middle" | "low"; export interface BandwidthScheduleEntry { id: string; @@ -81,6 +82,8 @@ export interface AppSettings { confirmDeleteSelection: boolean; totalDownloadedAllTime: number; bandwidthSchedules: BandwidthScheduleEntry[]; + columnOrder: string[]; + extractCpuPriority: ExtractCpuPriority; } export interface DownloadItem {