Release v1.5.87

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-04 02:38:05 +01:00
parent d63afcce89
commit 7af9d67770
7 changed files with 635 additions and 239 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.5.86", "version": "1.5.87",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -16,6 +16,10 @@ export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
export const REQUEST_RETRIES = 3; export const REQUEST_RETRIES = 3;
export const CHUNK_SIZE = 512 * 1024; 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_DIR_NAMES = new Set(["sample", "samples"]);
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]); export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
@ -78,6 +82,8 @@ export function defaultSettings(): AppSettings {
autoSkipExtracted: false, autoSkipExtracted: false,
confirmDeleteSelection: true, confirmDeleteSelection: true,
totalDownloadedAllTime: 0, totalDownloadedAllTime: 0,
bandwidthSchedules: [] bandwidthSchedules: [],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "high"
}; };
} }

View File

@ -19,7 +19,7 @@ import {
StartConflictResolutionResult, StartConflictResolutionResult,
UiSnapshot UiSnapshot
} from "../shared/types"; } 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 { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid"; import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid";
import { collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates } from "./extractor"; import { collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates } from "./extractor";
@ -267,6 +267,14 @@ function isExtractedLabel(statusText: string): boolean {
return /^entpackt\b/i.test(String(statusText || "").trim()); 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 { function providerLabel(provider: DownloadItem["provider"]): string {
if (provider === "realdebrid") { if (provider === "realdebrid") {
return "Real-Debrid"; return "Real-Debrid";
@ -2432,12 +2440,89 @@ export class DownloadManager extends EventEmitter {
this.emitState(true); this.emitState(true);
} }
public resetItems(itemIds: string[]): void {
const affectedPackageIds = new Set<string>();
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 { public setPackagePriority(packageId: string, priority: PackagePriority): void {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg) return; if (!pkg) return;
if (priority !== "high" && priority !== "normal" && priority !== "low") return; if (priority !== "high" && priority !== "normal" && priority !== "low") return;
pkg.priority = priority; pkg.priority = priority;
pkg.updatedAt = nowMs(); 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.persistSoon();
this.emitState(); this.emitState();
} }
@ -4601,7 +4686,22 @@ export class DownloadManager extends EventEmitter {
} }
await fs.promises.mkdir(path.dirname(effectiveTargetPath), { recursive: true }); 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 written = writeMode === "a" ? existingBytes : 0;
let windowBytes = 0; let windowBytes = 0;
let windowStarted = nowMs(); let windowStarted = nowMs();
@ -4694,6 +4794,28 @@ export class DownloadManager extends EventEmitter {
active.abortController.signal.addEventListener("abort", onAbort, { once: true }); 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<void> => {
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; let bodyError: unknown = null;
try { try {
const body = response.body; const body = response.body;
@ -4814,9 +4936,24 @@ export class DownloadManager extends EventEmitter {
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`); 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; written += buffer.length;
windowBytes += buffer.length; windowBytes += buffer.length;
this.session.totalDownloadedBytes += buffer.length; this.session.totalDownloadedBytes += buffer.length;
@ -4868,6 +5005,14 @@ export class DownloadManager extends EventEmitter {
bodyError = error; bodyError = error;
throw error; throw error;
} finally { } finally {
// Flush remaining buffered data before closing stream
try {
await alignedFlush(true);
} catch (flushError) {
if (!bodyError) {
bodyError = flushError;
}
}
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
if (stream.closed || stream.destroyed) { 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}"` : ""}`); 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.downloadedBytes = written;
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
item.speedBps = 0; item.speedBps = 0;
@ -5434,6 +5587,7 @@ export class DownloadManager extends EventEmitter {
// Track multiple active archives for parallel hybrid extraction // Track multiple active archives for parallel hybrid extraction
const activeHybridArchiveMap = new Map<string, DownloadItem[]>(); const activeHybridArchiveMap = new Map<string, DownloadItem[]>();
const hybridArchiveStartTimes = new Map<string, number>();
let hybridLastEmitAt = 0; let hybridLastEmitAt = 0;
// Mark hybrid items as pending, others as waiting for parts // Mark hybrid items as pending, others as waiting for parts
@ -5470,19 +5624,23 @@ export class DownloadManager extends EventEmitter {
packageId, packageId,
hybridMode: true, hybridMode: true,
maxParallel: this.settings.maxParallelExtract || 2, maxParallel: this.settings.maxParallelExtract || 2,
extractCpuPriority: this.settings.extractCpuPriority,
onProgress: (progress) => { onProgress: (progress) => {
if (progress.phase === "done") { if (progress.phase === "done") {
// Mark all remaining active archives as done // Mark all remaining active archives as done
for (const [, archItems] of activeHybridArchiveMap) { for (const [archName, archItems] of activeHybridArchiveMap) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = hybridArchiveStartTimes.get(archName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archItems) { for (const entry of archItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = "Entpackt - Done"; entry.fullStatus = doneLabel;
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
} }
} }
activeHybridArchiveMap.clear(); activeHybridArchiveMap.clear();
hybridArchiveStartTimes.clear();
return; return;
} }
@ -5490,19 +5648,23 @@ export class DownloadManager extends EventEmitter {
// Resolve items for this archive if not yet tracked // Resolve items for this archive if not yet tracked
if (!activeHybridArchiveMap.has(progress.archiveName)) { if (!activeHybridArchiveMap.has(progress.archiveName)) {
activeHybridArchiveMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName)); activeHybridArchiveMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName));
hybridArchiveStartTimes.set(progress.archiveName, nowMs());
} }
const archItems = activeHybridArchiveMap.get(progress.archiveName)!; const archItems = activeHybridArchiveMap.get(progress.archiveName)!;
// If archive is at 100%, mark its items as done and remove from active // If archive is at 100%, mark its items as done and remove from active
if (Number(progress.archivePercent ?? 0) >= 100) { if (Number(progress.archivePercent ?? 0) >= 100) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = hybridArchiveStartTimes.get(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archItems) { for (const entry of archItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = "Entpackt - Done"; entry.fullStatus = doneLabel;
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
} }
activeHybridArchiveMap.delete(progress.archiveName); activeHybridArchiveMap.delete(progress.archiveName);
hybridArchiveStartTimes.delete(progress.archiveName);
} else { } else {
// Update this archive's items with current progress // Update this archive's items with current progress
const archive = ` · ${progress.archiveName}`; const archive = ` · ${progress.archiveName}`;
@ -5704,6 +5866,7 @@ export class DownloadManager extends EventEmitter {
try { try {
// Track multiple active archives for parallel extraction // Track multiple active archives for parallel extraction
const activeArchiveItemsMap = new Map<string, DownloadItem[]>(); const activeArchiveItemsMap = new Map<string, DownloadItem[]>();
const archiveStartTimes = new Map<string, number>();
const result = await extractPackageArchives({ const result = await extractPackageArchives({
packageDir: pkg.outputDir, packageDir: pkg.outputDir,
@ -5716,19 +5879,23 @@ export class DownloadManager extends EventEmitter {
signal: extractAbortController.signal, signal: extractAbortController.signal,
packageId, packageId,
maxParallel: this.settings.maxParallelExtract || 2, maxParallel: this.settings.maxParallelExtract || 2,
extractCpuPriority: this.settings.extractCpuPriority,
onProgress: (progress) => { onProgress: (progress) => {
if (progress.phase === "done") { if (progress.phase === "done") {
// Mark all remaining active archives as done // Mark all remaining active archives as done
for (const [, items] of activeArchiveItemsMap) { for (const [archName, items] of activeArchiveItemsMap) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = archiveStartTimes.get(archName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of items) { for (const entry of items) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = "Entpackt - Done"; entry.fullStatus = doneLabel;
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
} }
} }
activeArchiveItemsMap.clear(); activeArchiveItemsMap.clear();
archiveStartTimes.clear();
emitExtractStatus("Entpacken 100%", true); emitExtractStatus("Entpacken 100%", true);
return; return;
} }
@ -5737,19 +5904,23 @@ export class DownloadManager extends EventEmitter {
// Resolve items for this archive if not yet tracked // Resolve items for this archive if not yet tracked
if (!activeArchiveItemsMap.has(progress.archiveName)) { if (!activeArchiveItemsMap.has(progress.archiveName)) {
activeArchiveItemsMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName)); activeArchiveItemsMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName));
archiveStartTimes.set(progress.archiveName, nowMs());
} }
const archiveItems = activeArchiveItemsMap.get(progress.archiveName)!; const archiveItems = activeArchiveItemsMap.get(progress.archiveName)!;
// If archive is at 100%, mark its items as done and remove from active // If archive is at 100%, mark its items as done and remove from active
if (Number(progress.archivePercent ?? 0) >= 100) { if (Number(progress.archivePercent ?? 0) >= 100) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = archiveStartTimes.get(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archiveItems) { for (const entry of archiveItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = "Entpackt - Done"; entry.fullStatus = doneLabel;
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
} }
activeArchiveItemsMap.delete(progress.archiveName); activeArchiveItemsMap.delete(progress.archiveName);
archiveStartTimes.delete(progress.archiveName);
} else { } else {
// Update this archive's items with current progress // Update this archive's items with current progress
const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; 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 || ""}`); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
if (result.failed > 0) { if (result.failed > 0) {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
const failAt = nowMs();
for (const entry of completedItems) { for (const entry of completedItems) {
entry.fullStatus = `Entpack-Fehler: ${reason}`; // Preserve per-archive "Entpackt - Done (X.Xs)" labels for successfully extracted archives
entry.updatedAt = nowMs(); if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = `Entpack-Fehler: ${reason}`;
}
entry.updatedAt = failAt;
} }
pkg.status = "failed"; pkg.status = "failed";
} else { } else {
@ -5821,9 +5996,13 @@ export class DownloadManager extends EventEmitter {
finalStatusText = "Entpackt (keine Archive)"; finalStatusText = "Entpackt (keine Archive)";
} }
const finalAt = nowMs();
for (const entry of completedItems) { for (const entry of completedItems) {
entry.fullStatus = finalStatusText; // Preserve per-archive duration labels (e.g. "Entpackt - Done (5.3s)")
entry.updatedAt = nowMs(); if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = finalStatusText;
}
entry.updatedAt = finalAt;
} }
pkg.status = "completed"; pkg.status = "completed";
} }

View File

@ -72,6 +72,7 @@ const EXTRACTOR_RETRY_AFTER_MS = 30_000;
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000; const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80; const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
let currentExtractCpuPriority: string | undefined;
export interface ExtractOptions { export interface ExtractOptions {
packageDir: string; packageDir: string;
@ -88,6 +89,7 @@ export interface ExtractOptions {
packageId?: string; packageId?: string;
hybridMode?: boolean; hybridMode?: boolean;
maxParallel?: number; maxParallel?: number;
extractCpuPriority?: string;
} }
export interface ExtractProgressUpdate { export interface ExtractProgressUpdate {
@ -566,15 +568,30 @@ function shouldUseExtractorPerformanceFlags(): boolean {
return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no"; 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); const envValue = Number(process.env.RD_EXTRACT_CPU_BUDGET_PERCENT ?? NaN);
if (Number.isFinite(envValue) && envValue >= 40 && envValue <= 95) { if (Number.isFinite(envValue) && envValue >= 40 && envValue <= 95) {
return Math.floor(envValue); 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) { if (hybridMode) {
// 2 threads during hybrid extraction (download + extract simultaneously). // 2 threads during hybrid extraction (download + extract simultaneously).
// JDownloader 2 uses in-process 7-Zip-JBinding which naturally limits throughput // 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)}`; return `-mt${Math.floor(envValue)}`;
} }
const cpuCount = Math.max(1, os.cpus().length || 1); const cpuCount = Math.max(1, os.cpus().length || 1);
const budgetPercent = extractCpuBudgetPercent(); const budgetPercent = extractCpuBudgetPercent(priority);
const budgetedThreads = Math.floor((cpuCount * budgetPercent) / 100); const budgetedThreads = Math.floor((cpuCount * budgetPercent) / 100);
const threadCount = Math.max(1, Math.min(16, Math.max(1, budgetedThreads))); const threadCount = Math.max(1, Math.min(16, Math.max(1, budgetedThreads)));
return `-mt${threadCount}`; return `-mt${threadCount}`;
} }
function lowerExtractProcessPriority(childPid: number | undefined): void { function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?: string): void {
if (process.platform !== "win32") { if (process.platform !== "win32") {
return; return;
} }
@ -601,9 +618,9 @@ function lowerExtractProcessPriority(childPid: number | undefined): void {
return; return;
} }
try { try {
// IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction // Lowers CPU scheduling priority so extraction doesn't starve other processes.
// doesn't starve other processes. I/O priority stays Normal (like JDownloader 2). // high → BELOW_NORMAL, middle/low → IDLE. I/O priority stays Normal (like JDownloader 2).
os.setPriority(pid, os.constants.priority.PRIORITY_LOW); os.setPriority(pid, extractOsPriority(cpuPriority));
} catch { } catch {
// ignore: priority lowering is best-effort // ignore: priority lowering is best-effort
} }
@ -673,7 +690,7 @@ function runExtractCommand(
let settled = false; let settled = false;
let output = ""; let output = "";
const child = spawn(command, args, { windowsHide: true }); const child = spawn(command, args, { windowsHide: true });
lowerExtractProcessPriority(child.pid); lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
let timeoutId: NodeJS.Timeout | null = null; let timeoutId: NodeJS.Timeout | null = null;
let timedOutByWatchdog = false; let timedOutByWatchdog = false;
let abortedBySignal = false; let abortedBySignal = false;
@ -995,7 +1012,7 @@ function runJvmExtractCommand(
let stderrBuffer = ""; let stderrBuffer = "";
const child = spawn(layout.javaCommand, args, { windowsHide: true }); const child = spawn(layout.javaCommand, args, { windowsHide: true });
lowerExtractProcessPriority(child.pid); lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
const flushLines = (rawChunk: string, fromStdErr = false): void => { const flushLines = (rawChunk: string, fromStdErr = false): void => {
if (!rawChunk) { 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. // On Windows (the target platform) this is less of a concern than on shared Unix systems.
const pass = password ? `-p${password}` : "-p-"; const pass = password ? `-p${password}` : "-p-";
const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags() const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags()
? ["-idc", extractorThreadSwitch(hybridMode)] ? ["-idc", extractorThreadSwitch(hybridMode, currentExtractCpuPriority)]
: []; : [];
return ["x", overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`]; 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) { if (options.signal?.aborted) {
throw new Error("aborted:extract"); throw new Error("aborted:extract");
} }
const allCandidates = await findArchiveCandidates(options.packageDir); const allCandidates = await findArchiveCandidates(options.packageDir);
const candidates = options.onlyArchives const candidates = options.onlyArchives
? allCandidates.filter((archivePath) => { ? 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 }); } ? (attempt: number, total: number) => { emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); }
: undefined; : undefined;
try { try {
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
currentExtractCpuPriority = options.extractCpuPriority;
const ext = path.extname(archivePath).toLowerCase(); const ext = path.extname(archivePath).toLowerCase();
if (ext === ".zip") { if (ext === ".zip") {
const preferExternal = await shouldPreferExternalZip(archivePath); const preferExternal = await shouldPreferExternalZip(archivePath);

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; 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 { defaultSettings } from "./constants";
import { logger } from "./logger"; 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_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
const VALID_SPEED_MODES = new Set(["global", "per_download"]); const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]); const VALID_THEMES = new Set(["dark", "light"]);
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([ const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" "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); 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<string>();
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 { export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings(); const defaults = defaultSettings();
const normalized: AppSettings = { const normalized: AppSettings = {
@ -112,7 +137,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, 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)) { if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
@ -142,6 +169,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
if (!VALID_SPEED_MODES.has(normalized.speedLimitMode)) { if (!VALID_SPEED_MODES.has(normalized.speedLimitMode)) {
normalized.speedLimitMode = defaults.speedLimitMode; normalized.speedLimitMode = defaults.speedLimitMode;
} }
if (!VALID_EXTRACT_CPU_PRIORITIES.has(normalized.extractCpuPriority)) {
normalized.extractCpuPriority = defaults.extractCpuPriority;
}
return normalized; return normalized;
} }
@ -274,6 +304,7 @@ function normalizeLoadedSession(raw: unknown): SessionState {
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled), cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), 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), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
}; };

View File

@ -15,6 +15,7 @@ import type {
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress UpdateInstallProgress
} from "../shared/types"; } from "../shared/types";
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "./package-order";
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; 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", maxParallel: 4, maxParallelExtract: 2, retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, autoSkipExtracted: false, confirmDeleteSelection: true, theme: "dark", collapseNewPackages: true, autoSkipExtracted: false, confirmDeleteSelection: true,
bandwidthSchedules: [], totalDownloadedAllTime: 0 bandwidthSchedules: [], totalDownloadedAllTime: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"]
}, },
session: { session: {
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
@ -93,6 +95,16 @@ 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"
}; };
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 { function extractHoster(url: string): string {
try { try {
const host = new URL(url).hostname.replace(/^www\./, ""); 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.font = "13px 'Manrope', sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; 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; return;
} }
@ -276,13 +288,17 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
}, [drawChart]); }, [drawChart]);
useEffect(() => { useEffect(() => {
// Only record samples while the session is running and not paused
if (!running || paused) return;
const now = Date.now(); const now = Date.now();
const totalSpeed = Object.values(items) const activeItems = Object.values(items).filter((item) => item.status === "downloading");
.filter((item) => item.status === "downloading") if (activeItems.length === 0) return;
.reduce((sum, item) => sum + (item.speedBps || 0), 0);
const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0);
const history = speedHistoryRef.current; const history = speedHistoryRef.current;
history.push({ time: now, speed: paused ? 0 : totalSpeed }); history.push({ time: now, speed: totalSpeed });
const cutoff = now - 60000; const cutoff = now - 60000;
let trimIndex = 0; let trimIndex = 0;
@ -294,7 +310,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
} }
lastUpdateRef.current = now; lastUpdateRef.current = now;
}, [items, paused]); }, [items, paused, running]);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@ -327,29 +343,6 @@ function createScheduleId(): string {
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; 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<string, PackageEntry>, 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<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] { function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
const sorted = [...order]; const sorted = [...order];
@ -401,6 +394,20 @@ function computePackageProgress(pkg: PackageEntry | undefined, items: Record<str
type PkgSortColumn = "name" | "size" | "hoster" | "progress"; type PkgSortColumn = "name" | "size" | "hoster" | "progress";
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"];
const COLUMN_DEFS: Record<string, { label: string; width: string; sortable?: PkgSortColumn }> = {
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 { function sameStringArray(a: string[], b: string[]): boolean {
if (a.length !== b.length) { if (a.length !== b.length) {
return false; return false;
@ -511,6 +518,12 @@ export function App(): ReactElement {
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null); const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
const [dragColId, setDragColId] = useState<string | null>(null);
const [dropTargetCol, setDropTargetCol] = useState<string | null>(null);
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
const colHeaderBarRef = useRef<HTMLDivElement>(null);
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]); const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const historyEntriesRef = useRef<HistoryEntry[]>([]); const historyEntriesRef = useRef<HistoryEntry[]>([]);
const [historyCollapsed, setHistoryCollapsed] = useState<Record<string, boolean>>({}); const [historyCollapsed, setHistoryCollapsed] = useState<Record<string, boolean>>({});
@ -537,6 +550,16 @@ export function App(): ReactElement {
useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]); 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]; const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
useEffect(() => { useEffect(() => {
@ -615,6 +638,9 @@ export function App(): ReactElement {
return; return;
} }
setSnapshot(state); setSnapshot(state);
if (state.settings.columnOrder?.length > 0) {
setColumnOrder(state.settings.columnOrder);
}
setSettingsDraft(state.settings); setSettingsDraft(state.settings);
settingsDirtyRef.current = false; settingsDirtyRef.current = false;
setSettingsDirty(false); setSettingsDirty(false);
@ -654,6 +680,9 @@ export function App(): ReactElement {
if (latestStateRef.current) { if (latestStateRef.current) {
const next = latestStateRef.current; const next = latestStateRef.current;
setSnapshot(next); setSnapshot(next);
if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder);
}
if (!settingsDirtyRef.current) { if (!settingsDirtyRef.current) {
setSettingsDraft(next.settings); setSettingsDraft(next.settings);
} }
@ -706,6 +735,7 @@ export function App(): ReactElement {
const deferredDownloadSearch = useDeferredValue(downloadSearch); const deferredDownloadSearch = useDeferredValue(downloadSearch);
const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase(); const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase();
const downloadSearchActive = downloadSearchQuery.length > 0; 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 totalPackageCount = snapshot.session.packageOrder.length;
const shouldLimitPackageRendering = downloadsTabActive const shouldLimitPackageRendering = downloadsTabActive
&& snapshot.session.running && snapshot.session.running
@ -1188,18 +1218,10 @@ export function App(): ReactElement {
const onExportQueue = async (): Promise<void> => { const onExportQueue = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
const json = await window.rd.exportQueue(); const result = await window.rd.exportQueue();
const blob = new Blob([json], { type: "application/json" }); if (result.saved) {
const url = URL.createObjectURL(blob); showToast("Queue exportiert");
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");
}, (error) => { }, (error) => {
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
}); });
@ -1670,6 +1692,32 @@ export function App(): ReactElement {
} }
}, [contextMenu]); }, [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(() => { useEffect(() => {
if (!historyCtxMenu) return; if (!historyCtxMenu) return;
const close = (): void => setHistoryCtxMenu(null); const close = (): void => setHistoryCtxMenu(null);
@ -2150,65 +2198,47 @@ export function App(): ReactElement {
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>} {snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
</div> </div>
)} )}
<div className="downloads-action-bar"> {/* Action buttons moved to footer */}
<button <div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
className="btn tab-action-btn" {columnOrder.map((col) => {
onClick={() => { const def = COLUMN_DEFS[col];
setCollapsedPackages((prev) => { if (!def) return null;
const next: Record<string, boolean> = { ...prev }; const sortCol = def.sortable;
const targetState = !allPackagesCollapsed; const isActive = sortCol ? downloadsSortColumn === sortCol : false;
for (const pkg of packages) { return (
next[pkg.id] = targetState;
}
return next;
});
}}
>
{allPackagesCollapsed ? "Alles ausklappen" : "Alles einklappen"}
</button>
<button
className="btn tab-action-btn"
disabled={actionBusy}
onClick={() => {
void performQuickAction(async () => {
const confirmed = await askConfirmPrompt({
title: "Queue löschen",
message: "Wirklich alle Einträge aus der Queue löschen?",
confirmLabel: "Alles löschen",
danger: true
});
if (!confirmed) {
return;
}
await window.rd.clearAll();
});
}}
>
Alles leeren
</button>
<button className={`btn tab-action-btn${snapshot.clipboardActive ? " btn-active" : ""}`} disabled={actionBusy} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
Clipboard: {snapshot.clipboardActive ? "An" : "Aus"}
</button>
</div>
<div className="pkg-column-header">
{(["name", "size", "progress", "hoster"] as PkgSortColumn[]).flatMap((col) => {
const labels: Record<PkgSortColumn, string> = { name: "Name", progress: "Fortschritt", size: "Geladen / Größe", hoster: "Hoster" };
const isActive = downloadsSortColumn === col;
return [
<span <span
key={col} key={col}
className={`pkg-col pkg-col-${col} sortable${isActive ? " sort-active" : ""}`} className={`pkg-col pkg-col-${col}${sortCol ? " sortable" : ""}${isActive ? " sort-active" : ""}${dragColId === col ? " pkg-col-dragging" : ""}${dropTargetCol === col ? " pkg-col-drop-target" : ""}`}
onClick={() => { 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; const nextDesc = isActive ? !downloadsSortDescending : false;
setDownloadsSortColumn(col); setDownloadsSortColumn(sortCol);
setDownloadsSortDescending(nextDesc); setDownloadsSortDescending(nextDesc);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
let sorted: string[]; let sorted: string[];
if (col === "progress") { if (sortCol === "progress") {
sorted = sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); 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); 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); sorted = sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc);
} else { } else {
sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDesc); sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDesc);
@ -2222,16 +2252,12 @@ export function App(): ReactElement {
packageOrderRef.current = serverPackageOrderRef.current; packageOrderRef.current = serverPackageOrderRef.current;
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
}); });
}} } : undefined}
> >
{labels[col]} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""} {def.label} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""}
</span>, </span>
]; );
})} })}
<span className="pkg-col pkg-col-account">Service</span>
<span className="pkg-col pkg-col-prio">Priorität</span>
<span className="pkg-col pkg-col-status">Status</span>
<span className="pkg-col pkg-col-speed">Geschwindigkeit</span>
</div> </div>
{totalPackageCount === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>} {totalPackageCount === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
{totalPackageCount > 0 && packages.length === 0 && <div className="empty">Keine Pakete passend zur Suche.</div>} {totalPackageCount > 0 && packages.length === 0 && <div className="empty">Keine Pakete passend zur Suche.</div>}
@ -2253,6 +2279,8 @@ export function App(): ReactElement {
editingName={editingName} editingName={editingName}
collapsed={collapsedPackages[pkg.id] ?? false} collapsed={collapsedPackages[pkg.id] ?? false}
selectedIds={selectedIds} selectedIds={selectedIds}
columnOrder={columnOrder}
gridTemplate={gridTemplate}
onSelect={onSelectId} onSelect={onSelectId}
onSelectMouseDown={onSelectMouseDown} onSelectMouseDown={onSelectMouseDown}
onSelectMouseEnter={onSelectMouseEnter} onSelectMouseEnter={onSelectMouseEnter}
@ -2321,28 +2349,38 @@ export function App(): ReactElement {
}} }}
> >
<header onClick={(e) => { if (e.ctrlKey) return; setHistoryCollapsed((prev) => ({ ...prev, [entry.id]: !collapsed })); }} style={{ cursor: "pointer" }}> <header onClick={(e) => { if (e.ctrlKey) return; setHistoryCollapsed((prev) => ({ ...prev, [entry.id]: !collapsed })); }} style={{ cursor: "pointer" }}>
<div className="pkg-columns"> <div className="pkg-columns" style={{ gridTemplateColumns: gridTemplate }}>
<div className="pkg-col pkg-col-name"> {columnOrder.map((col) => {
<button className="pkg-toggle" title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button> switch (col) {
<h4>{entry.name}</h4> case "name": return (
</div> <div key={col} className="pkg-col pkg-col-name">
<span className="pkg-col pkg-col-size">{(() => { <button className="pkg-toggle" title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button>
const pct = entry.totalBytes > 0 ? Math.min(100, Math.round((entry.downloadedBytes / entry.totalBytes) * 100)) : 0; <h4>{entry.name}</h4>
const label = `${humanSize(entry.downloadedBytes)} / ${humanSize(entry.totalBytes)}`; </div>
return entry.totalBytes > 0 ? ( );
<span className="progress-size"> case "size": return (
<span className="progress-size-bar" style={{ width: `${pct}%` }} /> <span key={col} className="pkg-col pkg-col-size">{(() => {
<span className="progress-size-text">{label}</span> const pct = entry.totalBytes > 0 ? Math.min(100, Math.round((entry.downloadedBytes / entry.totalBytes) * 100)) : 0;
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span> const label = `${humanSize(entry.downloadedBytes)} / ${humanSize(entry.totalBytes)}`;
</span> return entry.totalBytes > 0 ? (
) : "-"; <span className="progress-size">
})()}</span> <span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="pkg-col pkg-col-progress">{entry.status === "completed" ? "100%" : "-"}</span> <span className="progress-size-text">{label}</span>
<span className="pkg-col pkg-col-hoster">-</span> <span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
<span className="pkg-col pkg-col-account">{entry.provider ? providerLabels[entry.provider] : "-"}</span> </span>
<span className="pkg-col pkg-col-prio"></span> ) : "";
<span className="pkg-col pkg-col-status">{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span> })()}</span>
<span className="pkg-col pkg-col-speed">-</span> );
case "progress": return <span key={col} className="pkg-col pkg-col-progress">{entry.status === "completed" ? "100%" : ""}</span>;
case "hoster": return <span key={col} className="pkg-col pkg-col-hoster"></span>;
case "account": return <span key={col} className="pkg-col pkg-col-account">{entry.provider ? providerLabels[entry.provider] : ""}</span>;
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
case "status": return <span key={col} className="pkg-col pkg-col-status">{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span>;
case "speed": return <span key={col} className="pkg-col pkg-col-speed"></span>;
case "added": return <span key={col} className="pkg-col pkg-col-added">{formatDateTime(entry.completedAt)}</span>;
default: return null;
}
})}
</div> </div>
</header> </header>
<div className="progress"><div className="progress-dl" style={{ width: entry.status === "completed" ? "100%" : "0%" }} /></div> <div className="progress"><div className="progress-dl" style={{ width: entry.status === "completed" ? "100%" : "0%" }} /></div>
@ -2360,11 +2398,11 @@ export function App(): ReactElement {
<span className="history-label">Dauer</span> <span className="history-label">Dauer</span>
<span>{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`}</span> <span>{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`}</span>
<span className="history-label">Durchschnitt</span> <span className="history-label">Durchschnitt</span>
<span>{entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : "-"}</span> <span>{entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : ""}</span>
<span className="history-label">Provider</span> <span className="history-label">Provider</span>
<span>{entry.provider ? providerLabels[entry.provider] : "-"}</span> <span>{entry.provider ? providerLabels[entry.provider] : ""}</span>
<span className="history-label">Zielordner</span> <span className="history-label">Zielordner</span>
<span className="history-path" title={entry.outputDir}>{entry.outputDir || "-"}</span> <span className="history-path" title={entry.outputDir}>{entry.outputDir || ""}</span>
<span className="history-label">Status</span> <span className="history-label">Status</span>
<span>{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span> <span>{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span>
</div> </div>
@ -2554,6 +2592,11 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
<div><label>Parallele Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /></div> <div><label>Parallele Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /></div>
<div><label>Extraktions-Priorität</label><select value={settingsDraft.extractCpuPriority} onChange={(e) => setText("extractCpuPriority", e.target.value)}>
<option value="high">Hoch (80% CPU)</option>
<option value="middle">Mittel (50% CPU)</option>
<option value="low">Niedrig (25% CPU)</option>
</select></div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collectMkvToLibrary} onChange={(e) => setBool("collectMkvToLibrary", e.target.checked)} /> MKV nach Paketabschluss in Sammelordner verschieben (flach)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.collectMkvToLibrary} onChange={(e) => setBool("collectMkvToLibrary", e.target.checked)} /> MKV nach Paketabschluss in Sammelordner verschieben (flach)</label>
<label>MKV-Sammelordner</label> <label>MKV-Sammelordner</label>
<div className="input-row"> <div className="input-row">
@ -2739,6 +2782,31 @@ export function App(): ReactElement {
<span>Hoster: {configuredProviders.length}</span> <span>Hoster: {configuredProviders.length}</span>
<span>{snapshot.speedText}</span> <span>{snapshot.speedText}</span>
<span>{snapshot.etaText}</span> <span>{snapshot.etaText}</span>
<span className="footer-spacer" />
{totalPackageCount > 0 && (
<button className="btn footer-btn" title={allPackagesCollapsed ? "Alle Pakete in der Liste ausklappen und Details anzeigen" : "Alle Pakete in der Liste einklappen und nur die Kopfzeilen anzeigen"} onClick={() => {
setCollapsedPackages((prev) => {
const next: Record<string, boolean> = { ...prev };
const targetState = !allPackagesCollapsed;
for (const pkg of packages) { next[pkg.id] = targetState; }
return next;
});
}}>{allPackagesCollapsed ? "Ausklappen" : "Einklappen"}</button>
)}
{totalPackageCount > 0 && (
<button className="btn footer-btn" title="Alle Pakete und Links aus der Download-Queue entfernen" disabled={actionBusy} onClick={() => {
void performQuickAction(async () => {
const confirmed = await askConfirmPrompt({ title: "Queue löschen", message: "Wirklich alle Einträge aus der Queue löschen?", confirmLabel: "Alles löschen", danger: true });
if (!confirmed) return;
await window.rd.clearAll();
});
}}>Leeren</button>
)}
{snapshot.clipboardActive && (
<button className="btn footer-btn btn-active" title="Zwischenablage-Überwachung ist aktiv — kopierte Links werden automatisch erkannt und zur Queue hinzugefügt. Zum Deaktivieren: Einstellungen → Zwischenablage überwachen" disabled={actionBusy} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
Clipboard: An
</button>
)}
</footer> </footer>
{updateInstallProgress && ( {updateInstallProgress && (
@ -2806,6 +2874,15 @@ export function App(): ReactElement {
setContextMenu(null); setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.packages[id]).length})` : ""}</button> }}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.packages[id]).length})` : ""}</button>
)} )}
{contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
const itemIds = multi
? [...selectedIds].filter((id) => snapshot.session.items[id])
: [contextMenu.itemId!];
void window.rd.resetItems(itemIds);
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.items[id]).length})` : ""}</button>
)}
{hasPackages && !multi && (() => { {hasPackages && !multi && (() => {
const pkg = snapshot.session.packages[contextMenu.packageId]; const pkg = snapshot.session.packages[contextMenu.packageId];
const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || []; const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || [];
@ -2847,6 +2924,46 @@ export function App(): ReactElement {
</div> </div>
); );
})()} })()}
{colHeaderCtx && (
<div ref={colHeaderCtxRef} className="ctx-menu" style={{ left: colHeaderCtx.x, top: colHeaderCtx.y }} onClick={(e) => 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 (
<button
key={col}
className={`ctx-menu-item${isRequired ? " ctx-menu-disabled" : ""}${isVisible ? " ctx-menu-active" : ""}`}
disabled={isRequired}
onClick={() => {
if (isRequired) return;
let newOrder: string[];
if (isVisible) {
newOrder = columnOrder.filter((c) => c !== col);
} else {
// Insert at original default position relative to existing columns
newOrder = [...columnOrder];
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
let insertAt = newOrder.length;
for (let i = 0; i < newOrder.length; i++) {
if (ALL_COLUMN_KEYS.indexOf(newOrder[i]) > defaultIdx) {
insertAt = i;
break;
}
}
newOrder.splice(insertAt, 0, col);
}
setColumnOrder(newOrder);
void window.rd.updateSettings({ columnOrder: newOrder });
}}
>
{isVisible ? "\u2713 " : "\u2003 "}{def.label}
</button>
);
})}
</div>
)}
{historyCtxMenu && (() => { {historyCtxMenu && (() => {
const multi = selectedHistoryIds.size > 1; const multi = selectedHistoryIds.size > 1;
const contextEntry = historyEntries.find(e => e.id === historyCtxMenu.entryId); const contextEntry = historyEntries.find(e => e.id === historyCtxMenu.entryId);
@ -2929,6 +3046,8 @@ interface PackageCardProps {
editingName: string; editingName: string;
collapsed: boolean; collapsed: boolean;
selectedIds: Set<string>; selectedIds: Set<string>;
columnOrder: string[];
gridTemplate: string;
onSelect: (id: string, ctrlKey: boolean) => void; onSelect: (id: string, ctrlKey: boolean) => void;
onSelectMouseDown: (id: string, e: React.MouseEvent) => void; onSelectMouseDown: (id: string, e: React.MouseEvent) => void;
onSelectMouseEnter: (id: string) => void; onSelectMouseEnter: (id: string) => void;
@ -2947,7 +3066,7 @@ interface PackageCardProps {
onDragEnd: () => void; 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 done = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length; const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").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; if (tag === "BUTTON" || tag === "INPUT" || tag === "SELECT") return;
onToggleCollapse(pkg.id); onToggleCollapse(pkg.id);
}} style={{ cursor: "pointer" }}> }} style={{ cursor: "pointer" }}>
<div className="pkg-columns"> <div className="pkg-columns" style={{ gridTemplateColumns: gridTemplate }}>
<div className="pkg-col pkg-col-name"> {columnOrder.map((col) => {
<button className="pkg-toggle" onClick={() => onToggleCollapse(pkg.id)} title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button> switch (col) {
<input type="checkbox" checked={pkg.enabled} onChange={() => onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> case "name": return (
{isEditing ? ( <div key={col} className="pkg-col pkg-col-name">
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus /> <button className="pkg-toggle" onClick={() => onToggleCollapse(pkg.id)} title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button>
) : ( <input type="checkbox" checked={pkg.enabled} onChange={() => onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
<h4 onClick={(e) => { e.stopPropagation(); onStartEdit(pkg.id, pkg.name); }} title="Klicken zum Umbenennen">{pkg.name}</h4> {isEditing ? (
)} <input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus />
</div> ) : (
<span className="pkg-col pkg-col-size">{(() => { <h4 onClick={(e) => { e.stopPropagation(); onStartEdit(pkg.id, pkg.name); }} title="Klicken zum Umbenennen">{pkg.name}</h4>
const totalBytes = items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); )}
const dlBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); </div>
const pct = totalBytes > 0 ? Math.min(100, Math.round((dlBytes / totalBytes) * 100)) : 0; );
const label = `${humanSize(dlBytes)} / ${humanSize(totalBytes)}`; case "size": return (
return totalBytes > 0 ? ( <span key={col} className="pkg-col pkg-col-size">{(() => {
<span className="progress-size"> const totalBytes = items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
<span className="progress-size-bar" style={{ width: `${pct}%` }} /> const dlBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
<span className="progress-size-text">{label}</span> const pct = totalBytes > 0 ? Math.min(100, Math.round((dlBytes / totalBytes) * 100)) : 0;
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span> const label = `${humanSize(dlBytes)} / ${humanSize(totalBytes)}`;
</span> return totalBytes > 0 ? (
) : "-"; <span className="progress-size">
})()}</span> <span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="pkg-col pkg-col-progress"> <span className="progress-size-text">{label}</span>
<span className="progress-inline"> <span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
<span className="progress-inline-bar" style={{ width: `${combinedProgress}%` }} /> </span>
<span className="progress-inline-text">{combinedProgress}%</span> ) : "";
<span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - combinedProgress}% 0 0)` }}>{combinedProgress}%</span> })()}</span>
</span> );
</span> case "progress": return (
<span className="pkg-col pkg-col-hoster" title={(() => { <span key={col} className="pkg-col pkg-col-progress">
const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; <span className="progress-inline">
return hosters.join(", "); <span className="progress-inline-bar" style={{ width: `${combinedProgress}%` }} />
})()}>{(() => { <span className="progress-inline-text">{combinedProgress}%</span>
const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; <span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - combinedProgress}% 0 0)` }}>{combinedProgress}%</span>
return hosters.length > 0 ? hosters.join(", ") : "-"; </span>
})()}</span> </span>
<span className="pkg-col pkg-col-account" title={(() => { );
const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; case "hoster": return (
return providers.map((p) => providerLabels[p!] || p).join(", "); <span key={col} className="pkg-col pkg-col-hoster" title={(() => {
})()}>{(() => { const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))];
const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; return hosters.join(", ");
return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : "-"; })()}>{(() => {
})()}</span> const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))];
<span className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : "-"}</span> return hosters.length > 0 ? hosters.join(", ") : "";
<span className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]</span> })()}</span>
<span className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : "-"}</span> );
case "account": return (
<span key={col} className="pkg-col pkg-col-account" title={(() => {
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(", ") : "";
})()}</span>
);
case "prio": return (
<span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span>
);
case "status": return (
<span key={col} className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]</span>
);
case "speed": return (
<span key={col} className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""}</span>
);
case "added": return (
<span key={col} className="pkg-col pkg-col-added">{formatDateTime(pkg.createdAt)}</span>
);
default: return null;
}
})}
</div> </div>
</header> </header>
<div className="progress"> <div className="progress">
@ -3051,40 +3194,54 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
{extracting && <div className="progress-ex" style={{ width: `${exProgress}%` }} />} {extracting && <div className="progress-ex" style={{ width: `${exProgress}%` }} />}
</div> </div>
{!collapsed && items.map((item) => ( {!collapsed && items.map((item) => (
<div key={item.id} className={`item-row${selectedIds.has(item.id) ? " item-selected" : ""}`} onClick={(e) => { 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); }}> <div key={item.id} className={`item-row${selectedIds.has(item.id) ? " item-selected" : ""}`} style={{ gridTemplateColumns: gridTemplate }} onClick={(e) => { 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); }}>
<span className="pkg-col pkg-col-name item-indent" title={item.fileName}> {columnOrder.map((col) => {
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />} switch (col) {
{item.fileName} case "name": return (
</span> <span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
<span className="pkg-col pkg-col-size">{(() => { {item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
const total = item.totalBytes || item.downloadedBytes || 0; {item.fileName}
const dl = item.downloadedBytes || 0; </span>
const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0; );
const label = `${humanSize(dl)} / ${humanSize(total)}`; case "size": return (
return total > 0 ? ( <span key={col} className="pkg-col pkg-col-size">{(() => {
<span className="progress-size progress-size-small"> const total = item.totalBytes || item.downloadedBytes || 0;
<span className="progress-size-bar" style={{ width: `${pct}%` }} /> const dl = item.downloadedBytes || 0;
<span className="progress-size-text">{label}</span> const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0;
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span> const label = `${humanSize(dl)} / ${humanSize(total)}`;
</span> return total > 0 ? (
) : "-"; <span className="progress-size progress-size-small">
})()}</span> <span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="pkg-col pkg-col-progress"> <span className="progress-size-text">{label}</span>
{item.totalBytes > 0 ? ( <span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
<span className="progress-inline progress-inline-small"> </span>
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} /> ) : "";
<span className="progress-inline-text">{item.progressPercent}%</span> })()}</span>
<span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - (item.progressPercent || 0)}% 0 0)` }}>{item.progressPercent}%</span> );
</span> case "progress": return (
) : "-"} <span key={col} className="pkg-col pkg-col-progress">
</span> {item.totalBytes > 0 ? (
<span className="pkg-col pkg-col-hoster" title={extractHoster(item.url)}>{extractHoster(item.url) || "-"}</span> <span className="progress-inline progress-inline-small">
<span className="pkg-col pkg-col-account">{item.provider ? providerLabels[item.provider] : "-"}</span> <span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
<span className="pkg-col pkg-col-prio"></span> <span className="progress-inline-text">{item.progressPercent}%</span>
<span className="pkg-col pkg-col-status" title={item.retries > 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}> <span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - (item.progressPercent || 0)}% 0 0)` }}>{item.progressPercent}%</span>
{item.fullStatus} </span>
</span> ) : ""}
<span className="pkg-col pkg-col-speed">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : "-"}</span> </span>
);
case "hoster": return <span key={col} className="pkg-col pkg-col-hoster" title={extractHoster(item.url)}>{extractHoster(item.url) || ""}</span>;
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.provider ? providerLabels[item.provider] : ""}</span>;
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
case "status": return (
<span key={col} className="pkg-col pkg-col-status" title={item.retries > 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}>
{item.fullStatus}
</span>
);
case "speed": return <span key={col} className="pkg-col pkg-col-speed">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
case "added": return <span key={col} className="pkg-col pkg-col-added">{formatDateTime(item.createdAt)}</span>;
default: return null;
}
})}
</div> </div>
))} ))}
</article> </article>
@ -3104,7 +3261,9 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|| prev.isLast !== next.isLast || prev.isLast !== next.isLast
|| prev.isEditing !== next.isEditing || prev.isEditing !== next.isEditing
|| prev.collapsed !== next.collapsed || prev.collapsed !== next.collapsed
|| prev.selectedIds !== next.selectedIds) { || prev.selectedIds !== next.selectedIds
|| prev.columnOrder !== next.columnOrder
|| prev.gridTemplate !== next.gridTemplate) {
return false; return false;
} }
if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) {

View File

@ -18,6 +18,7 @@ export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "allde
export type DebridFallbackProvider = DebridProvider | "none"; export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low"; export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id: string; id: string;
@ -81,6 +82,8 @@ export interface AppSettings {
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
bandwidthSchedules: BandwidthScheduleEntry[]; bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
extractCpuPriority: ExtractCpuPriority;
} }
export interface DownloadItem { export interface DownloadItem {