real-debrid-downloader/src/renderer/App.tsx
Sucukdeluxe 8e4b29a155 Encrypted backup system + hide extracted items in package list
Backup redesign (JDownloader 2 style):
- AES-256-GCM encrypted .mdd format with fixed app key
- All credentials exported (no more ***-masking), works on any PC
- Includes settings, session AND history in backup
- Backward-compatible: auto-detects legacy JSON backups
- Normalize history entries on import
- Added debridLinkApiKeys, linkSnappy credentials to sensitive keys

Hide extracted items:
- New setting: hide completed/extracted items from package item list
- Items still count in progress stats (done/total), only hidden in UI
- Default: enabled
- Toggle in settings: "Entpackte Items in Paketliste ausblenden"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:44:21 +01:00

5622 lines
262 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import type {
AllDebridHostInfo,
AppSettings,
AppTheme,
BandwidthScheduleEntry,
DebridFallbackProvider,
DebridLinkHostLimitInfo,
DebridProvider,
DownloadItem,
DownloadStats,
DuplicatePolicy,
HistoryEntry,
PackageEntry,
StartConflictEntry,
UiSnapshot,
UpdateCheckResult,
UpdateInstallProgress
} from "../shared/types";
import {
getDebridLinkApiKeyDailyLimitBytes,
getDebridLinkApiKeyDailyRemainingBytes,
getDebridLinkApiKeyDailyUsageBytes,
getProviderDailyLimitBytes,
getProviderDailyRemainingBytes,
getProviderDailyUsageBytes,
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
interface CollectorTab {
id: string;
name: string;
text: string;
}
interface StartConflictPromptState {
entry: StartConflictEntry;
applyToAll: boolean;
}
interface ConfirmPromptState {
title: string;
message: string;
confirmLabel: string;
danger?: boolean;
details?: string;
}
interface ContextMenuState {
x: number;
y: number;
packageId: string;
itemId?: string;
}
interface LinkPopupState {
title: string;
links: { name: string; url: string }[];
isPackage: boolean;
}
type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
type AccountKind =
| "realdebrid-api"
| "realdebrid-web"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid-api"
| "bestdebrid-web"
| "alldebrid-api"
| "alldebrid-web"
| "ddownload-login"
| "onefichier-api"
| "debridlink-api"
| "linksnappy-login";
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
type AccountColumnKey = "service" | "mode" | "status" | "secret";
interface AccountOption {
kind: AccountKind;
service: AccountService;
serviceLabel: string;
title: string;
modeLabel: string;
pickerDescription: string;
needsToken?: boolean;
needsCredentials?: boolean;
}
interface AccountDialogState {
mode: "create" | "edit";
kind: AccountKind | null;
token: string;
login: string;
password: string;
dailyLimitGb: string;
keyDailyLimitGbById: Record<string, string>;
}
interface DebridLinkAccountKeyEntry {
id: string;
label: string;
token: string;
masked: string;
disabled: boolean;
dailyUsedBytes: number;
dailyLimitBytes: number;
dailyRemainingBytes: number | null;
dailyLimitReached: boolean;
}
interface ConfiguredAccountEntry {
kind: AccountKind;
service: AccountService;
provider: DebridProvider;
serviceLabel: string;
modeLabel: string;
statusLabel: string;
summary: string;
summaryLines: string[];
note: string;
disabled: boolean;
dailyUsedBytes: number;
dailyLimitBytes: number;
dailyRemainingBytes: number | null;
dailyLimitReached: boolean;
debridLinkKeys: DebridLinkAccountKeyEntry[];
}
const ACCOUNT_OPTIONS: AccountOption[] = [
{
kind: "realdebrid-api",
service: "realdebrid",
serviceLabel: "Real-Debrid",
title: "Real-Debrid API",
modeLabel: "API",
pickerDescription: "Direkter Zugriff über API-Token.",
needsToken: true
},
{
kind: "realdebrid-web",
service: "realdebrid",
serviceLabel: "Real-Debrid",
title: "Real-Debrid Web",
modeLabel: "Web",
pickerDescription: "Login über Browserfenster statt Token."
},
{
kind: "megadebrid-api",
service: "megadebrid-api",
serviceLabel: "Mega-Debrid",
title: "Mega-Debrid API",
modeLabel: "API",
pickerDescription: "Login nur über die API, ohne Web-Fallback.",
needsCredentials: true
},
{
kind: "megadebrid-web",
service: "megadebrid-web",
serviceLabel: "Mega-Debrid",
title: "Mega-Debrid Web",
modeLabel: "Web",
pickerDescription: "Login nur über Web, ohne API-Fallback.",
needsCredentials: true
},
{
kind: "bestdebrid-api",
service: "bestdebrid",
serviceLabel: "BestDebrid",
title: "BestDebrid API",
modeLabel: "API",
pickerDescription: "Direkter Zugriff über API-Token.",
needsToken: true
},
{
kind: "bestdebrid-web",
service: "bestdebrid",
serviceLabel: "BestDebrid",
title: "BestDebrid Web",
modeLabel: "Web",
pickerDescription: "Cookie-Import aus dem Browser statt API-Token."
},
{
kind: "alldebrid-api",
service: "alldebrid",
serviceLabel: "AllDebrid",
title: "AllDebrid API",
modeLabel: "API",
pickerDescription: "Direkter Zugriff über API-Key.",
needsToken: true
},
{
kind: "alldebrid-web",
service: "alldebrid",
serviceLabel: "AllDebrid",
title: "AllDebrid Web",
modeLabel: "Web",
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
},
{
kind: "ddownload-login",
service: "ddownload",
serviceLabel: "DDownload",
title: "DDownload Login",
modeLabel: "Login",
pickerDescription: "Direkter Login für ddownload.com und ddl.to.",
needsCredentials: true
},
{
kind: "onefichier-api",
service: "onefichier",
serviceLabel: "1Fichier",
title: "1Fichier API",
modeLabel: "API",
pickerDescription: "API-Key für 1fichier.com.",
needsToken: true
},
{
kind: "debridlink-api",
service: "debridlink",
serviceLabel: "Debrid-Link",
title: "Debrid-Link API",
modeLabel: "API",
pickerDescription: "API-Key(s) für debrid-link.com. Mehrere Keys zeilenweise für Multi-Account.",
needsToken: true
},
{
kind: "linksnappy-login",
service: "linksnappy",
serviceLabel: "LinkSnappy",
title: "LinkSnappy Web",
modeLabel: "Web",
pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.",
needsCredentials: true
}
];
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths-v2";
const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = {
service: 240,
mode: 96,
status: 320,
secret: 210
};
const ACCOUNT_COLUMN_MIN_WIDTHS: Record<AccountColumnKey, number> = {
service: 180,
mode: 80,
status: 180,
secret: 140
};
function loadAccountColumnWidths(): Record<AccountColumnKey, number> {
if (typeof window === "undefined") {
return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS };
}
try {
const raw = window.localStorage.getItem(ACCOUNT_COLUMN_STORAGE_KEY);
if (!raw) {
return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS };
}
const parsed = JSON.parse(raw) as Partial<Record<AccountColumnKey, unknown>>;
return {
service: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.service, Number(parsed.service) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.service),
mode: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.mode, Number(parsed.mode) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.mode),
status: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.status, Number(parsed.status) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.status),
secret: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.secret, Number(parsed.secret) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.secret)
};
} catch {
return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS };
}
}
function findAccountOption(kind: AccountKind): AccountOption {
const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind);
if (!option) {
throw new Error(`Unbekannter Account-Typ: ${kind}`);
}
return option;
}
function getAccountServiceProvider(service: AccountService): DebridProvider {
return service as DebridProvider;
}
function formatAccountDailyLimitInput(limitBytes: number): string {
if (limitBytes <= 0) {
return "";
}
const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB;
const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2;
return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function parseAccountDailyLimitInputBytes(value: string): number | null {
const normalized = value.trim().replace(",", ".");
if (!normalized) {
return null;
}
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB);
}
function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record<string, string>, settings?: AppSettings): Record<string, string> {
const next: Record<string, string> = {};
for (const key of parseDebridLinkApiKeys(rawKeys)) {
next[key.id] = values?.[key.id]
?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0);
}
return next;
}
function getAccountPickerFunctionLabel(option: AccountOption): string {
switch (option.kind) {
case "realdebrid-api":
case "bestdebrid-api":
return "API-Token";
case "realdebrid-web":
case "alldebrid-web":
return "Browser-Login";
case "megadebrid-api":
return "Login + Passwort (API)";
case "megadebrid-web":
return "Login + Passwort (Web)";
case "bestdebrid-web":
return "Cookies.txt-Import";
case "alldebrid-api":
case "onefichier-api":
return "API-Key";
case "ddownload-login":
return "Login + Passwort";
default:
return option.modeLabel;
}
}
function hasMegaDebridCredentials(settings: AppSettings): boolean {
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
}
function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] {
const list: DebridProvider[] = [];
if (settings.token.trim() || settings.realDebridUseWebLogin) {
list.push("realdebrid");
}
if (hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled) {
list.push("megadebrid-api");
}
if (hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled) {
list.push("megadebrid-web");
}
if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) {
list.push("bestdebrid");
}
if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) {
list.push("alldebrid");
}
if ((settings.debridLinkApiKeys || "").trim()) {
list.push("debridlink");
}
if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) {
list.push("linksnappy");
}
return list;
}
function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] {
const disabled = new Set(settings.disabledProviders || []);
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
}
// Leitet die aktive Provider-Reihenfolge aus providerOrder ab,
// gefiltert auf tatsächlich konfigurierte und nicht deaktivierte Provider.
// Direkt-Hoster (onefichier, ddownload) werden ausgeschlossen.
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
// Behalte bestehende Reihenfolge aus providerOrder, filtere nicht-konfigurierte heraus
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
const inOrder = new Set(ordered);
// Füge neue Provider hinten an, die noch nicht in der Reihenfolge sind
for (const p of active) {
if (!inOrder.has(p)) ordered.push(p);
}
return ordered;
}
function normalizeProviderSelectionForSettings(
settings: AppSettings
): Pick<AppSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> {
const providerOrder = normalizeProviderOrderForSettings(settings);
return {
providerOrder,
providerPrimary: providerOrder[0] ?? settings.providerPrimary,
providerSecondary: (providerOrder[1] ?? "none") as DebridFallbackProvider,
providerTertiary: (providerOrder[2] ?? "none") as DebridFallbackProvider
};
}
function getConfiguredAccountKind(settings: AppSettings, service: AccountService): AccountKind | null {
switch (service) {
case "realdebrid":
if (settings.realDebridUseWebLogin) return "realdebrid-web";
return settings.token.trim() ? "realdebrid-api" : null;
case "megadebrid-api":
return hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled ? "megadebrid-api" : null;
case "megadebrid-web":
return hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled ? "megadebrid-web" : null;
case "bestdebrid":
if (settings.bestDebridUseWebLogin) return "bestdebrid-web";
return settings.bestToken.trim() ? "bestdebrid-api" : null;
case "alldebrid":
if (settings.allDebridUseWebLogin) return "alldebrid-web";
return settings.allDebridToken.trim() ? "alldebrid-api" : null;
case "ddownload":
return settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim() ? "ddownload-login" : null;
case "onefichier":
return settings.oneFichierApiKey.trim() ? "onefichier-api" : null;
case "debridlink":
return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null;
case "linksnappy":
return (settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim() ? "linksnappy-login" : null;
default:
return null;
}
}
function maskValue(value: string, keepStart = 2, keepEnd = 2): string {
const trimmed = value.trim();
if (!trimmed) return "Nicht hinterlegt";
if (trimmed.length <= keepStart + keepEnd) {
return "*".repeat(trimmed.length);
}
return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`;
}
function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
switch (kind) {
case "realdebrid-api":
return maskValue(settings.token, 3, 3);
case "realdebrid-web":
return "Browser-Login";
case "megadebrid-api":
case "megadebrid-web":
return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Login + Passwort";
case "bestdebrid-api":
return maskValue(settings.bestToken, 3, 3);
case "bestdebrid-web":
return "Cookie-Import";
case "alldebrid-api":
return maskValue(settings.allDebridToken, 3, 3);
case "alldebrid-web":
return "Browser-Login";
case "ddownload-login":
return settings.ddownloadLogin.trim() ? maskValue(settings.ddownloadLogin.trim(), 2, 6) : "Login + Passwort";
case "onefichier-api":
return maskValue(settings.oneFichierApiKey, 3, 3);
case "debridlink-api": {
const keys = (settings.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim());
if (keys.length > 1) return `${keys.length} API-Keys`;
return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt";
}
case "linksnappy-login":
return (settings.linkSnappyLogin || "").trim() ? maskValue((settings.linkSnappyLogin || "").trim(), 2, 4) : "Login + Passwort";
default:
return "Konfiguriert";
}
}
function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] {
if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
if (keys.length > 1) {
return keys.map((entry) => `${entry.label}: ${entry.masked}`);
}
}
return [summarizeAccount(kind, settings)];
}
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
if (!kind) {
return {
mode,
kind: null,
token: "",
login: "",
password: "",
dailyLimitGb: "",
keyDailyLimitGbById: {}
};
}
const provider = getAccountServiceProvider(findAccountOption(kind).service);
const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider));
switch (kind) {
case "realdebrid-api":
return { mode, kind, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "realdebrid-web":
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "megadebrid-api":
case "megadebrid-web":
return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword, dailyLimitGb, keyDailyLimitGbById: {} };
case "bestdebrid-api":
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "bestdebrid-web":
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "alldebrid-api":
return { mode, kind, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "alldebrid-web":
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "ddownload-login":
return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {} };
case "onefichier-api":
return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "debridlink-api":
return {
mode,
kind,
token: settings.debridLinkApiKeys || "",
login: "",
password: "",
dailyLimitGb,
keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings)
};
case "linksnappy-login":
return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {} };
default:
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
}
}
function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings {
if (!dialog.kind) {
return settings;
}
const token = dialog.token.trim();
const login = dialog.login.trim();
const password = dialog.password;
const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service);
const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) };
const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api"
? Object.fromEntries(
parseDebridLinkApiKeys(dialog.token).flatMap((entry) => {
const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || "");
return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : [];
})
) as Record<string, number>
: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) };
const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb);
if (dailyLimitBytes && dailyLimitBytes > 0) {
nextProviderDailyLimitBytes[provider] = dailyLimitBytes;
} else {
delete nextProviderDailyLimitBytes[provider];
}
switch (dialog.kind) {
case "realdebrid-api":
return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "realdebrid-web":
return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "megadebrid-api":
return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "megadebrid-web":
return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "bestdebrid-api":
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "bestdebrid-web":
return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "alldebrid-api":
return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "alldebrid-web":
return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "ddownload-login":
return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "onefichier-api":
return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "debridlink-api":
return {
...settings,
debridLinkApiKeys: token,
providerDailyLimitBytes: nextProviderDailyLimitBytes,
debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes
};
case "linksnappy-login":
return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes };
default:
return settings;
}
}
function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings {
const provider = getAccountServiceProvider(service);
const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) };
const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) };
const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) };
const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) };
delete nextProviderDailyLimitBytes[provider];
delete nextProviderDailyUsageBytes[provider];
if (service === "debridlink") {
for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) {
delete nextDebridLinkApiKeyDailyLimitBytes[key.id];
delete nextDebridLinkApiKeyDailyUsageBytes[key.id];
}
}
switch (service) {
case "realdebrid":
return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "megadebrid-api":
return settings.megaDebridWebEnabled
? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }
: { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "megadebrid-web":
return settings.megaDebridApiEnabled
? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }
: { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "bestdebrid":
return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "alldebrid":
return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "ddownload":
return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "onefichier":
return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "debridlink":
return {
...settings,
debridLinkApiKeys: "",
providerDailyLimitBytes: nextProviderDailyLimitBytes,
providerDailyUsageBytes: nextProviderDailyUsageBytes,
debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes
};
case "linksnappy":
return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
default:
return settings;
}
}
function validateAccountDialog(dialog: AccountDialogState): string | null {
if (!dialog.kind) {
return "Bitte zuerst einen Account-Typ auswählen.";
}
const option = findAccountOption(dialog.kind);
if (option.needsToken && !dialog.token.trim()) {
return `${option.title}: Bitte Zugangstoken eintragen.`;
}
if (option.needsCredentials) {
if (!dialog.login.trim()) {
return `${option.title}: Bitte Login oder E-Mail eintragen.`;
}
if (!dialog.password) {
return `${option.title}: Bitte Passwort eintragen.`;
}
}
if (dialog.dailyLimitGb.trim()) {
const parsed = Number(dialog.dailyLimitGb.trim().replace(",", "."));
if (!Number.isFinite(parsed) || parsed < 0) {
return `${option.title}: Tageslimit muss eine Zahl >= 0 sein.`;
}
}
if (dialog.kind === "debridlink-api") {
for (const key of parseDebridLinkApiKeys(dialog.token)) {
const raw = dialog.keyDailyLimitGbById?.[key.id] || "";
if (!raw.trim()) {
continue;
}
const parsed = Number(raw.trim().replace(",", "."));
if (!Number.isFinite(parsed) || parsed < 0) {
return `${option.title}: ${key.label} Limit muss eine Zahl >= 0 sein.`;
}
}
}
return null;
}
const emptyStats = (): DownloadStats => ({
totalDownloaded: 0,
totalDownloadedAllTime: 0,
totalFiles: 0,
totalPackages: 0,
sessionStartedAt: 0
});
const emptySnapshot = (): UiSnapshot => ({
settings: {
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
debridLinkDisabledKeyIds: [],
archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
collectMkvToLibrary: false, mkvLibraryDir: "",
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
autoExtractWhenStopped: true,
disabledProviders: [],
hosterRouting: {},
providerDailyLimitBytes: {},
providerDailyUsageBytes: {},
debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
},
session: {
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
paused: false, running: false, updatedAt: Date.now()
},
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
});
const cleanupLabels: Record<string, string> = {
never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist"
};
const AUTO_RENDER_PACKAGE_LIMIT = 260;
const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
megadebrid: "Mega-Debrid",
"megadebrid-api": "Mega-Debrid API",
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
linksnappy: "LinkSnappy"
};
const KNOWN_HOSTERS: { id: string; label: string }[] = [
{ id: "rapidgator", label: "Rapidgator" },
{ id: "uploaded", label: "Uploaded" },
{ id: "1fichier", label: "1Fichier" },
{ id: "ddownload", label: "DDownload" },
{ id: "ddl", label: "DDL.to" },
{ id: "turbobit", label: "Turbobit" },
{ id: "nitroflare", label: "Nitroflare" },
{ id: "filefactory", label: "FileFactory" },
{ id: "katfile", label: "Katfile" },
{ id: "hitfile", label: "Hitfile" },
{ id: "alfafile", label: "Alfafile" },
{ id: "k2s", label: "Keep2Share" },
{ id: "keep2share", label: "Keep2Share (alt)" },
{ id: "tezfiles", label: "Tezfiles" },
{ id: "fileboom", label: "Fileboom" },
{ id: "mexashare", label: "Mexashare" },
{ id: "wdupload", label: "WDUpload" },
{ id: "rosefile", label: "Rosefile" },
{ id: "filejoker", label: "FileJoker" },
{ id: "worldbytez", label: "Worldbytez" },
{ id: "fileland", label: "Fileland" },
{ id: "depositfiles", label: "DepositFiles" },
{ id: "mediafire", label: "MediaFire" },
{ id: "mega", label: "Mega.nz" },
{ id: "frdl", label: "FreeDownload" },
{ id: "hexupload", label: "HexUpload" },
{ id: "isra", label: "Isra.cloud" }
];
function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string {
const base = providerLabels[provider];
if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") {
return base;
}
const kind = getConfiguredAccountKind(settings, provider as AccountService);
if (!kind) return base;
const opt = ACCOUNT_OPTIONS.find((o) => o.kind === kind);
return opt?.modeLabel ? `${base} (${opt.modeLabel})` : base;
}
function compactProviderLabels(labels: string[]): string {
const unique = [...new Set(labels)];
const groups = new Map<string, string[]>();
for (const label of unique) {
const m = label.match(/^(.+?)\s*\((.+)\)$/);
if (m) {
const arr = groups.get(m[1]) || [];
arr.push(m[2]);
groups.set(m[1], arr);
} else {
groups.set(label, []);
}
}
return [...groups.entries()].map(([base, details]) =>
details.length === 0 ? base : `${base} (${details.join(" + ")})`
).join(", ");
}
function formatDateTime(ts: number): string {
if (!ts) return "";
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\./, "");
const parts = host.split(".");
return parts.length >= 2 ? parts[parts.length - 2] : host;
} catch { return ""; }
}
const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [
{ key: "allgemein", label: "Allgemein" },
{ key: "accounts", label: "Accounts" },
{ key: "entpacken", label: "Entpacken" },
{ key: "geschwindigkeit", label: "Geschwindigkeit" },
{ key: "bereinigung", label: "Bereinigung" },
{ key: "updates", label: "Updates" },
];
function formatSpeedMbps(speedBps: number): string {
const mbps = Math.max(0, speedBps || 0) / (1024 * 1024);
return `${mbps.toFixed(2)} MB/s`;
}
function humanSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) {
return "0 B";
}
if (bytes < 1024) { return `${bytes} B`; }
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
if (bytes < 1024 * 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; }
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`;
}
function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string {
return source === "web" ? "Web-Login" : "API-Key";
}
function formatAllDebridQuota(info: AllDebridHostInfo): string {
const suffix = info.quotaType ? ` (${info.quotaType})` : "";
if (info.quota !== null && info.quotaMax !== null) {
return `${info.quota} / ${info.quotaMax}${suffix}`;
}
if (info.quota !== null) {
return `${info.quota}${suffix}`;
}
if (info.quotaMax !== null) {
return `max. ${info.quotaMax}${suffix}`;
}
return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben";
}
function formatAllDebridSimuLimit(info: AllDebridHostInfo): string {
if (info.limitSimuDl === null) {
return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben";
}
return String(info.limitSimuDl);
}
function formatAllDebridTimestamp(info: AllDebridHostInfo): string {
return formatDateTime(info.lastCheckedAt || info.fetchedAt);
}
function formatDebridLinkTraffic(info: DebridLinkHostLimitInfo | null | undefined): string {
if (!info) {
return "Lade...";
}
const toGb = (bytes: number): string => `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
if (info.trafficCurrentBytes !== null && info.trafficMaxBytes !== null) {
return `${toGb(info.trafficCurrentBytes)} / ${toGb(info.trafficMaxBytes)}`;
}
if (info.trafficMaxBytes !== null) {
return `max. ${toGb(info.trafficMaxBytes)}`;
}
if (info.trafficCurrentBytes !== null) {
return toGb(info.trafficCurrentBytes);
}
return info.note || "Nicht verfügbar";
}
function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undefined): string {
if (!info) {
return "Lade...";
}
if (info.linksCurrent !== null && info.linksMax !== null) {
return `${info.linksCurrent} / ${info.linksMax}`;
}
if (info.linksMax !== null) {
return `max. ${info.linksMax}`;
}
if (info.linksCurrent !== null) {
return String(info.linksCurrent);
}
return info.note || "Nicht verfügbar";
}
interface BandwidthChartProps {
items: Record<string, DownloadItem>;
running: boolean;
paused: boolean;
speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>;
}
const BandwidthChart = memo(function BandwidthChart({ items, running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastUpdateRef = useRef<number>(0);
const animationFrameRef = useRef<number>(0);
const drawChart = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)";
const textColor = isDark ? "#90a4bf" : "#4e6482";
const accentColor = isDark ? "#38bdf8" : "#1168d9";
const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)";
const history = speedHistoryRef.current;
const now = Date.now();
const maxTime = now;
const minTime = now - 60000;
let maxSpeed = 0;
for (const point of history) {
if (point.speed > maxSpeed) maxSpeed = point.speed;
}
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
// Measure widest label to set dynamic left padding
ctx.font = "11px 'Manrope', sans-serif";
let maxLabelWidth = 0;
for (let i = 0; i <= 5; i += 1) {
const speedVal = niceMax * (1 - i / 5);
const w = ctx.measureText(formatSpeedMbps(speedVal)).width;
if (w > maxLabelWidth) maxLabelWidth = w;
}
const padding = { top: 20, right: 20, bottom: 30, left: Math.ceil(maxLabelWidth) + 16 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i += 1) {
const y = padding.top + (chartHeight / 5) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
ctx.fillStyle = textColor;
ctx.font = "11px 'Manrope', sans-serif";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let i = 0; i <= 5; i += 1) {
const y = padding.top + (chartHeight / 5) * i;
const speedVal = niceMax * (1 - i / 5);
ctx.fillText(formatSpeedMbps(speedVal), padding.left - 8, y);
}
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText("60s", padding.left, height - padding.bottom + 8);
ctx.fillText("30s", padding.left + chartWidth / 2, height - padding.bottom + 8);
ctx.fillText("0s", width - padding.right, height - padding.bottom + 8);
if (history.length < 2) {
ctx.fillStyle = textColor;
ctx.font = "13px 'Manrope', sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten für Statistiken", width / 2, height / 2);
return;
}
const points: { x: number; y: number }[] = [];
for (const point of history) {
const x = padding.left + ((point.time - minTime) / 60000) * chartWidth;
const y = padding.top + chartHeight - (point.speed / niceMax) * chartHeight;
points.push({ x, y });
}
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight);
ctx.lineTo(points[0].x, padding.top + chartHeight);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.strokeStyle = accentColor;
ctx.lineWidth = 2;
ctx.stroke();
const lastPoint = points[points.length - 1];
ctx.beginPath();
ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2);
ctx.fillStyle = accentColor;
ctx.fill();
}, [running, paused]);
useEffect(() => {
const interval = setInterval(() => {
drawChart();
}, 250);
return () => clearInterval(interval);
}, [drawChart]);
useEffect(() => {
// Only record samples while the session is running and not paused
if (!running || paused) return;
const now = Date.now();
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: totalSpeed });
const cutoff = now - 60000;
let trimIndex = 0;
while (trimIndex < history.length && history[trimIndex].time < cutoff) {
trimIndex += 1;
}
if (trimIndex > 0) {
speedHistoryRef.current = history.slice(trimIndex);
}
lastUpdateRef.current = now;
}, [items, paused, running]);
useEffect(() => {
const handleResize = () => {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = requestAnimationFrame(drawChart);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [drawChart]);
useEffect(() => {
drawChart();
}, [drawChart, items, paused]);
return (
<div ref={containerRef} className="bandwidth-chart-container">
<canvas ref={canvasRef} />
</div>
);
});
let nextCollectorId = 1;
function createScheduleId(): string {
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
const sorted = [...order];
sorted.sort((a, b) => {
const sizeA = (packages[a]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0);
const sizeB = (packages[b]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0);
const cmp = sizeA - sizeB;
return descending ? -cmp : cmp;
});
return sorted;
}
function sortPackageOrderByHoster(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
const sorted = [...order];
sorted.sort((a, b) => {
const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase();
const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase();
const cmp = hosterA.localeCompare(hosterB);
return descending ? -cmp : cmp;
});
return sorted;
}
function sortPackageOrderByProgress(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
const sorted = [...order];
sorted.sort((a, b) => {
const progressA = computePackageProgress(packages[a], items);
const progressB = computePackageProgress(packages[b], items);
const cmp = progressA - progressB;
return descending ? -cmp : cmp;
});
return sorted;
}
function computePackageProgress(pkg: PackageEntry | undefined, items: Record<string, DownloadItem>): number {
if (!pkg) return 0;
const ids = pkg.itemIds ?? [];
if (ids.length === 0) return 0;
let totalDown = 0;
let totalSize = 0;
for (const id of ids) {
const item = items[id];
if (!item) continue;
totalDown += item.downloadedBytes || 0;
totalSize += item.totalBytes || item.downloadedBytes || 0;
}
return totalSize > 0 ? totalDown / totalSize : 0;
}
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 {
if (a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false;
}
}
return true;
}
function formatMbpsInputFromKbps(kbps: number): string {
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
return String(Number(mbps.toFixed(2)));
}
function parseMbpsInput(value: string): number | null {
const normalized = String(value || "").trim().replace(/,/g, ".");
if (!normalized) {
return 0;
}
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
function formatUpdateInstallProgress(progress: UpdateInstallProgress): string {
if (progress.stage === "downloading") {
if (progress.totalBytes && progress.totalBytes > 0 && progress.percent !== null) {
return `Update-Download: ${progress.percent}% (${humanSize(progress.downloadedBytes)} / ${humanSize(progress.totalBytes)})`;
}
return `Update-Download: ${humanSize(progress.downloadedBytes)}`;
}
if (progress.stage === "starting") {
return "Update wird vorbereitet...";
}
if (progress.stage === "verifying") {
return "Download fertig | Prüfe Integrität...";
}
if (progress.stage === "launching") {
return "Starte Installer...";
}
if (progress.stage === "done") {
return "Installer gestartet";
}
return `Update-Fehler: ${progress.message}`;
}
export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [appVersion, setAppVersion] = useState("");
const [tab, setTab] = useState<Tab>("downloads");
const [statusToast, setStatusToast] = useState("");
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
const [accountColumnWidths, setAccountColumnWidths] = useState<Record<AccountColumnKey, number>>(loadAccountColumnWidths);
const [settingsDirty, setSettingsDirty] = useState(false);
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleCountdown, setScheduleCountdown] = useState("");
const settingsDirtyRef = useRef(false);
const settingsDraftRevisionRef = useRef(0);
const panelDirtyRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null);
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const tabRef = useRef(tab);
const autoExpandedPkgsRef = useRef(new Set<string>());
tabRef.current = tab;
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
const [dragOver, setDragOver] = useState(false);
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([
{ id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" }
]);
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]);
const pendingPackageOrderRef = useRef<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const draggedPackageIdRef = useRef<string | null>(null);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState("");
const [downloadsSortColumn, setDownloadsSortColumn] = useState<PkgSortColumn>("name");
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const dragOverRef = useRef(false);
const dragDepthRef = useRef(0);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSubTab>("allgemein");
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const ctxMenuRef = useRef<HTMLDivElement>(null);
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
const [accountDialogSearch, setAccountDialogSearch] = useState("");
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({});
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
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 historyEntriesRef = useRef<HistoryEntry[]>([]);
const [historyCollapsed, setHistoryCollapsed] = useState<Record<string, boolean>>({});
const [selectedHistoryIds, setSelectedHistoryIds] = useState<Set<string>>(new Set());
const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null);
const historyCtxMenuRef = useRef<HTMLDivElement>(null);
const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null);
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
const allDebridHostRequestRef = useRef(0);
const debridLinkHostLimitsRequestRef = useRef(0);
const accountColumnResizeRef = useRef<{ key: AccountColumnKey; startX: number; startWidth: number } | null>(null);
const onAccountColumnResizeMove = useCallback((event: MouseEvent): void => {
const active = accountColumnResizeRef.current;
if (!active) {
return;
}
const nextWidth = Math.max(
ACCOUNT_COLUMN_MIN_WIDTHS[active.key],
Math.round(active.startWidth + (event.clientX - active.startX))
);
setAccountColumnWidths((prev) => (
prev[active.key] === nextWidth ? prev : { ...prev, [active.key]: nextWidth }
));
}, []);
const stopAccountColumnResize = useCallback((): void => {
accountColumnResizeRef.current = null;
window.removeEventListener("mousemove", onAccountColumnResizeMove);
window.removeEventListener("mouseup", stopAccountColumnResize);
}, [onAccountColumnResizeMove]);
const startAccountColumnResize = useCallback((key: AccountColumnKey, clientX: number): void => {
accountColumnResizeRef.current = {
key,
startX: clientX,
startWidth: accountColumnWidths[key]
};
window.addEventListener("mousemove", onAccountColumnResizeMove);
window.addEventListener("mouseup", stopAccountColumnResize);
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
// Load history when tab changes to history
useEffect(() => {
if (tab !== "history") return;
const loadHistory = async (): Promise<void> => {
try {
const entries = await window.rd.getHistory();
if (mountedRef.current && entries) {
setHistoryEntries(entries);
}
} catch (err) {
console.error("Failed to load history:", err);
}
};
void loadHistory();
}, [tab]);
useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]);
useEffect(() => {
try {
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
} catch {
// Ignore local persistence failures for optional UI state.
}
}, [accountColumnWidths]);
const resetAccountColumnWidths = useCallback((): void => {
setAccountColumnWidths({ ...ACCOUNT_COLUMN_DEFAULT_WIDTHS });
try {
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
} catch {
// Ignore local persistence failures for optional UI state.
}
showToast("Accounts-Spalten zurückgesetzt", 1800);
}, []);
// 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(() => {
activeCollectorTabRef.current = activeCollectorTab;
}, [activeCollectorTab]);
useEffect(() => {
collectorTabsRef.current = collectorTabs;
}, [collectorTabs]);
useEffect(() => {
activeTabRef.current = tab;
}, [tab]);
useEffect(() => {
const incoming = snapshot.session.packageOrder;
serverPackageOrderRef.current = incoming;
const pending = pendingPackageOrderRef.current;
if (!pending) {
packageOrderRef.current = incoming;
return;
}
if (sameStringArray(pending, incoming)) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
const maxOptimisticHoldMs = 1500;
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
packageOrderRef.current = pending;
}, [snapshot.session.packageOrder]);
useEffect(() => {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
}, [settingsDraft.speedLimitKbps]);
useEffect(() => {
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
if (schedMs <= 0) { setScheduleCountdown(""); return; }
const update = (): void => {
const remaining = schedMs - Date.now();
if (remaining <= 0) { setScheduleCountdown(""); return; }
const totalSec = Math.ceil(remaining / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
setScheduleCountdown(h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`);
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [snapshot.settings.scheduledStartEpochMs]);
const showToast = useCallback((message: string, timeoutMs = 2200): void => {
setStatusToast(message);
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
toastTimerRef.current = setTimeout(() => {
setStatusToast("");
toastTimerRef.current = null;
}, timeoutMs);
}, []);
const loadAllDebridHostInfo = useCallback(async (silent = false): Promise<void> => {
const requestId = allDebridHostRequestRef.current + 1;
allDebridHostRequestRef.current = requestId;
setAllDebridHostLoading(true);
try {
const info = await window.rd.getAllDebridHostInfo();
if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) {
return;
}
setAllDebridHostInfo(info);
} catch (error) {
if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) {
return;
}
setAllDebridHostInfo(null);
if (!silent) {
showToast(`AllDebrid Status fehlgeschlagen: ${String(error)}`, 3200);
}
} finally {
if (mountedRef.current && allDebridHostRequestRef.current === requestId) {
setAllDebridHostLoading(false);
}
}
}, [showToast]);
const loadDebridLinkHostLimits = useCallback(async (silent = false): Promise<void> => {
const requestId = debridLinkHostLimitsRequestRef.current + 1;
debridLinkHostLimitsRequestRef.current = requestId;
setDebridLinkHostLimitsLoading(true);
setDebridLinkHostLimitsError("");
setDebridLinkHostLimits({});
try {
const apiKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "");
if (apiKeys.length === 0) {
throw new Error("Debrid-Link ist nicht konfiguriert");
}
let loadedAny = false;
let firstError = "";
for (let index = 0; index < apiKeys.length; index += 1) {
if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) {
return;
}
const apiKey = apiKeys[index];
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 8000);
let info: DebridLinkHostLimitInfo;
try {
const readLimitsPayload = async (path: "limits" | "limits/all") => {
const response = await fetch(`https://debrid-link.com/api/v2/downloader/${path}`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey.token}`
},
signal: controller.signal
});
const payload = await response.json() as {
success?: boolean;
value?: {
hosters?: Array<{
name?: string;
displayName?: string;
daySize?: { current?: number; value?: number };
dayCount?: { current?: number; value?: number };
}>;
};
error?: string;
error_description?: string;
};
if (!response.ok || !payload?.success) {
throw new Error(String(payload?.error_description || payload?.error || `HTTP ${response.status}`));
}
return payload;
};
let payload = await readLimitsPayload("limits/all");
let hostEntry = (payload.value?.hosters || []).find((entry) => String(entry.name || "").toLowerCase() === "rapidgator");
if (!hostEntry) {
payload = await readLimitsPayload("limits");
hostEntry = (payload.value?.hosters || []).find((entry) => String(entry.name || "").toLowerCase() === "rapidgator");
}
if (!hostEntry) {
info = {
keyId: apiKey.id,
keyLabel: apiKey.label,
host: "rapidgator",
fetchedAt: Date.now(),
trafficCurrentBytes: null,
trafficMaxBytes: null,
linksCurrent: null,
linksMax: null,
note: "Rapidgator nicht in der API-Antwort gefunden."
};
} else {
info = {
keyId: apiKey.id,
keyLabel: apiKey.label,
host: String(hostEntry.displayName || hostEntry.name || "rapidgator"),
fetchedAt: Date.now(),
trafficCurrentBytes: typeof hostEntry.daySize?.current === "number" ? hostEntry.daySize.current : null,
trafficMaxBytes: typeof hostEntry.daySize?.value === "number" ? hostEntry.daySize.value : null,
linksCurrent: typeof hostEntry.dayCount?.current === "number" ? hostEntry.dayCount.current : null,
linksMax: typeof hostEntry.dayCount?.value === "number" ? hostEntry.dayCount.value : null,
note: ""
};
}
} catch (error) {
const message = String(error || "Quota konnte nicht geladen werden");
if (!firstError) {
firstError = message;
}
info = {
keyId: apiKey.id,
keyLabel: apiKey.label,
host: "rapidgator",
fetchedAt: Date.now(),
trafficCurrentBytes: null,
trafficMaxBytes: null,
linksCurrent: null,
linksMax: null,
note: message
};
} finally {
window.clearTimeout(timer);
}
if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) {
return;
}
loadedAny = true;
setDebridLinkHostLimits((prev) => ({
...prev,
[info.keyId]: info
}));
}
if (!loadedAny && firstError) {
throw new Error(firstError);
}
} catch (error) {
if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) {
return;
}
setDebridLinkHostLimits({});
setDebridLinkHostLimitsError(String(error));
if (!silent) {
showToast(`Debrid-Link Quota fehlgeschlagen: ${String(error)}`, 3200);
}
} finally {
if (mountedRef.current && debridLinkHostLimitsRequestRef.current === requestId) {
setDebridLinkHostLimitsLoading(false);
}
}
}, [settingsDraft.debridLinkApiKeys, showToast]);
useEffect(() => {
if (keyStatsPopup !== "debridlink") {
setDebridLinkHostLimits({});
setDebridLinkHostLimitsError("");
setDebridLinkHostLimitsLoading(false);
return;
}
void loadDebridLinkHostLimits(true);
}, [keyStatsPopup, loadDebridLinkHostLimits]);
const clearImportQueueFocusListener = useCallback((): void => {
const handler = importQueueFocusHandlerRef.current;
if (!handler) {
return;
}
window.removeEventListener("focus", handler);
importQueueFocusHandlerRef.current = null;
}, []);
useEffect(() => {
document.title = `Multi Debrid Downloader${appVersion ? ` - v${appVersion}` : ""}`;
}, [appVersion]);
useEffect(() => {
let unsubscribe: (() => void) | null = null;
let unsubClipboard: (() => void) | null = null;
let unsubUpdateInstallProgress: (() => void) | null = null;
void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined);
void window.rd.getSnapshot().then((state) => {
if (!mountedRef.current) {
return;
}
setSnapshot(state);
if (state.settings.columnOrder?.length > 0) {
setColumnOrder(state.settings.columnOrder);
}
setSettingsDraft(state.settings);
settingsDirtyRef.current = false;
panelDirtyRevisionRef.current = 0;
setSettingsDirty(false);
applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => {
if (!mountedRef.current) {
return;
}
void handleUpdateResult(result, "startup");
}).catch(() => undefined);
}
}).catch((error) => {
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
});
unsubscribe = window.rd.onStateUpdate((state) => {
latestStateRef.current = state;
if (stateFlushTimerRef.current) { return; }
const itemCount = Object.keys(state.session.items).length;
let flushDelay = itemCount >= 1500
? 900
: itemCount >= 700
? 650
: itemCount >= 250
? 400
: 150;
if (!state.session.running) {
flushDelay = Math.min(flushDelay, 200);
}
if (activeTabRef.current !== "downloads") {
flushDelay = Math.max(flushDelay, 800);
}
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
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);
}
latestStateRef.current = null;
}
}, flushDelay);
});
unsubClipboard = window.rd.onClipboardDetected((links) => {
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
setCollectorTabs((prev) => {
const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0];
if (!active) { return prev; }
const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n");
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
});
});
unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => {
if (!mountedRef.current) {
return;
}
setUpdateInstallProgress(progress);
});
return () => {
mountedRef.current = false;
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); }
clearImportQueueFocusListener();
if (startConflictResolverRef.current) {
const resolver = startConflictResolverRef.current;
startConflictResolverRef.current = null;
resolver(null);
}
if (confirmResolverRef.current) {
const resolver = confirmResolverRef.current;
confirmResolverRef.current = null;
resolver(false);
}
while (confirmQueueRef.current.length > 0) {
const request = confirmQueueRef.current.shift();
request?.resolve(false);
}
stopAccountColumnResize();
if (unsubscribe) { unsubscribe(); }
if (unsubClipboard) { unsubClipboard(); }
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
};
}, [clearImportQueueFocusListener, stopAccountColumnResize]);
const downloadsTabActive = tab === "downloads";
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
&& !downloadSearchActive
&& totalPackageCount > AUTO_RENDER_PACKAGE_LIMIT
&& !showAllPackages;
const packageIdsForView = useMemo(() => {
if (!downloadsTabActive) {
return [] as string[];
}
if (downloadSearchActive) {
return snapshot.session.packageOrder;
}
if (shouldLimitPackageRendering) {
return snapshot.session.packageOrder.slice(0, AUTO_RENDER_PACKAGE_LIMIT);
}
return snapshot.session.packageOrder;
}, [downloadsTabActive, downloadSearchActive, shouldLimitPackageRendering, snapshot.session.packageOrder]);
const packageOrderKey = useMemo(() => {
if (!downloadsTabActive) {
return "";
}
return packageIdsForView.join("|");
}, [downloadsTabActive, packageIdsForView]);
const packages = useMemo(() => {
if (!downloadsTabActive) {
return [] as PackageEntry[];
}
if (downloadSearchActive) {
return snapshot.session.packageOrder
.map((id: string) => snapshot.session.packages[id])
.filter((pkg): pkg is PackageEntry => Boolean(pkg) && pkg.name.toLowerCase().includes(downloadSearchQuery));
}
return packageIdsForView
.map((id) => snapshot.session.packages[id])
.filter((pkg): pkg is PackageEntry => Boolean(pkg));
}, [downloadsTabActive, downloadSearchActive, downloadSearchQuery, packageIdsForView, snapshot.session.packageOrder, snapshot.session.packages]);
const packagePosition = useMemo(() => {
if (!downloadsTabActive) {
return new Map<string, number>();
}
const map = new Map<string, number>();
snapshot.session.packageOrder.forEach((id, index) => {
map.set(id, index);
});
return map;
}, [downloadsTabActive, snapshot.session.packageOrder]);
const itemsByPackage = useMemo(() => {
if (!downloadsTabActive) {
return new Map<string, DownloadItem[]>();
}
const map = new Map<string, DownloadItem[]>();
for (const pkg of packages) {
const items = pkg.itemIds
.map((id) => snapshot.session.items[id])
.filter(Boolean) as DownloadItem[];
map.set(pkg.id, items);
}
return map;
}, [downloadsTabActive, packageOrderKey, packages, snapshot.session.items]);
useEffect(() => {
if (!downloadsTabActive) {
return;
}
setCollapsedPackages((prev) => {
let changed = false;
const next: Record<string, boolean> = { ...prev };
const defaultCollapsed = totalPackageCount >= 24;
for (const packageId of snapshot.session.packageOrder) {
if (!(packageId in prev)) {
next[packageId] = defaultCollapsed;
changed = true;
}
}
for (const packageId of Object.keys(next)) {
if (!snapshot.session.packages[packageId]) {
delete next[packageId];
changed = true;
}
}
return changed ? next : prev;
});
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length)
: 0;
const visiblePackages = useMemo(() => {
return sortPackagesForDisplay(
packages,
snapshot.session.items,
snapshot.session.running,
settingsDraft.autoSortPackagesByProgress
);
}, [packages, settingsDraft.autoSortPackagesByProgress, snapshot.session.running, snapshot.session.items]);
const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim());
const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin
|| snapshot.settings.allDebridToken !== settingsDraft.allDebridToken;
useEffect(() => {
if (!snapshot.session.running) {
setShowAllPackages(false);
}
}, [snapshot.session.running]);
useEffect(() => {
if (settingsSubTab !== "accounts") {
return;
}
if (!hasSavedAllDebridAccount) {
setAllDebridHostInfo(null);
setAllDebridHostLoading(false);
return;
}
void loadAllDebridHostInfo(true);
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
// Auto-expand packages that are currently extracting (only once per extraction cycle)
useEffect(() => {
const extractingPkgIds: string[] = [];
const currentlyExtracting = new Set<string>();
for (const pkg of packages) {
const items = (pkg.itemIds ?? []).map((id) => snapshot.session.items[id]).filter(Boolean);
const isExtracting = items.some((item) => item.fullStatus?.startsWith("Entpacken -") && !item.fullStatus?.includes("Done"));
if (isExtracting) {
currentlyExtracting.add(pkg.id);
if (collapsedPackages[pkg.id] && !autoExpandedPkgsRef.current.has(pkg.id)) {
extractingPkgIds.push(pkg.id);
autoExpandedPkgsRef.current.add(pkg.id);
}
}
}
// Reset tracking for packages no longer extracting
for (const id of autoExpandedPkgsRef.current) {
if (!currentlyExtracting.has(id)) {
autoExpandedPkgsRef.current.delete(id);
}
}
if (extractingPkgIds.length > 0) {
setCollapsedPackages((prev) => {
const next = { ...prev };
for (const id of extractingPkgIds) next[id] = false;
return next;
});
}
}, [packages, snapshot.session.items, collapsedPackages]);
const allPackagesCollapsed = useMemo(() => (
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
), [packages, collapsedPackages]);
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
// DDownload is a direct file hoster (not a debrid service) and is used automatically
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
// appear in the primary/secondary/tertiary provider dropdowns.
const hasDdownloadAccount = useMemo(() =>
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
const hasOneFichierAccount = useMemo(() =>
Boolean((settingsDraft.oneFichierApiKey || "").trim()),
[settingsDraft.oneFichierApiKey]);
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
// Dynamische Provider-Reihenfolge (ersetzt altes primary/secondary/tertiary)
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
// Setzt providerOrder + backwards-kompatible Felder synchron
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
...prev,
providerOrder: newOrder,
providerPrimary: newOrder[0] ?? prev.providerPrimary,
providerSecondary: newOrder[1] ?? "none",
providerTertiary: newOrder[2] ?? "none"
}));
}, []);
const normalizedSettingsDraft: AppSettings = useMemo(() => ({
...settingsDraft,
...normalizeProviderSelectionForSettings(settingsDraft)
}), [settingsDraft]);
const configuredAccounts = useMemo(() => {
const entries: ConfiguredAccountEntry[] = [];
for (const service of ACCOUNT_SERVICES) {
const kind = getConfiguredAccountKind(settingsDraft, service);
if (!kind) {
continue;
}
const option = findAccountOption(kind);
let statusLabel = "Aktiviert";
let note = "";
if (kind === "megadebrid-api") {
note = "Nur API aktiv. Kein Web-Fallback.";
} else if (kind === "megadebrid-web") {
note = "Nur Web aktiv. Kein API-Fallback.";
} else if (kind === "realdebrid-web") {
note = "Login kann bei Bedarf direkt aus der Liste geöffnet werden.";
} else if (kind === "bestdebrid-web") {
note = "Cookie-Import lässt sich direkt aus der Liste erneut starten.";
} else if (service === "alldebrid") {
if (allDebridHostLoading) {
statusLabel = "Lade Status";
note = "Rapidgator-Status wird aktualisiert.";
} else if (allDebridHostInfo) {
statusLabel = allDebridHostInfo.statusLabel;
note = allDebridHostInfo.note || `Update: ${formatAllDebridTimestamp(allDebridHostInfo)}`;
} else if (hasSavedAllDebridAccount) {
note = "Rapidgator-Status kann direkt aus der Liste geladen werden.";
}
if (allDebridSettingsDirty && hasSavedAllDebridAccount) {
note = "Status basiert auf den zuletzt gespeicherten AllDebrid-Daten.";
}
}
if (kind === "debridlink-api") {
const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length;
statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Aktiviert";
}
const provider = getAccountServiceProvider(service);
const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider);
const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider);
const dailyRemainingBytes = getProviderDailyRemainingBytes({
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes,
providerDailyUsageDay: snapshot.settings.providerDailyUsageDay
}, provider);
let dailyLimitReached = dailyLimitBytes > 0 && dailyUsedBytes >= dailyLimitBytes;
const isDisabled = (settingsDraft.disabledProviders || []).includes(provider);
const debridLinkKeys = kind === "debridlink-api"
? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => {
const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id);
const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id);
const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({
debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes,
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes,
providerDailyUsageDay: snapshot.settings.providerDailyUsageDay
}, key.id);
return {
id: key.id,
label: key.label,
token: key.token,
masked: key.masked,
disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id),
dailyUsedBytes: keyDailyUsedBytes,
dailyLimitBytes: keyDailyLimitBytes,
dailyRemainingBytes: keyDailyRemainingBytes,
dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes
};
})
: [];
if (kind === "debridlink-api" && debridLinkKeys.length > 0) {
const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length;
const disabledKeyCount = debridLinkKeys.filter((entry) => entry.disabled).length;
const keyNotes: string[] = [];
if (limitedCount > 0) {
keyNotes.push(`${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`);
}
if (disabledKeyCount > 0) {
keyNotes.push(`${disabledKeyCount}/${debridLinkKeys.length} API-Keys deaktiviert.`);
}
if (keyNotes.length > 0) {
const combinedKeyNote = keyNotes.join(" ");
note = note ? `${combinedKeyNote} ${note}` : combinedKeyNote;
}
if (debridLinkKeys.every((entry) => entry.disabled || entry.dailyLimitReached)) {
dailyLimitReached = true;
}
}
if (dailyLimitReached) {
note = note
? `Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster. ${note}`
: "Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.";
}
entries.push({
kind,
service,
provider,
serviceLabel: option.serviceLabel,
modeLabel: option.modeLabel,
statusLabel: isDisabled ? "Deaktiviert" : statusLabel,
summary: summarizeAccount(kind, settingsDraft),
summaryLines: summarizeAccountLines(kind, settingsDraft),
note,
disabled: isDisabled,
dailyUsedBytes,
dailyLimitBytes,
dailyRemainingBytes,
dailyLimitReached,
debridLinkKeys
});
}
return entries;
}, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]);
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]);
const availableAccountOptions = useMemo(() => (
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
), [configuredAccountServices]);
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
const accountDialogSelectableOptions = useMemo(() => {
if (!accountDialog) {
return [];
}
if (accountDialog.mode === "edit") {
if (!accountDialogOption) {
return [];
}
return ACCOUNT_OPTIONS.filter((option) => option.service === accountDialogOption.service);
}
return availableAccountOptions;
}, [accountDialog, accountDialogOption, availableAccountOptions]);
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
const filteredAccountDialogOptions = useMemo(() => (
accountDialogSelectableOptions.filter((option) => {
if (!accountDialogSearchQuery) {
return true;
}
const haystack = [
option.title,
option.serviceLabel,
option.modeLabel,
option.pickerDescription,
getAccountPickerFunctionLabel(option)
].join(" ").toLowerCase();
return haystack.includes(accountDialogSearchQuery);
})
), [accountDialogSearchQuery, accountDialogSelectableOptions]);
const accountTableStyle = useMemo(() => ({
"--account-col-service": `${accountColumnWidths.service}px`,
"--account-col-mode": `${accountColumnWidths.mode}px`,
"--account-col-status": `${accountColumnWidths.status}px`,
"--account-col-secret": `${accountColumnWidths.secret}px`
} as CSSProperties), [accountColumnWidths]);
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
if (!mountedRef.current) {
return;
}
if (result.error) {
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
return;
}
if (!result.updateAvailable) {
setUpdateInstallProgress(null);
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
return;
}
let changelogText = "";
if (result.releaseNotes) {
const lines = result.releaseNotes.split("\n");
const compactLines: string[] = [];
for (const line of lines) {
if (/^\s{2,}[-*]/.test(line)) continue;
if (/^#{1,6}\s/.test(line)) continue;
if (!line.trim()) continue;
let clean = line
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/^\s*[-*]\s+/, "- ")
.trim();
const colonIdx = clean.indexOf(":");
if (colonIdx > 0 && colonIdx < clean.length - 1) {
const afterColon = clean.slice(colonIdx + 1).trim();
if (afterColon.length > 60) {
clean = clean.slice(0, colonIdx + 1).trim();
}
}
if (clean) compactLines.push(clean);
}
changelogText = compactLines.join("\n");
}
const approved = await askConfirmPrompt({
title: "Update verfügbar",
message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`,
confirmLabel: "Jetzt installieren",
details: changelogText || undefined
});
if (!mountedRef.current) {
return;
}
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
setUpdateInstallProgress({
stage: "starting",
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: "Update wird vorbereitet"
});
const install = await window.rd.installUpdate();
if (!mountedRef.current) {
return;
}
if (install.started) { showToast("Stilles Update gestartet - App wird neu gestartet", 2600); return; }
setUpdateInstallProgress({
stage: "error",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: install.message
});
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
};
const onSaveSettings = async (): Promise<void> => {
await performQuickAction(async () => {
const result = await persistDraftSettings();
applyTheme(result.theme);
showToast("Einstellungen gespeichert", 1800);
}, (error) => {
showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800);
});
};
const onOpenRealDebridLogin = async (): Promise<void> => {
await performQuickAction(async () => {
await persistDraftSettings();
await window.rd.openRealDebridLogin();
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
}, (error) => {
showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800);
});
};
const onOpenAllDebridLogin = async (): Promise<void> => {
await performQuickAction(async () => {
await persistDraftSettings();
await window.rd.openAllDebridLogin();
showToast("AllDebrid Login-Fenster geöffnet", 2200);
}, (error) => {
showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800);
});
};
const onImportBestDebridCookies = async (): Promise<void> => {
await performQuickAction(async () => {
await persistDraftSettings();
const count = await window.rd.importBestDebridCookies();
if (count > 0) {
showToast(`${count} BestDebrid-Cookies importiert`, 2200);
} else {
showToast("Keine Cookie-Datei ausgewählt", 2200);
}
}, (error) => {
showToast(`BestDebrid Cookie-Import fehlgeschlagen: ${String(error)}`, 2800);
});
};
const applyPersistedSettings = (result: AppSettings): void => {
setSettingsDraft(result);
settingsDirtyRef.current = false;
panelDirtyRevisionRef.current = 0;
setSettingsDirty(false);
applyTheme(result.theme);
};
const syncLiveProviderUsageSettings = (result: AppSettings): void => {
setSnapshot((prev) => ({ ...prev, settings: result }));
if (!settingsDirtyRef.current) {
applyPersistedSettings(result);
return;
}
setSettingsDraft((prev) => ({
...prev,
totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime),
providerDailyUsageDay: result.providerDailyUsageDay,
providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) },
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) }
}));
};
const persistSpecificSettings = async (nextDraft: AppSettings): Promise<AppSettings> => {
const normalizedDraft = {
...nextDraft,
...normalizeProviderSelectionForSettings(nextDraft)
};
const result = await window.rd.updateSettings(normalizedDraft);
applyPersistedSettings(result);
return result;
};
const runAccountQuickAction = async (action: AccountQuickAction): Promise<void> => {
switch (action) {
case "realdebrid-login":
await window.rd.openRealDebridLogin();
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
return;
case "bestdebrid-cookies": {
const count = await window.rd.importBestDebridCookies();
showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewählt", 2200);
return;
}
case "alldebrid-login":
await window.rd.openAllDebridLogin();
showToast("AllDebrid Login-Fenster geöffnet", 2200);
return;
case "alldebrid-status":
await loadAllDebridHostInfo(false);
return;
default:
return;
}
};
const getAccountQuickActionMeta = (kind: AccountKind): { label: string; action: AccountQuickAction } | null => {
switch (kind) {
case "realdebrid-web":
return { label: "Login", action: "realdebrid-login" };
case "bestdebrid-web":
return { label: "Cookies", action: "bestdebrid-cookies" };
case "alldebrid-api":
return { label: "Status", action: "alldebrid-status" };
case "alldebrid-web":
return { label: "Login", action: "alldebrid-login" };
default:
return null;
}
};
const openCreateAccountDialog = (): void => {
setAccountDialogSearch("");
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
};
const openEditAccountDialog = (kind: AccountKind): void => {
setAccountDialogSearch("");
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
};
const updateAccountDialogKind = (kind: AccountKind): void => {
setAccountDialog((prev) => {
const next = createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft);
if (!prev) {
return next;
}
if (findAccountOption(kind).needsToken) {
next.token = prev.token;
}
if (findAccountOption(kind).needsCredentials) {
next.login = prev.login;
next.password = prev.password;
}
next.dailyLimitGb = prev.dailyLimitGb;
return next;
});
};
const closeAccountDialog = useCallback((): void => {
setAccountDialog(null);
setAccountDialogSearch("");
}, []);
const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
if (!accountDialog) {
return;
}
const validationError = validateAccountDialog(accountDialog);
if (validationError) {
showToast(validationError, 2800);
return;
}
const dialogSnapshot = accountDialog;
const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null;
await performQuickAction(async () => {
const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot);
await persistSpecificSettings(nextDraft);
closeAccountDialog();
if (quickAction) {
await runAccountQuickAction(quickAction);
} else if (selectedOption) {
showToast(`${selectedOption.title} gespeichert`, 2200);
}
}, (error) => {
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
});
};
const onRemoveAccount = async (entry: ConfiguredAccountEntry): Promise<void> => {
const confirmed = await askConfirmPrompt({
title: `${entry.serviceLabel} entfernen`,
message: `Soll ${entry.serviceLabel} wirklich aus der Accountliste entfernt werden?`,
confirmLabel: "Entfernen",
danger: true
});
if (!confirmed) {
return;
}
await performQuickAction(async () => {
const nextDraft = clearAccountServiceFromSettings(settingsDraft, entry.service);
await persistSpecificSettings(nextDraft);
if (entry.service === "alldebrid") {
setAllDebridHostInfo(null);
}
showToast(`${entry.serviceLabel} entfernt`, 2200);
}, (error) => {
showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200);
});
};
const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise<void> => {
await performQuickAction(async () => {
const result = await window.rd.resetProviderDailyUsage(getAccountServiceProvider(entry.service));
syncLiveProviderUsageSettings(result);
showToast(`${entry.serviceLabel}: Tageszähler zurückgesetzt`, 2200);
}, (error) => {
showToast(`${entry.serviceLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise<void> => {
await performQuickAction(async () => {
const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId);
syncLiveProviderUsageSettings(result);
showToast(`${entry.serviceLabel} ${keyLabel}: Tageszähler zurückgesetzt`, 2200);
}, (error) => {
showToast(`${entry.serviceLabel} ${keyLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<void> => {
await performQuickAction(async () => {
const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || [];
const nextDisabledIds = key.disabled
? currentDisabledIds.filter((existingId) => existingId !== key.id)
: [...currentDisabledIds, key.id];
const nextDraft: AppSettings = {
...settingsDraft,
debridLinkDisabledKeyIds: nextDisabledIds
};
await persistSpecificSettings(nextDraft);
showToast(
key.disabled
? `${entry.serviceLabel} ${key.label} aktiviert`
: `${entry.serviceLabel} ${key.label} deaktiviert`,
2200
);
}, (error) => {
showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
const meta = getAccountQuickActionMeta(entry.kind);
if (!meta) {
return;
}
await performQuickAction(async () => {
await runAccountQuickAction(meta.action);
}, (error) => {
showToast(`${entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
await performQuickAction(async () => {
const provider = entry.service as DebridProvider;
const current = settingsDraft.disabledProviders || [];
const nextDisabledProviders = current.includes(provider)
? current.filter((existing) => existing !== provider)
: [...current, provider];
const nextDraft: AppSettings = {
...settingsDraft,
disabledProviders: nextDisabledProviders
};
await persistSpecificSettings(nextDraft);
showToast(
nextDisabledProviders.includes(provider)
? `${entry.serviceLabel} deaktiviert`
: `${entry.serviceLabel} aktiviert`,
2200
);
}, (error) => {
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
});
};
const onCheckUpdates = async (): Promise<void> => {
let updateResult: UpdateCheckResult | null = null;
await performQuickAction(async () => {
setUpdateInstallProgress(null);
updateResult = await window.rd.checkUpdates();
}, (error) => {
showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800);
});
if (updateResult) await handleUpdateResult(updateResult, "manual");
};
const persistDraftSettings = async (): Promise<AppSettings> => {
const revisionAtStart = settingsDraftRevisionRef.current;
const result = await window.rd.updateSettings(normalizedSettingsDraft);
if (settingsDraftRevisionRef.current === revisionAtStart) {
applyPersistedSettings(result);
}
return result;
};
const closeStartConflictPrompt = (result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null): void => {
const resolver = startConflictResolverRef.current;
startConflictResolverRef.current = null;
setStartConflictPrompt(null);
if (resolver) {
resolver(result);
}
};
const askStartConflictDecision = (entry: StartConflictEntry): Promise<{ policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null> => {
return new Promise((resolve) => {
startConflictResolverRef.current = resolve;
setStartConflictPrompt({
entry,
applyToAll: false
});
});
};
const pumpConfirmQueue = useCallback((): void => {
if (confirmResolverRef.current) {
return;
}
const next = confirmQueueRef.current.shift();
if (!next) {
return;
}
confirmResolverRef.current = next.resolve;
setConfirmPrompt(next.prompt);
}, []);
const closeConfirmPrompt = useCallback((confirmed: boolean): void => {
const resolver = confirmResolverRef.current;
confirmResolverRef.current = null;
setConfirmPrompt(null);
if (resolver) {
resolver(confirmed);
}
pumpConfirmQueue();
}, [pumpConfirmQueue]);
const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise<boolean> => {
return new Promise((resolve) => {
confirmQueueRef.current.push({ prompt, resolve });
pumpConfirmQueue();
});
}, [pumpConfirmQueue]);
const onStartDownloads = async (): Promise<void> => {
await performQuickAction(async () => {
if (totalConfiguredAccounts === 0) {
setTab("settings");
showToast("Bitte zuerst mindestens einen Hoster-Account eintragen", 3000);
return;
}
await persistDraftSettings();
const conflicts = await window.rd.getStartConflicts();
let skipped = 0;
let overwritten = 0;
let rememberedPolicy: Extract<DuplicatePolicy, "skip" | "overwrite"> | null = null;
if (settingsDraft.autoSkipExtracted && conflicts.length > 0) {
rememberedPolicy = "skip";
}
for (const conflict of conflicts) {
let decisionPolicy = rememberedPolicy;
if (!decisionPolicy) {
const decision = await askStartConflictDecision(conflict);
if (!decision) {
showToast("Start abgebrochen", 1800);
return;
}
decisionPolicy = decision.policy;
if (decision.applyToAll) {
rememberedPolicy = decision.policy;
}
}
const result = await window.rd.resolveStartConflict(conflict.packageId, decisionPolicy);
if (result.skipped) {
skipped += 1;
}
if (result.overwritten) {
overwritten += 1;
}
}
if (conflicts.length > 0 && !settingsDraft.autoSkipExtracted) {
showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800);
}
await window.rd.start();
});
};
const collapseNewPackages = async (existingIds: Set<string>): Promise<void> => {
const fresh = await window.rd.getSnapshot();
const newIds = Object.keys(fresh.session.packages).filter((id) => !existingIds.has(id));
if (newIds.length > 0) {
setCollapsedPackages((prev) => {
const next = { ...prev };
for (const id of newIds) { next[id] = true; }
return next;
});
}
};
const onAddLinks = async (): Promise<void> => {
await performQuickAction(async () => {
const activeId = activeCollectorTabRef.current;
const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0];
const rawText = active?.text ?? "";
const persisted = await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName });
if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t));
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
showToast("Keine gültigen Links gefunden");
}
}, (error) => {
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
});
};
const onImportDlc = async (): Promise<void> => {
await performQuickAction(async () => {
const files = await window.rd.pickContainers();
if (files.length === 0) { return; }
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const result = await window.rd.addContainers(files);
if (result.addedLinks > 0) {
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
}
}, (error) => {
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
});
};
onImportDlcRef.current = onImportDlc;
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
event.preventDefault();
dragDepthRef.current = 0;
dragOverRef.current = false;
setDragOver(false);
const hasFiles = event.dataTransfer.types.includes("Files");
const hasUri = event.dataTransfer.types.includes("text/uri-list");
if (!hasFiles && !hasUri) { return; }
const files = Array.from(event.dataTransfer.files ?? []) as File[];
const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v);
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) {
await performQuickAction(async () => {
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const result = await window.rd.addContainers(dlc);
if (result.addedLinks > 0) {
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
}
}, (error) => {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} else if (droppedText.trim()) {
const activeCollectorId = activeCollectorTabRef.current;
setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
setTab("collector");
showToast("Links per Drag-and-Drop eingefügt");
}
};
const onExportQueue = async (): Promise<void> => {
await performQuickAction(async () => {
const result = await window.rd.exportQueue();
if (result.saved) {
showToast("Queue exportiert");
}
}, (error) => {
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
});
};
const onImportQueue = async (): Promise<void> => {
if (actionBusyRef.current) {
return;
}
actionBusyRef.current = true;
setActionBusy(true);
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
const releasePickerBusy = (): void => {
actionBusyRef.current = false;
setActionBusy(false);
};
const onWindowFocus = (): void => {
clearImportQueueFocusListener();
if (!input.files || input.files.length === 0) {
releasePickerBusy();
}
};
input.onchange = async () => {
clearImportQueueFocusListener();
const file = input.files?.[0];
if (!file) {
releasePickerBusy();
return;
}
releasePickerBusy();
await performQuickAction(async () => {
const text = await file.text();
const result = await window.rd.importQueue(text);
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
}, (error) => {
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
});
};
clearImportQueueFocusListener();
importQueueFocusHandlerRef.current = onWindowFocus;
window.addEventListener("focus", onWindowFocus, { once: true });
input.click();
};
const setBool = (key: keyof AppSettings, value: boolean): void => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setText = (key: keyof AppSettings, value: string): void => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setNum = (key: keyof AppSettings, value: number): void => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setSpeedLimitMbps = (value: number): void => {
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
};
const performQuickAction = async (
action: () => Promise<unknown>,
onError?: (error: unknown) => void
): Promise<void> => {
if (actionBusyRef.current) {
return;
}
actionBusyRef.current = true;
setActionBusy(true);
try {
await action();
} catch (error) {
if (onError) {
onError(error);
} else {
showToast(`Fehler: ${String(error)}`, 2600);
}
} finally {
if (actionUnlockTimerRef.current) {
clearTimeout(actionUnlockTimerRef.current);
}
actionUnlockTimerRef.current = setTimeout(() => {
if (!mountedRef.current) {
actionUnlockTimerRef.current = null;
return;
}
actionBusyRef.current = false;
setActionBusy(false);
actionUnlockTimerRef.current = null;
}, 80);
}
};
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
const currentOrder = packageOrderRef.current;
const order = [...currentOrder];
const idx = order.indexOf(packageId);
if (idx < 0) { return; }
const target = direction === "up" ? idx - 1 : idx + 1;
if (target < 0 || target >= order.length) { return; }
[order[idx], order[target]] = [order[target], order[idx]];
setDownloadsSortDescending(false);
pendingPackageOrderRef.current = [...order];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = [...order];
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
});
void window.rd.reorderPackages(order).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
});
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
const currentOrder = packageOrderRef.current;
const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId);
const unchanged = nextOrder.length === currentOrder.length
&& nextOrder.every((id, index) => id === currentOrder[index]);
if (unchanged) {
return;
}
setDownloadsSortDescending(false);
pendingPackageOrderRef.current = [...nextOrder];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = [...nextOrder];
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } };
});
void window.rd.reorderPackages(nextOrder).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
});
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const addCollectorTab = (): void => {
const id = `tab-${nextCollectorId++}`;
setCollectorTabs((prev) => {
const name = `Tab ${prev.length + 1}`;
return [...prev, { id, name, text: "" }];
});
setActiveCollectorTab(id);
};
const removeCollectorTab = (id: string): void => {
let fallbackId = "";
setCollectorTabs((prev) => {
if (prev.length <= 1) return prev;
const index = prev.findIndex((tabEntry) => tabEntry.id === id);
if (index < 0) return prev;
const next = prev.filter((tabEntry) => tabEntry.id !== id);
if (activeCollectorTabRef.current === id) {
fallbackId = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? "";
}
return next;
});
if (fallbackId) setActiveCollectorTab(fallbackId);
};
const onPackageDragStart = useCallback((packageId: string) => {
draggedPackageIdRef.current = packageId;
}, []);
const onPackageDrop = useCallback((targetPackageId: string) => {
const draggedPackageId = draggedPackageIdRef.current;
draggedPackageIdRef.current = null;
if (!draggedPackageId || draggedPackageId === targetPackageId) {
return;
}
reorderPackagesByDrop(draggedPackageId, targetPackageId);
}, [reorderPackagesByDrop]);
const onPackageDragEnd = useCallback(() => {
draggedPackageIdRef.current = null;
}, []);
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
setEditingPackageId(packageId);
setEditingName(packageName);
}, []);
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
let shouldRename = false;
setEditingPackageId((prev) => {
if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
shouldRename = true;
return null;
});
if (shouldRename) {
const normalized = nextName.trim();
if (normalized && normalized !== currentName.trim()) {
void window.rd.renamePackage(packageId, normalized).catch((error) => {
showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400);
});
}
}
}, [showToast]);
const onPackageToggleCollapse = useCallback((packageId: string): void => {
setCollapsedPackages((prev) => ({ ...prev, [packageId]: !(prev[packageId] ?? false) }));
}, []);
const onPackageCancel = useCallback((packageId: string): void => {
setSnapshot((prev) => {
if (!prev) { return prev; }
const nextPackages = { ...prev.session.packages };
const nextItems = { ...prev.session.items };
const pkg = nextPackages[packageId];
if (pkg) {
for (const itemId of pkg.itemIds) {
delete nextItems[itemId];
}
delete nextPackages[packageId];
}
return {
...prev,
session: {
...prev.session,
packages: nextPackages,
items: nextItems,
packageOrder: prev.session.packageOrder.filter((id) => id !== packageId)
}
};
});
void window.rd.cancelPackage(packageId).catch((error) => {
showToast(`Paket-Löschung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const onPackageMoveUp = useCallback((packageId: string): void => {
movePackage(packageId, "up");
}, [movePackage]);
const onPackageMoveDown = useCallback((packageId: string): void => {
movePackage(packageId, "down");
}, [movePackage]);
const moveSelectedPackages = useCallback((direction: "up" | "down") => {
const currentOrder = packageOrderRef.current;
const selPkgs = new Set([...selectedIds].filter((id) => snapshot.session.packages[id]));
if (selPkgs.size === 0) return;
const order = [...currentOrder];
if (direction === "up") {
for (let i = 0; i < order.length; i++) {
if (selPkgs.has(order[i]) && i > 0 && !selPkgs.has(order[i - 1])) {
[order[i - 1], order[i]] = [order[i], order[i - 1]];
}
}
} else {
for (let i = order.length - 1; i >= 0; i--) {
if (selPkgs.has(order[i]) && i < order.length - 1 && !selPkgs.has(order[i + 1])) {
[order[i], order[i + 1]] = [order[i + 1], order[i]];
}
}
}
const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]);
if (unchanged) return;
setDownloadsSortDescending(false);
pendingPackageOrderRef.current = [...order];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = [...order];
// Optimistic UI update ? apply the new order immediately so the user
// sees the change without waiting for the backend round-trip.
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
});
void window.rd.reorderPackages(order).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
// Rollback: restore original order from server
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
});
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [selectedIds, snapshot.session.packages, showToast]);
const onPackageToggle = useCallback((packageId: string): void => {
void window.rd.togglePackage(packageId).catch((error) => {
showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const onPackageRemoveItem = useCallback((itemId: string): void => {
setSnapshot((prev) => {
if (!prev) { return prev; }
const item = prev.session.items[itemId];
if (!item) { return prev; }
const nextItems = { ...prev.session.items };
delete nextItems[itemId];
const nextPackages = { ...prev.session.packages };
const pkg = nextPackages[item.packageId];
if (pkg) {
const nextItemIds = pkg.itemIds.filter((id) => id !== itemId);
if (nextItemIds.length === 0) {
delete nextPackages[item.packageId];
return {
...prev,
session: {
...prev.session,
packages: nextPackages,
items: nextItems,
packageOrder: prev.session.packageOrder.filter((id) => id !== item.packageId)
}
};
}
nextPackages[item.packageId] = { ...pkg, itemIds: nextItemIds };
}
return { ...prev, session: { ...prev.session, packages: nextPackages, items: nextItems } };
});
void window.rd.removeItem(itemId).catch((error) => {
showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const onPackageContextMenu = useCallback((packageId: string, itemId: string | undefined, x: number, y: number): void => {
const clickedId = itemId ?? packageId;
setSelectedIds((prev) => {
if (prev.has(clickedId)) return prev;
return new Set([clickedId]);
});
setContextMenu({ x, y, packageId, itemId });
}, []);
const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]);
const dragSelectRef = useRef(false);
const dragAnchorRef = useRef<string | null>(null);
const dragDidMoveRef = useRef(false);
const onSelectId = useCallback((id: string, ctrlKey: boolean): void => {
if (dragDidMoveRef.current) return; // drag handled it, skip click
setSelectedIds((prev) => {
if (ctrlKey) {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
}
if (prev.size === 1 && prev.has(id)) return new Set();
return new Set([id]);
});
}, []);
const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => {
if (!e.ctrlKey || e.button !== 0) return;
e.preventDefault();
dragSelectRef.current = true;
dragAnchorRef.current = id;
dragDidMoveRef.current = false;
const onUp = (): void => {
dragSelectRef.current = false;
dragAnchorRef.current = null;
dragDidMoveRef.current = false;
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mouseup", onUp);
}, []);
const onSelectMouseEnter = useCallback((id: string): void => {
if (!dragSelectRef.current) return;
if (!dragDidMoveRef.current) {
dragDidMoveRef.current = true;
// Add anchor item now that we know it's a drag
const anchor = dragAnchorRef.current;
if (anchor) {
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
}
}
setSelectedIds((prev) => { if (prev.has(id)) return prev; const next = new Set(prev); next.add(id); return next; });
}, []);
const showLinksPopup = useCallback((packageId: string, itemId?: string): void => {
const sel = selectedIds;
const currentPackages = snapshotRef.current.session.packages;
const currentItems = snapshotRef.current.session.items;
// Multi-select: collect links from all selected packages/items
if (sel.size > 1) {
const allLinks: { name: string; url: string }[] = [];
for (const id of sel) {
const pkg = currentPackages[id];
if (pkg) {
for (const iid of pkg.itemIds) {
const item = currentItems[iid];
if (item) allLinks.push({ name: item.fileName, url: item.url });
}
} else {
const item = currentItems[id];
if (item) allLinks.push({ name: item.fileName, url: item.url });
}
}
setLinkPopup({ title: `${sel.size} ausgewählt`, links: allLinks, isPackage: allLinks.length > 1 });
setContextMenu(null);
return;
}
const pkg = currentPackages[packageId];
if (!pkg) { return; }
if (itemId) {
const item = currentItems[itemId];
if (item) {
setLinkPopup({ title: item.fileName, links: [{ name: item.fileName, url: item.url }], isPackage: false });
}
} else {
const links = pkg.itemIds
.map((id) => currentItems[id])
.filter(Boolean)
.map((item) => ({ name: item.fileName, url: item.url }));
setLinkPopup({ title: pkg.name, links, isPackage: true });
}
setContextMenu(null);
}, [selectedIds]);
const schedules = settingsDraft.bandwidthSchedules ?? [];
useEffect(() => {
setScheduleSpeedInputs((prev) => {
const syncFromSettings = !settingsDirtyRef.current;
let changed = false;
const next: Record<string, string> = {};
for (let index = 0; index < schedules.length; index += 1) {
const schedule = schedules[index];
const key = schedule.id || `schedule-${index}`;
const normalized = formatMbpsInputFromKbps(schedule.speedLimitKbps);
if (syncFromSettings || !Object.prototype.hasOwnProperty.call(prev, key)) {
next[key] = normalized;
if (prev[key] !== normalized) {
changed = true;
}
} else {
next[key] = prev[key];
}
}
const prevKeys = Object.keys(prev);
if (prevKeys.length !== Object.keys(next).length) {
changed = true;
}
return changed ? next : prev;
});
}, [schedules, settingsDirty]);
const addSchedule = (): void => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
...prev,
bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
}));
};
const removeSchedule = (idx: number): void => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
...prev,
bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx)
}));
};
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
...prev,
bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s)
}));
};
const applyTheme = (theme: AppTheme): void => {
document.documentElement.setAttribute("data-theme", theme);
};
const closeMenus = (): void => {
setOpenMenu(null);
setOpenSubmenu(null);
};
useEffect(() => {
if (!contextMenu) { return; }
const close = (): void => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [contextMenu]);
useLayoutEffect(() => {
if (!contextMenu || !ctxMenuRef.current) return;
const el = ctxMenuRef.current;
const rect = el.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
el.style.top = `${Math.max(0, contextMenu.y - rect.height)}px`;
}
if (rect.right > window.innerWidth) {
el.style.left = `${Math.max(0, contextMenu.x - rect.width)}px`;
}
}, [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);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [historyCtxMenu]);
useLayoutEffect(() => {
if (!historyCtxMenu || !historyCtxMenuRef.current) return;
const el = historyCtxMenuRef.current;
const rect = el.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
el.style.top = `${Math.max(0, historyCtxMenu.y - rect.height)}px`;
}
if (rect.right > window.innerWidth) {
el.style.left = `${Math.max(0, historyCtxMenu.x - rect.width)}px`;
}
}, [historyCtxMenu]);
const executeDeleteSelection = useCallback((ids: Set<string>): void => {
const current = snapshotRef.current;
const promises: Promise<void>[] = [];
for (const id of ids) {
if (current.session.items[id]) promises.push(window.rd.removeItem(id));
else if (current.session.packages[id]) promises.push(window.rd.cancelPackage(id));
}
void Promise.all(promises).catch(() => {});
setSelectedIds(new Set());
}, []);
const requestDeleteSelection = useCallback((): void => {
if (selectedIds.size === 0) return;
if (!settingsDraft.confirmDeleteSelection) {
executeDeleteSelection(selectedIds);
return;
}
setDeleteConfirm({ ids: new Set(selectedIds), dontAsk: false });
}, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]);
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
const target = e.target as HTMLElement;
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
// Don't clear selection if an overlay is open ? let the overlay close first
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
if (tabRef.current === "downloads") setSelectedIds(new Set());
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
}
}
if (e.key === "Delete" && tabRef.current === "downloads" && selectedIds.size > 0) {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
e.preventDefault();
requestDeleteSelection();
}
};
const onDown = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
if (target.closest(".package-card") || target.closest(".ctx-menu") || target.closest(".modal-backdrop") || target.closest(".modal-card")) return;
setSelectedIds(new Set());
};
window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onDown);
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
}, [selectedIds, requestDeleteSelection]);
const onExportBackup = async (): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.exportBackup();
if (result.saved) {
showToast("Sicherung exportiert");
}
}, (error) => {
showToast(`Sicherung fehlgeschlagen: ${String(error)}`, 2600);
});
};
const onImportBackup = async (): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.importBackup();
if (result.restored) {
showToast(result.message, 4000);
} else if (result.message !== "Abgebrochen") {
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
}
}, (error) => {
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
});
};
const onMenuRestart = (): void => {
closeMenus();
void window.rd.restart();
};
const onMenuQuit = (): void => {
closeMenus();
void window.rd.quit();
};
useEffect(() => {
const handler = (e: globalThis.KeyboardEvent): void => {
if (e.ctrlKey && !e.altKey && !e.metaKey) {
const target = e.target as HTMLElement;
const inInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
if (e.shiftKey && e.key.toLowerCase() === "r") {
if (inInput) return;
e.preventDefault();
void window.rd.restart();
return;
}
if (!e.shiftKey && e.key.toLowerCase() === "q") {
if (inInput) return;
e.preventDefault();
void window.rd.quit();
return;
}
if (!e.shiftKey && e.key.toLowerCase() === "l") {
if (inInput) return;
e.preventDefault();
setTab("collector");
setOpenMenu(null);
return;
}
if (!e.shiftKey && e.key.toLowerCase() === "p") {
if (inInput) return;
e.preventDefault();
setTab("settings");
setOpenMenu(null);
return;
}
if (!e.shiftKey && e.key.toLowerCase() === "o") {
if (inInput) return;
e.preventDefault();
setOpenMenu(null);
void onImportDlcRef.current();
return;
}
if (!e.shiftKey && e.key.toLowerCase() === "a") {
if (inInput) return;
if (tabRef.current === "downloads") {
e.preventDefault();
setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));
} else if (tabRef.current === "history") {
e.preventDefault();
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
}
return;
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
useEffect(() => {
if (!openMenu) { return; }
const handler = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
if (!target.closest(".menu-bar")) {
setOpenMenu(null);
setOpenSubmenu(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [openMenu]);
const packageSpeedMap = useMemo(() => {
const map = new Map<string, number>();
for (const [pid, bps] of Object.entries(snapshot.packageSpeedBps)) {
if (bps > 0) map.set(pid, bps);
}
return map;
}, [snapshot.packageSpeedBps]);
const itemStatusCounts = useMemo(() => {
const counts = { downloading: 0, queued: 0, failed: 0 };
for (const item of Object.values(snapshot.session.items)) {
if (item.status === "downloading") {
counts.downloading += 1;
} else if (item.status === "queued" || item.status === "reconnect_wait") {
counts.queued += 1;
} else if (item.status === "failed") {
counts.failed += 1;
}
}
return counts;
}, [snapshot.session.items]);
const providerStats = useMemo(() => {
const stats: Record<string, { total: number; completed: number; failed: number; bytes: number }> = {};
for (const item of Object.values(snapshot.session.items)) {
const hoster = extractHoster(item.url) || "unknown";
if (!stats[hoster]) {
stats[hoster] = { total: 0, completed: 0, failed: 0, bytes: 0 };
}
stats[hoster].total += 1;
if (item.status === "completed") stats[hoster].completed += 1;
if (item.status === "failed") stats[hoster].failed += 1;
stats[hoster].bytes += item.downloadedBytes;
}
return Object.entries(stats);
}, [snapshot.session.items]);
return (
<div
className={`app-shell${dragOver ? " drag-over" : ""}`}
onDragEnter={(event) => {
event.preventDefault();
if (draggedPackageIdRef.current) { return; }
const hasFiles = event.dataTransfer.types.includes("Files");
const hasUri = event.dataTransfer.types.includes("text/uri-list");
if (!hasFiles && !hasUri) { return; }
dragDepthRef.current += 1;
if (!dragOverRef.current) {
dragOverRef.current = true;
setDragOver(true);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDragLeave={() => {
if (draggedPackageIdRef.current) { return; }
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0 && dragOverRef.current) {
dragOverRef.current = false;
setDragOver(false);
}
}}
onDrop={onDrop}
>
<nav className="menu-bar">
<div className="menu-bar-item">
<button
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")}
onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }}
>
Datei
</button>
{openMenu === "datei" && (
<div className="menu-dropdown">
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("collector"); }}>
<span>Text mit Links analysieren</span>
<span className="shortcut">Strg+L</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportDlc(); }}>
<span>Linkcontainer laden</span>
<span className="shortcut">Strg+O</span>
</button>
<div className="menu-separator" />
<div
className="menu-submenu"
onMouseEnter={() => setOpenSubmenu("sicherung")}
onMouseLeave={() => setOpenSubmenu(null)}
>
<button className="menu-submenu-trigger">Sicherung</button>
{openSubmenu === "sicherung" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Erstellen</button>
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Laden</button>
</div>
)}
</div>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={onMenuRestart}>
<span>Neustart</span>
<span className="shortcut">Strg+Umschalt+R</span>
</button>
<button className="menu-dropdown-item" onClick={onMenuQuit}>
<span>Beenden</span>
<span className="shortcut">Strg+Q</span>
</button>
</div>
)}
</div>
<div className="menu-bar-item">
<button
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")}
onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }}
>
Einstellungen
</button>
{openMenu === "einstellungen" && (
<div className="menu-dropdown">
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("settings"); }}>
<span>Einstellungen</span>
<span className="shortcut">Strg+P</span>
</button>
<div className="menu-separator" />
<div className="menu-settings-grid" onClick={(e) => e.stopPropagation()}>
<span>Max. gleichzeitige Downloads</span>
<span />
<div className="menu-spinner">
<input
type="text"
inputMode="numeric"
value={settingsDraft.maxParallel}
onChange={(e) => {
const val = Math.max(1, Math.min(50, Number(e.target.value) || 1));
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, maxParallel: val }));
void window.rd.updateSettings({ maxParallel: val }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
}}
/>
<div className="menu-spinner-arrows">
<button onClick={() => {
const val = Math.min(50, settingsDraft.maxParallel + 1);
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, maxParallel: val }));
void window.rd.updateSettings({ maxParallel: val }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
}}>&#9650;</button>
<button onClick={() => {
const val = Math.max(1, settingsDraft.maxParallel - 1);
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, maxParallel: val }));
void window.rd.updateSettings({ maxParallel: val }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
}}>&#9660;</button>
</div>
</div>
<span />
<span>Geschwindigkeitslimit</span>
<input
type="checkbox"
checked={settingsDraft.speedLimitEnabled}
onChange={(e) => {
const next = e.target.checked;
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, speedLimitEnabled: next }));
void window.rd.updateSettings({ speedLimitEnabled: next }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
}}
/>
<div className={`menu-spinner${!settingsDraft.speedLimitEnabled ? " disabled" : ""}`}>
<input
type="text"
inputMode="decimal"
value={speedLimitInput}
onChange={(e) => {
setSpeedLimitInput(e.target.value);
}}
onBlur={() => {
const parsed = parseMbpsInput(speedLimitInput);
if (parsed === null) {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
return;
}
const kbps = Math.floor(parsed * 1024);
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: kbps }));
void window.rd.updateSettings({ speedLimitKbps: kbps }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
setSpeedLimitInput(formatMbpsInputFromKbps(kbps));
}}
/>
<div className="menu-spinner-arrows">
<button onClick={() => {
const cur = (settingsDraft.speedLimitKbps || 0) / 1024;
const next = Math.floor((cur + 1) * 1024);
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: next }));
void window.rd.updateSettings({ speedLimitKbps: next }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
setSpeedLimitInput(formatMbpsInputFromKbps(next));
}}>&#9650;</button>
<button onClick={() => {
const cur = (settingsDraft.speedLimitKbps || 0) / 1024;
const next = Math.max(0, Math.floor((cur - 1) * 1024));
settingsDirtyRef.current = true;
const rev = ++settingsDraftRevisionRef.current;
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: next }));
void window.rd.updateSettings({ speedLimitKbps: next }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; });
setSpeedLimitInput(formatMbpsInputFromKbps(next));
}}>&#9660;</button>
</div>
</div>
<span className="menu-speed-unit">MB/s</span>
</div>
</div>
)}
</div>
<div className="menu-bar-item">
<button
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")}
onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }}
>
Hilfe
</button>
{openMenu === "hilfe" && (
<div className="menu-dropdown">
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}>
<span>Log öffnen</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}>
<span>Session-Log öffnen</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
<span>Suche Aktualisierungen</span>
</button>
</div>
)}
</div>
</nav>
<section className="control-strip">
<div className="buttons buttons-left">
<button
className="ctrl-icon-btn ctrl-play"
title={snapshot.session.paused ? "Fortsetzen" : "Start"}
disabled={actionBusy || (!snapshot.canStart && !snapshot.session.paused)}
onClick={() => {
if (snapshot.session.paused) {
setSnapshot((prev) => ({ ...prev, session: { ...prev.session, paused: false } }));
void window.rd.togglePause().catch(() => {});
} else {
void onStartDownloads();
}
}}
>
<svg viewBox="0 0 24 24" width="18" height="18"><polygon points="6,3 20,12 6,21" fill="currentColor" /></svg>
</button>
<button
className={`ctrl-icon-btn ctrl-pause${snapshot.session.paused ? " paused" : ""}`}
title="Pause"
disabled={!snapshot.canPause || snapshot.session.paused}
onClick={() => {
setSnapshot((prev) => ({ ...prev, session: { ...prev.session, paused: true } }));
void window.rd.togglePause().catch(() => {});
}}
>
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="5" y="3" width="4.5" height="18" rx="1" fill="currentColor" /><rect x="14.5" y="3" width="4.5" height="18" rx="1" fill="currentColor" /></svg>
</button>
<button
className="ctrl-icon-btn ctrl-stop"
title="Stop"
disabled={actionBusy || !snapshot.canStop}
onClick={() => { void performQuickAction(() => window.rd.stop()); }}
>
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor" /></svg>
</button>
<div className="ctrl-separator" />
<div className="schedule-ctrl">
{(snapshot.settings.scheduledStartEpochMs || 0) > 0 ? (
<div className="schedule-active">
<span className="schedule-badge" title="Geplanter Start">⏰ {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
<button className="schedule-cancel" title="Geplanten Start abbrechen" onClick={() => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }}>{"\u2715"}</button>
</div>
) : (
<button
className={`ctrl-icon-btn schedule-btn${schedulePickerOpen ? " active" : ""}`}
title="Download-Start planen"
onClick={() => { setSchedulePickerOpen((v) => !v); setScheduleTimeInput(""); }}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="9"/><polyline points="12,6 12,12 16,14"/></svg>
</button>
)}
{schedulePickerOpen && (snapshot.settings.scheduledStartEpochMs || 0) === 0 && (
<div className="schedule-picker">
<span className="schedule-picker-label">Starten um</span>
<input
type="time"
className="schedule-time-input"
value={scheduleTimeInput}
onChange={(e) => setScheduleTimeInput(e.target.value)}
/>
<button className="btn btn-sm btn-primary" onClick={() => {
if (!scheduleTimeInput) return;
const [hStr, mStr] = scheduleTimeInput.split(":");
const now = new Date();
const target = new Date(now);
target.setHours(Number(hStr), Number(mStr), 0, 0);
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
setSchedulePickerOpen(false);
}}>Aktivieren</button>
</div>
)}
</div>
<div className="ctrl-separator" />
<button
className="ctrl-icon-btn ctrl-move"
title="Ausgewählte nach oben"
disabled={tab !== "downloads" || [...selectedIds].filter((id) => snapshot.session.packages[id]).length === 0}
onClick={() => moveSelectedPackages("up")}
>
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 4l-7 7h4.5v9h5v-9H19z" fill="currentColor" /></svg>
</button>
<button
className="ctrl-icon-btn ctrl-move"
title="Ausgewählte nach unten"
disabled={tab !== "downloads" || [...selectedIds].filter((id) => snapshot.session.packages[id]).length === 0}
onClick={() => moveSelectedPackages("down")}
>
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 20l7-7h-4.5V4h-5v9H5z" fill="currentColor" /></svg>
</button>
</div>
{snapshot.reconnectSeconds > 0 && (
<div className="reconnect-badge" style={{ marginLeft: "auto" }}>Reconnect: {snapshot.reconnectSeconds}s</div>
)}
</section>
<nav className="tabs">
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
<button className={tab === "history" ? "tab active" : "tab"} onClick={() => setTab("history")}>Verlauf</button>
<button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button>
<div className="tab-actions">
{tab === "downloads" && (
<input
className="search-input tab-search"
type="search"
value={downloadSearch}
onChange={(event) => setDownloadSearch(event.target.value)}
placeholder="Pakete durchsuchen..."
/>
)}
</div>
</nav>
<main className="tab-content">
{tab === "collector" && (
<section className="grid-two">
<article className="card wide">
<div className="collector-header">
<h3>Linksammler</h3>
<div className="link-actions">
<button className="btn" disabled={actionBusy} onClick={onImportDlc}>DLC import</button>
<button className="btn" disabled={actionBusy} onClick={onExportQueue}>Queue Export</button>
<button className="btn" disabled={actionBusy} onClick={onImportQueue}>Queue Import</button>
<button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button>
</div>
</div>
<div className="collector-tabs">
{collectorTabs.map((ct) => (
<div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}>
<button onClick={() => setActiveCollectorTab(ct.id)}>{ct.name}</button>
{collectorTabs.length > 1 && <button className="close-tab" onClick={() => removeCollectorTab(ct.id)}>x</button>}
</div>
))}
<button className="btn add-tab" onClick={addCollectorTab}>+</button>
</div>
<textarea
value={currentCollectorTab.text}
onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))}
onDragOver={(e) => e.preventDefault()}
placeholder={"# package: Release-Name\nhttps://...\nhttps://...\n\nLinks oder .dlc Dateien hier ablegen"}
/>
</article>
</section>
)}
{tab === "downloads" && (
<section className="downloads-view">
{snapshot.reconnectSeconds > 0 && (
<div className="reconnect-banner">
Reconnect aktiv: {snapshot.reconnectSeconds}s verbleibend
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
</div>
)}
{/* Action buttons moved to footer */}
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { 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 (
<span
key={col}
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 }).catch(() => {});
}}
onDragEnd={() => { setDragColId(null); setDropTargetCol(null); }}
onClick={sortCol ? () => {
const nextDesc = isActive ? !downloadsSortDescending : false;
setDownloadsSortColumn(sortCol);
setDownloadsSortDescending(nextDesc);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
let sorted: string[];
if (sortCol === "progress") {
sorted = sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc);
} else if (sortCol === "size") {
sorted = sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc);
} else if (sortCol === "hoster") {
sorted = sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc);
} else {
sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDesc);
}
pendingPackageOrderRef.current = [...sorted];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = sorted;
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: [...sorted] } };
});
void window.rd.reorderPackages(sorted).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
});
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
} : undefined}
>
{def.label} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""}
</span>
);
})}
</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>}
{hiddenPackageCount > 0 && (
<div className="reconnect-banner">
Performance-Modus aktiv: {hiddenPackageCount} Paket(e) sind temporar ausgeblendet.
<button className="btn" onClick={() => setShowAllPackages(true)}>Alle trotzdem anzeigen</button>
</div>
)}
{visiblePackages.map((pkg, idx) => (
<PackageCard
key={pkg.id}
pkg={pkg}
items={itemsByPackage.get(pkg.id) ?? []}
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
isFirst={idx === 0}
isLast={idx === visiblePackages.length - 1}
isEditing={editingPackageId === pkg.id}
editingName={editingName}
collapsed={collapsedPackages[pkg.id] ?? false}
hideExtractedItems={snapshot.settings.hideExtractedItems}
selectedIds={selectedIds}
columnOrder={columnOrder}
gridTemplate={gridTemplate}
onSelect={onSelectId}
onSelectMouseDown={onSelectMouseDown}
onSelectMouseEnter={onSelectMouseEnter}
onStartEdit={onPackageStartEdit}
onFinishEdit={onPackageFinishEdit}
onEditChange={setEditingName}
onToggleCollapse={onPackageToggleCollapse}
onCancel={onPackageCancel}
onMoveUp={onPackageMoveUp}
onMoveDown={onPackageMoveDown}
onToggle={onPackageToggle}
onRemoveItem={onPackageRemoveItem}
onContextMenu={onPackageContextMenu}
onDragStart={onPackageDragStart}
onDrop={onPackageDrop}
onDragEnd={onPackageDragEnd}
/>
))}
</section>
)}
{tab === "history" && (
<section className="history-view">
<div className="history-toolbar">
<span className="history-count">
{selectedHistoryIds.size > 0
? `${selectedHistoryIds.size} von ${historyEntries.length} ausgewählt`
: `${historyEntries.length} Paket${historyEntries.length !== 1 ? "e" : ""} im Verlauf`}
</span>
{selectedHistoryIds.size > 0 && (
<button className="btn danger" onClick={() => {
const idSet = new Set(selectedHistoryIds);
void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => {
setHistoryEntries((prev) => prev.filter((e) => !idSet.has(e.id)));
setSelectedHistoryIds(new Set());
}).catch(() => {
void window.rd.getHistory().then((entries) => { setHistoryEntries(entries); setSelectedHistoryIds(new Set()); }).catch(() => {});
});
}}>Ausgewählte entfernen ({selectedHistoryIds.size})</button>
)}
{historyEntries.length > 0 && (
<button className="btn danger" onClick={() => { void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {}); }}>Verlauf leeren</button>
)}
</div>
{historyEntries.length === 0 && <div className="empty">Noch keine abgeschlossenen Pakete im Verlauf.</div>}
{historyEntries.map((entry) => {
const collapsed = historyCollapsed[entry.id] ?? true;
const isSelected = selectedHistoryIds.has(entry.id);
return (
<article
key={entry.id}
className={`package-card history-card${isSelected ? " pkg-selected" : ""}`}
onClick={(e) => {
if (e.ctrlKey) {
e.preventDefault();
setSelectedHistoryIds((prev) => {
const next = new Set(prev);
if (next.has(entry.id)) next.delete(entry.id); else next.add(entry.id);
return next;
});
}
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedHistoryIds((prev) => prev.has(entry.id) ? prev : new Set([entry.id]));
setHistoryCtxMenu({ x: e.clientX, y: e.clientY, entryId: entry.id });
}}
>
<header onClick={(e) => { if (e.ctrlKey) return; setHistoryCollapsed((prev) => ({ ...prev, [entry.id]: !collapsed })); }} style={{ cursor: "pointer" }}>
<div className="pkg-columns" style={{ gridTemplateColumns: gridTemplate }}>
{columnOrder.map((col) => {
switch (col) {
case "name": return (
<div key={col} className="pkg-col pkg-col-name">
<button className="pkg-toggle" title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button>
<h4>{entry.name}</h4>
</div>
);
case "size": return (
<span key={col} className="pkg-col pkg-col-size">{(() => {
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 ? (
<span className="progress-size">
<span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="progress-size-text">{label}</span>
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
</span>
) : "";
})()}</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>
</header>
<div className="progress"><div className="progress-dl" style={{ width: entry.status === "completed" ? "100%" : "0%" }} /></div>
{!collapsed && (
<div className="history-details">
<div className="history-detail-grid">
<span className="history-label">Abgeschlossen am</span>
<span>{new Date(entry.completedAt).toLocaleString("de-DE")}</span>
<span className="history-label">Dateien</span>
<span>{entry.fileCount} Datei{entry.fileCount !== 1 ? "en" : ""}</span>
<span className="history-label">Gesamtgröße</span>
<span>{humanSize(entry.totalBytes)}</span>
<span className="history-label">Heruntergeladen</span>
<span>{humanSize(entry.downloadedBytes)}</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 className="history-label">Durchschnitt</span>
<span>{entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : ""}</span>
<span className="history-label">Provider</span>
<span>{entry.provider ? providerLabels[entry.provider] : ""}</span>
<span className="history-label">Zielordner</span>
<span className="history-path" title={entry.outputDir}>{entry.outputDir || ""}</span>
<span className="history-label">Status</span>
<span>{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span>
</div>
<div className="history-actions">
<button className="btn" onClick={() => { void window.rd.removeHistoryEntry(entry.id).then(() => { setHistoryEntries((prev) => prev.filter((e) => e.id !== entry.id)); setSelectedHistoryIds((prev) => { const n = new Set(prev); n.delete(entry.id); return n; }); }).catch(() => {}); }}>Eintrag entfernen</button>
</div>
</div>
)}
</article>
);
})}
</section>
)}
{tab === "statistics" && (
<section className="statistics-view">
<article className="card stats-overview">
<h3>Session-Übersicht</h3>
<div className="stats-grid">
<div className="stat-item">
<span className="stat-label">Aktuelle Geschwindigkeit</span>
<span className="stat-value">{snapshot.speedText.replace("Geschwindigkeit: ", "")}</span>
</div>
<div className="stat-item">
<span className="stat-label">Heruntergeladen (Session)</span>
<span className="stat-value">{humanSize(snapshot.stats.totalDownloaded)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Heruntergeladen (Gesamt)</span>
<span className="stat-value">{humanSize(snapshot.stats.totalDownloadedAllTime)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Fertige Dateien</span>
<span className="stat-value">{snapshot.stats.totalFiles}</span>
</div>
<div className="stat-item">
<span className="stat-label">Pakete</span>
<span className="stat-value">{snapshot.stats.totalPackages}</span>
</div>
<div className="stat-item">
<span className="stat-label">Aktive Downloads</span>
<span className="stat-value">{itemStatusCounts.downloading}</span>
</div>
<div className="stat-item">
<span className="stat-label">In Warteschlange</span>
<span className="stat-value">{itemStatusCounts.queued}</span>
</div>
<div
className={`stat-item${itemStatusCounts.failed > 0 ? " stat-item-clickable" : ""}`}
title={itemStatusCounts.failed > 0 ? "Klicken zum Zurücksetzen aller fehlerhaften Downloads" : undefined}
onClick={() => {
if (itemStatusCounts.failed === 0) return;
const failedIds = Object.values(snapshot.session.items)
.filter((it) => it.status === "failed")
.map((it) => it.id);
void window.rd.resetItems(failedIds).catch(() => {});
}}
>
<span className="stat-label">Fehlerhaft</span>
<span className="stat-value danger">{itemStatusCounts.failed}</span>
</div>
<div className="stat-item">
<span className="stat-label">{snapshot.etaText.includes(": ") ? snapshot.etaText.slice(0, snapshot.etaText.indexOf(": ")) : snapshot.etaText}</span>
<span className="stat-value">{snapshot.etaText.includes(": ") ? snapshot.etaText.slice(snapshot.etaText.indexOf(": ") + 2) : "--"}</span>
</div>
</div>
</article>
<article className="card stats-chart-card">
<h3>Bandbreitenverlauf</h3>
<BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} />
</article>
<article className="card stats-provider-card">
<h3>Hoster-Statistik</h3>
<div className="provider-stats">
{providerStats.map(([provider, stats]) => (
<div key={provider} className="provider-stat-item">
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : provider}</span>
<div className="provider-bars">
<div className="provider-bar">
<div className="bar-fill completed" style={{ width: `${stats.total > 0 ? (stats.completed / stats.total) * 100 : 0}%` }} />
</div>
</div>
<span className="provider-detail">
{stats.completed}/{stats.total} fertig | {humanSize(stats.bytes)}
{stats.failed > 0 && <span className="danger"> | {stats.failed} Fehler</span>}
</span>
</div>
))}
{Object.keys(snapshot.session.items).length === 0 && (
<div className="empty-provider">Noch keine Downloads vorhanden.</div>
)}
</div>
</article>
</section>
)}
{tab === "settings" && (
<section className="settings-shell">
<article className="card settings-toolbar">
<div className="settings-toolbar-copy">
<h3>Einstellungen</h3>
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
</div>
<div className="settings-toolbar-actions-wrap">
<div className="settings-toolbar-actions">
<button className="btn accent" disabled={actionBusy} onClick={onSaveSettings}>Einstellungen speichern</button>
</div>
</div>
</article>
<div className="settings-body">
<nav className="settings-sidebar">
{settingsSubTabs.map((st) => (
<button key={st.key} className={`settings-sidebar-tab${settingsSubTab === st.key ? " active" : ""}`} onClick={() => setSettingsSubTab(st.key)}>{st.label}</button>
))}
</nav>
<div className="settings-content" key={settingsSubTab}>
{settingsSubTab === "allgemein" && (
<div className="settings-section card">
<h3>Allgemein</h3>
<label>Download-Ordner</label>
<div className="input-row">
<input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} />
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wählen</button>
</div>
<label>Paketname (optional)</label>
<input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} />
<div className="field-grid two">
<div><label>Max. Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Math.max(1, Math.min(50, Number(e.target.value) || 1)))} /></div>
<div><label>Auto-Retry Limit (0 = inf)</label><input type="number" min={0} max={99} value={settingsDraft.retryLimit} onChange={(e) => setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} /></div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSortPackagesByProgress} onChange={(e) => setBool("autoSortPackagesByProgress", e.target.checked)} /> Automatisches Sortieren laufender Pakete nach Fortschritt</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
applyTheme(next as AppTheme);
}} /> Light Mode</label>
</div>
)}
{settingsSubTab === "accounts" && (
<div className="account-settings-layout">
<div className="settings-section card account-board">
<div className="account-board-header">
<div>
<h3>Accounts</h3>
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
</div>
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
Account hinzufügen
</button>
</div>
<div className="account-board-summary">
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
</div>
<label className="toggle-line account-display-toggle">
<input
type="checkbox"
checked={settingsDraft.accountListShowDetailedDebridLinkKeys}
onChange={(e) => setBool("accountListShowDetailedDebridLinkKeys", e.target.checked)}
/>
Debrid-Link-Keys im Feld "Zugang" einzeln untereinander anzeigen
</label>
<div className="account-display-actions">
<button className="btn btn-sm" disabled={actionBusy} onClick={resetAccountColumnWidths}>
Spalten zurücksetzen
</button>
</div>
{configuredAccounts.length === 0 && (
<div className="account-empty-state">
<strong>Noch keine Accounts hinterlegt</strong>
<span>Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span>
</div>
)}
{configuredAccounts.length > 0 && (
<div className="account-table" style={accountTableStyle}>
<div className="account-table-head">
<div className="account-header-cell">
<span>Account</span>
<button
className="account-resize-handle"
title="Spalte ziehen"
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("service", event.clientX);
}}
/>
</div>
<div className="account-header-cell">
<span>Typ</span>
<button
className="account-resize-handle"
title="Spalte ziehen"
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("mode", event.clientX);
}}
/>
</div>
<div className="account-header-cell">
<span>Status</span>
<button
className="account-resize-handle"
title="Spalte ziehen"
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("status", event.clientX);
}}
/>
</div>
<div className="account-header-cell">
<span>Info</span>
</div>
<div className="account-header-cell">
<span>Zugang</span>
<button
className="account-resize-handle"
title="Spalte ziehen"
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("secret", event.clientX);
}}
/>
</div>
<div className="account-header-cell">
<span>Aktionen</span>
</div>
</div>
{configuredAccounts.map((entry) => {
const option = findAccountOption(entry.kind);
const quickAction = getAccountQuickActionMeta(entry.kind);
const showStatusButton = entry.service === "alldebrid";
const showQuickActionButton = Boolean(quickAction && !(showStatusButton && quickAction.action === "alldebrid-status"));
const allDebridStateClass = entry.service === "alldebrid" && allDebridHostInfo ? ` account-status-${allDebridHostInfo.state}` : "";
return (
<div key={entry.service} className={`account-row${entry.disabled ? " account-row-disabled" : ""}`}>
<div className="account-cell account-service-cell">
<strong>{entry.serviceLabel}</strong>
<span>{option.title}</span>
</div>
<div className="account-cell">
<span className="account-mode-pill">{entry.modeLabel}</span>
</div>
<div className="account-cell account-status-cell">
<span className={`account-status-pill${entry.disabled ? " account-status-disabled" : ""}${allDebridStateClass}`}>{entry.statusLabel}</span>
{entry.note && <span className="account-note">{entry.note}</span>}
</div>
<div className="account-cell account-info-cell">
{entry.debridLinkKeys.length > 0 ? (
<button className="btn btn-sm" onClick={() => setKeyStatsPopup(entry.service)}>
Statistik
</button>
) : (
<div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}>
<span>Heute: {humanSize(entry.dailyUsedBytes)}</span>
<span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span>
{entry.dailyLimitBytes > 0 && (
<span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span>
)}
</div>
)}
</div>
<div className="account-cell">
{entry.summaryLines.length > 1 ? (
<div className="account-secret account-secret-multiline">
{entry.summaryLines.map((line) => (
<span key={line}>{line}</span>
))}
</div>
) : (
<span className="account-secret">{entry.summary}</span>
)}
</div>
<div className="account-cell account-row-actions">
{showStatusButton && (
<button className="btn" disabled={actionBusy || allDebridHostLoading || !hasSavedAllDebridAccount} onClick={() => { void performQuickAction(async () => { await runAccountQuickAction("alldebrid-status"); }, (error) => { showToast(`AllDebrid Status fehlgeschlagen: ${String(error)}`, 3200); }); }}>
Status
</button>
)}
{showQuickActionButton && quickAction && (
<button className="btn" disabled={actionBusy} onClick={() => { void onAccountRowQuickAction(entry); }}>
{quickAction.label}
</button>
)}
<button className="btn" disabled={actionBusy} onClick={() => { void onToggleAccountEnabled(entry); }}>
{entry.disabled ? "Aktivieren" : "Deaktivieren"}
</button>
<button className="btn" disabled={actionBusy || entry.dailyUsedBytes <= 0} onClick={() => { void onResetAccountDailyUsage(entry); }}>
Reset Heute
</button>
<button className="btn" disabled={actionBusy} onClick={() => openEditAccountDialog(entry.kind)}>
Bearbeiten
</button>
<button className="btn danger" disabled={actionBusy} onClick={() => { void onRemoveAccount(entry); }}>
Entfernen
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="settings-section card">
<h3>Hoster-Reihenfolge</h3>
<div className="hint">
Lege fest, in welcher Reihenfolge die Debrid-Accounts für Links genutzt werden.
Der erste Eintrag ist der Hauptaccount. Direkt-Hoster (1Fichier, DDownload) laufen separat und erscheinen nicht hier.
</div>
{activeProviderOrder.length === 0 && (
<div className="account-empty-state compact">
<strong>Keine Debrid-Reihenfolge verfügbar</strong>
<span>Füge mindestens einen Debrid-Account hinzu, dann kannst Du die Reihenfolge festlegen.</span>
</div>
)}
{activeProviderOrder.length > 0 && (
<div className="provider-order-list">
{activeProviderOrder.map((provider, idx) => (
<div key={provider} className="provider-order-row">
<span className="provider-order-num">{idx + 1}.</span>
<span className="provider-order-label">{providerLabelWithMode(provider, settingsDraft)}</span>
<div className="provider-order-actions">
<button
className="btn btn-sm"
disabled={idx === 0}
onClick={() => {
const next = [...activeProviderOrder];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
setProviderOrder(next);
}}
title="Nach oben"
>{"\u25B2"}</button>
<button
className="btn btn-sm"
disabled={idx === activeProviderOrder.length - 1}
onClick={() => {
const next = [...activeProviderOrder];
[next[idx + 1], next[idx]] = [next[idx], next[idx + 1]];
setProviderOrder(next);
}}
title="Nach unten"
>{"\u25BC"}</button>
</div>
</div>
))}
</div>
)}
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehlern oder Fair-Use automatisch zum nächsten Provider wechseln</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
</div>
{configuredProviders.length >= 1 && (
<div className="settings-section card">
<h3>Hoster-Zuordnung</h3>
<div className="hint">Lege fest, welcher Debrid-Provider sich um welchen Filehoster kümmert. Nicht zugeordnete Hoster nutzen die Standard-Reihenfolge oben.</div>
{(() => {
const routing: Record<string, DebridProvider> = settingsDraft.hosterRouting || {};
const routingEntries = Object.entries(routing).sort(([a], [b]) => a.localeCompare(b));
const usedHosters = new Set(routingEntries.map(([h]) => h));
const availableHosters = KNOWN_HOSTERS.filter((h) => !usedHosters.has(h.id));
const setRouting = (newRouting: Record<string, DebridProvider>) => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, hosterRouting: newRouting }));
};
const addEntry = (hosterId: string) => {
if (!hosterId || routing[hosterId]) return;
setRouting({ ...routing, [hosterId]: configuredProviders[0] });
};
const removeEntry = (hosterId: string) => {
const copy = { ...routing };
delete copy[hosterId];
setRouting(copy);
};
const changeProvider = (hosterId: string, provider: DebridProvider) => {
setRouting({ ...routing, [hosterId]: provider });
};
return (
<>
{routingEntries.length > 0 && (
<div className="hoster-routing-table">
<div className="hoster-routing-header">
<span>Filehoster</span>
<span>Zuständiger Provider</span>
<span></span>
</div>
{routingEntries.map(([hosterId, provider]) => {
const hosterLabel = KNOWN_HOSTERS.find((h) => h.id === hosterId)?.label || hosterId;
return (
<div key={hosterId} className="hoster-routing-row">
<span className="hoster-routing-label">{hosterLabel}</span>
<select value={provider} onChange={(e) => changeProvider(hosterId, e.target.value as DebridProvider)}>
{configuredProviders.map((p) => (
<option key={p} value={p}>{providerLabelWithMode(p, settingsDraft)}</option>
))}
</select>
<button className="btn btn-sm btn-danger" onClick={() => removeEntry(hosterId)} title="Zuordnung entfernen">&times;</button>
</div>
);
})}
</div>
)}
{routingEntries.length === 0 && (
<div className="hint" style={{ fontStyle: "italic", opacity: 0.7 }}>Noch keine Zuordnungen. Alle Hoster nutzen die Standard-Reihenfolge.</div>
)}
<div className="hoster-routing-add">
<select
value=""
onChange={(e) => {
const val = e.target.value;
if (val === "__custom") {
const name = window.prompt("Hoster-Domain eingeben (z.B. rapidgator, turbobit):");
const clean = (name || "").trim().toLowerCase().replace(/^www\./, "").split(".")[0];
if (clean) addEntry(clean);
} else {
addEntry(val);
}
e.target.value = "";
}}
>
<option value="" disabled>Hoster hinzufügen...</option>
{availableHosters.map((h) => (
<option key={h.id} value={h.id}>{h.label}</option>
))}
<option value="" disabled>âââââââââââ</option>
<option value="__custom">Eigener Hoster...</option>
</select>
</div>
</>
);
})()}
</div>
)}
<div hidden>
<div className="settings-section card">
<h3>Accounts</h3>
<label>Real-Debrid API Token</label>
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.realDebridUseWebLogin} onChange={(e) => setBool("realDebridUseWebLogin", e.target.checked)} /> Real-Debrid per Web-Login statt API-Token verwenden</label>
{settingsDraft.realDebridUseWebLogin && (
<>
<div className="hint">Beim ersten Link oder über den Button unten öffnet sich ein Real-Debrid-Browserfenster. Der Login läuft dort manuell über die Website.</div>
<button className="btn" disabled={actionBusy} onClick={() => { void onOpenRealDebridLogin(); }}>Real-Debrid Web-Login öffnen</button>
</>
)}
<label>Mega-Debrid Login</label>
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
<label>Mega-Debrid Passwort</label>
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.megaDebridPreferApi} onChange={(e) => setBool("megaDebridPreferApi", e.target.checked)} /> Mega-Debrid bevorzugt über API (schneller, Fallback auf Web)</label>
<label>BestDebrid API Token</label>
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.bestDebridUseWebLogin} onChange={(e) => setBool("bestDebridUseWebLogin", e.target.checked)} /> BestDebrid per Cookie-Import statt API-Token verwenden</label>
{settingsDraft.bestDebridUseWebLogin && (
<>
<div className="hint">Exportiere deine BestDebrid-Cookies als Netscape-Textdatei (z.B. mit der Browser-Extension &quot;Get cookies.txt LOCALLY&quot;) und importiere sie hier.</div>
<button className="btn" disabled={actionBusy} onClick={() => { void onImportBestDebridCookies(); }}>BestDebrid Cookies importieren</button>
</>
)}
<label>AllDebrid API Key</label>
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.allDebridUseWebLogin} onChange={(e) => setBool("allDebridUseWebLogin", e.target.checked)} /> AllDebrid per Web-Login statt API-Key verwenden</label>
{settingsDraft.allDebridUseWebLogin && (
<>
<div className="hint">Beim ersten Link oder über den Button unten öffnet sich ein echtes AllDebrid-Browserfenster. Der Login läuft dort manuell, damit reCAPTCHA sauber funktioniert.</div>
<button className="btn" disabled={actionBusy} onClick={() => { void onOpenAllDebridLogin(); }}>AllDebrid Web-Login öffnen</button>
</>
)}
<div style={{ marginTop: 12, padding: 14, borderRadius: 14, border: "1px solid rgba(83, 168, 255, 0.22)", background: "rgba(10, 20, 35, 0.32)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<strong>AllDebrid Rapidgator Status</strong>
<button className="btn" disabled={allDebridHostLoading || !hasSavedAllDebridAccount} onClick={() => { void loadAllDebridHostInfo(false); }}>
{allDebridHostLoading ? "Lade..." : "Status aktualisieren"}
</button>
</div>
{!hasSavedAllDebridAccount && (
<div className="hint" style={{ marginTop: 10 }}>Nach dem Speichern eines AllDebrid-Accounts wird hier der Rapidgator-Status angezeigt.</div>
)}
{hasSavedAllDebridAccount && !allDebridHostInfo && !allDebridHostLoading && (
<div className="hint" style={{ marginTop: 10 }}>Noch keine Host-Information geladen.</div>
)}
{hasSavedAllDebridAccount && allDebridHostLoading && !allDebridHostInfo && (
<div className="hint" style={{ marginTop: 10 }}>Rapidgator-Status wird geladen...</div>
)}
{allDebridHostInfo && (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginTop: 12 }}>
<div>
<div className="hint" style={{ margin: 0 }}>Status</div>
<div>{allDebridHostInfo.statusLabel}</div>
</div>
<div>
<div className="hint" style={{ margin: 0 }}>Quelle</div>
<div>{formatAllDebridSourceLabel(allDebridHostInfo.source)}</div>
</div>
<div>
<div className="hint" style={{ margin: 0 }}>Letztes Update</div>
<div>{formatAllDebridTimestamp(allDebridHostInfo)}</div>
</div>
<div>
<div className="hint" style={{ margin: 0 }}>Quota</div>
<div>{formatAllDebridQuota(allDebridHostInfo)}</div>
</div>
<div>
<div className="hint" style={{ margin: 0 }}>Simultan-Downloads</div>
<div>{formatAllDebridSimuLimit(allDebridHostInfo)}</div>
</div>
</div>
{allDebridHostInfo.note && (
<div className="hint" style={{ marginTop: 10 }}>{allDebridHostInfo.note}</div>
)}
</>
)}
{allDebridSettingsDirty && hasSavedAllDebridAccount && (
<div className="hint" style={{ marginTop: 10 }}>Status basiert auf den zuletzt gespeicherten AllDebrid-Einstellungen.</div>
)}
</div>
<label>DDownload Login</label>
<input value={settingsDraft.ddownloadLogin || ""} onChange={(e) => setText("ddownloadLogin", e.target.value)} />
<label>DDownload Passwort</label>
<input type="password" value={settingsDraft.ddownloadPassword || ""} onChange={(e) => setText("ddownloadPassword", e.target.value)} />
<label>1Fichier API Key</label>
<input type="password" value={settingsDraft.oneFichierApiKey || ""} onChange={(e) => setText("oneFichierApiKey", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
</div>
</div>
</div>
)}
{settingsSubTab === "entpacken" && (
<div className="settings-section card">
<h3>Entpacken</h3>
<label>Entpacken nach</label>
<div className="input-row">
<input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} />
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wählen</button>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</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.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</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>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>MKV-Sammelordner</label>
<div className="input-row">
<input value={settingsDraft.mkvLibraryDir} onChange={(e) => setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} />
<button className="btn" disabled={!settingsDraft.collectMkvToLibrary} onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("mkvLibraryDir", s); } }); }}>Wählen</button>
</div>
<label>Passwortliste (eine Zeile pro Passwort)</label>
<textarea className="password-list" value={settingsDraft.archivePasswordList} onChange={(e) => setText("archivePasswordList", e.target.value)} placeholder={"serienfans.org\nserienjunkies.org\nmein-passwort"} />
</div>
)}
{settingsSubTab === "geschwindigkeit" && (
<div className="settings-section card">
<h3>Geschwindigkeit</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label>
<div className="field-grid two">
<div>
<label>Limit (MB/s)</label>
<input type="number" min={0} step={0.1} value={speedLimitInput} onChange={(event) => setSpeedLimitInput(event.target.value)} onBlur={(event) => { const parsed = parseMbpsInput(event.target.value); if (parsed === null) { setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); return; } setSpeedLimitMbps(parsed); setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024))); }} disabled={!settingsDraft.speedLimitEnabled} />
</div>
<div>
<label>Limit-Modus</label>
<select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)} disabled={!settingsDraft.speedLimitEnabled}>
<option value="global">Global</option>
<option value="per_download">Pro Download</option>
</select>
</div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label>
<div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Math.max(10, Math.min(600, Number(e.target.value) || 45)))} /></div>
<h4>Bandbreitenplanung</h4>
{schedules.map((s, i) => {
const scheduleKey = s.id || `schedule-${i}`;
const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps);
return (
<div key={scheduleKey} className="schedule-row">
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) updateSchedule(i, "startHour", Math.max(0, Math.min(23, v))); }} title="Von (Stunde)" />
<span>-</span>
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) updateSchedule(i, "endHour", Math.max(0, Math.min(23, v))); }} title="Bis (Stunde)" />
<span>Uhr</span>
<input type="number" min={0} step={0.1} value={speedInput} onChange={(event) => { setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: event.target.value })); }} onBlur={(event) => { const parsed = parseMbpsInput(event.target.value); if (parsed === null) { setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(s.speedLimitKbps) })); return; } const nextKbps = Math.floor(parsed * 1024); setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(nextKbps) })); updateSchedule(i, "speedLimitKbps", nextKbps); }} title="MB/s (0=unbegrenzt)" />
<span>MB/s</span>
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
</div>
);
})}
<button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button>
</div>
)}
{settingsSubTab === "bereinigung" && (
<div className="settings-section card">
<h3>Bereinigung</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label>
<label>Fertiggestellte Downloads entfernen</label>
<select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}>
{Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
</select>
<div className="field-grid two">
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
<option value="none">keine Archive löschen</option>
<option value="trash">Archive in Papierkorb</option>
<option value="delete">Archive löschen</option>
</select></div>
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
<option value="overwrite">überschreiben</option>
<option value="skip">überspringen</option>
<option value="rename">umbenennen</option>
<option value="ask">nachfragen</option>
</select></div>
</div>
</div>
)}
{settingsSubTab === "updates" && (
<div className="settings-section card">
<h3>Updates</h3>
<label>Codeberg Repo</label>
<input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prüfen</label>
</div>
)}
</div>
</div>
</section>
)}
</main>
{confirmPrompt && (
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>{confirmPrompt.title}</h3>
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
{confirmPrompt.details && (
<details className="modal-details">
<summary>Changelog anzeigen</summary>
<pre>{confirmPrompt.details}</pre>
</details>
)}
<div className="modal-actions">
<button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button>
<button
className={confirmPrompt.danger ? "btn danger" : "btn"}
onClick={() => closeConfirmPrompt(true)}
>
{confirmPrompt.confirmLabel}
</button>
</div>
</div>
</div>
)}
{deleteConfirm && (() => {
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;
const removedItemIds = new Set<string>();
for (const id of deleteConfirm.ids) {
if (snapshot.session.items[id]) removedItemIds.add(id);
const pkg = snapshot.session.packages[id];
if (pkg) { for (const iid of pkg.itemIds) removedItemIds.add(iid); }
}
const totalRemaining = Math.max(0, Object.keys(snapshot.session.items).length - removedItemIds.size);
const parts: string[] = [];
if (pkgCount > 0) parts.push(`${pkgCount} Paket(e)`);
if (itemCount > 0) parts.push(`${itemCount} Link(s)`);
return (
<div className="modal-backdrop" onClick={() => setDeleteConfirm(null)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Bist Du Dir sicher?</h3>
<p>Möchtest Du wirklich diese Aufräumaktion(en) durchführen?<br />Ausgewählte Links löschen</p>
<p><strong>Zu erledigende Aufgaben:</strong><br />{parts.join(" + ")} löschen ? {totalRemaining} Link(s) verbleiben!</p>
<label className="toggle-line">
<input type="checkbox" checked={deleteConfirm.dontAsk} onChange={(e) => setDeleteConfirm((prev) => prev ? { ...prev, dontAsk: e.target.checked } : prev)} />
Nicht mehr anzeigen
</label>
<div className="modal-actions">
<button className="btn" onClick={() => setDeleteConfirm(null)}>Abbrechen</button>
<button className="btn danger" onClick={() => {
if (deleteConfirm.dontAsk) {
setSettingsDraft((prev) => ({ ...prev, confirmDeleteSelection: false }));
void window.rd.updateSettings({ confirmDeleteSelection: false }).catch(() => {});
}
executeDeleteSelection(deleteConfirm.ids);
setDeleteConfirm(null);
}}>Fortfahren</button>
</div>
</div>
</div>
);
})()}
{startConflictPrompt && (
<div className="modal-backdrop" onClick={() => closeStartConflictPrompt(null)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Paket bereits entpackt</h3>
<p>
<strong>{startConflictPrompt.entry.packageName}</strong> ist im Ziel bereits vorhanden.
</p>
<p>Bei "überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.</p>
<p className="modal-path" title={startConflictPrompt.entry.extractDir}>{startConflictPrompt.entry.extractDir}</p>
<label className="toggle-line">
<input
type="checkbox"
checked={startConflictPrompt.applyToAll}
onChange={(event) => {
const checked = event.target.checked;
setStartConflictPrompt((prev) => prev ? { ...prev, applyToAll: checked } : prev);
}}
/>
Für alle weiteren Pakete dieselbe Auswahl verwenden
</label>
<div className="modal-actions">
<button className="btn" onClick={() => closeStartConflictPrompt(null)}>Abbrechen</button>
<button
className="btn"
onClick={() => closeStartConflictPrompt({ policy: "skip", applyToAll: startConflictPrompt.applyToAll })}
>
Entpacktes überspringen
</button>
<button
className="btn danger"
onClick={() => closeStartConflictPrompt({ policy: "overwrite", applyToAll: startConflictPrompt.applyToAll })}
>
überschreiben
</button>
</div>
</div>
</div>
)}
{accountDialog && (
<div className="modal-backdrop" onClick={closeAccountDialog}>
<div className="modal-card account-modal" onClick={(event) => event.stopPropagation()}>
<div className="account-modal-header">
<div>
<h3>{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufügen"}</h3>
<p>Wie in JDownloader: oben Account-Typ auswaehlen, unten Zugangsdaten direkt eintragen.</p>
</div>
</div>
<div className="account-modal-body">
<div className="account-dialog-step">
<div className="account-dialog-step-label">1. Account-Typ auswaehlen</div>
<input
className="account-picker-search"
placeholder="Dienst oder Typ suchen"
value={accountDialogSearch}
onChange={(event) => setAccountDialogSearch(event.target.value)}
/>
<div className="account-picker-table">
<div className="account-picker-head">
<span>Account</span>
<span>Typ / Funktion</span>
</div>
{filteredAccountDialogOptions.length === 0 && (
<div className="account-empty-state compact">
<strong>Kein passender Account-Typ gefunden</strong>
<span>
{accountDialogSelectableOptions.length === 0
? "Alle verfügbaren Typen sind bereits vorhanden."
: "Passe den Suchbegriff an oder waehle einen Eintrag aus der Liste."}
</span>
</div>
)}
{filteredAccountDialogOptions.map((option) => (
<button
key={option.kind}
className={`account-picker-row${accountDialog.kind === option.kind ? " active" : ""}`}
onClick={() => updateAccountDialogKind(option.kind)}
>
<div>
<strong>{option.title}</strong>
<span>{option.serviceLabel}</span>
</div>
<div>
<strong>{getAccountPickerFunctionLabel(option)}</strong>
<span>{option.pickerDescription}</span>
</div>
</button>
))}
</div>
</div>
<div className="account-dialog-step">
<div className="account-dialog-step-label">
{accountDialogOption ? `2. ${accountDialogOption.serviceLabel} - Zugangsdaten eingeben` : "2. Zugangsdaten eingeben"}
</div>
{!accountDialogOption && (
<div className="account-empty-state compact">
<strong>Oben zuerst einen Account-Typ wählen</strong>
<span>Danach erscheinen hier direkt die passenden Felder für Login, Passwort oder API-Token.</span>
</div>
)}
{accountDialogOption && (
<>
<div className="account-form-summary">
<strong>{accountDialogOption.title}</strong>
<span>{accountDialogOption.pickerDescription}</span>
</div>
<div className="account-modal-fields">
{accountDialogOption.needsToken && (
<div>
<label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label>
{accountDialogOption.service === "debridlink" ? (
<textarea
rows={4}
placeholder="Ein API-Key pro Zeile"
value={accountDialog.token}
onChange={(event) => setAccountDialog((prev) => prev ? {
...prev,
token: event.target.value,
keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(event.target.value, prev.keyDailyLimitGbById, settingsDraft)
} : prev)}
style={{ fontFamily: "monospace", resize: "vertical" }}
/>
) : (
<input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
)}
</div>
)}
{accountDialogOption.needsCredentials && (
<div className="field-grid two">
<div>
<label>Login / E-Mail</label>
<input value={accountDialog.login} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, login: event.target.value } : prev)} />
</div>
<div>
<label>Passwort</label>
<input type="password" value={accountDialog.password} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, password: event.target.value } : prev)} />
</div>
</div>
)}
<div>
<label>Tageslimit (GB, optional)</label>
<input
inputMode="decimal"
placeholder="z.B. 250"
value={accountDialog.dailyLimitGb}
onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, dailyLimitGb: event.target.value } : prev)}
/>
<div className="account-modal-note">Ab 00:00 wird der Zähler automatisch zurückgesetzt. Wenn das Limit erreicht ist, nutzt die App den nächsten Hoster aus der Reihenfolge.</div>
</div>
{accountDialog.kind === "debridlink-api" && parseDebridLinkApiKeys(accountDialog.token).length > 0 && (
<div>
<label>API-Key Limits (GB, optional pro Key)</label>
<div className="account-dl-key-limit-list">
{parseDebridLinkApiKeys(accountDialog.token).map((key) => (
<div key={key.id} className="account-dl-key-limit-row">
<div className="account-dl-key-meta">
<strong>{key.label}</strong>
<span>{key.masked}</span>
</div>
<input
inputMode="decimal"
placeholder="Kein Limit"
value={accountDialog.keyDailyLimitGbById[key.id] || ""}
onChange={(event) => setAccountDialog((prev) => prev ? {
...prev,
keyDailyLimitGbById: {
...prev.keyDailyLimitGbById,
[key.id]: event.target.value
}
} : prev)}
/>
</div>
))}
</div>
<div className="account-modal-note">Leer lassen = unbegrenzt. Die Limits gelten pro API-Key und werden täglich um 00:00 zurückgesetzt.</div>
</div>
)}
{accountDialog.kind === "realdebrid-web" && (
<div className="account-modal-note">Nach dem Speichern kannst Du direkt das Browserfenster für den Web-Login öffnen.</div>
)}
{accountDialog.kind === "bestdebrid-web" && (
<div className="account-modal-note">Der Web-Account arbeitet über einen Cookies.txt-Import aus dem Browser.</div>
)}
{accountDialog.kind === "alldebrid-web" && (
<div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber läuft.</div>
)}
{accountDialog.kind === "megadebrid-api" && (
<div className="account-modal-note">Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div>
)}
{accountDialog.kind === "megadebrid-web" && (
<div className="account-modal-note">Dieser Account nutzt nur Mega-Debrid Web. Kein API-Fallback.</div>
)}
{accountDialogOption.service === "alldebrid" && allDebridHostInfo && (
<div className="account-status-grid">
<div>
<span className="hint">Rapidgator-Status</span>
<strong>{allDebridHostInfo.statusLabel}</strong>
</div>
<div>
<span className="hint">Quelle</span>
<strong>{formatAllDebridSourceLabel(allDebridHostInfo.source)}</strong>
</div>
<div>
<span className="hint">Quota</span>
<strong>{formatAllDebridQuota(allDebridHostInfo)}</strong>
</div>
<div>
<span className="hint">Simultan</span>
<strong>{formatAllDebridSimuLimit(allDebridHostInfo)}</strong>
</div>
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" onClick={closeAccountDialog}>Abbrechen</button>
{accountDialog.kind === "realdebrid-web" && (
<button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("realdebrid-login"); }}>
Speichern + Login
</button>
)}
{accountDialog.kind === "bestdebrid-web" && (
<button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("bestdebrid-cookies"); }}>
Speichern + Cookies
</button>
)}
{accountDialog.kind === "alldebrid-web" && (
<button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("alldebrid-login"); }}>
Speichern + Login
</button>
)}
<button className="btn accent" disabled={actionBusy || !accountDialog.kind} onClick={() => { void onSaveAccountDialog(); }}>
Speichern
</button>
</div>
</>
)}
</div>
</div>
</div>
</div>
)}
<footer className="status-bar">
<span>Pakete: {snapshot.stats.totalPackages}</span>
<span>Links: {Object.keys(snapshot.session.items).length}</span>
<span>Session: {humanSize(snapshot.stats.totalDownloaded)}</span>
<span>Gesamt: {humanSize(snapshot.stats.totalDownloadedAllTime)}</span>
<span>Hoster: {providerStats.length}</span>
<span>{snapshot.speedText}</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>
{updateInstallProgress && (
<div className={`update-popup update-popup-${updateInstallProgress.stage}`}>
<div className="update-popup-header">
<span className="update-popup-title">Update</span>
{(updateInstallProgress.stage === "done" || updateInstallProgress.stage === "error") && (
<button className="update-popup-close" onClick={() => setUpdateInstallProgress(null)} title="Schließen">&times;</button>
)}
</div>
<div className="update-popup-message">{formatUpdateInstallProgress(updateInstallProgress)}</div>
{updateInstallProgress.stage === "downloading" && updateInstallProgress.percent !== null && (
<div className="update-popup-bar-track">
<div className="update-popup-bar-fill" style={{ width: `${updateInstallProgress.percent}%` }} />
</div>
)}
</div>
)}
{statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
{contextMenu && (() => {
const multi = selectedIds.size > 1;
const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
const selectedItemIds = [...selectedIds].filter((id) => snapshot.session.items[id]);
const hasPackages = selectedPackageIds.length > 0;
const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]);
const hasStartableItems = [...selectedIds].some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
const hasItems = selectedItemIds.length > 0;
return (
<div ref={ctxMenuRef} className="ctx-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(e) => e.stopPropagation()}>
{(hasPackages || hasStartableItems) && (
<button className="ctx-menu-item" onClick={() => {
const pkgIds = selectedPackageIds;
const itemIds = selectedItemIds.filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
if (pkgIds.length > 0) void window.rd.startPackages(pkgIds).catch(() => {});
if (itemIds.length > 0) void window.rd.startItems(itemIds).catch(() => {});
setContextMenu(null);
}}>Ausgewählte Downloads starten{multi ? ` (${selectedIds.size})` : ""}</button>
)}
<button className="ctx-menu-item" onClick={() => { void window.rd.start().catch(() => {}); setContextMenu(null); }}>Alle Downloads starten</button>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button>
<div className="ctx-menu-sep" />
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
for (const id of selectedIds) { if (snapshot.session.packages[id]) void window.rd.togglePackage(id).catch(() => {}); }
setContextMenu(null);
}}>
{multi ? `Alle ${selectedIds.size} umschalten` : (snapshot.session.packages[contextMenu.packageId]?.enabled ? "Deaktivieren" : "Aktivieren")}
</button>
)}
{!multi && contextMenu.itemId && (
<button className="ctx-menu-item ctx-danger" onClick={() => {
setContextMenu(null);
const ids = new Set([contextMenu.itemId!]);
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
else { executeDeleteSelection(ids); }
}}>Entfernen</button>
)}
{selectedItemIds.length > 1 && !hasPackages && (
<button className="ctx-menu-item ctx-danger" onClick={() => {
setContextMenu(null);
const ids = new Set(selectedItemIds);
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
else { executeDeleteSelection(ids); }
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
)}
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
)}
{contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
void window.rd.resetItems(itemIds).catch(() => {});
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
)}
{hasPackages && !multi && (() => {
const pkg = snapshot.session.packages[contextMenu.packageId];
const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || [];
const someCompleted = items.some((item) => item && item.status === "completed");
return (<>
{someCompleted && (
<button className="ctx-menu-item" onClick={() => { void window.rd.extractNow(contextMenu.packageId).catch(() => {}); setContextMenu(null); }}>Jetzt entpacken</button>
)}
</>);
})()}
{hasPackages && !contextMenu.itemId && (<>
<div className="ctx-menu-sep" />
<div className="ctx-menu-sub">
<button className="ctx-menu-item">Priorität ?</button>
<div className="ctx-menu-sub-items">
{(["high", "normal", "low"] as const).map((p) => {
const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard";
const pkgIds = selectedPackageIds;
const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p);
return <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p).catch(() => {}); setContextMenu(null); }}>{allMatch ? `? ${label}` : label}</button>;
})}
</div>
</div>
</>)}
{hasItems && (() => {
const itemIds = selectedItemIds;
const skippable = itemIds.filter((id) => { const it = snapshot.session.items[id]; return it && (it.status === "queued" || it.status === "reconnect_wait"); });
if (skippable.length === 0) return null;
return <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable).catch(() => {}); setContextMenu(null); }}>überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>;
})()}
{hasPackages && (
<button className="ctx-menu-item ctx-danger" onClick={() => {
setContextMenu(null);
const ids = new Set(selectedPackageIds);
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
else { executeDeleteSelection(ids); }
}}>{multi ? `Ausgewählte entfernen (${selectedPackageIds.length})` : "Paket entfernen"}</button>
)}
</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 }).catch(() => {});
}}
>
{isVisible ? "\u2713 " : "\u2003 "}{def.label}
</button>
);
})}
</div>
)}
{historyCtxMenu && (() => {
const multi = selectedHistoryIds.size > 1;
const contextEntry = historyEntries.find(e => e.id === historyCtxMenu.entryId);
const hasUrls = (contextEntry?.urls?.length ?? 0) > 0;
const removeSelected = (): void => {
const idSet = new Set(selectedHistoryIds);
void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => {
setHistoryEntries((prev) => prev.filter((e) => !idSet.has(e.id)));
setSelectedHistoryIds(new Set());
}).catch(() => {
void window.rd.getHistory().then((entries) => { setHistoryEntries(entries); setSelectedHistoryIds(new Set()); }).catch(() => {});
});
setHistoryCtxMenu(null);
};
return (
<div ref={historyCtxMenuRef} className="ctx-menu" style={{ left: historyCtxMenu.x, top: historyCtxMenu.y }} onClick={(e) => e.stopPropagation()}>
<button className="ctx-menu-item ctx-danger" onClick={removeSelected}>
{multi ? `Ausgewählte entfernen (${selectedHistoryIds.size})` : "Eintrag entfernen"}
</button>
{hasUrls && !multi && (
<>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => {
const rawText = contextEntry!.urls!.join("\n");
void window.rd.addLinks({ rawText, packageName: contextEntry!.name }).then((result) => {
if (result.addedLinks > 0) showToast(`${result.addedLinks} Link(s) zur Queue hinzugefügt`);
else showToast("Keine Links hinzugefügt");
}).catch(() => showToast("Fehler beim Hinzufügen"));
setHistoryCtxMenu(null);
}}>Erneut herunterladen</button>
<button className="ctx-menu-item" onClick={() => {
const urls = contextEntry!.urls!;
const links = urls.map((u) => ({ name: u, url: u }));
setLinkPopup({ title: contextEntry!.name, links, isPackage: links.length > 1 });
setHistoryCtxMenu(null);
}}>Linkadressen anzeigen</button>
</>
)}
<div className="ctx-menu-sep" />
<button className="ctx-menu-item ctx-danger" onClick={() => {
void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {});
setHistoryCtxMenu(null);
}}>Verlauf leeren</button>
</div>
);
})()}
{keyStatsPopup && (() => {
const entry = configuredAccounts.find((a) => a.service === keyStatsPopup);
if (!entry || entry.debridLinkKeys.length === 0) return null;
const totalUsed = entry.debridLinkKeys.reduce((s, k) => s + k.dailyUsedBytes, 0);
const limitedCount = entry.debridLinkKeys.filter((k) => k.dailyLimitReached).length;
const disabledCount = entry.debridLinkKeys.filter((k) => k.disabled).length;
const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length;
return (
<div className="modal-backdrop" onClick={() => setKeyStatsPopup(null)}>
<div className="modal-card key-stats-popup" onClick={(e) => e.stopPropagation()}>
<div className="key-stats-popup-header">
<div>
<h3>API-Key Statistik</h3>
<p className="key-stats-summary">
{entry.debridLinkKeys.length} Keys &middot; Heute: {humanSize(totalUsed)}
{limitedCount > 0 && <span className="key-stats-warn"> &middot; {limitedCount} am Limit</span>}
{disabledCount > 0 && <span className="key-stats-warn"> &middot; {disabledCount} deaktiviert</span>}
{debridLinkHostLimitsLoading && <span> &middot; Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})</span>}
{!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && <span> &middot; Rapidgator API-Quota</span>}
{debridLinkHostLimitsError && <span className="key-stats-warn"> &middot; API-Quota konnte nicht geladen werden</span>}
</p>
</div>
<button className="update-popup-close" onClick={() => setKeyStatsPopup(null)}>&times;</button>
</div>
<div className="account-subkey-table">
<div className="account-subkey-table-head">
<span className="col-key">#</span>
<span className="col-masked">Key</span>
<span className="col-usage">Heute</span>
<span className="col-limit">Lokal</span>
<span className="col-traffic">RG Traffic</span>
<span className="col-links">RG Links</span>
<span className="col-action"></span>
</div>
{entry.debridLinkKeys.map((key, ki) => (
<div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached ? " warning" : ""}${key.disabled ? " disabled" : ""}`}>
{(() => {
const hostInfo = debridLinkHostLimits[key.id];
return (
<>
<span className="col-key">{ki + 1}</span>
<span
className="col-masked link-popup-click"
title={`${key.masked}\nKlicken zum Kopieren`}
onClick={() => {
void navigator.clipboard.writeText(key.token)
.then(() => showToast(`${key.label} kopiert`, 1800))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}}
>
{key.masked}
</span>
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
<span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
<span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span>
<span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span>
<span className="col-action">
<button
className={`btn btn-sm ${key.disabled ? "success" : "danger"}`}
disabled={actionBusy}
onClick={() => { void onToggleDebridLinkApiKeyEnabled(entry, key); }}
>
{key.disabled ? "Aktivieren" : "Deaktivieren"}
</button>
<button
className="btn btn-sm"
disabled={actionBusy || key.dailyUsedBytes <= 0}
onClick={() => { void onResetDebridLinkApiKeyDailyUsage(entry, key.id, key.label); }}
>
Reset
</button>
</span>
</>
);
})()}
</div>
))}
</div>
<div className="modal-actions">
<button className="btn" onClick={() => setKeyStatsPopup(null)}>Schließen</button>
</div>
</div>
</div>
);
})()}
{linkPopup && (
<div className="modal-backdrop" onClick={() => setLinkPopup(null)}>
<div className="modal-card link-popup" onClick={(e) => e.stopPropagation()}>
<h3>Linkadressen anzeigen</h3>
<p>{linkPopup.title}</p>
<div className="link-popup-list">
{linkPopup.links.map((link, i) => (
<div key={i} className="link-popup-row">
<span className="link-popup-name link-popup-click" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</span>
<span className="link-popup-url link-popup-click" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</span>
</div>
))}
</div>
<div className="modal-actions">
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.name).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Namen kopieren</button>
)}
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.url).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Links kopieren</button>
)}
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
</div>
</div>
</div>
)}
</div>
);
}
interface PackageCardProps {
pkg: PackageEntry;
items: DownloadItem[];
packageSpeed: number;
isFirst: boolean;
isLast: boolean;
isEditing: boolean;
editingName: string;
collapsed: boolean;
hideExtractedItems: boolean;
selectedIds: Set<string>;
columnOrder: string[];
gridTemplate: string;
onSelect: (id: string, ctrlKey: boolean) => void;
onSelectMouseDown: (id: string, e: React.MouseEvent) => void;
onSelectMouseEnter: (id: string) => void;
onStartEdit: (packageId: string, packageName: string) => void;
onFinishEdit: (packageId: string, currentName: string, nextName: string) => void;
onEditChange: (name: string) => void;
onToggleCollapse: (packageId: string) => void;
onCancel: (packageId: string) => void;
onMoveUp: (packageId: string) => void;
onMoveDown: (packageId: string) => void;
onToggle: (packageId: string) => void;
onRemoveItem: (itemId: string) => void;
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
onDragStart: (packageId: string) => void;
onDrop: (packageId: string) => void;
onDragEnd: () => void;
}
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, 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;
const extracted = items.filter((item) => item.fullStatus?.startsWith("Entpackt")).length;
const extracting = items.some((item) => item.fullStatus?.startsWith("Entpacken"));
const total = Math.max(1, items.length);
// Use 50/50 split when extraction is active OR package is in extracting state
// (prevents bar jumping from 100% to 50% when extraction starts)
const allDownloaded = done + failed + cancelled >= total;
const allExtracted = extracted >= total;
const useExtractSplit = extracting || pkg.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
// Include fractional progress from active downloads so the bar moves continuously
const activeProgress = items.reduce((sum, item) => {
if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
return sum + (item.progressPercent || 0) / 100;
}
return sum;
}, 0);
const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
// Include fractional progress from items currently being extracted
const extractingProgress = items.reduce((sum, item) => {
const fs = item.fullStatus || "";
if (fs.startsWith("Entpackt")) return sum;
const m = fs.match(/^Entpacken\s+(\d+)%/);
if (m) return sum + Number(m[1]) / 100;
return sum;
}, 0);
const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
};
return (
<article
className={`package-card queue-package-card${pkg.enabled ? "" : " disabled-pkg"}${selectedIds.has(pkg.id) ? " pkg-selected" : ""}`}
draggable
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, undefined, e.clientX, e.clientY); }}
onClick={(e) => { if (e.ctrlKey) onSelect(pkg.id, true); }}
onMouseDown={(e) => onSelectMouseDown(pkg.id, e)}
onMouseEnter={() => onSelectMouseEnter(pkg.id)}
onDragStart={(event) => { event.stopPropagation(); onDragStart(pkg.id); }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(pkg.id); }}
onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }}
>
<header onClick={(e) => {
if (e.ctrlKey) return;
const tag = (e.target as HTMLElement).tagName;
if (tag === "BUTTON" || tag === "INPUT" || tag === "SELECT") return;
onToggleCollapse(pkg.id);
}} style={{ cursor: "pointer" }}>
<div className="pkg-columns" style={{ gridTemplateColumns: gridTemplate }}>
{columnOrder.map((col) => {
switch (col) {
case "name": return (
<div key={col} className="pkg-col pkg-col-name">
<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"} />
{isEditing ? (
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus />
) : (
<h4 onClick={(e) => { e.stopPropagation(); onStartEdit(pkg.id, pkg.name); }} title="Klicken zum Umbenennen">{pkg.name}</h4>
)}
</div>
);
case "size": return (
<span key={col} className="pkg-col pkg-col-size">{(() => {
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 ? (
<span className="progress-size">
<span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="progress-size-text">{label}</span>
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
</span>
) : "";
})()}</span>
);
case "progress": return (
<span key={col} className="pkg-col pkg-col-progress">
<span className="progress-inline">
<span className="progress-inline-bar" style={{ width: `${combinedProgress}%` }} />
<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>
);
case "hoster": {
const hosterText = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
return <span key={col} className="pkg-col pkg-col-hoster" title={hosterText}>{hosterText}</span>;
}
case "account": {
const accountText = compactProviderLabels(items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span key={col} className="pkg-col pkg-col-account" title={accountText}>{accountText}</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.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""}</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>
</header>
<div className="progress">
<div className="progress-dl" style={{ width: `${dlProgress}%` }} />
{useExtractSplit && <div className="progress-ex" style={{ width: `${exProgress}%` }} />}
</div>
{!collapsed && items.filter((item) => !hideExtractedItems || !item.fullStatus?.startsWith("Entpackt")).map((item) => (
<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); }}>
{columnOrder.map((col) => {
switch (col) {
case "name": return (
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
{item.fileName}
</span>
);
case "size": return (
<span key={col} className="pkg-col pkg-col-size">{(() => {
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 ? (
<span className="progress-size progress-size-small">
<span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="progress-size-text">{label}</span>
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
</span>
) : "";
})()}</span>
);
case "progress": return (
<span key={col} className="pkg-col pkg-col-progress">
{item.totalBytes > 0 ? (
<span className="progress-inline progress-inline-small">
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
<span className="progress-inline-text">{item.progressPercent}%</span>
<span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - (item.progressPercent || 0)}% 0 0)` }}>{item.progressPercent}%</span>
</span>
) : ""}
</span>
);
case "hoster": { const h = extractHoster(item.url) || ""; return <span key={col} className="pkg-col pkg-col-hoster" title={h}>{h}</span>; }
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.providerLabel || (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>
))}
</article>
);
}, (prev, next) => {
if (prev.pkg.id !== next.pkg.id) {
return false;
}
if (prev.pkg.updatedAt !== next.pkg.updatedAt
|| prev.pkg.status !== next.pkg.status
|| prev.pkg.enabled !== next.pkg.enabled
|| prev.pkg.name !== next.pkg.name
|| prev.pkg.priority !== next.pkg.priority
|| prev.pkg.createdAt !== next.pkg.createdAt) {
return false;
}
if (prev.packageSpeed !== next.packageSpeed
|| prev.isFirst !== next.isFirst
|| prev.isLast !== next.isLast
|| prev.isEditing !== next.isEditing
|| prev.collapsed !== next.collapsed
|| prev.hideExtractedItems !== next.hideExtractedItems
|| prev.selectedIds !== next.selectedIds
|| prev.columnOrder !== next.columnOrder
|| prev.gridTemplate !== next.gridTemplate) {
return false;
}
if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) {
return false;
}
if (prev.pkg.itemIds.length !== next.pkg.itemIds.length) {
return false;
}
for (let index = 0; index < prev.pkg.itemIds.length; index += 1) {
if (prev.pkg.itemIds[index] !== next.pkg.itemIds[index]) {
return false;
}
}
if (prev.items.length !== next.items.length) {
return false;
}
for (let index = 0; index < prev.items.length; index += 1) {
const a = prev.items[index];
const b = next.items[index];
if (!a || !b) {
return false;
}
if (a.id !== b.id
|| a.updatedAt !== b.updatedAt
|| a.url !== b.url
|| a.status !== b.status
|| a.fileName !== b.fileName
|| a.progressPercent !== b.progressPercent
|| a.speedBps !== b.speedBps
|| a.retries !== b.retries
|| a.provider !== b.provider
|| a.fullStatus !== b.fullStatus
|| a.onlineStatus !== b.onlineStatus
|| a.downloadedBytes !== b.downloadedBytes
|| a.totalBytes !== b.totalBytes) {
return false;
}
}
return true;
});