6730 lines
284 KiB
TypeScript
6730 lines
284 KiB
TypeScript
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|
import type { RealDebridLoginRequest } from "../shared/preload-api";
|
|
import type {
|
|
AccountCreateCommand,
|
|
AllDebridHostInfo,
|
|
AppTheme,
|
|
BandwidthScheduleEntry,
|
|
DebugSetupCheckResult,
|
|
DebridFallbackProvider,
|
|
DebridLinkHostLimitInfo,
|
|
DebridProvider,
|
|
DownloadItem,
|
|
DownloadStats,
|
|
DuplicatePolicy,
|
|
HistoryEntry,
|
|
PackageEntry,
|
|
RemoteDiagnosticsInfo,
|
|
RendererAccount,
|
|
RendererSettings,
|
|
RendererSettingsUpdate,
|
|
StartConflictEntry,
|
|
UiSnapshot,
|
|
UpdateCheckResult,
|
|
UpdateInstallProgress
|
|
} from "../shared/types";
|
|
import {
|
|
getDebridLinkApiKeyTotalUsageBytes,
|
|
getDebridLinkApiKeyDailyLimitBytes,
|
|
getDebridLinkApiKeyDailyRemainingBytes,
|
|
getDebridLinkApiKeyDailyUsageBytes,
|
|
getMegaDebridAccountDailyLimitBytes,
|
|
getMegaDebridAccountDailyUsageBytes,
|
|
getMegaDebridAccountTotalUsageBytes,
|
|
getProviderDailyLimitBytes,
|
|
getProviderDailyRemainingBytes,
|
|
getProviderTotalUsageBytes,
|
|
getProviderDailyUsageBytes,
|
|
getProviderUsageDayKey
|
|
} from "../shared/provider-daily-limits";
|
|
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
|
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
|
|
import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runAccountEnableRefresh, runOptimisticAccountUpdate, sortAccountServices, updateAccountRowSelection } from "./account-ui";
|
|
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
|
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
|
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
|
import { createUiLocalizer, normalizeLanguage } from "./i18n";
|
|
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
|
|
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
|
import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format";
|
|
import { AppShell } from "./shell/AppShell";
|
|
import { AvatarMenu } from "./shell/AvatarMenu";
|
|
import { OverlayHost } from "./shell/OverlayHost";
|
|
import { UpdateExperience } from "./shell/UpdateExperience";
|
|
import type { MainView } from "./shell/shell-model";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog";
|
|
import { Dialog } from "./ui/Dialog";
|
|
import { Icon } from "./ui/Icon";
|
|
import { Toast } from "./ui/Toast";
|
|
import {
|
|
buildCollectorViewModel,
|
|
type CollectorSourceTab
|
|
} from "./views/collector/collector-model";
|
|
import {
|
|
CollectorContent,
|
|
CollectorInputDialog,
|
|
CollectorSidebar,
|
|
CollectorToolbar,
|
|
type CollectorViewActions
|
|
} from "./views/collector/CollectorView";
|
|
import {
|
|
buildHistoryViewModel,
|
|
mergeLiveHistoryEntry,
|
|
pruneHistoryIds,
|
|
selectVisibleHistoryIds,
|
|
type HistoryFilter
|
|
} from "./views/history/history-model";
|
|
import {
|
|
HistoryContent,
|
|
HistoryFooter,
|
|
HistorySidebar,
|
|
HistoryToolbar,
|
|
type HistoryViewActions
|
|
} from "./views/history/HistoryView";
|
|
import {
|
|
buildStatisticsViewModel,
|
|
type StatisticsRange
|
|
} from "./views/statistics/statistics-model";
|
|
import {
|
|
StatisticsContent,
|
|
StatisticsSidebar,
|
|
StatisticsSidebarStatus,
|
|
type StatisticsViewActions
|
|
} from "./views/statistics/StatisticsView";
|
|
import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
|
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
|
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog";
|
|
import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag";
|
|
import {
|
|
DownloadsContent,
|
|
DownloadsFooter,
|
|
DownloadsSidebar,
|
|
DownloadsSidebarStatus,
|
|
DownloadsToolbar,
|
|
type DownloadsViewActions,
|
|
type DownloadsViewModel
|
|
} from "./views/downloads/DownloadsView";
|
|
import {
|
|
buildAccountRowId,
|
|
buildSettingsFormViewModel,
|
|
buildTargetedAccountCheck,
|
|
projectAccountRows,
|
|
resolveHistoryRetentionSelection,
|
|
sortAccountRows,
|
|
formatAccountContextHeading,
|
|
type AccountAddOption,
|
|
type AccountRowSource,
|
|
type SettingsFormViewModel,
|
|
type SettingsSaveState,
|
|
type SettingsSection
|
|
} from "./views/settings/settings-model";
|
|
import {
|
|
AccountAddDialog,
|
|
AccountEditDialog,
|
|
type AccountDialogField,
|
|
type AccountWorkspaceActions,
|
|
type AccountWorkspaceViewModel
|
|
} from "./views/settings/AccountWorkspace";
|
|
import {
|
|
SettingsContent,
|
|
SettingsSidebar,
|
|
type SettingsViewActions,
|
|
type SettingsViewModel
|
|
} from "./views/settings/SettingsView";
|
|
|
|
type Tab = MainView;
|
|
|
|
type CollectorTab = CollectorSourceTab;
|
|
|
|
interface CollectorInputState {
|
|
tabId: string;
|
|
tabName: string;
|
|
baseText: string;
|
|
draft: string;
|
|
}
|
|
export function mergeCollectorDraftText(baseText: string, currentText: string, draft: string): string {
|
|
if (currentText === baseText) {
|
|
return draft;
|
|
}
|
|
const appended = currentText.startsWith(baseText) ? currentText.slice(baseText.length) : currentText;
|
|
if (!appended) {
|
|
return draft;
|
|
}
|
|
const normalizedAppend = appended.replace(/^\r?\n/, "");
|
|
if (!draft) {
|
|
return normalizedAppend;
|
|
}
|
|
if (!normalizedAppend) {
|
|
return draft;
|
|
}
|
|
return `${draft}${draft.endsWith("\n") ? "" : "\n"}${normalizedAppend}`;
|
|
}
|
|
|
|
export function planCollectorTabRemoval(
|
|
tabs: CollectorTab[],
|
|
activeTabId: string,
|
|
removedTabId: string
|
|
): { tabs: CollectorTab[]; activeTabId: string } {
|
|
if (tabs.length <= 1) {
|
|
return { tabs, activeTabId };
|
|
}
|
|
const removedIndex = tabs.findIndex((tab) => tab.id === removedTabId);
|
|
if (removedIndex < 0) {
|
|
return {
|
|
tabs,
|
|
activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : (tabs[0]?.id ?? "")
|
|
};
|
|
}
|
|
const nextTabs = tabs.filter((tab) => tab.id !== removedTabId);
|
|
const nextActiveTabId = activeTabId === removedTabId
|
|
? (nextTabs[Math.max(0, removedIndex - 1)]?.id ?? nextTabs[0]?.id ?? "")
|
|
: (nextTabs.some((tab) => tab.id === activeTabId) ? activeTabId : (nextTabs[0]?.id ?? ""));
|
|
return { tabs: nextTabs, activeTabId: nextActiveTabId };
|
|
}
|
|
|
|
export function planCollectorTextReplacement(
|
|
tabs: CollectorTab[],
|
|
tabId: string,
|
|
text: string
|
|
): { tabs: CollectorTab[]; selectedIds: string[] } {
|
|
return {
|
|
tabs: tabs.map((tab) => tab.id === tabId ? { ...tab, text } : tab),
|
|
selectedIds: []
|
|
};
|
|
}
|
|
|
|
interface StartConflictPromptState {
|
|
entry: StartConflictEntry;
|
|
applyToAll: boolean;
|
|
}
|
|
|
|
interface ConfirmPromptState {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel: string;
|
|
cancelLabel?: string;
|
|
danger?: boolean;
|
|
details?: string;
|
|
detailsLabel?: string;
|
|
}
|
|
|
|
interface OnlineBackupDialogState {
|
|
mode: "export" | "import";
|
|
key: string;
|
|
busy: boolean;
|
|
error: string;
|
|
}
|
|
|
|
interface ContextMenuState {
|
|
x: number;
|
|
y: number;
|
|
packageId: string;
|
|
itemId?: string;
|
|
}
|
|
|
|
interface LinkPopupState {
|
|
title: string;
|
|
links: { name: string; url: string }[];
|
|
isPackage: boolean;
|
|
}
|
|
|
|
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
|
|
interface AccountOption {
|
|
kind: AccountKind;
|
|
service: AccountService;
|
|
serviceLabel: string;
|
|
title: string;
|
|
modeLabel: string;
|
|
pickerDescription: string;
|
|
needsToken?: boolean;
|
|
needsCredentials?: boolean;
|
|
}
|
|
|
|
interface MegaDialogAccount {
|
|
login: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface AccountDialogState {
|
|
mode: "create" | "edit";
|
|
kind: AccountKind | null;
|
|
service: AccountService | null;
|
|
token: string;
|
|
login: string;
|
|
password: string;
|
|
dailyLimitGb: string;
|
|
keyDailyLimitGbById: Record<string, string>;
|
|
megaAccounts: MegaDialogAccount[];
|
|
megaNewLogin: string;
|
|
megaNewPassword: string;
|
|
megaDisabledIds: string[];
|
|
}
|
|
|
|
interface DebridLinkAccountKeyEntry {
|
|
id: string;
|
|
label: string;
|
|
token: string;
|
|
masked: string;
|
|
disabled: boolean;
|
|
dailyUsedBytes: number;
|
|
totalUsedBytes: 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;
|
|
totalUsedBytes: number;
|
|
dailyLimitBytes: number;
|
|
dailyRemainingBytes: number | null;
|
|
dailyLimitReached: boolean;
|
|
debridLinkKeys: DebridLinkAccountKeyEntry[];
|
|
}
|
|
|
|
interface AccountTableRow {
|
|
rowKey: string;
|
|
entry: ConfiguredAccountEntry;
|
|
hosterLabel: string;
|
|
modeLabel: string;
|
|
username: string;
|
|
credentialLabel: string;
|
|
accountId: string | null;
|
|
checkable: boolean;
|
|
disabled: boolean;
|
|
dailyUsedBytes: number;
|
|
dailyLimitBytes: number;
|
|
dailyRemainingBytes: number;
|
|
totalUsedBytes: number;
|
|
toggleKind: "rd" | "mega" | "dl" | "single";
|
|
dlKey?: DebridLinkAccountKeyEntry;
|
|
editTarget: AccountEditTarget;
|
|
}
|
|
|
|
interface AccountContextMenuState {
|
|
x: number;
|
|
y: number;
|
|
rowId: string;
|
|
}
|
|
|
|
type SettingsThemeChoice = AppTheme | "system";
|
|
|
|
interface RendererSettingsDraft extends RendererSettings {
|
|
archivePasswordList: string;
|
|
notifyUrl: string;
|
|
}
|
|
|
|
function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
|
return {
|
|
...settings,
|
|
archivePasswordList: current?.archivePasswordList || "",
|
|
notifyUrl: current?.notifyUrl || ""
|
|
};
|
|
}
|
|
|
|
function settingsValueEqual(left: unknown, right: unknown): boolean {
|
|
return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
export function mergeConcurrentSpecificSettings(
|
|
base: RendererSettingsDraft,
|
|
requested: RendererSettingsDraft,
|
|
persisted: RendererSettings,
|
|
current: RendererSettingsDraft
|
|
): RendererSettingsDraft {
|
|
const merged = { ...current } as Record<string, unknown>;
|
|
for (const key of Object.keys(requested) as Array<keyof RendererSettingsDraft>) {
|
|
if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key]) && key in persisted) {
|
|
merged[key] = persisted[key as keyof RendererSettings];
|
|
}
|
|
}
|
|
return merged as unknown as RendererSettingsDraft;
|
|
}
|
|
|
|
export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme {
|
|
return choice === "system" ? (prefersLight ? "light" : "dark") : choice;
|
|
}
|
|
|
|
function 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;
|
|
}
|
|
}
|
|
|
|
function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
|
|
const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => {
|
|
if (value.freeBytes === null || value.totalBytes === null) {
|
|
return `${label}: unbekannt (${value.path})`;
|
|
}
|
|
return `${label}: ${humanSize(value.freeBytes)} frei von ${humanSize(value.totalBytes)} (${value.freePercent ?? "?"}% frei) | ${value.path}`;
|
|
};
|
|
|
|
const formatFileLine = (label: string, bytes: number): string => `${label}: ${humanSize(bytes)}`;
|
|
const lines: string[] = [
|
|
`Status: ${setup.status === "ok" ? "OK" : "Warnung"}`,
|
|
`Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`,
|
|
`Runtime-Ordner: ${setup.runtimeBaseDir}`,
|
|
`Host: ${setup.host}`,
|
|
`Port: ${setup.port}`,
|
|
`Token-Datei: ${setup.tokenPath}`,
|
|
`Support-Manifest: ${setup.supportManifestPresent ? "vorhanden" : "fehlt"} (${setup.supportManifestPath})`,
|
|
`Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`,
|
|
`Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`,
|
|
"",
|
|
"Freier Speicherplatz:",
|
|
formatDiskLine("Runtime", setup.diskSpace.runtime),
|
|
formatDiskLine("Download-Ziel", setup.diskSpace.output),
|
|
formatDiskLine("Entpack-Ziel", setup.diskSpace.extract),
|
|
"",
|
|
"Support-Logs:",
|
|
formatFileLine("Gesamt", setup.logSummary.totalBytes),
|
|
formatFileLine("Hauptlog", setup.logSummary.main.bytes + setup.logSummary.mainBackup.bytes),
|
|
formatFileLine("Audit", setup.logSummary.audit.bytes + setup.logSummary.auditBackup.bytes),
|
|
formatFileLine("Rename", setup.logSummary.rename.bytes + setup.logSummary.renameBackup.bytes),
|
|
formatFileLine("Trace", setup.logSummary.trace.bytes + setup.logSummary.traceBackup.bytes),
|
|
`${formatFileLine("Session-Logs", setup.logSummary.session.bytes + setup.logSummary.sessionLogs.bytes)} | Dateien: ${setup.logSummary.sessionLogs.fileCount}`,
|
|
`${formatFileLine("Paket-Logs", setup.logSummary.packageLogs.bytes)} | Dateien: ${setup.logSummary.packageLogs.fileCount}`,
|
|
`${formatFileLine("Item-Logs", setup.logSummary.itemLogs.bytes)} | Dateien: ${setup.logSummary.itemLogs.fileCount}`,
|
|
"",
|
|
"Support-Bundle:",
|
|
`${formatFileLine("Schätzwert", setup.supportBundle.estimatedBytes)} | Einträge: ${setup.supportBundle.estimatedEntries}`,
|
|
formatFileLine("Doppelte Live-Log-Spiegelung", setup.supportBundle.duplicatedLiveLogBytes),
|
|
setup.supportBundle.note,
|
|
"",
|
|
"Lokale URLs:",
|
|
setup.localUrls.health,
|
|
setup.localUrls.meta,
|
|
setup.localUrls.diagnostics,
|
|
"",
|
|
"Remote-Vorlagen:",
|
|
setup.remoteUrlTemplates.health,
|
|
setup.remoteUrlTemplates.meta,
|
|
setup.remoteUrlTemplates.diagnostics
|
|
];
|
|
|
|
if (setup.warnings.length > 0) {
|
|
lines.push("", "Warnungen:");
|
|
for (const warning of setup.warnings) {
|
|
lines.push(`- ${warning}`);
|
|
}
|
|
}
|
|
|
|
if (setup.notes.length > 0) {
|
|
lines.push("", "Hinweise:");
|
|
for (const note of setup.notes) {
|
|
lines.push(`- ${note}`);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
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-Login",
|
|
modeLabel: "Web-Login",
|
|
pickerDescription: "Login über Browserfenster statt Token."
|
|
},
|
|
{
|
|
kind: "megadebrid-api",
|
|
service: "megadebrid-api",
|
|
serviceLabel: "Mega-Debrid",
|
|
title: "Mega-Debrid API",
|
|
modeLabel: "API",
|
|
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account."
|
|
},
|
|
{
|
|
kind: "megadebrid-web",
|
|
service: "megadebrid-web",
|
|
serviceLabel: "Mega-Debrid",
|
|
title: "Mega-Debrid Web-Login",
|
|
modeLabel: "Web-Login",
|
|
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account."
|
|
},
|
|
{
|
|
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-Login",
|
|
modeLabel: "Web-Login",
|
|
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-Login",
|
|
modeLabel: "Web-Login",
|
|
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-Login",
|
|
modeLabel: "Web-Login",
|
|
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;
|
|
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 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 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 getAccountCredentialLabel(kind: AccountKind): string {
|
|
switch (kind) {
|
|
case "megadebrid-api":
|
|
case "megadebrid-web":
|
|
case "ddownload-login":
|
|
case "linksnappy-login":
|
|
return "••••••";
|
|
case "bestdebrid-web":
|
|
case "realdebrid-web":
|
|
case "alldebrid-web":
|
|
return "Login gespeichert";
|
|
case "realdebrid-api":
|
|
case "bestdebrid-api":
|
|
case "alldebrid-api":
|
|
case "onefichier-api":
|
|
case "debridlink-api":
|
|
return "API-Key gespeichert";
|
|
default:
|
|
return "Zugang gespeichert";
|
|
}
|
|
}
|
|
|
|
function getConfiguredProvidersFromSettings(settings: RendererSettings): DebridProvider[] {
|
|
return [...settings.configuredProviders];
|
|
}
|
|
|
|
function getActiveProvidersFromSettings(settings: RendererSettings): DebridProvider[] {
|
|
const disabled = new Set(settings.disabledProviders || []);
|
|
return getConfiguredProvidersFromSettings(settings).filter((provider) => !disabled.has(provider));
|
|
}
|
|
|
|
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
|
|
|
function normalizeProviderOrderForSettings(settings: RendererSettings): DebridProvider[] {
|
|
const configured = getConfiguredProvidersFromSettings(settings).filter((provider) => !DIRECT_HOSTERS.has(provider));
|
|
return buildConfiguredProviderOrder(settings.providerOrder || [], configured);
|
|
}
|
|
|
|
function normalizeProviderSelectionForSettings(
|
|
settings: RendererSettings
|
|
): Pick<RendererSettings, "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: RendererSettings, service: AccountService): AccountKind | null {
|
|
const configured = new Set(settings.configuredProviders);
|
|
switch (service) {
|
|
case "realdebrid":
|
|
if (settings.realDebridUseWebLogin) return "realdebrid-web";
|
|
return configured.has("realdebrid") ? "realdebrid-api" : null;
|
|
case "megadebrid-api":
|
|
return configured.has("megadebrid-api") ? "megadebrid-api" : null;
|
|
case "megadebrid-web":
|
|
return configured.has("megadebrid-web") ? "megadebrid-web" : null;
|
|
case "bestdebrid":
|
|
if (settings.bestDebridUseWebLogin) return "bestdebrid-web";
|
|
return configured.has("bestdebrid") ? "bestdebrid-api" : null;
|
|
case "alldebrid":
|
|
if (settings.allDebridUseWebLogin) return "alldebrid-web";
|
|
return configured.has("alldebrid") ? "alldebrid-api" : null;
|
|
case "ddownload":
|
|
return configured.has("ddownload") ? "ddownload-login" : null;
|
|
case "onefichier":
|
|
return configured.has("onefichier") ? "onefichier-api" : null;
|
|
case "debridlink":
|
|
return configured.has("debridlink") ? "debridlink-api" : null;
|
|
case "linksnappy":
|
|
return configured.has("linksnappy") ? "linksnappy-login" : null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function accountsOfKind(kind: AccountKind, accounts: readonly RendererAccount[]): RendererAccount[] {
|
|
return accounts.filter((account) => account.kind === kind);
|
|
}
|
|
|
|
function summarizeAccount(kind: AccountKind, accounts: readonly RendererAccount[]): string {
|
|
const matching = accountsOfKind(kind, accounts);
|
|
if (matching.length > 1) return kind === "debridlink-api" ? `${matching.length} API-Keys` : `${matching.length} Accounts`;
|
|
return matching[0]?.maskedIdentity || "Konfiguriert";
|
|
}
|
|
|
|
function summarizeAccountLines(kind: AccountKind, accounts: readonly RendererAccount[], detailed: boolean): string[] {
|
|
const matching = accountsOfKind(kind, accounts);
|
|
if (matching.length > 1 && (kind !== "debridlink-api" || detailed)) {
|
|
return matching.map((entry, index) => `${kind === "debridlink-api" ? "Key" : "Account"} ${index + 1}: ${entry.maskedIdentity}`);
|
|
}
|
|
return [summarizeAccount(kind, accounts)];
|
|
}
|
|
|
|
function getStoredAccountUsername(kind: AccountKind, accounts: readonly RendererAccount[]): string {
|
|
return accountsOfKind(kind, accounts)[0]?.identity || "";
|
|
}
|
|
|
|
export function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, _settings: RendererSettings): AccountDialogState {
|
|
const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] };
|
|
return {
|
|
mode,
|
|
kind,
|
|
service: kind ? findAccountOption(kind).service : null,
|
|
token: "",
|
|
login: "",
|
|
password: "",
|
|
dailyLimitGb: "",
|
|
keyDailyLimitGbById: {},
|
|
...baseMega
|
|
};
|
|
}
|
|
|
|
export function buildAccountAddFields(dialog: AccountDialogState | null): AccountDialogField[] {
|
|
if (!dialog?.kind) {
|
|
return [];
|
|
}
|
|
const option = findAccountOption(dialog.kind);
|
|
return [
|
|
...((dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") ? [
|
|
{ id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: dialog.megaNewLogin },
|
|
{ id: "megaNewPassword", label: "Passwort", type: "password" as const, value: dialog.megaNewPassword }
|
|
] : option.needsCredentials ? [
|
|
{ id: "login", label: "Login / E-Mail", type: "text" as const, value: dialog.login },
|
|
{ id: "password", label: "Passwort", type: "password" as const, value: dialog.password }
|
|
] : []),
|
|
...(option.needsToken ? [
|
|
{ id: "token", label: dialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: dialog.token }
|
|
] : []),
|
|
{
|
|
id: "dailyLimitGb",
|
|
label: "Tageslimit (GB, optional)",
|
|
type: "number" as const,
|
|
value: dialog.dailyLimitGb,
|
|
placeholder: "Kein Limit",
|
|
help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt."
|
|
}
|
|
];
|
|
}
|
|
|
|
function validateAccountDialog(dialog: AccountDialogState): string | null {
|
|
if (!dialog.kind) {
|
|
return "Bitte zuerst einen Account-Typ auswählen.";
|
|
}
|
|
const option = findAccountOption(dialog.kind);
|
|
if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") {
|
|
if (!dialog.megaNewLogin.trim()) return `${option.title}: Bitte Login oder E-Mail eintragen.`;
|
|
if (!dialog.megaNewPassword) return `${option.title}: Bitte Passwort eintragen.`;
|
|
} else 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") {
|
|
if (parseDebridLinkApiKeys(dialog.token).length === 0) {
|
|
return `${option.title}: Bitte mindestens einen gültigen API-Key eintragen.`;
|
|
}
|
|
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;
|
|
}
|
|
|
|
export function buildAccountCreateCommand(dialog: AccountDialogState): AccountCreateCommand | null {
|
|
if (!dialog.kind) return null;
|
|
const option = findAccountOption(dialog.kind);
|
|
return {
|
|
action: "create",
|
|
kind: dialog.kind,
|
|
identity: dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web" ? dialog.megaNewLogin.trim() : dialog.login.trim(),
|
|
secret: dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web" ? dialog.megaNewPassword : option.needsToken ? dialog.token : dialog.password,
|
|
dailyLimitBytes: parseAccountDailyLimitInputBytes(dialog.dailyLimitGb) || 0
|
|
};
|
|
}
|
|
|
|
export function buildRealDebridWebCreateLoginRequest(dialog: AccountDialogState, accountId: string): RealDebridLoginRequest | null {
|
|
if (dialog.kind !== "realdebrid-web") return null;
|
|
return {
|
|
accountId,
|
|
create: true,
|
|
dailyLimitBytes: parseAccountDailyLimitInputBytes(dialog.dailyLimitGb) || 0
|
|
};
|
|
}
|
|
|
|
const emptyStats = (): DownloadStats => ({
|
|
totalDownloaded: 0,
|
|
totalDownloadedAllTime: 0,
|
|
totalFiles: 0,
|
|
totalFilesSession: 0,
|
|
totalFilesAllTime: 0,
|
|
totalPackages: 0,
|
|
sessionStartedAt: 0,
|
|
appSessionStartedAt: 0,
|
|
sessionRuntimeMs: 0,
|
|
totalRuntimeMs: 0,
|
|
runtimeMeasuredAt: 0
|
|
});
|
|
|
|
const emptySnapshot = (): UiSnapshot => ({
|
|
settings: {
|
|
language: "en", realDebridUseWebLogin: false, realDebridDisabledAccountIds: [], realDebridAccountDailyLimitBytes: {}, realDebridAccountDailyUsageBytes: {}, realDebridAccountTotalUsageBytes: {}, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false,
|
|
debridLinkDisabledKeyIds: [],
|
|
archivePasswordListConfigured: false, notifyUrlConfigured: false,
|
|
rememberToken: true, configuredProviders: [], providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
|
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
|
|
autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", 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", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
|
|
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
|
accountListShowDetailedDebridLinkKeys: false,
|
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
|
columnOrderVersion: 3,
|
|
autoExtractWhenStopped: true,
|
|
disabledProviders: [],
|
|
hosterRouting: {},
|
|
providerDailyLimitBytes: {},
|
|
providerDailyUsageBytes: {},
|
|
providerTotalUsageBytes: {},
|
|
debridLinkApiKeyDailyLimitBytes: {},
|
|
debridLinkApiKeyDailyUsageBytes: {},
|
|
debridLinkApiKeyTotalUsageBytes: {},
|
|
megaDebridDisabledAccountIds: [],
|
|
megaDebridApiDisabledAccountIds: [],
|
|
megaDebridWebDisabledAccountIds: [],
|
|
megaDebridAccountDailyLimitBytes: {},
|
|
megaDebridAccountDailyUsageBytes: {},
|
|
megaDebridAccountTotalUsageBytes: {},
|
|
debridAccountStatuses: {},
|
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
scheduledStartEpochMs: 0
|
|
},
|
|
accounts: [],
|
|
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: false, 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 historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], string> = {
|
|
never: "Nie",
|
|
session: "Nur aktuelle Session",
|
|
permanent: "Dauerhaft"
|
|
};
|
|
|
|
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
|
|
|
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
|
let delay = running && activeTab === "downloads" ? 0 : itemCount >= 250 ? 0 : 100;
|
|
if (!running) delay = Math.min(delay, 200);
|
|
if (activeTab !== "downloads") delay = Math.max(delay, 800);
|
|
return delay;
|
|
}
|
|
|
|
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: RendererSettings): 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 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";
|
|
}
|
|
|
|
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string {
|
|
const untilRestart = /bis zum Tagesreset gesperrt/i.test(ev.reason || "");
|
|
switch (ev.event) {
|
|
case "OK": return "erfolgreich";
|
|
case "FAILED": {
|
|
if (untilRestart) {
|
|
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
|
return `Tageslimit erreicht, bis zum Tagesreset gesperrt${nx}`;
|
|
}
|
|
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
|
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
|
return `fehlgeschlagen${cd}${nx}`;
|
|
}
|
|
case "FATAL": return "abgebrochen (fataler Fehler)";
|
|
case "TIMEOUT_COOLDOWN": {
|
|
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
|
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
|
|
}
|
|
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis zum Tagesreset gesperrt)" : "übersprungen (Cooldown aktiv)";
|
|
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
|
|
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
|
|
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
|
|
case "PROVIDER_WIDE": return "Provider-weiter Fehler, restliche Keys übersprungen";
|
|
case "TRANSPORT_CASCADE": return "Netzwerk-Kaskade, restliche Keys übersprungen";
|
|
default: return ev.event;
|
|
}
|
|
}
|
|
|
|
function getDebridLinkKeyStatusDisplay(
|
|
key: DebridLinkAccountKeyEntry,
|
|
info: DebridLinkHostLimitInfo | null | undefined
|
|
): { label: string; tone: "ok" | "warn" | "bad" | "muted"; title: string } {
|
|
if (key.disabled) {
|
|
return {
|
|
label: "Deaktiviert",
|
|
tone: "muted",
|
|
title: "Key ist manuell deaktiviert."
|
|
};
|
|
}
|
|
if (key.dailyLimitReached) {
|
|
return {
|
|
label: "Lokales Limit",
|
|
tone: "warn",
|
|
title: key.dailyLimitBytes > 0
|
|
? `Lokales Tageslimit erreicht (${humanSize(key.dailyUsedBytes)} / ${humanSize(key.dailyLimitBytes)}).`
|
|
: "Lokales Tageslimit erreicht."
|
|
};
|
|
}
|
|
if (!info) {
|
|
return {
|
|
label: "Pruefe...",
|
|
tone: "muted",
|
|
title: "Live-Status wird geladen."
|
|
};
|
|
}
|
|
|
|
const title = [info.stateDetail, info.note, info.hostNote]
|
|
.filter((value) => Boolean(String(value || "").trim()))
|
|
.join("\n");
|
|
|
|
if (info.state === "ready") {
|
|
if (info.hostState === "down") {
|
|
return {
|
|
label: "Host offline",
|
|
tone: "warn",
|
|
title: title || "Der Hoster ist laut Debrid-Link aktuell offline."
|
|
};
|
|
}
|
|
return {
|
|
label: "Bereit",
|
|
tone: "ok",
|
|
title: title || "Key ist nutzbar."
|
|
};
|
|
}
|
|
|
|
if (info.state === "invalid" || info.state === "error") {
|
|
return {
|
|
label: info.stateLabel,
|
|
tone: "bad",
|
|
title: title || info.stateLabel
|
|
};
|
|
}
|
|
|
|
if (info.state === "quota" || info.state === "rate_limit" || info.state === "cooldown") {
|
|
return {
|
|
label: info.stateLabel,
|
|
tone: "warn",
|
|
title: title || info.stateLabel
|
|
};
|
|
}
|
|
|
|
return {
|
|
label: info.stateLabel || "Unbekannt",
|
|
tone: "muted",
|
|
title: title || info.stateLabel || "Unbekannt"
|
|
};
|
|
}
|
|
|
|
export function readBandwidthChartPalette(
|
|
readProperty: (property: string) => string,
|
|
fontFamily: string
|
|
): { grid: string; text: string; accent: string; fontFamily: string } {
|
|
return {
|
|
grid: readProperty("--ui-border").trim(),
|
|
text: readProperty("--ui-text-muted").trim(),
|
|
accent: readProperty("--ui-speed-accent").trim(),
|
|
fontFamily: fontFamily.trim()
|
|
};
|
|
}
|
|
|
|
export function readDownloadSpeedSparklinePalette(
|
|
readProperty: (property: string) => string
|
|
): { accent: string } {
|
|
return {
|
|
accent: readProperty("--ui-speed-accent").trim()
|
|
};
|
|
}
|
|
|
|
export function appendBandwidthSample(
|
|
history: { time: number; speed: number }[],
|
|
speed: number,
|
|
now = Date.now()
|
|
): { time: number; speed: number }[] {
|
|
const next = [...history, { time: now, speed: Number.isFinite(speed) ? Math.max(0, speed) : 0 }];
|
|
const cutoff = now - 60000;
|
|
const firstVisible = next.findIndex((point) => point.time >= cutoff);
|
|
return firstVisible > 0 ? next.slice(firstVisible) : next;
|
|
}
|
|
|
|
interface BandwidthChartProps {
|
|
running: boolean;
|
|
paused: boolean;
|
|
speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>;
|
|
}
|
|
|
|
const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
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 rootStyle = getComputedStyle(document.documentElement);
|
|
const bodyStyle = getComputedStyle(document.body);
|
|
const palette = readBandwidthChartPalette(
|
|
(property) => rootStyle.getPropertyValue(property),
|
|
bodyStyle.fontFamily || rootStyle.fontFamily
|
|
);
|
|
|
|
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)));
|
|
|
|
ctx.font = `11px ${palette.fontFamily}`;
|
|
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 = palette.grid;
|
|
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 = palette.text;
|
|
ctx.font = `11px ${palette.fontFamily}`;
|
|
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 = palette.text;
|
|
ctx.font = `13px ${palette.fontFamily}`;
|
|
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.save();
|
|
ctx.globalAlpha = 0.15;
|
|
ctx.fillStyle = palette.accent;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
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 = palette.accent;
|
|
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 = palette.accent;
|
|
ctx.fill();
|
|
}, [running, paused]);
|
|
|
|
useEffect(() => {
|
|
drawChart();
|
|
if (!running || paused) {
|
|
return;
|
|
}
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const interval = setInterval(() => {
|
|
drawChart();
|
|
}, reducedMotion ? 1000 : 750);
|
|
return () => clearInterval(interval);
|
|
}, [drawChart, running, paused]);
|
|
|
|
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, paused]);
|
|
|
|
return (
|
|
<div ref={containerRef} className="bandwidth-chart-container">
|
|
<canvas aria-label="Bandbreitenverlauf der letzten 60 Sekunden" ref={canvasRef} role="img">
|
|
Bandbreitenverlauf der letzten 60 Sekunden
|
|
</canvas>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface DownloadSpeedSparklineProps {
|
|
speedBps: number;
|
|
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
|
|
hidden?: boolean;
|
|
}
|
|
|
|
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const speedRef = useRef(speedBps);
|
|
speedRef.current = speedBps;
|
|
|
|
useEffect(() => {
|
|
const draw = (): void => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const cssW = canvas.clientWidth;
|
|
const cssH = canvas.clientHeight;
|
|
if (cssW <= 0 || cssH <= 0) return;
|
|
canvas.width = Math.round(cssW * dpr);
|
|
canvas.height = Math.round(cssH * dpr);
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, cssW, cssH);
|
|
|
|
const hist = speedStateRef.current.history;
|
|
if (hist.length < 2) return;
|
|
|
|
const rootStyle = getComputedStyle(document.documentElement);
|
|
const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property));
|
|
|
|
let maxV = 0;
|
|
for (const v of hist) if (v > maxV) maxV = v;
|
|
maxV = Math.max(maxV, 1024 * 1024);
|
|
|
|
const pad = 2;
|
|
const h = cssH - pad * 2;
|
|
const step = cssW / (DOWNLOAD_SPEED_MAX_SAMPLES - 1);
|
|
const startIdx = DOWNLOAD_SPEED_MAX_SAMPLES - hist.length;
|
|
const px = (i: number): number => (startIdx + i) * step;
|
|
const py = (v: number): number => pad + h - (v / maxV) * h;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(px(0), py(hist[0]));
|
|
for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i]));
|
|
ctx.lineTo(px(hist.length - 1), pad + h);
|
|
ctx.lineTo(px(0), pad + h);
|
|
ctx.closePath();
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.fillStyle = palette.accent;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(px(0), py(hist[0]));
|
|
for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i]));
|
|
ctx.strokeStyle = palette.accent;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.lineJoin = "round";
|
|
ctx.stroke();
|
|
};
|
|
|
|
const tick = (): void => {
|
|
const target = speedRef.current;
|
|
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
|
|
draw();
|
|
};
|
|
|
|
const id = window.setInterval(tick, 750);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`speed-sparkline${hidden ? " speed-sparkline-hidden" : ""}`} aria-hidden={hidden} title="Aktuelle Download-Geschwindigkeit (geglättet)">
|
|
<canvas ref={canvasRef} className="speed-sparkline-canvas" />
|
|
<span className="speed-sparkline-value">{speedBps > 0 ? formatSpeedMbps(speedBps) : "0 B/s"}</span>
|
|
</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;
|
|
}
|
|
|
|
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
|
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
|
const COLUMN_DEFS = downloadColumnDefinitions;
|
|
|
|
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 shouldApplyUpdateCheckResult(
|
|
completedGeneration: number,
|
|
currentGeneration: number
|
|
): boolean {
|
|
return completedGeneration === currentGeneration;
|
|
}
|
|
|
|
export async function runLatestUpdateCheck(
|
|
generationRef: { current: number },
|
|
check: () => Promise<UpdateCheckResult>,
|
|
apply: (result: UpdateCheckResult, generation: number) => Promise<void> | void
|
|
): Promise<void> {
|
|
const generation = ++generationRef.current;
|
|
const result = await check();
|
|
if (!shouldApplyUpdateCheckResult(generation, generationRef.current)) {
|
|
return;
|
|
}
|
|
await apply(result, generation);
|
|
}
|
|
|
|
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 [availableUpdate, setAvailableUpdate] = useState<UpdateCheckResult | null>(null);
|
|
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
|
|
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
|
|
const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings));
|
|
const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme);
|
|
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
|
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
|
const [settingsDirty, setSettingsDirty] = useState(false);
|
|
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
|
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
|
const [scheduleCountdown, setScheduleCountdown] = useState("");
|
|
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
|
|
const updateCheckGenerationRef = useRef(0);
|
|
const settingsDirtyRef = useRef(false);
|
|
const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">());
|
|
const archivePasswordLoadGenerationRef = useRef(0);
|
|
const settingsDraftRevisionRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const localizer = createUiLocalizer(document, normalizeLanguage(settingsDraft.language));
|
|
return () => localizer.disconnect();
|
|
}, [settingsDraft.language]);
|
|
const panelDirtyRevisionRef = useRef(0);
|
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
|
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
|
const snapshotRef = useRef(snapshot);
|
|
snapshotRef.current = snapshot;
|
|
const tabRef = useRef(tab);
|
|
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 [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null);
|
|
const [providerDropTarget, setProviderDropTarget] = useState<DebridProvider | null>(null);
|
|
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 [collectorQuery, setCollectorQuery] = useState("");
|
|
const [selectedCollectorRowIds, setSelectedCollectorRowIds] = useState<Set<string>>(() => new Set());
|
|
const [collectorError, setCollectorError] = useState("");
|
|
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
|
|
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 [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
|
const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0);
|
|
const [downloadSearch, setDownloadSearch] = useState("");
|
|
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
|
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
|
|
const [downloadProviderFilter, setDownloadProviderFilter] = useState("all");
|
|
const [downloadsSortColumn, setDownloadsSortColumn] = useState<DownloadSortColumn>("name");
|
|
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
|
const [showAllPackages, setShowAllPackages] = useState(false);
|
|
const [actionBusy, setActionBusy] = useState(false);
|
|
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
|
const actionBusyRef = useRef(false);
|
|
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const mountedRef = useRef(true);
|
|
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
|
const dragOverRef = useRef(false);
|
|
const dragDepthRef = useRef(0);
|
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
|
|
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview");
|
|
const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set());
|
|
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 [backupPassphraseMode, setBackupPassphraseMode] = useState<BackupPassphraseMode | null>(null);
|
|
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
|
|
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
|
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
|
|
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
|
|
const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local");
|
|
const [rdPublicHost, setRdPublicHost] = useState("");
|
|
const [rdPort, setRdPort] = useState("9868");
|
|
const [rdAllowlist, setRdAllowlist] = useState("");
|
|
const [rdName, setRdName] = useState("");
|
|
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
|
const backupPassphraseResolverRef = useRef<((passphrase: string | null) => 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 [accountContextMenu, setAccountContextMenu] = useState<AccountContextMenuState | null>(null);
|
|
const accountContextMenuRef = useRef<HTMLDivElement>(null);
|
|
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
|
|
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
|
|
const [accountEditDialog, setAccountEditDialog] = useState<AccountEditState | null>(null);
|
|
const [accountEditSecretVisible, setAccountEditSecretVisible] = useState<Record<string, boolean>>({});
|
|
const [accountEditSecretBusy, setAccountEditSecretBusy] = useState<string | null>(null);
|
|
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
|
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
|
|
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 columnOrderPersistenceRef = useRef<DownloadColumnOrderPersistence | null>(null);
|
|
if (!columnOrderPersistenceRef.current) {
|
|
columnOrderPersistenceRef.current = createDownloadColumnOrderPersistence(
|
|
DEFAULT_COLUMN_ORDER,
|
|
async (order) => {
|
|
const settings = await window.rd.updateSettings({ columnOrder: order });
|
|
return settings.columnOrder?.length ? settings.columnOrder : order;
|
|
},
|
|
setColumnOrder
|
|
);
|
|
}
|
|
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
|
|
const columnDragSettleTimerRef = useRef<number | null>(null);
|
|
const columnDragAnimationsRef = useRef<Animation[]>([]);
|
|
const suppressColumnSortRef = useRef(false);
|
|
const cancelColumnDragAnimations = useCallback((): void => {
|
|
columnDragAnimationsRef.current.forEach((animation) => animation.cancel());
|
|
columnDragAnimationsRef.current = [];
|
|
}, []);
|
|
useEffect(() => () => {
|
|
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
|
|
cancelColumnDragAnimations();
|
|
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
|
}, [cancelColumnDragAnimations]);
|
|
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
|
|
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
|
|
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
|
const historyEntriesRef = useRef<HistoryEntry[]>([]);
|
|
const [historyExpandedIds, setHistoryExpandedIds] = useState<Set<string>>(() => new Set());
|
|
const [selectedHistoryIds, setSelectedHistoryIds] = useState<Set<string>>(new Set());
|
|
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
|
|
const [historyQuery, setHistoryQuery] = useState("");
|
|
const [statisticsRange, setStatisticsRange] = useState<StatisticsRange>("session");
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
const [historyError, setHistoryError] = useState("");
|
|
const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null);
|
|
const historyCtxMenuRef = useRef<HTMLDivElement>(null);
|
|
const historyLoadGenerationRef = useRef(0);
|
|
const historyLoadActiveRef = useRef(false);
|
|
const pendingLiveHistoryEntriesRef = useRef<HistoryEntry[]>([]);
|
|
const historyVisibleIdsRef = useRef<string[]>([]);
|
|
const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null);
|
|
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
|
|
const allDebridHostRequestRef = useRef(0);
|
|
const debridLinkHostLimitsRequestRef = useRef(0);
|
|
|
|
const persistColumnOrder = useCallback((order: string[]): void => {
|
|
columnOrderPersistenceRef.current?.enqueue(order);
|
|
}, []);
|
|
|
|
const collectorViewModel = useMemo(() => buildCollectorViewModel(
|
|
collectorTabs,
|
|
activeCollectorTab,
|
|
collectorQuery,
|
|
actionBusy,
|
|
[...selectedCollectorRowIds],
|
|
collectorError
|
|
), [actionBusy, activeCollectorTab, collectorError, collectorQuery, collectorTabs, selectedCollectorRowIds]);
|
|
|
|
const historyViewModel = useMemo(() => buildHistoryViewModel(
|
|
historyEntries,
|
|
historyFilter,
|
|
historyQuery,
|
|
selectedHistoryIds,
|
|
historyExpandedIds,
|
|
historyLoading,
|
|
historyError,
|
|
runtimeNow,
|
|
snapshot.settings.animatePackageDisclosure
|
|
), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds, snapshot.settings.animatePackageDisclosure]);
|
|
historyVisibleIdsRef.current = historyViewModel.rows.map((entry) => entry.id);
|
|
const statisticsViewModel = useMemo(
|
|
() => buildStatisticsViewModel(snapshot, statisticsRange, runtimeNow),
|
|
[runtimeNow, snapshot, statisticsRange]
|
|
);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setRuntimeNow(Date.now()), 1000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
const showToast = useCallback((message: string, timeoutMs = 2200): void => {
|
|
setStatusToast(message);
|
|
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
|
toastTimerRef.current = setTimeout(() => {
|
|
setStatusToast("");
|
|
toastTimerRef.current = null;
|
|
}, timeoutMs);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (settingsSubTab !== "extract" || writeOnlySettingsDirtyRef.current.has("archivePasswordList")) {
|
|
archivePasswordLoadGenerationRef.current += 1;
|
|
return;
|
|
}
|
|
const generation = archivePasswordLoadGenerationRef.current + 1;
|
|
archivePasswordLoadGenerationRef.current = generation;
|
|
void window.rd.getArchivePasswordList().then(({ passwords }) => {
|
|
if (archivePasswordLoadGenerationRef.current !== generation || writeOnlySettingsDirtyRef.current.has("archivePasswordList")) {
|
|
return;
|
|
}
|
|
setSettingsDraft((current) => current.archivePasswordList === passwords
|
|
? current
|
|
: { ...current, archivePasswordList: passwords });
|
|
}).catch(() => {
|
|
if (archivePasswordLoadGenerationRef.current === generation) {
|
|
showToast("Archiv-Passwortliste konnte nicht geladen werden", 2800);
|
|
}
|
|
});
|
|
return () => {
|
|
if (archivePasswordLoadGenerationRef.current === generation) {
|
|
archivePasswordLoadGenerationRef.current += 1;
|
|
}
|
|
};
|
|
}, [settingsSubTab, snapshot.settings.archivePasswordListConfigured, showToast]);
|
|
|
|
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
|
|
const availableIds = entries.map((entry) => entry.id);
|
|
const availableSet = new Set(availableIds);
|
|
historyEntriesRef.current = entries;
|
|
setHistoryEntries(entries);
|
|
setSelectedHistoryIds((current) => pruneHistoryIds(current, availableIds));
|
|
setHistoryExpandedIds((current) => pruneHistoryIds(current, availableIds));
|
|
setHistoryCtxMenu((current) => current && availableSet.has(current.entryId) ? current : null);
|
|
}, []);
|
|
|
|
const loadHistoryEntries = useCallback(async (): Promise<void> => {
|
|
const generation = ++historyLoadGenerationRef.current;
|
|
historyLoadActiveRef.current = true;
|
|
pendingLiveHistoryEntriesRef.current = [];
|
|
setHistoryLoading(true);
|
|
setHistoryError("");
|
|
try {
|
|
const entries = await window.rd.getHistory();
|
|
if (!mountedRef.current || generation !== historyLoadGenerationRef.current) {
|
|
return;
|
|
}
|
|
const settings = snapshotRef.current.settings;
|
|
const merged = [...pendingLiveHistoryEntriesRef.current].reverse().reduce(
|
|
(current, entry) => mergeLiveHistoryEntry(current, entry, {
|
|
maxEntries: settings.historyMaxEntries,
|
|
maxAgeDays: settings.historyMaxAgeDays
|
|
}),
|
|
entries
|
|
);
|
|
pendingLiveHistoryEntriesRef.current = [];
|
|
applyHistoryEntries(merged);
|
|
} catch {
|
|
if (mountedRef.current && generation === historyLoadGenerationRef.current) {
|
|
setHistoryError("Verlauf konnte nicht geladen werden");
|
|
}
|
|
} finally {
|
|
if (mountedRef.current && generation === historyLoadGenerationRef.current) {
|
|
historyLoadActiveRef.current = false;
|
|
pendingLiveHistoryEntriesRef.current = [];
|
|
setHistoryLoading(false);
|
|
}
|
|
}
|
|
}, [applyHistoryEntries]);
|
|
|
|
useEffect(() => {
|
|
if (tab !== "history") {
|
|
return;
|
|
}
|
|
void loadHistoryEntries();
|
|
return () => {
|
|
historyLoadGenerationRef.current += 1;
|
|
historyLoadActiveRef.current = false;
|
|
pendingLiveHistoryEntriesRef.current = [];
|
|
};
|
|
}, [loadHistoryEntries, tab]);
|
|
|
|
useEffect(() => {
|
|
const unsubscribeHistoryEntryAdded = window.rd.onHistoryEntryAdded((entry) => {
|
|
if (activeTabRef.current !== "history") {
|
|
return;
|
|
}
|
|
if (historyLoadActiveRef.current) {
|
|
pendingLiveHistoryEntriesRef.current = [
|
|
entry,
|
|
...pendingLiveHistoryEntriesRef.current.filter((pending) => pending.id !== entry.id)
|
|
];
|
|
}
|
|
const settings = snapshotRef.current.settings;
|
|
applyHistoryEntries(mergeLiveHistoryEntry(historyEntriesRef.current, entry, {
|
|
maxEntries: settings.historyMaxEntries,
|
|
maxAgeDays: settings.historyMaxAgeDays
|
|
}));
|
|
setHistoryError("");
|
|
});
|
|
return unsubscribeHistoryEntryAdded;
|
|
}, [applyHistoryEntries]);
|
|
|
|
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 = snapshot.accounts.filter((account) => account.kind === "debridlink-api");
|
|
if (apiKeys.length === 0) {
|
|
throw new Error("Debrid-Link ist nicht konfiguriert");
|
|
}
|
|
const limits = await window.rd.getDebridLinkHostLimits();
|
|
if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) {
|
|
return;
|
|
}
|
|
setDebridLinkHostLimits(
|
|
Object.fromEntries(limits.map((info) => [info.keyId, info]))
|
|
);
|
|
} 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);
|
|
}
|
|
}
|
|
}, [snapshot.accounts, 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(() => {
|
|
mountedRef.current = true;
|
|
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.getTraceConfig().then((config) => {
|
|
if (mountedRef.current) {
|
|
setSupportTraceEnabled(config.enabled);
|
|
}
|
|
}).catch(() => undefined);
|
|
void window.rd.getSnapshot().then((state) => {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
masterSnapshotRef.current = state;
|
|
setSnapshot(state);
|
|
if (state.settings.columnOrder?.length > 0) {
|
|
columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder);
|
|
}
|
|
setSettingsDraft((current) => createSettingsDraft(state.settings, current));
|
|
writeOnlySettingsDirtyRef.current.clear();
|
|
settingsDirtyRef.current = false;
|
|
panelDirtyRevisionRef.current = 0;
|
|
setSettingsDirty(false);
|
|
setSettingsSaveState("clean");
|
|
setSettingsThemeChoice(state.settings.theme);
|
|
applyTheme(state.settings.theme);
|
|
if (state.settings.autoUpdateCheck) {
|
|
void runLatestUpdateCheck(
|
|
updateCheckGenerationRef,
|
|
() => window.rd.checkUpdates(),
|
|
(result, generation) => handleUpdateResult(result, "startup", generation)
|
|
).catch(() => undefined);
|
|
}
|
|
}).catch((error) => {
|
|
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
|
|
});
|
|
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
|
let merged: UiSnapshot;
|
|
const master = masterSnapshotRef.current;
|
|
if (wireState.payloadKind === "delta" && master) {
|
|
const newItems: Record<string, DownloadItem> = { ...master.session.items, ...wireState.session.items };
|
|
if (wireState.removedItemIds && wireState.removedItemIds.length > 0) {
|
|
for (const id of wireState.removedItemIds) delete newItems[id];
|
|
}
|
|
const newPackages: Record<string, PackageEntry> = { ...master.session.packages, ...wireState.session.packages };
|
|
if (wireState.removedPackageIds && wireState.removedPackageIds.length > 0) {
|
|
for (const id of wireState.removedPackageIds) delete newPackages[id];
|
|
}
|
|
merged = {
|
|
...wireState,
|
|
session: {
|
|
...wireState.session,
|
|
items: newItems,
|
|
packages: newPackages,
|
|
},
|
|
};
|
|
} else {
|
|
merged = wireState;
|
|
}
|
|
masterSnapshotRef.current = merged;
|
|
latestStateRef.current = merged;
|
|
if (stateFlushTimerRef.current) { return; }
|
|
|
|
const itemCount = Object.keys(merged.session.items).length;
|
|
const flushDelay = getSnapshotRenderDelay(itemCount, merged.session.running, activeTabRef.current);
|
|
|
|
stateFlushTimerRef.current = setTimeout(() => {
|
|
stateFlushTimerRef.current = null;
|
|
if (latestStateRef.current) {
|
|
const next = latestStateRef.current;
|
|
setSnapshot(next);
|
|
if (!settingsDirtyRef.current) {
|
|
setSettingsDraft((current) => createSettingsDraft(next.settings, current));
|
|
}
|
|
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);
|
|
}
|
|
if (backupPassphraseResolverRef.current) {
|
|
const resolver = backupPassphraseResolverRef.current;
|
|
backupPassphraseResolverRef.current = null;
|
|
resolver(null);
|
|
}
|
|
while (confirmQueueRef.current.length > 0) {
|
|
const request = confirmQueueRef.current.shift();
|
|
request?.resolve(false);
|
|
}
|
|
if (unsubscribe) { unsubscribe(); }
|
|
if (unsubClipboard) { unsubClipboard(); }
|
|
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
|
};
|
|
}, [clearImportQueueFocusListener]);
|
|
|
|
const downloadsTabActive = tab === "downloads";
|
|
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
|
const gridTemplate = useMemo(() => columnOrder.map((col) => COLUMN_DEFS[col]?.width ?? "100px").join(" "), [columnOrder]);
|
|
const totalPackageCount = snapshot.session.packageOrder.length;
|
|
|
|
const packageOrderKey = useMemo(() => {
|
|
if (!downloadsTabActive) {
|
|
return "";
|
|
}
|
|
return snapshot.session.packageOrder.join("|");
|
|
}, [downloadsTabActive, snapshot.session.packageOrder]);
|
|
|
|
const packages = useMemo(() => {
|
|
if (!downloadsTabActive) {
|
|
return [] as PackageEntry[];
|
|
}
|
|
return snapshot.session.packageOrder
|
|
.map((id) => snapshot.session.packages[id])
|
|
.filter((pkg): pkg is PackageEntry => Boolean(pkg));
|
|
}, [downloadsTabActive, packageOrderKey, 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]);
|
|
|
|
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]);
|
|
|
|
// Prune selection when its packages/items disappear (e.g. via delta-removal or
|
|
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
|
|
// a stale id would otherwise inflate the selection count and the "(N)" labels.
|
|
useEffect(() => {
|
|
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
|
|
}, [snapshot.session.packages, snapshot.session.items]);
|
|
|
|
const visiblePackages = useMemo(() => {
|
|
return preservePackageOrderForDisplay(packages);
|
|
}, [packages]);
|
|
|
|
const downloadsViewCore = useMemo(() => buildDownloadsViewModel({
|
|
packageOrder: visiblePackages.map((entry) => entry.id),
|
|
packages: snapshot.session.packages,
|
|
items: snapshot.session.items,
|
|
displayMode: downloadDisplayMode,
|
|
filter: downloadFilter,
|
|
providerFilter: downloadProviderFilter,
|
|
query: deferredDownloadSearch,
|
|
collapsedPackageIds: Object.entries(collapsedPackages).filter(([, value]) => value).map(([id]) => id),
|
|
selectedIds,
|
|
hideExtractedItems: snapshot.settings.hideExtractedItems,
|
|
showAllPackages: showAllPackages || !snapshot.session.running,
|
|
renderLimit: AUTO_RENDER_PACKAGE_LIMIT
|
|
}), [collapsedPackages, deferredDownloadSearch, downloadDisplayMode, downloadFilter, downloadProviderFilter, selectedIds, showAllPackages, snapshot.session.items, snapshot.session.packages, snapshot.session.running, snapshot.settings.hideExtractedItems, visiblePackages]);
|
|
|
|
const hasSavedAllDebridAccount = snapshot.accounts.some((account) => account.provider === "alldebrid");
|
|
const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin;
|
|
|
|
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.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
|
|
|
const allPackagesCollapsed = useMemo(() => (
|
|
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
|
), [packages, collapsedPackages]);
|
|
|
|
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
|
|
|
const totalConfiguredAccounts = snapshot.accounts.length;
|
|
|
|
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
|
|
|
|
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
|
settingsDraftRevisionRef.current += 1;
|
|
panelDirtyRevisionRef.current += 1;
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("dirty");
|
|
setSettingsDraft((prev) => ({
|
|
...prev,
|
|
providerOrder: newOrder,
|
|
providerPrimary: newOrder[0] ?? prev.providerPrimary,
|
|
providerSecondary: newOrder[1] ?? "none",
|
|
providerTertiary: newOrder[2] ?? "none"
|
|
}));
|
|
}, []);
|
|
|
|
const onProviderDragStart = useCallback((event: DragEvent<HTMLElement>, provider: DebridProvider): void => {
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", provider);
|
|
setDraggedProvider(provider);
|
|
setProviderDropTarget(provider);
|
|
}, []);
|
|
|
|
const onProviderDragOver = useCallback((event: DragEvent<HTMLElement>, provider: DebridProvider): void => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
if (providerDropTarget !== provider) {
|
|
setProviderDropTarget(provider);
|
|
}
|
|
}, [providerDropTarget]);
|
|
|
|
const onProviderDrop = useCallback((event: DragEvent<HTMLElement>, provider: DebridProvider): void => {
|
|
event.preventDefault();
|
|
if (!draggedProvider || draggedProvider === provider) {
|
|
return;
|
|
}
|
|
const currentOrder = [...activeProviderOrder];
|
|
const fromIndex = currentOrder.indexOf(draggedProvider);
|
|
const toIndex = currentOrder.indexOf(provider);
|
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
|
return;
|
|
}
|
|
currentOrder.splice(fromIndex, 1);
|
|
currentOrder.splice(toIndex, 0, draggedProvider);
|
|
setProviderOrder(currentOrder);
|
|
setProviderDropTarget(provider);
|
|
}, [activeProviderOrder, draggedProvider, setProviderOrder]);
|
|
|
|
const onProviderDragEnd = useCallback((): void => {
|
|
setDraggedProvider(null);
|
|
setProviderDropTarget(null);
|
|
}, []);
|
|
|
|
const normalizedSettingsDraft: RendererSettingsDraft = 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") {
|
|
const megaAccountCount = accountsOfKind(kind, snapshot.accounts).length;
|
|
statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert";
|
|
note = "Nur API aktiv. Kein Web-Fallback.";
|
|
} else if (kind === "megadebrid-web") {
|
|
const megaAccountCount = accountsOfKind(kind, snapshot.accounts).length;
|
|
statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert";
|
|
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 = accountsOfKind(kind, snapshot.accounts).length;
|
|
statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Aktiviert";
|
|
}
|
|
const provider = getAccountServiceProvider(service);
|
|
const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider);
|
|
const totalUsedBytes = getProviderTotalUsageBytes(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"
|
|
? accountsOfKind(kind, snapshot.accounts).map((account, index) => {
|
|
const keyDailyUsedBytes = account.dailyUsageBytes;
|
|
const keyDailyLimitBytes = account.dailyLimitBytes;
|
|
const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({
|
|
debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes,
|
|
debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes,
|
|
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
|
|
providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes,
|
|
providerDailyUsageDay: snapshot.settings.providerDailyUsageDay
|
|
}, account.accountId);
|
|
return {
|
|
id: account.accountId,
|
|
label: `Key ${index + 1}`,
|
|
token: "",
|
|
masked: account.maskedIdentity,
|
|
disabled: settingsDraft.debridLinkDisabledKeyIds.includes(account.accountId),
|
|
dailyUsedBytes: keyDailyUsedBytes,
|
|
totalUsedBytes: account.totalUsageBytes,
|
|
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, snapshot.accounts),
|
|
summaryLines: summarizeAccountLines(kind, snapshot.accounts, settingsDraft.accountListShowDetailedDebridLinkKeys),
|
|
note,
|
|
disabled: isDisabled,
|
|
dailyUsedBytes,
|
|
totalUsedBytes,
|
|
dailyLimitBytes,
|
|
dailyRemainingBytes,
|
|
dailyLimitReached,
|
|
debridLinkKeys
|
|
});
|
|
}
|
|
return entries;
|
|
}, [settingsDraft, snapshot.accounts, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]);
|
|
|
|
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]);
|
|
|
|
const accountRows = useMemo(() => {
|
|
const rows: AccountTableRow[] = [];
|
|
for (const entry of configuredAccounts) {
|
|
if (entry.service === "realdebrid") {
|
|
const accounts = snapshot.accounts.filter((account): account is RendererAccount & { kind: "realdebrid-api" | "realdebrid-web" } => (
|
|
account.provider === "realdebrid" && (account.kind === "realdebrid-api" || account.kind === "realdebrid-web")
|
|
));
|
|
for (const account of accounts) {
|
|
const option = findAccountOption(account.kind);
|
|
const disabled = (settingsDraft.disabledProviders || []).includes("realdebrid")
|
|
|| (settingsDraft.realDebridDisabledAccountIds || []).includes(account.accountId);
|
|
const rowEntry: ConfiguredAccountEntry = {
|
|
...entry,
|
|
kind: account.kind,
|
|
modeLabel: option.modeLabel,
|
|
statusLabel: disabled ? "Deaktiviert" : "Aktiviert",
|
|
summary: account.maskedIdentity,
|
|
summaryLines: [account.maskedIdentity],
|
|
disabled,
|
|
dailyUsedBytes: account.dailyUsageBytes,
|
|
totalUsedBytes: account.totalUsageBytes,
|
|
dailyLimitBytes: account.dailyLimitBytes,
|
|
dailyRemainingBytes: account.dailyLimitBytes > 0 ? Math.max(0, account.dailyLimitBytes - account.dailyUsageBytes) : 0,
|
|
dailyLimitReached: account.dailyLimitBytes > 0 && account.dailyUsageBytes >= account.dailyLimitBytes
|
|
};
|
|
rows.push({
|
|
rowKey: `rd-${account.accountId}`,
|
|
entry: rowEntry,
|
|
hosterLabel: option.serviceLabel,
|
|
modeLabel: option.modeLabel,
|
|
username: account.identity,
|
|
credentialLabel: getAccountCredentialLabel(account.kind),
|
|
accountId: account.accountId,
|
|
checkable: true,
|
|
disabled,
|
|
dailyUsedBytes: account.dailyUsageBytes,
|
|
dailyLimitBytes: account.dailyLimitBytes,
|
|
dailyRemainingBytes: account.dailyLimitBytes > 0 ? Math.max(0, account.dailyLimitBytes - account.dailyUsageBytes) : 0,
|
|
totalUsedBytes: account.totalUsageBytes,
|
|
toggleKind: "rd",
|
|
editTarget: {
|
|
type: "single",
|
|
rowKey: `rd-${account.accountId}`,
|
|
kind: account.kind,
|
|
service: "realdebrid",
|
|
provider: "realdebrid",
|
|
accountId: account.accountId
|
|
}
|
|
});
|
|
}
|
|
} else if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") {
|
|
const accounts = accountsOfKind(entry.kind, snapshot.accounts);
|
|
for (const acc of accounts) {
|
|
const used = acc.dailyUsageBytes;
|
|
const limit = acc.dailyLimitBytes;
|
|
rows.push({
|
|
rowKey: `mega-${entry.kind}-${acc.accountId}`,
|
|
entry,
|
|
hosterLabel: entry.serviceLabel,
|
|
modeLabel: entry.modeLabel,
|
|
username: acc.identity,
|
|
credentialLabel: "••••••",
|
|
accountId: acc.accountId,
|
|
checkable: true,
|
|
disabled: entry.disabled || (entry.kind === "megadebrid-api"
|
|
? !settingsDraft.megaDebridApiEnabled || settingsDraft.megaDebridApiDisabledAccountIds.includes(acc.accountId)
|
|
: !settingsDraft.megaDebridWebEnabled || settingsDraft.megaDebridWebDisabledAccountIds.includes(acc.accountId)),
|
|
dailyUsedBytes: used,
|
|
dailyLimitBytes: limit,
|
|
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
|
|
totalUsedBytes: acc.totalUsageBytes,
|
|
toggleKind: "mega",
|
|
editTarget: {
|
|
type: "mega",
|
|
rowKey: `mega-${entry.kind}-${acc.accountId}`,
|
|
kind: entry.kind,
|
|
service: entry.service as "megadebrid-api" | "megadebrid-web",
|
|
accountId: acc.accountId
|
|
}
|
|
});
|
|
}
|
|
} else if (entry.kind === "debridlink-api") {
|
|
for (const key of entry.debridLinkKeys) {
|
|
rows.push({
|
|
rowKey: `dl-${key.id}`,
|
|
entry,
|
|
hosterLabel: entry.serviceLabel,
|
|
modeLabel: entry.modeLabel,
|
|
username: "",
|
|
credentialLabel: "API-Key",
|
|
accountId: key.id,
|
|
checkable: true,
|
|
disabled: entry.disabled || settingsDraft.debridLinkDisabledKeyIds.includes(key.id),
|
|
dailyUsedBytes: key.dailyUsedBytes,
|
|
dailyLimitBytes: key.dailyLimitBytes,
|
|
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
|
|
totalUsedBytes: key.totalUsedBytes,
|
|
toggleKind: "dl",
|
|
dlKey: key,
|
|
editTarget: {
|
|
type: "debridlink",
|
|
rowKey: `dl-${key.id}`,
|
|
kind: "debridlink-api",
|
|
service: "debridlink",
|
|
keyId: key.id
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
const serviceAccountId = null;
|
|
rows.push({
|
|
rowKey: `svc-${entry.service}`,
|
|
entry,
|
|
hosterLabel: entry.serviceLabel,
|
|
modeLabel: entry.modeLabel,
|
|
username: getStoredAccountUsername(entry.kind, snapshot.accounts),
|
|
credentialLabel: getAccountCredentialLabel(entry.kind),
|
|
accountId: serviceAccountId,
|
|
checkable: serviceAccountId !== null,
|
|
disabled: entry.disabled,
|
|
dailyUsedBytes: entry.dailyUsedBytes,
|
|
dailyLimitBytes: entry.dailyLimitBytes,
|
|
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
|
|
totalUsedBytes: entry.totalUsedBytes,
|
|
toggleKind: "single",
|
|
editTarget: {
|
|
type: "single",
|
|
rowKey: `svc-${entry.service}`,
|
|
kind: entry.kind as SingleAccountKind,
|
|
service: entry.service,
|
|
provider: entry.provider
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}, [configuredAccounts, settingsDraft, snapshot.accounts]);
|
|
|
|
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
|
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
|
|
|
useEffect(() => {
|
|
const existingRowKeys = accountRows.map((row) => row.rowKey);
|
|
setSelectedAccountRowKeys((current) => {
|
|
const next = pruneAccountRowSelections([...current], existingRowKeys);
|
|
if (next.length === current.size && next.every((rowKey) => current.has(rowKey))) {
|
|
return current;
|
|
}
|
|
return new Set(next);
|
|
});
|
|
}, [accountRows]);
|
|
const availableAccountOptions = useMemo(() => (
|
|
getAvailableAccountOptions(ACCOUNT_OPTIONS, [...configuredAccountServices])
|
|
), [configuredAccountServices]);
|
|
const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null;
|
|
const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null;
|
|
const accountEditStatus = accountEditRow?.accountId ? snapshot.settings.debridAccountStatuses?.[accountEditRow.accountId] ?? null : null;
|
|
const accountEditQuickAction = accountEditOption ? getAccountQuickActionMeta(accountEditOption.kind) : null;
|
|
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
|
|
const accountDialogSelectableOptions = useMemo(() => {
|
|
if (!accountDialog) {
|
|
return [];
|
|
}
|
|
return getAccountDialogSelectableOptions(
|
|
ACCOUNT_OPTIONS,
|
|
availableAccountOptions,
|
|
accountDialog.mode,
|
|
accountDialog.service
|
|
);
|
|
}, [accountDialog, availableAccountOptions]);
|
|
const accountDialogServiceFilters = useMemo(() => (
|
|
sortAccountServices(accountDialogSelectableOptions.map((option) => option.serviceLabel))
|
|
), [accountDialogSelectableOptions]);
|
|
const filteredAccountDialogOptions = useMemo(() => (
|
|
filterAccountDialogOptions(accountDialogSelectableOptions, accountDialogSearch, accountDialogServiceFilter)
|
|
), [accountDialogSearch, accountDialogSelectableOptions, accountDialogServiceFilter]);
|
|
const handleUpdateResult = async (
|
|
result: UpdateCheckResult,
|
|
source: "manual" | "startup",
|
|
generation: number
|
|
): Promise<void> => {
|
|
if (!mountedRef.current || !shouldApplyUpdateCheckResult(generation, updateCheckGenerationRef.current)) {
|
|
return;
|
|
}
|
|
if (result.error) {
|
|
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
|
|
return;
|
|
}
|
|
if (!result.updateAvailable) {
|
|
setAvailableUpdate(null);
|
|
setUpdateDialogOpen(false);
|
|
setUpdateInstallProgress(null);
|
|
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
|
|
return;
|
|
}
|
|
let changelogText = "";
|
|
if (result.releaseNotes) {
|
|
changelogText = result.releaseNotes
|
|
.split("\n")
|
|
.filter((line) => !/^#{1,6}\s/.test(line))
|
|
.map((line) => line
|
|
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
.replace(/\*([^*]+)\*/g, "$1")
|
|
.replace(/`([^`]+)`/g, "$1"))
|
|
.join("\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
setAvailableUpdate({
|
|
...result,
|
|
releaseNotes: changelogText
|
|
});
|
|
setUpdateInstallProgress(null);
|
|
setUpdateDialogOpen(true);
|
|
};
|
|
|
|
const installUpdate = async (): Promise<void> => {
|
|
if (!availableUpdate) {
|
|
return;
|
|
}
|
|
setUpdateDialogOpen(true);
|
|
setUpdateInstallProgress({
|
|
stage: "starting",
|
|
percent: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
message: "Update wird vorbereitet"
|
|
});
|
|
try {
|
|
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);
|
|
} catch (error) {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
const message = String(error);
|
|
setUpdateInstallProgress({
|
|
stage: "error",
|
|
percent: null,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
message
|
|
});
|
|
showToast(`Auto-Update fehlgeschlagen: ${message}`, 3200);
|
|
}
|
|
};
|
|
|
|
const onSaveSettings = async (): Promise<void> => {
|
|
if (actionBusyRef.current) {
|
|
return;
|
|
}
|
|
const revisionAtStart = settingsDraftRevisionRef.current;
|
|
setSettingsSaveState("saving");
|
|
await performQuickAction(async () => {
|
|
const result = await persistDraftSettings();
|
|
applyTheme(result.theme);
|
|
setSettingsSaveState(settingsDraftRevisionRef.current === revisionAtStart ? "saved" : "dirty");
|
|
showToast("Einstellungen gespeichert", 1800);
|
|
}, (error) => {
|
|
setSettingsSaveState("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: RendererSettings, preserveWriteOnlyValues = true): void => {
|
|
if (!preserveWriteOnlyValues) {
|
|
archivePasswordLoadGenerationRef.current += 1;
|
|
}
|
|
setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined));
|
|
if (result.columnOrder?.length) {
|
|
columnOrderPersistenceRef.current?.applyAuthoritative(result.columnOrder);
|
|
}
|
|
writeOnlySettingsDirtyRef.current.clear();
|
|
settingsDirtyRef.current = false;
|
|
panelDirtyRevisionRef.current = 0;
|
|
setSettingsDirty(false);
|
|
setSettingsSaveState("clean");
|
|
setSettingsThemeChoice((current) => current === "system" ? current : result.theme);
|
|
applyTheme(result.theme);
|
|
};
|
|
|
|
const syncLiveProviderUsageSettings = (result: RendererSettings): 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 || {}) },
|
|
providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) },
|
|
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) },
|
|
debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) },
|
|
megaDebridAccountDailyUsageBytes: { ...(result.megaDebridAccountDailyUsageBytes || {}) },
|
|
megaDebridAccountTotalUsageBytes: { ...(result.megaDebridAccountTotalUsageBytes || {}) }
|
|
}));
|
|
};
|
|
|
|
const persistSpecificSettings = async (nextDraft: RendererSettingsDraft): Promise<RendererSettings> => {
|
|
const revisionAtStart = settingsDraftRevisionRef.current;
|
|
const draftAtStart = settingsDraft;
|
|
const normalizedDraft = {
|
|
...nextDraft,
|
|
...normalizeProviderSelectionForSettings(nextDraft)
|
|
};
|
|
const update: RendererSettingsUpdate = { ...normalizedDraft };
|
|
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
|
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
|
const result = await window.rd.updateSettings(update);
|
|
if (settingsDraftRevisionRef.current === revisionAtStart) {
|
|
applyPersistedSettings(result);
|
|
} else {
|
|
setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current));
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("dirty");
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const runAccountQuickAction = async (action: AccountQuickAction, accountId?: string | null): Promise<void> => {
|
|
switch (action) {
|
|
case "realdebrid-login":
|
|
if (!accountId) throw new Error("Der ausgewählte Real-Debrid-Account wurde nicht gefunden.");
|
|
await window.rd.openRealDebridLogin({ accountId });
|
|
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 checkAccounts = useCallback(async (scope: "active" | "all"): Promise<void> => {
|
|
setAccountCheckBusy(true);
|
|
try {
|
|
const statuses = await window.rd.checkDebridAccounts(scope);
|
|
if (!statuses || statuses.length === 0) {
|
|
showToast(scope === "active" ? "Keine aktiven prüfbaren Accounts konfiguriert." : "Keine prüfbaren Accounts konfiguriert.", 3200);
|
|
} else {
|
|
const valid = statuses.filter((st) => st.valid).length;
|
|
const premium = statuses.filter((st) => st.isPremium).length;
|
|
const label = scope === "active" ? "Aktive Accounts" : "Alle Accounts";
|
|
showToast(`${label}: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
|
|
}
|
|
} catch (error) {
|
|
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600);
|
|
} finally {
|
|
setAccountCheckBusy(false);
|
|
}
|
|
}, [showToast]);
|
|
|
|
const openCreateAccountDialog = (): void => {
|
|
setAccountDialogSearch("");
|
|
setAccountDialogServiceFilter("all");
|
|
setAccountDialog(createAccountDialogState("create", availableAccountOptions[0]?.kind ?? null, settingsDraft));
|
|
};
|
|
|
|
const openEditAccountDialog = (row: AccountTableRow): void => {
|
|
try {
|
|
setAccountEditSecretVisible({});
|
|
setAccountEditSecretBusy(null);
|
|
setAccountEditDialog(createAccountEditState(row.editTarget, snapshot.accounts));
|
|
} catch (error) {
|
|
showToast(String(error), 3200);
|
|
}
|
|
};
|
|
|
|
const updateAccountDialogKind = useCallback((kind: AccountKind): void => {
|
|
setAccountDialog((prev) => createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft));
|
|
}, [settingsDraft]);
|
|
|
|
useEffect(() => {
|
|
if (!accountDialog) {
|
|
return;
|
|
}
|
|
const visibleKind = resolveVisibleAccountKind(
|
|
accountDialog.kind,
|
|
filteredAccountDialogOptions.map((option) => option.kind)
|
|
);
|
|
if (visibleKind === accountDialog.kind) {
|
|
return;
|
|
}
|
|
if (visibleKind) {
|
|
updateAccountDialogKind(visibleKind);
|
|
return;
|
|
}
|
|
setAccountDialog((current) => current ? createAccountDialogState(current.mode, null, settingsDraft) : current);
|
|
}, [accountDialog?.kind, filteredAccountDialogOptions, updateAccountDialogKind]);
|
|
|
|
const closeAccountDialog = useCallback((): void => {
|
|
setAccountDialog(null);
|
|
setAccountDialogSearch("");
|
|
setAccountDialogServiceFilter("all");
|
|
}, []);
|
|
|
|
const closeAccountEditDialog = useCallback((): void => {
|
|
setAccountEditDialog(null);
|
|
setAccountEditSecretVisible({});
|
|
setAccountEditSecretBusy(null);
|
|
}, []);
|
|
|
|
const onSaveAccountEditDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
|
|
if (!accountEditDialog) {
|
|
return;
|
|
}
|
|
const editSnapshot = accountEditDialog;
|
|
const validationError = validateAccountEdit(editSnapshot, snapshot.accounts);
|
|
if (validationError) {
|
|
showToast(validationError, 2800);
|
|
return;
|
|
}
|
|
await performQuickAction(async () => {
|
|
await persistDraftSettings();
|
|
const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot));
|
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
|
applyPersistedSettings(result.settings);
|
|
closeAccountEditDialog();
|
|
if (quickAction) {
|
|
await runAccountQuickAction(quickAction, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null);
|
|
} else {
|
|
showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200);
|
|
}
|
|
}, (error) => {
|
|
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
|
});
|
|
};
|
|
|
|
const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
|
|
if (!accountDialog) {
|
|
return;
|
|
}
|
|
const dialogSnapshot = accountDialog;
|
|
if (dialogSnapshot.kind === "debridlink-api" && parseDebridLinkApiKeys(dialogSnapshot.token).length !== 1) {
|
|
showToast("Debrid-Link: Bitte genau einen API-Key eintragen.", 2800);
|
|
return;
|
|
}
|
|
const validationError = validateAccountDialog(dialogSnapshot);
|
|
if (validationError) {
|
|
showToast(validationError, 2800);
|
|
return;
|
|
}
|
|
const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null;
|
|
await performQuickAction(async () => {
|
|
await persistDraftSettings();
|
|
if (dialogSnapshot.kind === "realdebrid-web") {
|
|
const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId);
|
|
if (!request) throw new Error("Account-Payload ist ungültig");
|
|
await window.rd.openRealDebridLogin(request);
|
|
closeAccountDialog();
|
|
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
|
|
return;
|
|
}
|
|
const command = buildAccountCreateCommand(dialogSnapshot);
|
|
if (!command) throw new Error("Account-Payload ist ungültig");
|
|
const result = await window.rd.createAccount(command);
|
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
|
applyPersistedSettings(result.settings);
|
|
closeAccountDialog();
|
|
if (quickAction) {
|
|
await runAccountQuickAction(quickAction, result.accountId);
|
|
} else if (selectedOption) {
|
|
showToast(`${selectedOption.title} gespeichert`, 2200);
|
|
}
|
|
void checkAccounts("active");
|
|
}, (error) => {
|
|
showToast(`Account konnte nicht gespeichert 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 persistAccountToggle = async (
|
|
nextDraft: RendererSettingsDraft,
|
|
refreshBeforePersist?: () => ReturnType<typeof window.rd.checkAccountCredentials>
|
|
): Promise<RendererSettings> => {
|
|
const previousDraft = settingsDraft;
|
|
const previousDirty = settingsDirtyRef.current;
|
|
const previousSaveState = settingsSaveState;
|
|
const revision = ++settingsDraftRevisionRef.current;
|
|
return runOptimisticAccountUpdate(
|
|
() => {
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("saving");
|
|
setSettingsDraft(nextDraft);
|
|
},
|
|
() => runAccountEnableRefresh(
|
|
refreshBeforePersist,
|
|
() => persistSpecificSettings(nextDraft)
|
|
),
|
|
() => {
|
|
if (settingsDraftRevisionRef.current !== revision) return;
|
|
settingsDraftRevisionRef.current += 1;
|
|
settingsDirtyRef.current = previousDirty;
|
|
setSettingsDirty(previousDirty);
|
|
setSettingsSaveState(previousSaveState);
|
|
setSettingsDraft(previousDraft);
|
|
}
|
|
);
|
|
};
|
|
|
|
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry, enabled: boolean): Promise<void> => {
|
|
await performQuickAction(async () => {
|
|
const nextState = buildScopedAccountEnabledState(
|
|
settingsDraft.disabledProviders || [],
|
|
["debridlink"],
|
|
settingsDraft.debridLinkDisabledKeyIds || [],
|
|
key.id,
|
|
enabled
|
|
);
|
|
const nextDraft: RendererSettingsDraft = {
|
|
...settingsDraft,
|
|
disabledProviders: nextState.disabledProviders,
|
|
debridLinkDisabledKeyIds: nextState.disabledAccountIds
|
|
};
|
|
await persistAccountToggle(
|
|
nextDraft,
|
|
enabled ? () => window.rd.checkAccountCredentials({ kind: "debridlink-api", accountId: key.id }) : undefined
|
|
);
|
|
showToast(
|
|
enabled
|
|
? `${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 (row: AccountTableRow): Promise<void> => {
|
|
const meta = getAccountQuickActionMeta(row.entry.kind);
|
|
if (!meta) {
|
|
return;
|
|
}
|
|
await performQuickAction(async () => {
|
|
await runAccountQuickAction(meta.action, row.accountId);
|
|
}, (error) => {
|
|
showToast(`${row.entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200);
|
|
});
|
|
};
|
|
|
|
const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, enabled: boolean): Promise<void> => {
|
|
await performQuickAction(async () => {
|
|
const mode = kind === "megadebrid-web" ? "web" : "api";
|
|
const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds;
|
|
const nextState = buildScopedAccountEnabledState(
|
|
settingsDraft.disabledProviders || [],
|
|
["megadebrid", kind],
|
|
current,
|
|
accountId,
|
|
enabled
|
|
);
|
|
const next = nextState.disabledAccountIds;
|
|
const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds;
|
|
const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds;
|
|
await persistAccountToggle({
|
|
...settingsDraft,
|
|
disabledProviders: nextState.disabledProviders,
|
|
megaDebridApiEnabled: mode === "api" && enabled ? true : settingsDraft.megaDebridApiEnabled,
|
|
megaDebridWebEnabled: mode === "web" && enabled ? true : settingsDraft.megaDebridWebEnabled,
|
|
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
|
|
megaDebridApiDisabledAccountIds: apiDisabledIds,
|
|
megaDebridWebDisabledAccountIds: webDisabledIds
|
|
}, enabled
|
|
? () => window.rd.checkAccountCredentials({ kind, accountId })
|
|
: undefined
|
|
);
|
|
showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000);
|
|
}, (error) => {
|
|
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
});
|
|
};
|
|
|
|
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
|
|
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
|
|
if (!confirmed) return;
|
|
await performQuickAction(async () => {
|
|
await persistDraftSettings();
|
|
const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id });
|
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
|
applyPersistedSettings(result.settings);
|
|
showToast("Key entfernt", 2000);
|
|
}, (error) => { showToast(`Entfernen 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: RendererSettingsDraft = {
|
|
...settingsDraft,
|
|
disabledProviders: nextDisabledProviders
|
|
};
|
|
await persistAccountToggle(nextDraft);
|
|
showToast(
|
|
nextDisabledProviders.includes(provider)
|
|
? `${entry.serviceLabel} deaktiviert`
|
|
: `${entry.serviceLabel} aktiviert`,
|
|
2200
|
|
);
|
|
}, (error) => {
|
|
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
|
|
});
|
|
};
|
|
|
|
const onToggleRealDebridAccountEnabled = async (kind: "realdebrid-api" | "realdebrid-web", accountId: string, enabled: boolean): Promise<void> => {
|
|
await performQuickAction(async () => {
|
|
const nextState = buildScopedAccountEnabledState(
|
|
settingsDraft.disabledProviders || [],
|
|
["realdebrid"],
|
|
settingsDraft.realDebridDisabledAccountIds || [],
|
|
accountId,
|
|
enabled
|
|
);
|
|
await persistAccountToggle({
|
|
...settingsDraft,
|
|
disabledProviders: nextState.disabledProviders,
|
|
realDebridDisabledAccountIds: nextState.disabledAccountIds
|
|
}, enabled
|
|
? () => window.rd.checkAccountCredentials({ kind, accountId })
|
|
: undefined
|
|
);
|
|
showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000);
|
|
}, (error) => {
|
|
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
});
|
|
};
|
|
|
|
const toggleAccountTableRow = (row: AccountTableRow): void => {
|
|
setAccountContextMenu(null);
|
|
if (row.toggleKind === "rd" && row.accountId) {
|
|
void onToggleRealDebridAccountEnabled(row.entry.kind as "realdebrid-api" | "realdebrid-web", row.accountId, row.disabled);
|
|
} else if (row.toggleKind === "mega" && row.accountId) {
|
|
void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled);
|
|
} else if (row.toggleKind === "dl" && row.dlKey) {
|
|
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey, row.disabled);
|
|
} else {
|
|
void onToggleAccountEnabled(row.entry);
|
|
}
|
|
};
|
|
|
|
const removeAccountTableRows = (rows: readonly AccountTableRow[]): void => {
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
setAccountContextMenu(null);
|
|
void (async () => {
|
|
const row = rows[0];
|
|
const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined;
|
|
const username = rows.length === 1
|
|
? resolveAccountUsername(row.username, checkedStatus?.username || checkedStatus?.email)
|
|
: "—";
|
|
const confirmed = await askConfirmPrompt({
|
|
title: rows.length === 1 ? `${row.hosterLabel} entfernen` : `${rows.length} Accounts entfernen`,
|
|
message: rows.length === 1
|
|
? `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`
|
|
: `Sollen die ausgewählten ${rows.length} Accounts wirklich entfernt werden?`,
|
|
confirmLabel: "Entfernen",
|
|
danger: true
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
await performQuickAction(async () => {
|
|
await persistDraftSettings();
|
|
for (const selectedRow of rows) {
|
|
const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget));
|
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
|
applyPersistedSettings(result.settings);
|
|
if (selectedRow.entry.service === "alldebrid") {
|
|
setAllDebridHostInfo(null);
|
|
}
|
|
}
|
|
const removedRowKeys = new Set(rows.map((selectedRow) => selectedRow.rowKey));
|
|
setSelectedAccountRowKeys((current) => new Set([...current].filter((rowKey) => !removedRowKeys.has(rowKey))));
|
|
showToast(rows.length === 1 ? `${row.hosterLabel} entfernt` : `${rows.length} Accounts entfernt`, 2200);
|
|
}, (error) => {
|
|
showToast(`${rows.length === 1 ? "Account" : "Accounts"} konnte${rows.length === 1 ? "" : "n"} nicht entfernt werden: ${String(error)}`, 3200);
|
|
});
|
|
})();
|
|
};
|
|
|
|
const removeAccountTableRow = (row: AccountTableRow): void => removeAccountTableRows([row]);
|
|
|
|
const checkAccountTableRow = (row: AccountTableRow): void => {
|
|
setAccountContextMenu(null);
|
|
if ((row.entry.kind === "realdebrid-api" || row.entry.kind === "realdebrid-web") && row.accountId) {
|
|
const kind = row.entry.kind;
|
|
const accountId = row.accountId;
|
|
void performQuickAction(async () => {
|
|
const status = await window.rd.checkAccountCredentials({ kind, accountId });
|
|
showToast(status.valid ? "Account erfolgreich geprüft" : status.message || "Zugangsdaten ungültig", 2600);
|
|
}, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200));
|
|
return;
|
|
}
|
|
if (row.checkable) {
|
|
void checkAccounts("all");
|
|
return;
|
|
}
|
|
if (getAccountQuickActionMeta(row.entry.kind)) {
|
|
void onAccountRowQuickAction(row);
|
|
return;
|
|
}
|
|
showToast("Für diesen Account ist keine direkte Statusprüfung verfügbar.", 2800);
|
|
};
|
|
|
|
const onCheckUpdates = async (): Promise<void> => {
|
|
await performQuickAction(() => runLatestUpdateCheck(
|
|
updateCheckGenerationRef,
|
|
() => window.rd.checkUpdates(),
|
|
(result, generation) => handleUpdateResult(result, "manual", generation)
|
|
), (error) => {
|
|
showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800);
|
|
});
|
|
};
|
|
|
|
const persistDraftSettings = async (): Promise<RendererSettings> => {
|
|
const revisionAtStart = settingsDraftRevisionRef.current;
|
|
const update: RendererSettingsUpdate = { ...normalizedSettingsDraft };
|
|
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
|
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
|
const result = await window.rd.updateSettings(update);
|
|
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 closeBackupPassphraseDialog = useCallback((passphrase: string | null): void => {
|
|
const resolver = backupPassphraseResolverRef.current;
|
|
backupPassphraseResolverRef.current = null;
|
|
setBackupPassphraseMode(null);
|
|
resolver?.(passphrase);
|
|
}, []);
|
|
|
|
const askBackupPassphrase = useCallback((mode: BackupPassphraseMode): Promise<string | null> => {
|
|
return new Promise((resolve) => {
|
|
backupPassphraseResolverRef.current = resolve;
|
|
setBackupPassphraseMode(mode);
|
|
});
|
|
}, []);
|
|
|
|
const restoreHistoryEntries = useCallback(async (entryIds: string[]): Promise<void> => {
|
|
const requested = new Set(entryIds);
|
|
const entries = historyEntriesRef.current.filter((entry) => requested.has(entry.id));
|
|
const urls = entries.flatMap((entry) => entry.urls ?? []);
|
|
if (urls.length === 0) {
|
|
showToast("Keine gespeicherten Links vorhanden");
|
|
return;
|
|
}
|
|
try {
|
|
const result = await window.rd.addLinks({
|
|
rawText: urls.join("\n"),
|
|
packageName: entries.length === 1 ? entries[0].name : "Verlaufsauswahl"
|
|
});
|
|
showToast(result.addedLinks > 0 ? `${result.addedLinks} Link(s) zur Queue hinzugefügt` : "Keine Links hinzugefügt");
|
|
} catch {
|
|
showToast("Fehler beim Hinzufügen");
|
|
}
|
|
}, [showToast]);
|
|
|
|
const removeHistoryEntries = useCallback(async (entryIds: string[]): Promise<void> => {
|
|
const requested = new Set(entryIds);
|
|
const ids = historyEntriesRef.current.filter((entry) => requested.has(entry.id)).map((entry) => entry.id);
|
|
if (ids.length === 0) {
|
|
return;
|
|
}
|
|
const confirmed = await askConfirmPrompt({
|
|
title: ids.length === 1 ? "Verlaufseintrag entfernen" : "Verlaufseinträge entfernen",
|
|
message: ids.length === 1 ? "Diesen Eintrag aus dem Verlauf entfernen?" : `${ids.length} Einträge aus dem Verlauf entfernen?`,
|
|
confirmLabel: "Entfernen",
|
|
danger: true
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
const results = await Promise.allSettled(ids.map((id) => window.rd.removeHistoryEntry(id)));
|
|
if (results.some((result) => result.status === "rejected")) {
|
|
showToast("Einige Verlaufseinträge konnten nicht entfernt werden");
|
|
await loadHistoryEntries();
|
|
return;
|
|
}
|
|
const removed = new Set(ids);
|
|
applyHistoryEntries(historyEntriesRef.current.filter((entry) => !removed.has(entry.id)));
|
|
showToast(ids.length === 1 ? "Verlaufseintrag entfernt" : `${ids.length} Verlaufseinträge entfernt`);
|
|
}, [applyHistoryEntries, askConfirmPrompt, loadHistoryEntries, showToast]);
|
|
|
|
const clearHistoryEntries = useCallback(async (): Promise<void> => {
|
|
if (historyEntriesRef.current.length === 0) {
|
|
return;
|
|
}
|
|
const confirmed = await askConfirmPrompt({
|
|
title: "Verlauf leeren",
|
|
message: "Wirklich alle Einträge aus dem Verlauf entfernen?",
|
|
confirmLabel: "Verlauf leeren",
|
|
danger: true
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
try {
|
|
await window.rd.clearHistory();
|
|
applyHistoryEntries([]);
|
|
showToast("Verlauf geleert");
|
|
} catch {
|
|
showToast("Verlauf konnte nicht geleert werden");
|
|
await loadHistoryEntries();
|
|
}
|
|
}, [applyHistoryEntries, askConfirmPrompt, loadHistoryEntries, showToast]);
|
|
|
|
const revealHistoryEntry = useCallback(async (entryId: string): Promise<void> => {
|
|
try {
|
|
const result = await window.rd.revealHistoryEntry(entryId);
|
|
if (result.ok) {
|
|
showToast("Zielordner geöffnet");
|
|
return;
|
|
}
|
|
const messages = {
|
|
"entry-not-found": "Verlaufseintrag wurde nicht gefunden",
|
|
"invalid-output-dir": "Der gespeicherte Zielordner ist ungültig",
|
|
"output-dir-missing": "Der gespeicherte Zielordner existiert nicht mehr",
|
|
"output-dir-not-directory": "Das gespeicherte Ziel ist kein Ordner",
|
|
"open-failed": "Zielordner konnte nicht geöffnet werden"
|
|
} as const;
|
|
showToast(messages[result.reason]);
|
|
} catch {
|
|
showToast("Zielordner konnte nicht geöffnet werden");
|
|
}
|
|
}, [showToast]);
|
|
|
|
const historyActions = useMemo<HistoryViewActions>(() => ({
|
|
onFilterChange: setHistoryFilter,
|
|
onQueryChange: setHistoryQuery,
|
|
onToggleSelection: (entryId) => {
|
|
setSelectedHistoryIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(entryId)) {
|
|
next.delete(entryId);
|
|
} else {
|
|
next.add(entryId);
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
onToggleSelectAll: (visibleIds) => {
|
|
setSelectedHistoryIds((current) => {
|
|
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id));
|
|
return allSelected ? new Set() : selectVisibleHistoryIds(visibleIds);
|
|
});
|
|
},
|
|
onToggleExpansion: (entryId) => {
|
|
setHistoryExpandedIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(entryId)) {
|
|
next.delete(entryId);
|
|
} else {
|
|
next.add(entryId);
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
onRestore: (entryIds) => { void restoreHistoryEntries(entryIds); },
|
|
onReveal: (entryId) => { void revealHistoryEntry(entryId); },
|
|
onRemove: (entryIds) => { void removeHistoryEntries(entryIds); },
|
|
onClearSelection: () => setSelectedHistoryIds(new Set()),
|
|
onClearHistory: () => { void clearHistoryEntries(); },
|
|
onContextMenu: (entryId, x, y) => {
|
|
setSelectedHistoryIds((current) => {
|
|
const visibleSelection = pruneHistoryIds(current, historyVisibleIdsRef.current);
|
|
return visibleSelection.has(entryId) ? visibleSelection : new Set([entryId]);
|
|
});
|
|
setHistoryCtxMenu({ entryId, x, y });
|
|
}
|
|
}), [clearHistoryEntries, removeHistoryEntries, restoreHistoryEntries, revealHistoryEntry]);
|
|
|
|
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> => {
|
|
setCollectorError("");
|
|
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) => planCollectorTextReplacement(prev, activeId, "").tabs);
|
|
setSelectedCollectorRowIds(new Set());
|
|
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
|
} else {
|
|
showToast("Keine gültigen Links gefunden");
|
|
}
|
|
}, (error) => {
|
|
setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`);
|
|
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
|
|
});
|
|
};
|
|
|
|
const onImportDlc = async (): Promise<void> => {
|
|
setCollectorError("");
|
|
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 {
|
|
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
|
|
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
|
}
|
|
}, (error) => {
|
|
setCollectorError(`Fehler beim DLC-Import: ${String(error)}`);
|
|
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
|
|
});
|
|
};
|
|
|
|
const onExportPackageSelection = async (packageIds: string[]): Promise<void> => {
|
|
closeMenus();
|
|
await performQuickAction(async () => {
|
|
const result = await window.rd.exportPackageSelection(packageIds);
|
|
if (result.saved) {
|
|
showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800);
|
|
}
|
|
}, (error) => {
|
|
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
|
|
});
|
|
};
|
|
|
|
const onExportItemSelection = async (itemIds: string[]): Promise<void> => {
|
|
closeMenus();
|
|
await performQuickAction(async () => {
|
|
const result = await window.rd.exportItemSelection(itemIds);
|
|
if (result.saved) {
|
|
showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800);
|
|
}
|
|
}, (error) => {
|
|
showToast(`Export fehlgeschlagen: ${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 importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name));
|
|
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
|
|
if (dlc.length > 0) {
|
|
setCollectorError("");
|
|
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 {
|
|
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
|
|
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
|
}
|
|
}, (error) => {
|
|
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
|
|
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
|
});
|
|
} else if (importFiles.length > 0) {
|
|
setCollectorError("");
|
|
await performQuickAction(async () => {
|
|
await persistDraftSettings();
|
|
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
|
let addedPackages = 0;
|
|
let addedLinks = 0;
|
|
for (const file of importFiles) {
|
|
const text = await file.text();
|
|
const result = await window.rd.importQueue(text);
|
|
addedPackages += result.addedPackages;
|
|
addedLinks += result.addedLinks;
|
|
}
|
|
if (addedLinks > 0) {
|
|
showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`);
|
|
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
|
} else {
|
|
setCollectorError("Keine gültigen Links in den Import-Dateien gefunden");
|
|
showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000);
|
|
}
|
|
}, (error) => {
|
|
setCollectorError(`Fehler bei Drag-and-Drop: ${String(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;
|
|
}
|
|
|
|
setCollectorError("");
|
|
actionBusyRef.current = true;
|
|
setActionBusy(true);
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".json,.txt";
|
|
|
|
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 () => {
|
|
await persistDraftSettings();
|
|
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
|
const text = await file.text();
|
|
const result = await window.rd.importQueue(text);
|
|
if (result.addedLinks > 0) {
|
|
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
|
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
|
} else {
|
|
setCollectorError("Keine gültigen Links in der Datei gefunden");
|
|
showToast("Keine gültigen Links in der Datei gefunden", 3000);
|
|
}
|
|
}, (error) => {
|
|
setCollectorError(`Import fehlgeschlagen: ${String(error)}`);
|
|
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
|
|
});
|
|
};
|
|
|
|
clearImportQueueFocusListener();
|
|
importQueueFocusHandlerRef.current = onWindowFocus;
|
|
window.addEventListener("focus", onWindowFocus, { once: true });
|
|
input.click();
|
|
};
|
|
|
|
const setBool = (key: keyof RendererSettingsDraft, value: boolean): void => {
|
|
settingsDraftRevisionRef.current += 1;
|
|
panelDirtyRevisionRef.current += 1;
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("dirty");
|
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
const setText = (key: keyof RendererSettingsDraft, value: string): void => {
|
|
if (key === "archivePasswordList" || key === "notifyUrl") writeOnlySettingsDirtyRef.current.add(key);
|
|
settingsDraftRevisionRef.current += 1;
|
|
panelDirtyRevisionRef.current += 1;
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("dirty");
|
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
const setNum = (key: keyof RendererSettingsDraft, value: number): void => {
|
|
settingsDraftRevisionRef.current += 1;
|
|
panelDirtyRevisionRef.current += 1;
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("dirty");
|
|
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);
|
|
setSettingsSaveState("dirty");
|
|
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 addCollectorTab = (): void => {
|
|
const id = `tab-${nextCollectorId++}`;
|
|
setCollectorTabs((prev) => {
|
|
const name = `Tab ${prev.length + 1}`;
|
|
return [...prev, { id, name, text: "" }];
|
|
});
|
|
setActiveCollectorTab(id);
|
|
setSelectedCollectorRowIds(new Set());
|
|
setCollectorError("");
|
|
};
|
|
|
|
const removeCollectorTab = (id: string): void => {
|
|
const tab = collectorTabsRef.current.find((entry) => entry.id === id);
|
|
if (!tab || collectorTabsRef.current.length <= 1) {
|
|
return;
|
|
}
|
|
const linkCount = tab.text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
|
void askConfirmPrompt({
|
|
title: "Sammlung entfernen",
|
|
message: linkCount > 0
|
|
? `Soll die Sammlung ${tab.name} mit ${linkCount} Link(s) wirklich entfernt werden?`
|
|
: `Soll die leere Sammlung ${tab.name} wirklich entfernt werden?`,
|
|
confirmLabel: "Sammlung entfernen",
|
|
danger: true
|
|
}).then((confirmed) => {
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
const removal = planCollectorTabRemoval(
|
|
collectorTabsRef.current,
|
|
activeCollectorTabRef.current,
|
|
id
|
|
);
|
|
if (removal.tabs === collectorTabsRef.current) {
|
|
return;
|
|
}
|
|
collectorTabsRef.current = removal.tabs;
|
|
activeCollectorTabRef.current = removal.activeTabId;
|
|
setCollectorTabs(removal.tabs);
|
|
setActiveCollectorTab(removal.activeTabId);
|
|
setSelectedCollectorRowIds(new Set());
|
|
setCollectorError("");
|
|
});
|
|
};
|
|
|
|
const openCollectorInput = (): void => {
|
|
const activeId = activeCollectorTabRef.current;
|
|
const active = collectorTabsRef.current.find((entry) => entry.id === activeId) ?? collectorTabsRef.current[0];
|
|
if (!active) {
|
|
return;
|
|
}
|
|
setCollectorError("");
|
|
setCollectorInput({
|
|
tabId: active.id,
|
|
tabName: active.name,
|
|
baseText: active.text,
|
|
draft: active.text
|
|
});
|
|
};
|
|
|
|
const commitCollectorInput = (): void => {
|
|
if (!collectorInput) {
|
|
return;
|
|
}
|
|
const input = collectorInput;
|
|
setCollectorTabs((prev) => {
|
|
const currentText = prev.find((entry) => entry.id === input.tabId)?.text ?? input.baseText;
|
|
const text = mergeCollectorDraftText(input.baseText, currentText, input.draft);
|
|
return planCollectorTextReplacement(prev, input.tabId, text).tabs;
|
|
});
|
|
setSelectedCollectorRowIds(new Set());
|
|
setCollectorInput(null);
|
|
setCollectorError("");
|
|
};
|
|
|
|
const toggleCollectorRowSelection = (rowId: string): void => {
|
|
setSelectedCollectorRowIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(rowId)) {
|
|
next.delete(rowId);
|
|
} else {
|
|
next.add(rowId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const removeSelectedCollectorRows = (): void => {
|
|
if (selectedCollectorRowIds.size === 0) {
|
|
return;
|
|
}
|
|
const activeId = activeCollectorTabRef.current;
|
|
const indexes = new Set<number>();
|
|
for (const rowId of selectedCollectorRowIds) {
|
|
const separator = rowId.lastIndexOf(":");
|
|
if (separator <= 0 || rowId.slice(0, separator) !== activeId) {
|
|
continue;
|
|
}
|
|
const index = Number(rowId.slice(separator + 1));
|
|
if (Number.isInteger(index) && index >= 0) {
|
|
indexes.add(index);
|
|
}
|
|
}
|
|
if (indexes.size === 0) {
|
|
return;
|
|
}
|
|
void askConfirmPrompt({
|
|
title: "Ausgewählte Links löschen",
|
|
message: "Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.",
|
|
confirmLabel: "Links löschen",
|
|
danger: true
|
|
}).then((confirmed) => {
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId
|
|
? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") }
|
|
: entry));
|
|
setSelectedCollectorRowIds(new Set());
|
|
setCollectorError("");
|
|
});
|
|
};
|
|
|
|
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;
|
|
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 => {
|
|
setDownloadDisclosureRevision((current) => current + 1);
|
|
setCollapsedPackages((prev) => {
|
|
const nextCollapsed = !(prev[packageId] ?? false);
|
|
return { ...prev, [packageId]: nextCollapsed };
|
|
});
|
|
}, []);
|
|
|
|
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", ids: Iterable<string> = selectedIds) => {
|
|
const currentOrder = packageOrderRef.current;
|
|
const selPkgs = new Set([...ids].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];
|
|
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);
|
|
});
|
|
}, [selectedIds, snapshot.session.packages, showToast]);
|
|
|
|
const onPackageToggle = useCallback((packageId: string): void => {
|
|
let previousEnabled: boolean | null = null;
|
|
setSnapshot((prev) => {
|
|
const pkg = prev.session.packages[packageId];
|
|
if (!pkg) {
|
|
return prev;
|
|
}
|
|
previousEnabled = pkg.enabled;
|
|
const nextEnabled = !pkg.enabled;
|
|
const nextItems = { ...prev.session.items };
|
|
if (!nextEnabled) {
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = nextItems[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
|
nextItems[itemId] = {
|
|
...item,
|
|
fullStatus: "Paket gestoppt",
|
|
updatedAt: Date.now()
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = nextItems[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
|
|
nextItems[itemId] = {
|
|
...item,
|
|
fullStatus: "Wartet",
|
|
updatedAt: Date.now()
|
|
};
|
|
}
|
|
}
|
|
}
|
|
const nextPkgStatus = !nextEnabled
|
|
? (pkg.status === "downloading" || pkg.status === "extracting" ? "paused" : pkg.status)
|
|
: (pkg.status === "paused" ? "queued" : pkg.status);
|
|
const nextSnapshot: UiSnapshot = {
|
|
...prev,
|
|
session: {
|
|
...prev.session,
|
|
items: nextItems,
|
|
packages: {
|
|
...prev.session.packages,
|
|
[packageId]: {
|
|
...pkg,
|
|
enabled: nextEnabled,
|
|
status: nextPkgStatus,
|
|
updatedAt: Date.now()
|
|
}
|
|
},
|
|
updatedAt: Date.now()
|
|
}
|
|
};
|
|
latestStateRef.current = nextSnapshot;
|
|
return nextSnapshot;
|
|
});
|
|
void window.rd.togglePackage(packageId).catch((error) => {
|
|
if (previousEnabled !== null) {
|
|
const revertedEnabled = previousEnabled;
|
|
setSnapshot((prev) => {
|
|
const pkg = prev.session.packages[packageId];
|
|
if (!pkg) {
|
|
return prev;
|
|
}
|
|
const revertedSnapshot: UiSnapshot = {
|
|
...prev,
|
|
session: {
|
|
...prev.session,
|
|
packages: {
|
|
...prev.session.packages,
|
|
[packageId]: {
|
|
...pkg,
|
|
enabled: revertedEnabled,
|
|
status: revertedEnabled && pkg.status === "paused" ? "queued" : pkg.status,
|
|
updatedAt: Date.now()
|
|
}
|
|
},
|
|
updatedAt: Date.now()
|
|
}
|
|
};
|
|
latestStateRef.current = revertedSnapshot;
|
|
return revertedSnapshot;
|
|
});
|
|
}
|
|
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 speedSparklineStateRef = useRef<DownloadSpeedHistoryState>({ history: [], display: 0 });
|
|
const dragSelectRef = useRef(false);
|
|
const dragAnchorRef = useRef<string | null>(null);
|
|
const dragDidMoveRef = useRef(false);
|
|
const lastClickedIdRef = useRef<string | null>(null);
|
|
const dragMouseUpRef = useRef<(() => void) | null>(null);
|
|
|
|
const visibleOrderIds = downloadsViewCore.visibleRowIds;
|
|
|
|
// Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard
|
|
// handler can select exactly what the user sees — not the whole unfiltered map.
|
|
const visibleOrderIdsRef = useRef<string[]>(visibleOrderIds);
|
|
visibleOrderIdsRef.current = visibleOrderIds;
|
|
|
|
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
|
if (dragDidMoveRef.current) return;
|
|
if (shiftKey && lastClickedIdRef.current) {
|
|
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
|
|
const targetIdx = visibleOrderIds.indexOf(id);
|
|
if (anchorIdx !== -1 && targetIdx !== -1) {
|
|
const from = Math.min(anchorIdx, targetIdx);
|
|
const to = Math.max(anchorIdx, targetIdx);
|
|
const rangeIds = visibleOrderIds.slice(from, to + 1);
|
|
setSelectedIds((prev) => {
|
|
const next = ctrlKey ? new Set(prev) : new Set<string>();
|
|
for (const rid of rangeIds) next.add(rid);
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
lastClickedIdRef.current = id;
|
|
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]);
|
|
});
|
|
}, [visibleOrderIds]);
|
|
|
|
const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => {
|
|
if (!e.ctrlKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
if (dragMouseUpRef.current) {
|
|
window.removeEventListener("mouseup", dragMouseUpRef.current);
|
|
}
|
|
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);
|
|
if (dragMouseUpRef.current === onUp) {
|
|
dragMouseUpRef.current = null;
|
|
}
|
|
};
|
|
dragMouseUpRef.current = onUp;
|
|
window.addEventListener("mouseup", onUp);
|
|
}, []);
|
|
|
|
useEffect(() => () => {
|
|
if (dragMouseUpRef.current) {
|
|
window.removeEventListener("mouseup", dragMouseUpRef.current);
|
|
dragMouseUpRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const onSelectMouseEnter = useCallback((id: string): void => {
|
|
if (!dragSelectRef.current) return;
|
|
if (!dragDidMoveRef.current) {
|
|
dragDidMoveRef.current = true;
|
|
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;
|
|
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);
|
|
setSettingsSaveState("dirty");
|
|
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);
|
|
setSettingsSaveState("dirty");
|
|
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);
|
|
setSettingsSaveState("dirty");
|
|
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);
|
|
};
|
|
|
|
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: globalThis.KeyboardEvent): void => {
|
|
if (e.key === "Escape") {
|
|
const target = e.target as HTMLElement;
|
|
const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : "";
|
|
const selectionScope = resolveEscapeSelectionScope(tabRef.current, settingsSubTab, target.tagName, inputType);
|
|
if (selectionScope) {
|
|
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
|
if (selectionScope === "downloads") setSelectedIds(new Set());
|
|
else if (selectionScope === "history") setSelectedHistoryIds(new Set());
|
|
else if (selectedAccountRowKeys.size > 0) {
|
|
setSelectedAccountRowKeys(new Set());
|
|
releaseAccountSelectionFocus(document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
}
|
|
}
|
|
}
|
|
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 (!shouldClearDownloadSelection(target)) return;
|
|
setSelectedIds(new Set());
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
window.addEventListener("mousedown", onDown);
|
|
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
|
|
}, [requestDeleteSelection, selectedAccountRowKeys, selectedIds, settingsSubTab]);
|
|
|
|
const onExportBackup = async (): Promise<void> => {
|
|
closeMenus();
|
|
await performQuickAction(async () => {
|
|
const result = await runLocalBackupExport(window.rd, askBackupPassphrase);
|
|
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 runLocalBackupImport(window.rd, askBackupPassphrase);
|
|
if (result.restored) {
|
|
showToast(result.message, 4000);
|
|
// A settings-only import applies live without a relaunch, so the editable
|
|
// settings form would otherwise keep showing the old values. Pull the
|
|
// fresh settings and re-seed the draft so the UI reflects the import.
|
|
if (!result.relaunch) {
|
|
const fresh = await window.rd.getSnapshot();
|
|
applyPersistedSettings(fresh.settings, false);
|
|
}
|
|
} else if (result.message !== "Abgebrochen") {
|
|
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
|
}
|
|
}, (error) => {
|
|
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
|
|
});
|
|
};
|
|
|
|
const onCreateOnlineBackup = async (): Promise<void> => {
|
|
closeMenus();
|
|
setOnlineBackupDialog({ mode: "export", key: "", busy: true, error: "" });
|
|
try {
|
|
const result = await window.rd.exportOnlineBackup();
|
|
setOnlineBackupDialog({ mode: "export", key: result.key, busy: false, error: "" });
|
|
showToast("Online-Schlüssel erstellt", 2600);
|
|
} catch {
|
|
setOnlineBackupDialog({ mode: "export", key: "", busy: false, error: "Online-Sicherung konnte nicht erstellt werden." });
|
|
}
|
|
};
|
|
|
|
const onOpenOnlineBackupImport = (): void => {
|
|
closeMenus();
|
|
setOnlineBackupDialog({ mode: "import", key: "", busy: false, error: "" });
|
|
};
|
|
|
|
const onImportOnlineBackup = async (): Promise<void> => {
|
|
const key = onlineBackupDialog?.mode === "import" ? onlineBackupDialog.key.trim() : "";
|
|
if (!key) return;
|
|
setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current);
|
|
try {
|
|
const result = await window.rd.importOnlineBackup(key);
|
|
const fresh = await window.rd.getSnapshot();
|
|
applyPersistedSettings(fresh.settings, false);
|
|
setOnlineBackupDialog(null);
|
|
showToast(result.message, 4000);
|
|
} catch {
|
|
setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current);
|
|
}
|
|
};
|
|
|
|
const onCopyOnlineBackupKey = async (): Promise<void> => {
|
|
if (!onlineBackupDialog?.key) return;
|
|
try {
|
|
await navigator.clipboard.writeText(onlineBackupDialog.key);
|
|
showToast("Online-Schlüssel kopiert", 2200);
|
|
} catch {
|
|
showToast("Schlüssel konnte nicht kopiert werden", 2600);
|
|
}
|
|
};
|
|
|
|
const onExportSupportBundle = async (): Promise<void> => {
|
|
closeMenus();
|
|
await performQuickAction(async () => {
|
|
const result = await window.rd.exportSupportBundle();
|
|
if (result.saved) {
|
|
showToast("Support-Bundle exportiert", 2600);
|
|
}
|
|
}, (error) => {
|
|
showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800);
|
|
});
|
|
};
|
|
|
|
const onToggleSupportTrace = async (): Promise<void> => {
|
|
closeMenus();
|
|
const nextEnabled = !supportTraceEnabled;
|
|
await performQuickAction(async () => {
|
|
const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle", 120);
|
|
setSupportTraceEnabled(result.enabled);
|
|
showToast(result.enabled ? "Support-Trace für 2 Stunden aktiviert" : "Support-Trace deaktiviert", 2600);
|
|
}, (error) => {
|
|
showToast(`Support-Trace fehlgeschlagen: ${String(error)}`, 2800);
|
|
});
|
|
};
|
|
|
|
const onRunDebugSetupCheck = async (): Promise<void> => {
|
|
closeMenus();
|
|
try {
|
|
const setup = await window.rd.getDebugSetupCheck();
|
|
const warningText = setup.warnings.length > 0 ? `Warnungen: ${setup.warnings.length}` : "Keine akuten Warnungen";
|
|
const reachabilityText = setup.localOnly ? "Nur lokal gebunden" : "Remote-fähig konfiguriert";
|
|
const details = buildDebugSetupDetails(setup);
|
|
await askConfirmPrompt({
|
|
title: "Debug-Setup prüfen",
|
|
message: `${warningText}\n${reachabilityText}\nHost: ${setup.host}:${setup.port}`,
|
|
confirmLabel: "Schließen",
|
|
cancelLabel: "Schließen",
|
|
details,
|
|
detailsLabel: "Details anzeigen"
|
|
});
|
|
} catch (error) {
|
|
showToast(`Debug-Setup-Check fehlgeschlagen: ${String(error)}`, 3000);
|
|
}
|
|
};
|
|
|
|
const onShowRecentErrors = async (): Promise<void> => {
|
|
closeMenus();
|
|
try {
|
|
const entries = await window.rd.getRecentErrors();
|
|
const errorCount = entries.filter((e) => e.level === "ERROR").length;
|
|
const warnCount = entries.filter((e) => e.level === "WARN").length;
|
|
const details = entries.map((e) => `${e.ts} [${e.level}] ${e.message}`).join("\n");
|
|
const copy = await askConfirmPrompt({
|
|
title: "Letzte Fehler",
|
|
message: entries.length === 0
|
|
? "Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet."
|
|
: `${errorCount} Fehler, ${warnCount} Warnungen (letzte ${entries.length})`,
|
|
confirmLabel: entries.length > 0 ? "In Zwischenablage kopieren" : "Schließen",
|
|
cancelLabel: "Schließen",
|
|
details: details || undefined,
|
|
detailsLabel: "Einträge anzeigen"
|
|
});
|
|
if (copy && entries.length > 0) {
|
|
await navigator.clipboard.writeText(details);
|
|
showToast("Fehlerliste kopiert", 2600);
|
|
}
|
|
} catch (error) {
|
|
showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000);
|
|
}
|
|
};
|
|
|
|
const onRotateDebugToken = async (): Promise<void> => {
|
|
closeMenus();
|
|
const confirmed = await askConfirmPrompt({
|
|
title: "Debug-Token rotieren",
|
|
message: "Das aktuelle Debug-Token wird ersetzt. Externe Debug-Links mit altem Token funktionieren danach nicht mehr.",
|
|
confirmLabel: "Token rotieren",
|
|
danger: true
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
await performQuickAction(async () => {
|
|
const result = await window.rd.rotateDebugToken();
|
|
showToast(`Debug-Token rotiert: ${result.path}`, 4200);
|
|
}, (error) => {
|
|
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
|
|
});
|
|
};
|
|
|
|
const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => {
|
|
setRemoteDiag(info);
|
|
setRdHostMode(info.status.localOnly ? "local" : "network");
|
|
setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || "")));
|
|
setRdPort(String(info.status.port || 9868));
|
|
setRdAllowlist(info.allowlist.join("\n"));
|
|
setRdName(info.name || "");
|
|
};
|
|
|
|
const onOpenRemoteDiagnostics = async (): Promise<void> => {
|
|
closeMenus();
|
|
try {
|
|
const info = await window.rd.getRemoteDiagnostics();
|
|
applyRemoteDiagInfo(info);
|
|
setRemoteDiagOpen(true);
|
|
} catch (error) {
|
|
showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000);
|
|
}
|
|
};
|
|
|
|
const onSubmitRemoteDiagnostics = async (): Promise<void> => {
|
|
const port = Number(rdPort) || 9868;
|
|
const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
if (rdHostMode === "network" && allowlist.length === 0) {
|
|
showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600);
|
|
return;
|
|
}
|
|
setRemoteDiagBusy(true);
|
|
try {
|
|
const info = await window.rd.enableRemoteDiagnostics({
|
|
hostMode: rdHostMode,
|
|
publicHost: rdPublicHost,
|
|
port,
|
|
allowlist,
|
|
name: rdName
|
|
});
|
|
applyRemoteDiagInfo(info);
|
|
showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600);
|
|
} catch (error) {
|
|
showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600);
|
|
} finally {
|
|
setRemoteDiagBusy(false);
|
|
}
|
|
};
|
|
|
|
const onDisableRemoteDiagnostics = async (): Promise<void> => {
|
|
setRemoteDiagBusy(true);
|
|
try {
|
|
const info = await window.rd.disableRemoteDiagnostics();
|
|
applyRemoteDiagInfo(info);
|
|
showToast("Ferndiagnose deaktiviert", 2400);
|
|
} catch (error) {
|
|
showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000);
|
|
} finally {
|
|
setRemoteDiagBusy(false);
|
|
}
|
|
};
|
|
|
|
const onRotateRemoteDiagnosticsToken = async (): Promise<void> => {
|
|
setRemoteDiagBusy(true);
|
|
try {
|
|
const info = await window.rd.rotateRemoteDiagnosticsToken();
|
|
applyRemoteDiagInfo(info);
|
|
showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000);
|
|
} catch (error) {
|
|
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
|
|
} finally {
|
|
setRemoteDiagBusy(false);
|
|
}
|
|
};
|
|
|
|
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
|
|
if (!remoteDiag?.code) {
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(remoteDiag.code);
|
|
showToast("Verbindungscode kopiert", 2200);
|
|
} catch {
|
|
showToast("Kopieren fehlgeschlagen", 2200);
|
|
}
|
|
};
|
|
|
|
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();
|
|
// Select exactly the VISIBLE rows (packages + their items), honouring
|
|
// the active search / collapse / hide-extracted filters — selecting
|
|
// the unfiltered package map would let a later delete hit hidden ones.
|
|
setSelectedIds(new Set(visibleOrderIdsRef.current));
|
|
} else if (tabRef.current === "history") {
|
|
e.preventDefault();
|
|
setSelectedHistoryIds(selectVisibleHistoryIds(historyVisibleIdsRef.current));
|
|
}
|
|
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(".md-application-menu-tree")) {
|
|
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 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]);
|
|
|
|
const sortDownloadsByColumn = useCallback((column: DownloadSortColumn): void => {
|
|
const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false;
|
|
setDownloadsSortColumn(column);
|
|
setDownloadsSortDescending(nextDescending);
|
|
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
|
const sorted = column === "progress"
|
|
? sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
|
: column === "size"
|
|
? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
|
: column === "hoster"
|
|
? sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
|
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
|
pendingPackageOrderRef.current = [...sorted];
|
|
pendingPackageOrderAtRef.current = Date.now();
|
|
packageOrderRef.current = sorted;
|
|
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: [...sorted] } }));
|
|
void window.rd.reorderPackages(sorted).catch((error) => {
|
|
pendingPackageOrderRef.current = null;
|
|
pendingPackageOrderAtRef.current = 0;
|
|
packageOrderRef.current = serverPackageOrderRef.current;
|
|
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } }));
|
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
|
});
|
|
}, [downloadsSortColumn, downloadsSortDescending, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
|
|
|
|
const clearDownloadQueue = useCallback((): void => {
|
|
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) await window.rd.clearAll();
|
|
});
|
|
}, [askConfirmPrompt, performQuickAction]);
|
|
|
|
const setClipboardWatcherActive = useCallback((active: boolean): void => {
|
|
const update = (current: UiSnapshot): UiSnapshot => ({
|
|
...current,
|
|
clipboardActive: active,
|
|
settings: { ...current.settings, clipboardWatch: active }
|
|
});
|
|
const next = update(snapshotRef.current);
|
|
snapshotRef.current = next;
|
|
masterSnapshotRef.current = masterSnapshotRef.current ? update(masterSnapshotRef.current) : next;
|
|
if (latestStateRef.current) {
|
|
latestStateRef.current = update(latestStateRef.current);
|
|
}
|
|
setSnapshot(next);
|
|
setSettingsDraft((current) => ({ ...current, clipboardWatch: active }));
|
|
}, []);
|
|
|
|
const toggleClipboardWatcher = useCallback((): void => {
|
|
const previous = snapshotRef.current.clipboardActive;
|
|
const next = !previous;
|
|
setClipboardWatcherActive(next);
|
|
void window.rd.toggleClipboard()
|
|
.then(setClipboardWatcherActive)
|
|
.catch((error) => {
|
|
setClipboardWatcherActive(previous);
|
|
showToast(`Zwischenablage konnte nicht umgeschaltet werden: ${String(error)}`, 2600);
|
|
});
|
|
}, [setClipboardWatcherActive, showToast]);
|
|
|
|
const activateDownloadSchedule = useCallback((): void => {
|
|
if (!scheduleTimeInput) return;
|
|
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
|
|
const now = new Date();
|
|
const target = new Date(now);
|
|
target.setHours(hours, minutes, 0, 0);
|
|
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
|
|
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
|
|
setSchedulePickerOpen(false);
|
|
}, [scheduleTimeInput]);
|
|
|
|
const removeActionableDownloads = useCallback((): void => {
|
|
const ids = new Set(downloadsViewCore.actionableSelectedIds);
|
|
if (ids.size === 0) return;
|
|
if (settingsDraft.confirmDeleteSelection) {
|
|
setDeleteConfirm({ ids, dontAsk: false });
|
|
} else {
|
|
executeDeleteSelection(ids);
|
|
}
|
|
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
|
|
|
|
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
|
const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]);
|
|
useEffect(() => {
|
|
if (!snapshot.session.running || snapshot.session.paused) return;
|
|
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
|
|
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
|
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
|
const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
|
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
|
...downloadsViewCore,
|
|
running: snapshot.session.running,
|
|
paused: snapshot.session.paused,
|
|
canStart: snapshot.canStart,
|
|
canPause: snapshot.canPause,
|
|
canStop: snapshot.canStop,
|
|
actionBusy,
|
|
reconnectSeconds: snapshot.reconnectSeconds,
|
|
reconnectReason: snapshot.session.reconnectReason,
|
|
clipboardWatcher: snapshot.clipboardActive,
|
|
scheduleActive: snapshot.settings.scheduledStartEpochMs > 0,
|
|
scheduleOpen: schedulePickerOpen,
|
|
scheduleTime: scheduleTimeInput,
|
|
scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""),
|
|
packageSpeedBps: downloadPackageSpeeds,
|
|
editingPackageId,
|
|
editingName,
|
|
columnOrder,
|
|
gridTemplate,
|
|
sortColumn: downloadsSortColumn,
|
|
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
|
disclosureRevision: downloadDisclosureRevision,
|
|
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
|
status: {
|
|
packages: snapshot.stats.totalPackages,
|
|
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
|
session: humanSize(snapshot.stats.totalDownloaded),
|
|
sessionBytes: snapshot.stats.totalDownloaded,
|
|
total: humanSize(downloadQueueTotalBytes),
|
|
totalBytes: downloadQueueTotalBytes,
|
|
remaining: formatRemainingDownloadBytes(downloadRemaining),
|
|
remainingBytes: downloadRemaining.bytes,
|
|
hosters: providerStats.length,
|
|
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
|
eta: snapshot.etaText
|
|
}
|
|
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
|
|
|
const resetColumnLayout = useCallback((): void => {
|
|
if (columnDragSettleTimerRef.current !== null) {
|
|
window.clearTimeout(columnDragSettleTimerRef.current);
|
|
columnDragSettleTimerRef.current = null;
|
|
}
|
|
cancelColumnDragAnimations();
|
|
if (columnDragSessionRef.current) {
|
|
clearDownloadColumnDrag(columnDragSessionRef.current);
|
|
columnDragSessionRef.current = null;
|
|
}
|
|
setColumnOrder(DEFAULT_COLUMN_ORDER);
|
|
persistColumnOrder(DEFAULT_COLUMN_ORDER);
|
|
showToast("Spaltenlayout zurückgesetzt", 1800);
|
|
}, [cancelColumnDragAnimations, persistColumnOrder, showToast]);
|
|
|
|
const downloadsActions: DownloadsViewActions = {
|
|
onResetColumnLayout: resetColumnLayout,
|
|
onDisplayModeChange: setDownloadDisplayMode,
|
|
onFilterChange: setDownloadFilter,
|
|
onProviderFilterChange: setDownloadProviderFilter,
|
|
onQueryChange: setDownloadSearch,
|
|
onAddLinks: () => {
|
|
setTab("collector");
|
|
openCollectorInput();
|
|
},
|
|
onStartDownloads: () => {
|
|
if (snapshot.session.paused) {
|
|
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: false } }));
|
|
void window.rd.togglePause().catch(() => {});
|
|
} else {
|
|
void onStartDownloads();
|
|
}
|
|
},
|
|
onPauseDownloads: () => {
|
|
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
|
void window.rd.togglePause().catch(() => {});
|
|
},
|
|
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
|
|
onToggleSchedule: () => {
|
|
setSchedulePickerOpen((current) => !current);
|
|
setScheduleTimeInput("");
|
|
},
|
|
onScheduleTimeChange: setScheduleTimeInput,
|
|
onActivateSchedule: activateDownloadSchedule,
|
|
onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); },
|
|
onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds),
|
|
onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds),
|
|
onRenameSelection: () => {
|
|
const packageId = downloadsViewCore.actionableSelectedPackageIds[0];
|
|
const entry = packageId ? snapshot.session.packages[packageId] : null;
|
|
if (entry) onPackageStartEdit(entry.id, entry.name);
|
|
},
|
|
onRemoveSelection: removeActionableDownloads,
|
|
onToggleClipboardWatcher: toggleClipboardWatcher,
|
|
onClearAll: clearDownloadQueue,
|
|
onToggleAllPackages: () => {
|
|
const targetState = !allPackagesCollapsed;
|
|
setDownloadDisclosureRevision((current) => current + 1);
|
|
setCollapsedPackages((current) => {
|
|
const next = { ...current };
|
|
for (const entry of packages) {
|
|
next[entry.id] = targetState;
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
onShowAllPackages: () => setShowAllPackages(true),
|
|
onSetVisibleSelection: (ids, selected) => {
|
|
setSelectedIds((current) => {
|
|
const next = new Set(current);
|
|
for (const id of ids) {
|
|
if (selected) next.add(id);
|
|
else next.delete(id);
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
onToggleSelection: onSelectId,
|
|
onSelectionMouseDown: onSelectMouseDown,
|
|
onSelectionMouseEnter: onSelectMouseEnter,
|
|
onTogglePackageCollapse: onPackageToggleCollapse,
|
|
onStartPackageRename: onPackageStartEdit,
|
|
onPackageRenameChange: setEditingName,
|
|
onCommitPackageRename: (packageId, value) => {
|
|
const currentName = snapshot.session.packages[packageId]?.name ?? "";
|
|
onPackageFinishEdit(packageId, currentName, value);
|
|
},
|
|
onCancelPackageRename: () => {
|
|
setEditingPackageId(null);
|
|
setEditingName("");
|
|
},
|
|
onCancelPackage: onPackageCancel,
|
|
onMovePackageUp: onPackageMoveUp,
|
|
onMovePackageDown: onPackageMoveDown,
|
|
onRemoveItem: onPackageRemoveItem,
|
|
onOpenContextMenu: (id, x, y, packageId) => {
|
|
const item = snapshot.session.items[id];
|
|
onPackageContextMenu(packageId ?? item?.packageId ?? id, item?.id, x, y);
|
|
},
|
|
onSortColumn: (column) => {
|
|
if (suppressColumnSortRef.current) return;
|
|
sortDownloadsByColumn(column);
|
|
},
|
|
onColumnPointerDown: (column, event) => {
|
|
if (columnDragSettleTimerRef.current !== null) {
|
|
window.clearTimeout(columnDragSettleTimerRef.current);
|
|
columnDragSettleTimerRef.current = null;
|
|
}
|
|
cancelColumnDragAnimations();
|
|
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
|
columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX, snapshot.settings.animatePackageDisclosure);
|
|
},
|
|
onColumnPointerMove: (_column, event) => {
|
|
const session = columnDragSessionRef.current;
|
|
if (!session || session.pointerId !== event.pointerId) return;
|
|
if (updateDownloadColumnDrag(session, event.clientX)) event.preventDefault();
|
|
},
|
|
onColumnPointerUp: (_column, event) => {
|
|
const session = columnDragSessionRef.current;
|
|
if (!session || session.pointerId !== event.pointerId) return;
|
|
if (!session.active) {
|
|
clearDownloadColumnDrag(session);
|
|
columnDragSessionRef.current = null;
|
|
return;
|
|
}
|
|
const next = session.preview.order;
|
|
suppressColumnSortRef.current = true;
|
|
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
|
|
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
|
|
const animationsEnabled = snapshot.settings.animatePackageDisclosure;
|
|
columnDragAnimationsRef.current = commitDownloadColumnDrag(session, next, (order) => {
|
|
if (changed) flushSync(() => setColumnOrder(order));
|
|
}, () => {}, animationsEnabled);
|
|
if (changed) persistColumnOrder(next);
|
|
if (!animationsEnabled) {
|
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
|
return;
|
|
}
|
|
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
|
cancelColumnDragAnimations();
|
|
session.root.classList.remove("is-column-drag-settling");
|
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
|
columnDragSettleTimerRef.current = null;
|
|
}, DOWNLOAD_COLUMN_MOVE_DURATION_MS);
|
|
},
|
|
onColumnPointerCancel: (_column, event) => {
|
|
const session = columnDragSessionRef.current;
|
|
if (!session || session.pointerId !== event.pointerId) return;
|
|
if (!session.active) {
|
|
clearDownloadColumnDrag(session);
|
|
columnDragSessionRef.current = null;
|
|
return;
|
|
}
|
|
const originalOrder = session.measurements.map((measurement) => measurement.id);
|
|
const animationsEnabled = snapshot.settings.animatePackageDisclosure;
|
|
columnDragAnimationsRef.current = commitDownloadColumnDrag(session, originalOrder, () => {}, () => {}, animationsEnabled);
|
|
if (!animationsEnabled) {
|
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
|
return;
|
|
}
|
|
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
|
cancelColumnDragAnimations();
|
|
session.root.classList.remove("is-column-drag-settling");
|
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
|
columnDragSettleTimerRef.current = null;
|
|
}, DOWNLOAD_COLUMN_MOVE_DURATION_MS);
|
|
},
|
|
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
|
|
};
|
|
|
|
const statisticsActions: StatisticsViewActions = {
|
|
onRangeChange: setStatisticsRange,
|
|
onResetSession: () => {
|
|
void askConfirmPrompt({
|
|
title: "Sitzungsstatistik zurücksetzen",
|
|
message: "Die Zähler, Downloadmenge und Geschwindigkeitsdaten der aktuellen Sitzung werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.",
|
|
confirmLabel: "Sitzung zurücksetzen",
|
|
danger: true
|
|
}).then((confirmed) => {
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
return window.rd.resetSessionStats().then(() => {
|
|
showToast("Session-Statistik zurückgesetzt", 1800);
|
|
}).catch((error) => {
|
|
showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400);
|
|
});
|
|
});
|
|
},
|
|
onResetAll: () => {
|
|
void askConfirmPrompt({
|
|
title: "Gesamtstatistik zurücksetzen",
|
|
message: "Alle dauerhaft gespeicherten Download- und Providerstatistiken werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.",
|
|
confirmLabel: "Gesamt zurücksetzen",
|
|
danger: true
|
|
}).then((confirmed) => {
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
return window.rd.resetDownloadStats().then(() => {
|
|
showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800);
|
|
}).catch((error) => {
|
|
showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400);
|
|
});
|
|
});
|
|
},
|
|
onResetErrors: () => {
|
|
const failedIds = Object.values(snapshot.session.items)
|
|
.filter((item) => item.status === "failed")
|
|
.map((item) => item.id);
|
|
if (failedIds.length === 0) {
|
|
return;
|
|
}
|
|
void window.rd.resetItems(failedIds).catch(() => {});
|
|
}
|
|
};
|
|
const collectorActions: CollectorViewActions = {
|
|
onTabSelect: (tabId) => {
|
|
activeCollectorTabRef.current = tabId;
|
|
setActiveCollectorTab(tabId);
|
|
setSelectedCollectorRowIds(new Set());
|
|
setCollectorError("");
|
|
},
|
|
onTabAdd: addCollectorTab,
|
|
onTabRemove: removeCollectorTab,
|
|
onOpenInput: openCollectorInput,
|
|
onImportDlc: () => { void onImportDlc(); },
|
|
onImportFile: () => { void onImportQueue(); },
|
|
onExportQueue: () => { void onExportQueue(); },
|
|
onSubmit: () => { void onAddLinks(); },
|
|
onQueryChange: setCollectorQuery,
|
|
onSelectionChange: toggleCollectorRowSelection,
|
|
onRemoveSelected: removeSelectedCollectorRows
|
|
};
|
|
|
|
const settingsFormModel = useMemo<SettingsFormViewModel>(() => buildSettingsFormViewModel({
|
|
settings: settingsDraft,
|
|
section: settingsSubTab,
|
|
speedLimitInput,
|
|
scheduleSpeedInputs,
|
|
themeChoice: settingsThemeChoice
|
|
}), [scheduleSpeedInputs, settingsDraft, settingsSubTab, settingsThemeChoice, speedLimitInput]);
|
|
|
|
const accountRowViewId = (row: AccountTableRow): string => buildAccountRowId(
|
|
row.entry.service,
|
|
row.modeLabel,
|
|
row.accountId || row.rowKey
|
|
);
|
|
const accountRowBindings = useMemo(() => new Map(accountRows.map((row) => [accountRowViewId(row), row])), [accountRows]);
|
|
const activeAccountContextRow = accountContextMenu
|
|
? accountRowBindings.get(accountContextMenu.rowId) ?? null
|
|
: null;
|
|
const accountSources = useMemo<AccountRowSource[]>(() => accountRows.map((row) => {
|
|
const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined;
|
|
const state: AccountRowSource["status"]["state"] = resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow);
|
|
return {
|
|
identityId: row.accountId || row.rowKey,
|
|
service: row.entry.service,
|
|
hoster: row.hosterLabel,
|
|
mode: row.modeLabel,
|
|
icon: ACCOUNT_SERVICE_ICONS[row.entry.service],
|
|
enabled: !row.disabled,
|
|
status: {
|
|
state,
|
|
message: checkedStatus?.message || row.entry.statusLabel,
|
|
premiumUntilMs: checkedStatus?.premiumUntilMs ?? null,
|
|
checkedAt: checkedStatus?.checkedAt,
|
|
username: checkedStatus?.username,
|
|
email: checkedStatus?.email
|
|
},
|
|
dailyLimitBytes: row.dailyLimitBytes,
|
|
dailyUsageBytes: row.dailyUsedBytes,
|
|
username: row.username,
|
|
credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected",
|
|
canCheck: row.checkable
|
|
};
|
|
}), [accountRows, runtimeNow, snapshot.settings.debridAccountStatuses]);
|
|
const selectedAccountViewIds = useMemo(() => accountRows
|
|
.filter((row) => selectedAccountRowKeys.has(row.rowKey))
|
|
.map(accountRowViewId), [accountRows, selectedAccountRowKeys]);
|
|
const projectedAccountRows = useMemo(() => projectAccountRows(
|
|
accountSources,
|
|
selectedAccountViewIds,
|
|
runtimeNow
|
|
), [accountSources, runtimeNow, selectedAccountViewIds]);
|
|
const visibleAccountRows = useMemo(() => accountStatusSort === "none"
|
|
? projectedAccountRows
|
|
: sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]);
|
|
const accountRuntimeModel = useMemo<AccountWorkspaceViewModel["runtime"]>(() => {
|
|
const runtimeEntries = snapshot.accountRuntime || [];
|
|
const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry]));
|
|
const runtimeAccountIdCounts = new Map<string, number>();
|
|
for (const entry of runtimeEntries) {
|
|
runtimeAccountIdCounts.set(entry.accountId, (runtimeAccountIdCounts.get(entry.accountId) || 0) + 1);
|
|
}
|
|
const uniqueRuntimeByAccountId = new Map(runtimeEntries
|
|
.filter((entry) => runtimeAccountIdCounts.get(entry.accountId) === 1)
|
|
.map((entry) => [entry.accountId, entry]));
|
|
const projectedById = new Map(projectedAccountRows.map((row) => [row.id, row]));
|
|
const providerGroups = new Map<string, {
|
|
id: string;
|
|
label: string;
|
|
accountCount: number;
|
|
availableAccountCount: number;
|
|
activeDownloads: number;
|
|
dailyUsageBytes: number;
|
|
}>();
|
|
const stateLabels = {
|
|
ready: "Bereit",
|
|
active: "Aktiv",
|
|
checking: "Prüfung",
|
|
cooldown: "Cooldown",
|
|
disabled: "Deaktiviert",
|
|
daily_limit: "Tageslimit",
|
|
invalid: "Fehler"
|
|
} as const;
|
|
const stateTones = {
|
|
ready: "ok",
|
|
active: "active",
|
|
checking: "active",
|
|
cooldown: "warning",
|
|
disabled: "muted",
|
|
daily_limit: "warning",
|
|
invalid: "danger"
|
|
} as const;
|
|
const accounts = accountRows.map((row) => {
|
|
const viewId = accountRowViewId(row);
|
|
const projected = projectedById.get(viewId);
|
|
const runtimeId = row.accountId || `svc-${row.entry.provider}`;
|
|
const runtimeProvider = row.entry.provider === "megadebrid"
|
|
? (row.modeLabel.toLocaleLowerCase("de-DE").includes("web") ? "megadebrid-web" : "megadebrid-api")
|
|
: row.entry.provider;
|
|
const runtime = runtimeByAccountId.get(`${runtimeProvider}:${runtimeId}`)
|
|
?? uniqueRuntimeByAccountId.get(runtimeId);
|
|
const fallbackState = row.disabled
|
|
? "disabled"
|
|
: row.dailyLimitBytes > 0 && row.dailyUsedBytes >= row.dailyLimitBytes
|
|
? "daily_limit"
|
|
: projected?.problem
|
|
? "invalid"
|
|
: "ready";
|
|
const state = runtime?.state || fallbackState;
|
|
const outcomes = (runtime?.successes || 0) + (runtime?.failures || 0);
|
|
const successRateText = outcomes > 0
|
|
? `${Math.round(((runtime?.successes || 0) / outcomes) * 100)} % (${runtime?.successes || 0}/${outcomes})`
|
|
: "—";
|
|
const lastUsedAt = runtime?.lastUsedAt || null;
|
|
let lastUsedText = "Noch nicht in dieser Sitzung";
|
|
if (lastUsedAt) {
|
|
const ageSeconds = Math.max(0, Math.floor((runtimeNow - lastUsedAt) / 1000));
|
|
lastUsedText = ageSeconds < 60
|
|
? "Gerade eben"
|
|
: ageSeconds < 3600
|
|
? `vor ${Math.floor(ageSeconds / 60)} Min.`
|
|
: ageSeconds < 86400
|
|
? `vor ${Math.floor(ageSeconds / 3600)} Std.`
|
|
: formatDateTime(lastUsedAt);
|
|
}
|
|
let cooldownText = "—";
|
|
if (runtime?.cooldownUntil && runtime.cooldownUntil > runtimeNow) {
|
|
const seconds = Math.max(1, Math.ceil((runtime.cooldownUntil - runtimeNow) / 1000));
|
|
const duration = seconds < 60
|
|
? `${seconds} Sek.`
|
|
: seconds < 3600
|
|
? `${Math.ceil(seconds / 60)} Min.`
|
|
: `${Math.ceil(seconds / 3600)} Std.`;
|
|
cooldownText = `${runtime.reason} · ${duration}`;
|
|
} else if (state === "disabled" || state === "daily_limit" || state === "invalid") {
|
|
cooldownText = runtime?.reason || stateLabels[state];
|
|
}
|
|
const providerKey = row.hosterLabel.toLocaleLowerCase("de-DE");
|
|
const providerGroup = providerGroups.get(providerKey) ?? {
|
|
id: providerKey,
|
|
label: row.hosterLabel,
|
|
accountCount: 0,
|
|
availableAccountCount: 0,
|
|
activeDownloads: 0,
|
|
dailyUsageBytes: 0
|
|
};
|
|
providerGroup.accountCount += 1;
|
|
providerGroup.availableAccountCount += state === "ready" || state === "active" || state === "checking" ? 1 : 0;
|
|
providerGroup.activeDownloads += runtime?.activeDownloads || 0;
|
|
providerGroup.dailyUsageBytes += runtime?.dailyUsageBytes ?? row.dailyUsedBytes;
|
|
providerGroups.set(providerKey, providerGroup);
|
|
return {
|
|
id: viewId,
|
|
providerLabel: row.hosterLabel,
|
|
modeLabel: row.modeLabel,
|
|
identity: projected?.username !== "—" ? projected?.username || "" : projected?.email || "",
|
|
stateLabel: stateLabels[state],
|
|
stateTone: stateTones[state],
|
|
activeDownloads: runtime?.activeDownloads || 0,
|
|
dailyUsageText: humanSize(runtime?.dailyUsageBytes ?? row.dailyUsedBytes),
|
|
successRateText,
|
|
lastUsedText,
|
|
cooldownText
|
|
};
|
|
});
|
|
return {
|
|
providers: [...providerGroups.values()]
|
|
.sort((left, right) => left.label.localeCompare(right.label, "de-DE", { sensitivity: "base" }))
|
|
.map((provider) => ({
|
|
id: provider.id,
|
|
label: provider.label,
|
|
accountCount: provider.accountCount,
|
|
availableAccountCount: provider.availableAccountCount,
|
|
activeDownloads: provider.activeDownloads,
|
|
dailyUsageText: humanSize(provider.dailyUsageBytes)
|
|
})),
|
|
accounts
|
|
};
|
|
}, [accountRows, projectedAccountRows, runtimeNow, snapshot.accountRuntime]);
|
|
const routingEntries = useMemo(() => Object.entries(settingsDraft.hosterRouting || {}).sort(([left], [right]) => left.localeCompare(right)), [settingsDraft.hosterRouting]);
|
|
const usedRoutingHosters = useMemo(() => new Set(routingEntries.map(([hosterId]) => hosterId)), [routingEntries]);
|
|
const routingProviderOptions = useMemo(() => configuredProviders.map((provider) => ({
|
|
value: provider,
|
|
label: providerLabelWithMode(provider, settingsDraft)
|
|
})), [configuredProviders, settingsDraft]);
|
|
const accountWorkspaceModel: AccountWorkspaceViewModel = {
|
|
activePanel: accountManagementTab,
|
|
rows: visibleAccountRows,
|
|
selectedIds: selectedAccountViewIds,
|
|
busy: actionBusy || accountCheckBusy,
|
|
statusSort: accountStatusSort,
|
|
runtime: accountRuntimeModel,
|
|
rules: {
|
|
providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)),
|
|
routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId} → ${providerLabelWithMode(provider, settingsDraft)}`),
|
|
autoFallback: settingsDraft.autoProviderFallback,
|
|
rememberCredentials: settingsDraft.rememberToken,
|
|
rotationEvents: (snapshot.rotationEvents || []).map((event) => ({
|
|
id: event.id,
|
|
title: `${event.provider} · ${event.accountLabel}`,
|
|
detail: `${new Date(event.at).toLocaleTimeString()} · ${rotationEventText(event)}${event.reason ? ` (${event.reason})` : ""}`
|
|
})),
|
|
routingEntries: routingEntries.map(([hosterId, provider]) => ({
|
|
hosterId,
|
|
hosterLabel: KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId,
|
|
provider,
|
|
providers: routingProviderOptions
|
|
})),
|
|
availableRoutingHosters: KNOWN_HOSTERS
|
|
.filter((hoster) => !usedRoutingHosters.has(hoster.id))
|
|
.map((hoster) => ({ value: hoster.id, label: hoster.label }))
|
|
}
|
|
};
|
|
|
|
const setHosterRouting = (hosterRouting: Record<string, DebridProvider>): void => {
|
|
settingsDraftRevisionRef.current += 1;
|
|
panelDirtyRevisionRef.current += 1;
|
|
settingsDirtyRef.current = true;
|
|
setSettingsDirty(true);
|
|
setSettingsSaveState("dirty");
|
|
setSettingsDraft((current) => ({ ...current, hosterRouting }));
|
|
};
|
|
const accountWorkspaceActions: AccountWorkspaceActions = {
|
|
onPanelChange: setAccountManagementTab,
|
|
onSelect: (rowId, additive) => {
|
|
const rowKey = accountRowBindings.get(rowId)?.rowKey;
|
|
if (!rowKey) {
|
|
return;
|
|
}
|
|
setSelectedAccountRowKeys((current) => new Set(updateAccountRowSelection([...current], rowKey, additive)));
|
|
},
|
|
onToggleEnabled: (rowId) => {
|
|
const row = accountRowBindings.get(rowId);
|
|
if (row) toggleAccountTableRow(row);
|
|
},
|
|
onEdit: (rowId) => {
|
|
const row = accountRowBindings.get(rowId);
|
|
if (row) openEditAccountDialog(row);
|
|
},
|
|
onContextMenu: (rowId, x, y) => {
|
|
const row = accountRowBindings.get(rowId);
|
|
if (row) setAccountContextMenu({ x, y, rowId });
|
|
},
|
|
onCopyIdentity: (label, value) => {
|
|
void window.rd.writeClipboardText(value)
|
|
.then(() => showToast(`${label} kopiert`))
|
|
.catch(() => showToast("Kopieren fehlgeschlagen"));
|
|
},
|
|
onAdd: openCreateAccountDialog,
|
|
onRemoveSelected: () => {
|
|
const rows = selectedAccountViewIds
|
|
.map((rowId) => accountRowBindings.get(rowId))
|
|
.filter((row): row is AccountTableRow => Boolean(row));
|
|
removeAccountTableRows(rows);
|
|
},
|
|
onCheckActive: () => { void checkAccounts("active"); },
|
|
onCheckAll: () => { void checkAccounts("all"); },
|
|
onStatusSort: cycleAccountStatusSort,
|
|
onMoveProvider: (index, direction) => {
|
|
const target = index + direction;
|
|
if (target < 0 || target >= activeProviderOrder.length) return;
|
|
const next = [...activeProviderOrder];
|
|
[next[index], next[target]] = [next[target], next[index]];
|
|
setProviderOrder(next);
|
|
},
|
|
onProviderDragStart: (event, index) => {
|
|
const provider = activeProviderOrder[index];
|
|
if (provider) onProviderDragStart(event, provider);
|
|
},
|
|
onProviderDragOver: (event, index) => {
|
|
const provider = activeProviderOrder[index];
|
|
if (provider) onProviderDragOver(event, provider);
|
|
},
|
|
onProviderDrop: (event, index) => {
|
|
const provider = activeProviderOrder[index];
|
|
if (provider) onProviderDrop(event, provider);
|
|
},
|
|
onProviderDragEnd,
|
|
onToggleAutoFallback: (enabled) => setBool("autoProviderFallback", enabled),
|
|
onToggleRememberCredentials: (enabled) => setBool("rememberToken", enabled),
|
|
onRoutingProviderChange: (hosterId, provider) => setHosterRouting({
|
|
...(settingsDraft.hosterRouting || {}),
|
|
[hosterId]: provider as DebridProvider
|
|
}),
|
|
onRoutingRemove: (hosterId) => {
|
|
const next = { ...(settingsDraft.hosterRouting || {}) };
|
|
delete next[hosterId];
|
|
setHosterRouting(next);
|
|
},
|
|
onRoutingAdd: (hosterId) => {
|
|
const resolvedHosterId = hosterId === "__custom"
|
|
? (window.prompt("Hoster-Domain eingeben:") || "").trim().toLowerCase().replace(/^www\./, "").split(".")[0]
|
|
: hosterId;
|
|
if (!resolvedHosterId || settingsDraft.hosterRouting?.[resolvedHosterId] || !configuredProviders[0]) return;
|
|
setHosterRouting({ ...(settingsDraft.hosterRouting || {}), [resolvedHosterId]: configuredProviders[0] });
|
|
}
|
|
};
|
|
|
|
const settingsFormActions: SettingsViewActions["form"] = {
|
|
onChange: (fieldId, value) => {
|
|
const scheduleMatch = /^schedule:(\d+):(startHour|endHour|enabled|speedLimitMbps)$/.exec(fieldId);
|
|
if (scheduleMatch) {
|
|
const index = Number(scheduleMatch[1]);
|
|
const field = scheduleMatch[2];
|
|
if (field === "speedLimitMbps") {
|
|
const schedule = schedules[index];
|
|
if (schedule) {
|
|
const key = schedule.id || `schedule-${index}`;
|
|
setScheduleSpeedInputs((current) => ({ ...current, [key]: String(value) }));
|
|
}
|
|
} else if (field === "enabled") {
|
|
updateSchedule(index, "enabled", Boolean(value));
|
|
} else {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) updateSchedule(index, field as "startHour" | "endHour", Math.max(0, Math.min(23, parsed)));
|
|
}
|
|
return;
|
|
}
|
|
if (fieldId === "speedLimitInput") {
|
|
setSpeedLimitInput(String(value));
|
|
return;
|
|
}
|
|
if (fieldId === "theme") {
|
|
const choice = value as SettingsThemeChoice;
|
|
const next = resolveSettingsThemeChoice(choice, window.matchMedia("(prefers-color-scheme: light)").matches);
|
|
setSettingsThemeChoice(choice);
|
|
setText("theme", next);
|
|
applyTheme(next);
|
|
return;
|
|
}
|
|
if (fieldId === "historyRetentionMode" && typeof value === "string") {
|
|
const next = resolveHistoryRetentionSelection(settingsDraft.historyRetentionMode, settingsDraft.historyMaxEntries, value);
|
|
setText("historyRetentionMode", next.historyRetentionMode);
|
|
setNum("historyMaxEntries", next.historyMaxEntries);
|
|
return;
|
|
}
|
|
if (typeof value === "boolean") {
|
|
setBool(fieldId as keyof RendererSettingsDraft, value);
|
|
return;
|
|
}
|
|
const numericLimits: Partial<Record<keyof RendererSettingsDraft, [number, number, number]>> = {
|
|
maxParallel: [1, 50, 1],
|
|
retryLimit: [0, 99, 0],
|
|
historyMaxEntries: [50, 100000, 500],
|
|
historyMaxAgeDays: [0, 3650, 0],
|
|
maxParallelExtract: [1, 8, 2],
|
|
reconnectWaitSeconds: [10, 600, 45]
|
|
};
|
|
const bounds = numericLimits[fieldId as keyof RendererSettingsDraft];
|
|
if (bounds) {
|
|
const parsed = Number(value);
|
|
setNum(fieldId as keyof RendererSettingsDraft, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2])));
|
|
} else {
|
|
setText(fieldId as keyof RendererSettingsDraft, String(value));
|
|
}
|
|
},
|
|
onCommit: (fieldId, value) => {
|
|
const scheduleMatch = /^schedule:(\d+):speedLimitMbps$/.exec(fieldId);
|
|
if (scheduleMatch) {
|
|
const index = Number(scheduleMatch[1]);
|
|
const parsed = parseMbpsInput(value);
|
|
const schedule = schedules[index];
|
|
if (!schedule) return;
|
|
const key = schedule.id || `schedule-${index}`;
|
|
if (parsed === null) {
|
|
setScheduleSpeedInputs((current) => ({ ...current, [key]: formatMbpsInputFromKbps(schedule.speedLimitKbps) }));
|
|
return;
|
|
}
|
|
const nextKbps = Math.floor(parsed * 1024);
|
|
setScheduleSpeedInputs((current) => ({ ...current, [key]: formatMbpsInputFromKbps(nextKbps) }));
|
|
updateSchedule(index, "speedLimitKbps", nextKbps);
|
|
return;
|
|
}
|
|
if (fieldId === "speedLimitInput") {
|
|
const parsed = parseMbpsInput(value);
|
|
if (parsed === null) {
|
|
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
|
return;
|
|
}
|
|
setSpeedLimitMbps(parsed);
|
|
setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024)));
|
|
}
|
|
},
|
|
onAction: (fieldId) => {
|
|
if (fieldId === "schedule:add") {
|
|
addSchedule();
|
|
return;
|
|
}
|
|
const removeScheduleMatch = /^schedule:(\d+):remove$/.exec(fieldId);
|
|
if (removeScheduleMatch) {
|
|
removeSchedule(Number(removeScheduleMatch[1]));
|
|
return;
|
|
}
|
|
if (fieldId === "update:check") {
|
|
void onCheckUpdates();
|
|
return;
|
|
}
|
|
if (fieldId === "notifyUrl") {
|
|
void performQuickAction(async () => {
|
|
const ok = await window.rd.testNotification(settingsDraft.notifyUrl, settingsDraft.notifyMention);
|
|
showToast(ok ? "Test-Nachricht gesendet" : "Test fehlgeschlagen", ok ? 2400 : 3600);
|
|
});
|
|
return;
|
|
}
|
|
if (fieldId === "logStorageDirectory") {
|
|
void performQuickAction(async () => {
|
|
await window.rd.openLogDirectory();
|
|
});
|
|
return;
|
|
}
|
|
const targetKey = fieldId === "outputDir" ? "outputDir" : fieldId === "extractDir" ? "extractDir" : fieldId === "mkvLibraryDir" ? "mkvLibraryDir" : null;
|
|
if (targetKey) {
|
|
void performQuickAction(async () => {
|
|
const path = await window.rd.pickFolder();
|
|
if (path) setText(targetKey, path);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
const settingsViewModel: SettingsViewModel = {
|
|
section: settingsSubTab,
|
|
saveState: settingsDirty && settingsSaveState === "clean" ? "dirty" : settingsSaveState,
|
|
form: settingsFormModel,
|
|
accounts: accountWorkspaceModel
|
|
};
|
|
const settingsViewActions: SettingsViewActions = {
|
|
onSectionChange: setSettingsSubTab,
|
|
onSave: () => { void onSaveSettings(); },
|
|
form: settingsFormActions,
|
|
accounts: accountWorkspaceActions
|
|
};
|
|
|
|
const accountAddOptions: AccountAddOption[] = filteredAccountDialogOptions.map((option) => ({
|
|
id: option.kind,
|
|
service: option.service,
|
|
title: option.serviceLabel,
|
|
mode: option.modeLabel,
|
|
description: option.pickerDescription,
|
|
functionLabel: getAccountPickerFunctionLabel(option),
|
|
filter: option.modeLabel === "API" ? "api" : "web",
|
|
multi: option.kind === "realdebrid-api" || option.kind === "realdebrid-web" || option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api",
|
|
icon: ACCOUNT_SERVICE_ICONS[option.service]
|
|
}));
|
|
const accountAddFields = buildAccountAddFields(accountDialog);
|
|
const accountAddDialog = (
|
|
<AccountAddDialog
|
|
actions={{
|
|
onQueryChange: setAccountDialogSearch,
|
|
onServiceFilterChange: setAccountDialogServiceFilter,
|
|
onOptionSelect: (optionId) => updateAccountDialogKind(optionId as AccountKind),
|
|
onFieldChange: (fieldId, value) => setAccountDialog((current) => current ? { ...current, [fieldId]: value } : current),
|
|
onClose: closeAccountDialog,
|
|
onSubmit: () => {
|
|
const quickAction = accountDialog?.kind ? getAccountQuickActionMeta(accountDialog.kind)?.action : undefined;
|
|
void onSaveAccountDialog(quickAction);
|
|
}
|
|
}}
|
|
model={{
|
|
open: Boolean(accountDialog),
|
|
query: accountDialogSearch,
|
|
serviceFilter: accountDialogServiceFilter,
|
|
serviceFilters: accountDialogServiceFilters,
|
|
options: accountAddOptions,
|
|
selectedOptionId: accountDialog?.kind ?? null,
|
|
fields: accountAddFields,
|
|
error: "",
|
|
busy: actionBusy
|
|
}}
|
|
/>
|
|
);
|
|
const accountEditSecretRequest = accountEditDialog ? buildAccountSecretRequest(accountEditDialog.target) : null;
|
|
const accountEditHasStoredSecret = accountEditSecretRequest
|
|
? snapshot.accounts.some((account) => account.kind === accountEditSecretRequest.kind
|
|
&& account.accountId === accountEditSecretRequest.accountId
|
|
&& account.hasSecret)
|
|
: false;
|
|
const toggleAccountEditSecret = async (fieldId: string): Promise<void> => {
|
|
if (fieldId !== "password" && fieldId !== "token") return;
|
|
const dialog = accountEditDialog;
|
|
if (!dialog) return;
|
|
if (accountEditSecretVisible[fieldId]) {
|
|
setAccountEditSecretVisible((current) => ({ ...current, [fieldId]: false }));
|
|
return;
|
|
}
|
|
if (dialog[fieldId]) {
|
|
setAccountEditSecretVisible((current) => ({ ...current, [fieldId]: true }));
|
|
return;
|
|
}
|
|
const request = buildAccountSecretRequest(dialog.target);
|
|
setAccountEditSecretBusy(fieldId);
|
|
try {
|
|
const result = await window.rd.revealAccountSecret(request);
|
|
setAccountEditDialog((current) => {
|
|
if (!current) return current;
|
|
const currentRequest = buildAccountSecretRequest(current.target);
|
|
if (currentRequest.kind !== request.kind || currentRequest.accountId !== request.accountId) return current;
|
|
return { ...current, [fieldId]: result.secret };
|
|
});
|
|
setAccountEditSecretVisible((current) => ({ ...current, [fieldId]: true }));
|
|
} catch (error) {
|
|
showToast(`Gespeicherter Zugang konnte nicht angezeigt werden: ${String(error)}`, 3200);
|
|
} finally {
|
|
setAccountEditSecretBusy((current) => current === fieldId ? null : current);
|
|
}
|
|
};
|
|
const copyAccountEditSecret = async (fieldId: string): Promise<void> => {
|
|
if (fieldId !== "password" && fieldId !== "token") return;
|
|
const secret = accountEditDialog?.[fieldId] || "";
|
|
if (!secret) return;
|
|
try {
|
|
const copied = await window.rd.writeClipboardText(secret);
|
|
showToast(copied ? "Zugang in die Zwischenablage kopiert" : "Zugang konnte nicht kopiert werden", 2200);
|
|
} catch (error) {
|
|
showToast(`Zugang konnte nicht kopiert werden: ${String(error)}`, 3200);
|
|
}
|
|
};
|
|
const accountEditFields: AccountDialogField[] = accountEditDialog && accountEditOption ? [
|
|
...((accountEditDialog.target.type === "mega" || accountEditOption.needsCredentials) ? [
|
|
{ id: "login", label: "Login / E-Mail", type: "text" as const, value: accountEditDialog.login },
|
|
{
|
|
id: "password",
|
|
label: "Passwort",
|
|
type: "password" as const,
|
|
value: accountEditDialog.password,
|
|
storedSecret: accountEditHasStoredSecret,
|
|
secretVisible: Boolean(accountEditSecretVisible.password),
|
|
secretBusy: accountEditSecretBusy === "password"
|
|
}
|
|
] : []),
|
|
...((accountEditDialog.target.type === "debridlink" || accountEditOption.needsToken) ? [
|
|
{
|
|
id: "token",
|
|
label: accountEditDialog.target.type === "debridlink" ? "API-Key" : "Token / API-Key",
|
|
type: "password" as const,
|
|
value: accountEditDialog.token,
|
|
storedSecret: accountEditHasStoredSecret,
|
|
secretVisible: Boolean(accountEditSecretVisible.token),
|
|
secretBusy: accountEditSecretBusy === "token"
|
|
}
|
|
] : []),
|
|
{
|
|
id: "dailyLimitGb",
|
|
label: "Tageslimit (GB, optional)",
|
|
type: "number" as const,
|
|
value: accountEditDialog.dailyLimitGb,
|
|
placeholder: "Kein Limit"
|
|
}
|
|
] : [];
|
|
const checkAccountEditDialog = (): void => {
|
|
if (!accountEditDialog) return;
|
|
const validationError = validateAccountEdit(accountEditDialog, snapshot.accounts);
|
|
if (validationError) {
|
|
showToast(validationError, 2800);
|
|
return;
|
|
}
|
|
const editSnapshot = accountEditDialog;
|
|
void performQuickAction(async () => {
|
|
if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink"
|
|
|| editSnapshot.target.kind === "realdebrid-api" || editSnapshot.target.kind === "realdebrid-web") {
|
|
const secret = editSnapshot.target.type === "mega" ? editSnapshot.password : editSnapshot.token;
|
|
const kind = editSnapshot.target.kind as "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api";
|
|
const status = await window.rd.checkAccountCredentials({
|
|
kind,
|
|
accountId: editSnapshot.target.type === "mega"
|
|
? editSnapshot.target.accountId
|
|
: editSnapshot.target.type === "debridlink"
|
|
? editSnapshot.target.keyId
|
|
: editSnapshot.target.accountId,
|
|
identity: secret ? editSnapshot.login : undefined,
|
|
secret: secret || undefined
|
|
});
|
|
if (!status.valid) throw new Error(status.message || "Zugangsdaten ungültig");
|
|
showToast("Account erfolgreich geprüft", 2200);
|
|
return;
|
|
}
|
|
const quickAction = getAccountQuickActionMeta(editSnapshot.target.kind);
|
|
if (quickAction) {
|
|
await runAccountQuickAction(quickAction.action, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null);
|
|
} else {
|
|
showToast("Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", 2800);
|
|
}
|
|
}, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200));
|
|
};
|
|
const accountEditDialogView = accountEditDialog && accountEditOption ? (
|
|
<AccountEditDialog
|
|
actions={{
|
|
onFieldChange: (fieldId, value) => setAccountEditDialog((current) => current ? { ...current, [fieldId]: value } : current),
|
|
onClose: closeAccountEditDialog,
|
|
onCheck: checkAccountEditDialog,
|
|
onSave: () => { void onSaveAccountEditDialog(); },
|
|
onRemove: () => {
|
|
const row = accountEditRow;
|
|
closeAccountEditDialog();
|
|
if (row) removeAccountTableRow(row);
|
|
},
|
|
onToggleEnabled: () => {
|
|
if (accountEditRow) toggleAccountTableRow(accountEditRow);
|
|
},
|
|
onToggleSecret: (fieldId) => { void toggleAccountEditSecret(fieldId); },
|
|
onCopySecret: (fieldId) => { void copyAccountEditSecret(fieldId); }
|
|
}}
|
|
model={{
|
|
open: true,
|
|
hoster: accountEditOption.serviceLabel,
|
|
mode: accountEditOption.modeLabel,
|
|
identity: resolveAccountUsername(accountEditRow?.username || accountEditDialog.login, accountEditStatus?.username || accountEditStatus?.email),
|
|
enabled: !accountEditRow?.disabled,
|
|
fields: accountEditFields,
|
|
error: "",
|
|
busy: actionBusy
|
|
}}
|
|
/>
|
|
) : null;
|
|
|
|
return (
|
|
<div
|
|
className={`md-runtime-root${dragOver ? " drag-over" : ""}${tab === "settings" ? " settings-active" : ""}`}
|
|
onDragEnter={(event) => {
|
|
event.preventDefault();
|
|
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={() => {
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
if (dragDepthRef.current === 0 && dragOverRef.current) {
|
|
dragOverRef.current = false;
|
|
setDragOver(false);
|
|
}
|
|
}}
|
|
onDrop={onDrop}
|
|
>
|
|
<AppShell
|
|
activeView={tab}
|
|
contextInfo={tab === "downloads" ? (
|
|
<div>
|
|
Paket- und Linkstatus werden laufend aktualisiert. Auswahl, Reihenfolge und aktive Filter bleiben beim Ansichtswechsel erhalten.
|
|
</div>
|
|
) : tab === "collector" ? (
|
|
<div>
|
|
Rohzeilen bleiben lokal in der gewählten Sammlung, bis sie über „An Downloads übergeben“ an die Queue gesendet werden. Strg+L öffnet den Linksammler, Strg+O lädt DLC-Dateien.
|
|
</div>
|
|
) : tab === "history" ? (
|
|
<div>
|
|
Abgeschlossene und gelöschte Pakete bleiben hier durchsuchbar. Details zeigen Zielordner, Provider und gespeicherte Linkadressen.
|
|
</div>
|
|
) : tab === "statistics" ? (
|
|
<div>
|
|
{statisticsViewModel.message} Für sieben und 30 Tage bleiben Kennzahlen leer, solange keine historischen Buckets gespeichert werden.
|
|
</div>
|
|
) : null}
|
|
footer={tab === "downloads" ? <DownloadsFooter actions={downloadsActions} model={downloadsViewModel} /> : tab === "history" ? <HistoryFooter model={historyViewModel} /> : null}
|
|
headerActions={(
|
|
<>
|
|
<DownloadSpeedSparkline
|
|
speedBps={liveDownloadSpeedBps}
|
|
speedStateRef={speedSparklineStateRef}
|
|
hidden={tab !== "downloads"}
|
|
/>
|
|
<UpdateExperience
|
|
available={Boolean(availableUpdate)}
|
|
currentVersion={availableUpdate?.currentVersion ?? appVersion}
|
|
latestTag={availableUpdate?.latestTag ?? ""}
|
|
onClose={() => {
|
|
setUpdateDialogOpen(false);
|
|
setUpdateInstallProgress(null);
|
|
}}
|
|
onInstall={() => { void installUpdate(); }}
|
|
onLater={() => setUpdateDialogOpen(false)}
|
|
onOpen={() => setUpdateDialogOpen(true)}
|
|
open={updateDialogOpen || updateInstallProgress !== null}
|
|
progress={updateInstallProgress ? {
|
|
percent: updateInstallProgress.percent,
|
|
text: formatUpdateInstallProgress(updateInstallProgress)
|
|
} : 0}
|
|
releaseNotes={availableUpdate?.releaseNotes ?? ""}
|
|
renderDialog={false}
|
|
state={updateInstallProgress?.stage ?? "prompt"}
|
|
/>
|
|
<nav aria-label="Anwendungsmenü" className="md-application-menu-tree">
|
|
<div className="menu-bar-item">
|
|
<button
|
|
aria-expanded={openMenu === "datei"}
|
|
aria-haspopup="menu"
|
|
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
|
|
onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")}
|
|
onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }}
|
|
>
|
|
Datei
|
|
</button>
|
|
<div aria-hidden={openMenu !== "datei"} className={`menu-dropdown${openMenu === "datei" ? " is-open" : ""}`}>
|
|
<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 onImportQueue(); }}>
|
|
<span>Datei importieren</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>
|
|
<div
|
|
aria-hidden={openSubmenu !== "sicherung"}
|
|
{...(openSubmenu !== "sicherung" ? { inert: "" } : {})}
|
|
className={`menu-submenu-dropdown${openSubmenu === "sicherung" ? " is-open" : ""}`}
|
|
>
|
|
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
|
|
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
|
|
<div className="menu-separator" />
|
|
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel exportieren</button>
|
|
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</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
|
|
aria-expanded={openMenu === "einstellungen"}
|
|
aria-haspopup="menu"
|
|
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
|
|
onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")}
|
|
onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }}
|
|
>
|
|
Einstellungen
|
|
</button>
|
|
<div aria-hidden={openMenu !== "einstellungen"} className={`menu-dropdown${openMenu === "einstellungen" ? " is-open" : ""}`}>
|
|
<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; });
|
|
}}>▲</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; });
|
|
}}>▼</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));
|
|
}}>▲</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));
|
|
}}>▼</button>
|
|
</div>
|
|
</div>
|
|
<span className="menu-speed-unit">MB/s</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="menu-bar-item">
|
|
<button
|
|
aria-expanded={openMenu === "hilfe"}
|
|
aria-haspopup="menu"
|
|
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
|
|
onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")}
|
|
onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }}
|
|
>
|
|
Hilfe
|
|
</button>
|
|
<div aria-hidden={openMenu !== "hilfe"} className={`menu-dropdown${openMenu === "hilfe" ? " is-open" : ""}`}>
|
|
<div
|
|
className="menu-submenu"
|
|
onMouseEnter={() => setOpenSubmenu("hilfe-log")}
|
|
onMouseLeave={() => setOpenSubmenu(null)}
|
|
>
|
|
<button className="menu-submenu-trigger">Logs öffnen</button>
|
|
<div
|
|
aria-hidden={openSubmenu !== "hilfe-log"}
|
|
{...(openSubmenu !== "hilfe-log" ? { inert: "" } : {})}
|
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-log" ? " is-open" : ""}`}
|
|
>
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLogDirectory().catch(() => {}); }}><span>Log-Ordner öffnen</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="menu-submenu"
|
|
onMouseEnter={() => setOpenSubmenu("hilfe-remote")}
|
|
onMouseLeave={() => setOpenSubmenu(null)}
|
|
>
|
|
<button className="menu-submenu-trigger">Remote-Support</button>
|
|
<div
|
|
aria-hidden={openSubmenu !== "hilfe-remote"}
|
|
{...(openSubmenu !== "hilfe-remote" ? { inert: "" } : {})}
|
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
|
|
>
|
|
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
|
</div>
|
|
</div>
|
|
<div className="menu-separator" />
|
|
<button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}>
|
|
<span>Letzte Fehler anzeigen</span>
|
|
</button>
|
|
<div
|
|
className="menu-submenu"
|
|
onMouseEnter={() => setOpenSubmenu("hilfe-diagnose")}
|
|
onMouseLeave={() => setOpenSubmenu(null)}
|
|
>
|
|
<button className="menu-submenu-trigger">Diagnose</button>
|
|
<div
|
|
aria-hidden={openSubmenu !== "hilfe-diagnose"}
|
|
{...(openSubmenu !== "hilfe-diagnose" ? { inert: "" } : {})}
|
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-diagnose" ? " is-open" : ""}`}
|
|
>
|
|
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button>
|
|
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button>
|
|
</div>
|
|
</div>
|
|
<div className="menu-separator" />
|
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
|
|
<span>Suche Aktualisierungen</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
<div className="md-avatar-anchor">
|
|
<button
|
|
aria-expanded={avatarMenuOpen}
|
|
aria-haspopup="menu"
|
|
aria-label="Kontomenü"
|
|
className="md-avatar-trigger"
|
|
onClick={() => setAvatarMenuOpen((open) => !open)}
|
|
title="Kontomenü"
|
|
type="button"
|
|
>
|
|
<Icon name="account" size={18} />
|
|
</button>
|
|
<AvatarMenu
|
|
accountLabel={appVersion ? `Version ${appVersion}` : "Lokale Anwendung"}
|
|
actions={[
|
|
{ id: "settings", label: "Einstellungen", onSelect: () => setTab("settings") },
|
|
{ id: "quit", label: "Beenden", danger: true, onSelect: onMenuQuit }
|
|
]}
|
|
onClose={() => setAvatarMenuOpen(false)}
|
|
open={avatarMenuOpen}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
onSidebarCollapsedChange={setSidebarCollapsed}
|
|
onViewChange={setTab}
|
|
sidebar={tab === "downloads" ? (
|
|
<DownloadsSidebar actions={downloadsActions} model={downloadsViewModel} />
|
|
) : tab === "collector" ? (
|
|
<CollectorSidebar actions={collectorActions} model={collectorViewModel} />
|
|
) : tab === "history" ? (
|
|
<HistorySidebar actions={historyActions} model={historyViewModel} />
|
|
) : tab === "statistics" ? (
|
|
<StatisticsSidebar actions={statisticsActions} model={statisticsViewModel} />
|
|
) : tab === "settings" ? (
|
|
<SettingsSidebar actions={settingsViewActions} model={settingsViewModel} />
|
|
) : null}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
sidebarStatus={tab === "downloads" ? (
|
|
<DownloadsSidebarStatus model={downloadsViewModel} />
|
|
) : tab === "collector" ? (
|
|
<>
|
|
<span>Sammlungen: {collectorViewModel.tabs.length}</span>
|
|
<span>Links: {collectorViewModel.tabs.reduce((sum, entry) => sum + entry.linkCount, 0)}</span>
|
|
<span>Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"}</span>
|
|
</>
|
|
) : tab === "history" ? (
|
|
<>
|
|
<span>Einträge: {historyViewModel.totalCount}</span>
|
|
<span>Sichtbar: {historyViewModel.rows.length}</span>
|
|
<span>Ausgewählt: {historyViewModel.selectedIds.length}</span>
|
|
</>
|
|
) : tab === "statistics" ? (
|
|
<StatisticsSidebarStatus model={statisticsViewModel} />
|
|
) : null}
|
|
toolbar={tab === "downloads" ? (
|
|
<DownloadsToolbar actions={downloadsActions} model={downloadsViewModel} />
|
|
) : tab === "collector" ? (
|
|
<CollectorToolbar actions={collectorActions} model={collectorViewModel} />
|
|
) : tab === "history" ? (
|
|
<HistoryToolbar actions={historyActions} model={historyViewModel} />
|
|
) : null}
|
|
>
|
|
<main className="md-runtime-view-content">
|
|
{tab === "collector" && (
|
|
<CollectorContent actions={collectorActions} model={collectorViewModel} />
|
|
)}
|
|
|
|
{tab === "downloads" && <DownloadsContent actions={downloadsActions} model={downloadsViewModel} />}
|
|
|
|
{tab === "history" && (
|
|
<HistoryContent actions={historyActions} model={historyViewModel} />
|
|
)}
|
|
|
|
{tab === "statistics" && (
|
|
<StatisticsContent
|
|
actions={statisticsActions}
|
|
chart={<BandwidthChart running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} />}
|
|
model={statisticsViewModel}
|
|
/>
|
|
)}
|
|
|
|
{tab === "settings" && <SettingsContent actions={settingsViewActions} model={settingsViewModel} />}
|
|
|
|
</main>
|
|
</AppShell>
|
|
|
|
<OverlayHost
|
|
backupPassphrase={backupPassphraseMode ? (
|
|
<BackupPassphraseDialog
|
|
mode={backupPassphraseMode}
|
|
onCancel={() => closeBackupPassphraseDialog(null)}
|
|
onSubmit={(passphrase) => closeBackupPassphraseDialog(passphrase)}
|
|
/>
|
|
) : null}
|
|
confirm={confirmPrompt ? (
|
|
<Dialog actions={null} danger={confirmPrompt.danger} onClose={() => closeConfirmPrompt(false)} open title={confirmPrompt.title}>
|
|
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
|
|
{confirmPrompt.details && (
|
|
<details className="modal-details">
|
|
<summary>{confirmPrompt.detailsLabel || "Details anzeigen"}</summary>
|
|
<pre>{confirmPrompt.details}</pre>
|
|
</details>
|
|
)}
|
|
<div className="modal-actions">
|
|
<button className="btn" onClick={() => closeConfirmPrompt(false)}>{confirmPrompt.cancelLabel || "Abbrechen"}</button>
|
|
<button
|
|
className={confirmPrompt.danger ? "btn danger" : "btn"}
|
|
onClick={() => closeConfirmPrompt(true)}
|
|
>
|
|
{confirmPrompt.confirmLabel}
|
|
</button>
|
|
</div>
|
|
</Dialog>
|
|
) : null}
|
|
|
|
onlineBackup={onlineBackupDialog ? (
|
|
<Dialog
|
|
actions={null}
|
|
className="online-backup-modal"
|
|
closable={!onlineBackupDialog.busy}
|
|
onClose={() => setOnlineBackupDialog(null)}
|
|
open
|
|
restoreFocusFallback={() => document.querySelector<HTMLButtonElement>(".md-application-menu-tree .menu-bar-trigger")}
|
|
restoreFocusTarget={typeof document !== "undefined" && document.activeElement instanceof HTMLElement ? document.activeElement : null}
|
|
size="wide"
|
|
title={onlineBackupDialog.mode === "export" ? "Online-Schlüssel" : "Online-Schlüssel importieren"}
|
|
>
|
|
<p>
|
|
{onlineBackupDialog.mode === "export"
|
|
? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf."
|
|
: "Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."}
|
|
</p>
|
|
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert …</div>}
|
|
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
|
|
<textarea className="online-backup-key" value={onlineBackupDialog.key} readOnly spellCheck={false} aria-label="Online-Sicherungsschlüssel" />
|
|
)}
|
|
{onlineBackupDialog.mode === "import" && (
|
|
<textarea
|
|
className="online-backup-key"
|
|
value={onlineBackupDialog.key}
|
|
onChange={(event) => setOnlineBackupDialog((current) => current ? { ...current, key: event.target.value, error: "" } : current)}
|
|
placeholder="MDD2-…"
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
disabled={onlineBackupDialog.busy}
|
|
autoFocus
|
|
aria-label="Online-Sicherungsschlüssel eingeben"
|
|
/>
|
|
)}
|
|
{onlineBackupDialog.error && <div className="online-backup-error">{onlineBackupDialog.error}</div>}
|
|
<div className="modal-actions">
|
|
<button className="btn" onClick={() => setOnlineBackupDialog(null)} disabled={onlineBackupDialog.busy}>Schließen</button>
|
|
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && <button className="btn primary" onClick={() => { void onCopyOnlineBackupKey(); }}>Kopieren</button>}
|
|
{onlineBackupDialog.mode === "import" && <button className="btn primary" onClick={() => { void onImportOnlineBackup(); }} disabled={onlineBackupDialog.busy || !onlineBackupDialog.key.trim()}>{onlineBackupDialog.busy ? "Wird geladen …" : "Importieren"}</button>}
|
|
</div>
|
|
</Dialog>
|
|
) : null}
|
|
|
|
diagnostics={remoteDiagOpen ? (
|
|
<Dialog actions={null} onClose={() => setRemoteDiagOpen(false)} open title="Ferndiagnose">
|
|
<p>Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.</p>
|
|
<div className="rd-status-line">
|
|
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
|
|
<span>
|
|
{remoteDiag?.status.running
|
|
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
|
|
: "Inaktiv"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="rd-field">
|
|
<label>Sichtbarkeit</label>
|
|
<div className="rd-seg">
|
|
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
|
|
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
|
|
</div>
|
|
<span className="rd-hint">
|
|
{rdHostMode === "local"
|
|
? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante."
|
|
: "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="rd-field">
|
|
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
|
|
<input
|
|
value={rdPublicHost}
|
|
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
|
|
onChange={(event) => setRdPublicHost(event.target.value)}
|
|
/>
|
|
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
|
|
<div className="rd-chips">
|
|
{remoteDiag.suggestedHosts.map((host) => (
|
|
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rd-field rd-field-inline">
|
|
<div className="rd-field">
|
|
<label>Port</label>
|
|
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
|
|
</div>
|
|
<div className="rd-field">
|
|
<label>Name (optional)</label>
|
|
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
|
|
</div>
|
|
</div>
|
|
|
|
{rdHostMode === "network" && (
|
|
<div className="rd-field">
|
|
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
|
|
<textarea
|
|
value={rdAllowlist}
|
|
placeholder={"203.0.113.5\n10.0.0.0/24"}
|
|
onChange={(event) => setRdAllowlist(event.target.value)}
|
|
/>
|
|
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
|
|
</div>
|
|
)}
|
|
|
|
{remoteDiag?.status.running && remoteDiag.code && (
|
|
<div className="rd-field">
|
|
<label>Verbindungscode</label>
|
|
<div className="rd-code">{remoteDiag.code}</div>
|
|
<div className="rd-chips">
|
|
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
|
|
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
|
|
</div>
|
|
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="modal-actions">
|
|
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
|
|
{remoteDiag?.status.running && (
|
|
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
|
|
)}
|
|
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
|
|
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
|
|
</button>
|
|
</div>
|
|
</Dialog>
|
|
) : null}
|
|
|
|
deleteConfirmation={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 (
|
|
<DeleteConfirmationDialog
|
|
dontAsk={deleteConfirm.dontAsk}
|
|
parts={parts}
|
|
totalRemaining={totalRemaining}
|
|
onCancel={() => setDeleteConfirm(null)}
|
|
onConfirm={() => {
|
|
if (deleteConfirm.dontAsk) {
|
|
setSettingsDraft((prev) => ({ ...prev, confirmDeleteSelection: false }));
|
|
void window.rd.updateSettings({ confirmDeleteSelection: false }).catch(() => {});
|
|
}
|
|
executeDeleteSelection(deleteConfirm.ids);
|
|
setDeleteConfirm(null);
|
|
}}
|
|
onDontAskChange={(checked) => setDeleteConfirm((prev) => prev ? { ...prev, dontAsk: checked } : prev)}
|
|
/>
|
|
);
|
|
})() : null}
|
|
|
|
conflict={startConflictPrompt ? (
|
|
<Dialog actions={null} onClose={() => closeStartConflictPrompt(null)} open title="Paket bereits entpackt">
|
|
<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>
|
|
</Dialog>
|
|
) : null}
|
|
|
|
accountEdit={accountEditDialogView}
|
|
accountCreate={accountAddDialog}
|
|
toast={<Toast message={statusToast} />}
|
|
dropOverlay={dragOver ? <div className="drop-overlay md-drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div> : null}
|
|
accountContextMenu={accountContextMenu && activeAccountContextRow ? (
|
|
<ContextMenu
|
|
ariaLabel="Accountaktionen"
|
|
className="account-context-menu"
|
|
onClose={() => setAccountContextMenu(null)}
|
|
open
|
|
ref={accountContextMenuRef}
|
|
x={accountContextMenu.x}
|
|
y={accountContextMenu.y}
|
|
>
|
|
<div className="account-context-heading">
|
|
<strong>{formatAccountContextHeading(activeAccountContextRow.hosterLabel, activeAccountContextRow.modeLabel)}</strong>
|
|
</div>
|
|
<div className="ctx-menu-sep" />
|
|
<button className="ctx-menu-item" onClick={() => { setAccountContextMenu(null); openEditAccountDialog(activeAccountContextRow); }}>
|
|
Bearbeiten
|
|
</button>
|
|
<button className="ctx-menu-item" onClick={() => checkAccountTableRow(activeAccountContextRow)}>
|
|
Account prüfen
|
|
</button>
|
|
{getAccountQuickActionMeta(activeAccountContextRow.entry.kind) && (
|
|
<button className="ctx-menu-item" onClick={() => { setAccountContextMenu(null); void onAccountRowQuickAction(activeAccountContextRow); }}>
|
|
{getAccountQuickActionMeta(activeAccountContextRow.entry.kind)?.label}
|
|
</button>
|
|
)}
|
|
<button className="ctx-menu-item" onClick={() => toggleAccountTableRow(activeAccountContextRow)}>
|
|
{activeAccountContextRow.disabled ? "Account aktivieren" : "Account deaktivieren"}
|
|
</button>
|
|
<div className="ctx-menu-sep" />
|
|
<button className="ctx-menu-item ctx-danger" onClick={() => removeAccountTableRow(activeAccountContextRow)}>
|
|
Account entfernen
|
|
</button>
|
|
</ContextMenu>
|
|
) : null}
|
|
downloadContextMenu={contextMenu ? (() => {
|
|
const actionableSelectedIds = downloadsViewCore.actionableSelectedIds;
|
|
const multi = actionableSelectedIds.length > 1;
|
|
const selectedPackageIds = actionableSelectedIds.filter((id) => snapshot.session.packages[id]);
|
|
const selectedItemIds = actionableSelectedIds.filter((id) => snapshot.session.items[id]);
|
|
const hasPackages = selectedPackageIds.length > 0;
|
|
const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]);
|
|
const hasStartableItems = actionableSelectedIds.some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
|
|
const hasItems = selectedItemIds.length > 0;
|
|
return (
|
|
<ContextMenu ariaLabel="Downloadaktionen" onClose={() => setContextMenu(null)} open ref={ctxMenuRef} x={contextMenu.x} y={contextMenu.y}>
|
|
{(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 ? ` (${actionableSelectedIds.length})` : ""}</button>
|
|
)}
|
|
<button className="ctx-menu-item" onClick={() => { downloadsActions.onStartDownloads(); 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>
|
|
{hasPackages && !contextMenu.itemId && (
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
void onExportPackageSelection(selectedPackageIds);
|
|
setContextMenu(null);
|
|
}}>{multi ? `Ausgewählte Pakete exportieren (${selectedPackageIds.length})` : "Paket exportieren"}</button>
|
|
)}
|
|
{contextMenu.itemId && (
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
void onExportItemSelection(multi ? selectedItemIds : [contextMenu.itemId!]);
|
|
setContextMenu(null);
|
|
}}>{multi ? `Ausgewählte Dateien exportieren (${selectedItemIds.length})` : "Datei exportieren"}</button>
|
|
)}
|
|
{hasPackages && !contextMenu.itemId && (
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
for (const id of selectedPackageIds) {
|
|
void window.rd.openPackageLog(id).catch(() => {});
|
|
}
|
|
setContextMenu(null);
|
|
}}>Log öffnen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
|
)}
|
|
{contextMenu.itemId && (
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
|
for (const id of itemIds) {
|
|
void window.rd.openItemLog(id).catch(() => {});
|
|
}
|
|
setContextMenu(null);
|
|
}}>Item-Log öffnen{multi ? ` (${selectedItemIds.length})` : ""}</button>
|
|
)}
|
|
<div className="ctx-menu-sep" />
|
|
{hasPackages && !contextMenu.itemId && (
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
for (const id of actionableSelectedIds) { if (snapshot.session.packages[id]) onPackageToggle(id); }
|
|
setContextMenu(null);
|
|
}}>
|
|
{multi ? `Alle ${actionableSelectedIds.length} 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" && !/^Entpackt\b/i.test(item.fullStatus || ""));
|
|
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 aria-expanded="false" aria-haspopup="menu" className="ctx-menu-item">Priorität ></button>
|
|
<div aria-label="Priorität" className="ctx-menu-sub-items" role="menu">
|
|
{(["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" : ""}`} disabled={allMatch} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p).catch(() => {}); setContextMenu(null); }}>{allMatch ? `[Aktiv] ${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>
|
|
)}
|
|
</ContextMenu>
|
|
);
|
|
})() : null}
|
|
columnContextMenu={colHeaderCtx ? (
|
|
<ContextMenu
|
|
ariaLabel="Spaltenauswahl"
|
|
className="md-column-context-menu"
|
|
onClose={() => setColHeaderCtx(null)}
|
|
open
|
|
ref={colHeaderCtxRef}
|
|
x={colHeaderCtx.x}
|
|
y={colHeaderCtx.y}
|
|
>
|
|
{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" : ""}`}
|
|
disabled={isRequired}
|
|
onClick={() => {
|
|
if (isRequired) return;
|
|
let newOrder: string[];
|
|
if (isVisible) {
|
|
newOrder = columnOrder.filter((c) => c !== col);
|
|
} else {
|
|
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);
|
|
}
|
|
persistColumnOrder(newOrder);
|
|
setColumnOrder(newOrder);
|
|
}}
|
|
>
|
|
<span aria-hidden="true" className="column-menu-check">{isVisible ? "\u2713" : ""}</span>
|
|
<span>{def.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
<div className="ctx-menu-sep" />
|
|
<button className="ctx-menu-item column-menu-reset" onClick={() => {
|
|
resetColumnLayout();
|
|
setColHeaderCtx(null);
|
|
}}>Spaltenlayout zurücksetzen</button>
|
|
</ContextMenu>
|
|
) : null}
|
|
historyContextMenu={historyCtxMenu ? (() => {
|
|
const selectedEntryIds = [...pruneHistoryIds(selectedHistoryIds, historyVisibleIdsRef.current)];
|
|
const multi = selectedEntryIds.length > 1;
|
|
const contextEntry = historyEntriesRef.current.find(e => e.id === historyCtxMenu.entryId);
|
|
const hasUrls = (contextEntry?.urls?.length ?? 0) > 0;
|
|
return (
|
|
<ContextMenu ariaLabel="Verlaufsaktionen" onClose={() => setHistoryCtxMenu(null)} open ref={historyCtxMenuRef} x={historyCtxMenu.x} y={historyCtxMenu.y}>
|
|
<button className="ctx-menu-item ctx-danger" onClick={() => {
|
|
setHistoryCtxMenu(null);
|
|
void removeHistoryEntries(selectedEntryIds);
|
|
}}>
|
|
{multi ? `Ausgewählte entfernen (${selectedEntryIds.length})` : "Eintrag entfernen"}
|
|
</button>
|
|
{!multi && contextEntry ? (
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
setHistoryCtxMenu(null);
|
|
void revealHistoryEntry(contextEntry.id);
|
|
}}>Im Ordner zeigen</button>
|
|
) : null}
|
|
{hasUrls && !multi && (
|
|
<>
|
|
<div className="ctx-menu-sep" />
|
|
<button className="ctx-menu-item" onClick={() => {
|
|
setHistoryCtxMenu(null);
|
|
void restoreHistoryEntries([contextEntry!.id]);
|
|
}}>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={() => {
|
|
setHistoryCtxMenu(null);
|
|
void clearHistoryEntries();
|
|
}}>Verlauf leeren</button>
|
|
</ContextMenu>
|
|
);
|
|
})() : null}
|
|
keyStats={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 keyDiagnostics = entry.debridLinkKeys
|
|
.map((k) => debridLinkHostLimits[k.id])
|
|
.filter((info): info is DebridLinkHostLimitInfo => Boolean(info));
|
|
const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length;
|
|
const invalidCount = keyDiagnostics.filter((info) => info.state === "invalid").length;
|
|
const cooldownCount = keyDiagnostics.filter((info) => info.state === "cooldown" || info.state === "quota" || info.state === "rate_limit").length;
|
|
const hostStatusLabel = keyDiagnostics.find((info) => info.hostState !== "unknown")?.hostStateLabel || "";
|
|
return (
|
|
<Dialog actions={null} className="key-stats-popup" onClose={() => setKeyStatsPopup(null)} open showCloseButton size="wide" title="API-Key Statistik">
|
|
<div className="key-stats-popup-header">
|
|
<div>
|
|
<p className="key-stats-summary">
|
|
{entry.debridLinkKeys.length} Keys · Heute: {humanSize(totalUsed)}
|
|
{limitedCount > 0 && <span className="key-stats-warn"> · {limitedCount} am Limit</span>}
|
|
{disabledCount > 0 && <span className="key-stats-warn"> · {disabledCount} deaktiviert</span>}
|
|
{invalidCount > 0 && <span className="key-stats-warn"> · {invalidCount} invalid</span>}
|
|
{cooldownCount > 0 && <span className="key-stats-warn"> · {cooldownCount} im Cooldown</span>}
|
|
{debridLinkHostLimitsLoading && <span> · Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})</span>}
|
|
{!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && <span> · Rapidgator {hostStatusLabel || "Status unbekannt"}</span>}
|
|
{debridLinkHostLimitsError && <span className="key-stats-warn"> · API-Quota konnte nicht geladen werden</span>}
|
|
</p>
|
|
</div>
|
|
</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-status">Status</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 || (debridLinkHostLimits[key.id] && debridLinkHostLimits[key.id].state !== "ready") ? " warning" : ""}${entry.disabled || key.disabled ? " disabled" : ""}`}>
|
|
{(() => {
|
|
const hostInfo = debridLinkHostLimits[key.id];
|
|
const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo);
|
|
return (
|
|
<>
|
|
<span className="col-key">{ki + 1}</span>
|
|
<button
|
|
aria-label={`${key.label} maskierte Kennung kopieren`}
|
|
className="col-masked link-popup-click"
|
|
type="button"
|
|
title={`${key.masked}\nMaskierte Kennung kopieren`}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(key.masked)
|
|
.then(() => showToast("Maskierte Kennung kopiert", 1800))
|
|
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
|
|
}}
|
|
>
|
|
{key.masked}
|
|
</button>
|
|
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
|
<span className="col-limit">{entry.disabled || key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
|
<span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</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 ${entry.disabled || key.disabled ? "success" : "danger"}`}
|
|
disabled={actionBusy}
|
|
onClick={() => { void onToggleDebridLinkApiKeyEnabled(entry, key, entry.disabled || key.disabled); }}
|
|
>
|
|
{entry.disabled || 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>
|
|
</Dialog>
|
|
);
|
|
})() : null}
|
|
linkPopup={(
|
|
<>
|
|
{collectorInput ? (
|
|
<CollectorInputDialog
|
|
onChange={(draft) => setCollectorInput((prev) => prev ? { ...prev, draft } : prev)}
|
|
onClose={() => setCollectorInput(null)}
|
|
onCommit={commitCollectorInput}
|
|
open
|
|
tabName={collectorInput.tabName}
|
|
value={collectorInput.draft}
|
|
/>
|
|
) : null}
|
|
{linkPopup ? (
|
|
<Dialog actions={null} className="link-popup" onClose={() => setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen">
|
|
<p>{linkPopup.title}</p>
|
|
<div className="link-popup-list">
|
|
{linkPopup.links.map((link, i) => (
|
|
<div key={i} className="link-popup-row">
|
|
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
|
|
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
|
|
</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>
|
|
</Dialog>
|
|
) : null}
|
|
</>
|
|
)}
|
|
update={(
|
|
<UpdateExperience
|
|
available={Boolean(availableUpdate)}
|
|
currentVersion={availableUpdate?.currentVersion ?? appVersion}
|
|
latestTag={availableUpdate?.latestTag ?? ""}
|
|
onClose={() => {
|
|
setUpdateDialogOpen(false);
|
|
setUpdateInstallProgress(null);
|
|
}}
|
|
onInstall={() => { void installUpdate(); }}
|
|
onLater={() => setUpdateDialogOpen(false)}
|
|
onOpen={() => setUpdateDialogOpen(true)}
|
|
open={updateDialogOpen || updateInstallProgress !== null}
|
|
progress={updateInstallProgress ? {
|
|
percent: updateInstallProgress.percent,
|
|
text: formatUpdateInstallProgress(updateInstallProgress)
|
|
} : 0}
|
|
releaseNotes={availableUpdate?.releaseNotes ?? ""}
|
|
renderTrigger={false}
|
|
state={updateInstallProgress?.stage ?? "prompt"}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|