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 { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts"; import type { AllDebridHostInfo, AppSettings, AppTheme, BandwidthScheduleEntry, DebugSetupCheckResult, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider, DownloadItem, DownloadStats, DuplicatePolicy, HistoryEntry, PackageEntry, RemoteDiagnosticsInfo, 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 { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; import type { AccountModeFilter } from "./account-ui"; import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } 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 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 { 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, 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, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag"; import { DownloadsContent, DownloadsFooter, DownloadsSidebar, DownloadsSidebarStatus, DownloadsToolbar, type DownloadsViewActions, type DownloadsViewModel } from "./views/downloads/DownloadsView"; import { buildAccountRowId, buildSettingsFormViewModel, buildTargetedAccountCheck, projectAccountRows, sortAccountRows, 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; 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: "mega" | "dl" | "single"; megaLogin?: string; dlKey?: DebridLinkAccountKeyEntry; editTarget: AccountEditTarget; } interface AccountContextMenuState { x: number; y: number; rowId: string; } type SettingsThemeChoice = AppTheme | "system"; function settingsValueEqual(left: unknown, right: unknown): boolean { return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); } export function mergeConcurrentSpecificSettings( base: AppSettings, requested: AppSettings, persisted: AppSettings, current: AppSettings ): AppSettings { const merged = { ...current } as Record; for (const key of Object.keys(requested) as Array) { if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key])) { merged[key] = persisted[key]; } } return merged as unknown as AppSettings; } 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.", needsToken: true }, { 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.", needsToken: true }, { kind: "bestdebrid-api", service: "bestdebrid", serviceLabel: "BestDebrid", title: "BestDebrid API", modeLabel: "API", pickerDescription: "Direkter Zugriff über API-Token.", needsToken: true }, { kind: "bestdebrid-web", service: "bestdebrid", serviceLabel: "BestDebrid", title: "BestDebrid Web-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 formatAccountDailyLimitInput(limitBytes: number): string { if (limitBytes <= 0) { return ""; } const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB; const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); } function parseAccountDailyLimitInputBytes(value: string): number | null { const normalized = value.trim().replace(",", "."); if (!normalized) { return null; } const parsed = Number(normalized); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB); } function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record, settings?: AppSettings): Record { const next: Record = {}; for (const key of parseDebridLinkApiKeys(rawKeys)) { next[key.id] = values?.[key.id] ?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0); } return next; } function getAccountPickerFunctionLabel(option: AccountOption): string { switch (option.kind) { case "realdebrid-api": case "bestdebrid-api": return "API-Token"; case "realdebrid-web": case "alldebrid-web": return "Browser-Login"; case "megadebrid-api": return "Login:Passwort (API)"; case "megadebrid-web": return "Login:Passwort (Web)"; case "bestdebrid-web": return "Cookies.txt-Import"; case "alldebrid-api": case "onefichier-api": return "API-Key"; case "ddownload-login": return "Login + Passwort"; default: return option.modeLabel; } } function 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 hasMegaDebridCredentials(settings: AppSettings): boolean { if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true; return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); } function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] { const list: DebridProvider[] = []; if (settings.token.trim() || settings.realDebridUseWebLogin) { list.push("realdebrid"); } if (hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled) { list.push("megadebrid-api"); } if (hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled) { list.push("megadebrid-web"); } if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) { list.push("bestdebrid"); } if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) { list.push("alldebrid"); } if ((settings.debridLinkApiKeys || "").trim()) { list.push("debridlink"); } if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) { list.push("linksnappy"); } return list; } function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] { const disabled = new Set(settings.disabledProviders || []); return getConfiguredProvidersFromSettings(settings).filter((provider) => !disabled.has(provider)); } const DIRECT_HOSTERS: ReadonlySet = new Set(["onefichier", "ddownload"]); function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] { const configured = getConfiguredProvidersFromSettings(settings).filter((provider) => !DIRECT_HOSTERS.has(provider)); return buildConfiguredProviderOrder(settings.providerOrder || [], configured); } function normalizeProviderSelectionForSettings( settings: AppSettings ): Pick { const providerOrder = normalizeProviderOrderForSettings(settings); return { providerOrder, providerPrimary: providerOrder[0] ?? settings.providerPrimary, providerSecondary: (providerOrder[1] ?? "none") as DebridFallbackProvider, providerTertiary: (providerOrder[2] ?? "none") as DebridFallbackProvider }; } function getConfiguredAccountKind(settings: AppSettings, service: AccountService): AccountKind | null { switch (service) { case "realdebrid": if (settings.realDebridUseWebLogin) return "realdebrid-web"; return settings.token.trim() ? "realdebrid-api" : null; case "megadebrid-api": return hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled ? "megadebrid-api" : null; case "megadebrid-web": return hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled ? "megadebrid-web" : null; case "bestdebrid": if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; return settings.bestToken.trim() ? "bestdebrid-api" : null; case "alldebrid": if (settings.allDebridUseWebLogin) return "alldebrid-web"; return settings.allDebridToken.trim() ? "alldebrid-api" : null; case "ddownload": return settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim() ? "ddownload-login" : null; case "onefichier": return settings.oneFichierApiKey.trim() ? "onefichier-api" : null; case "debridlink": return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null; case "linksnappy": return (settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim() ? "linksnappy-login" : null; default: return null; } } function maskValue(value: string, keepStart = 2, keepEnd = 2): string { const trimmed = value.trim(); if (!trimmed) return "Nicht hinterlegt"; if (trimmed.length <= keepStart + keepEnd) { return "*".repeat(trimmed.length); } return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`; } function summarizeAccount(kind: AccountKind, settings: AppSettings): string { switch (kind) { case "realdebrid-api": return maskValue(settings.token, 3, 3); case "realdebrid-web": return "Browser-Login"; case "megadebrid-api": case "megadebrid-web": { const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`; if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin; return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt"; } case "bestdebrid-api": return maskValue(settings.bestToken, 3, 3); case "bestdebrid-web": return "Cookie-Import"; case "alldebrid-api": return maskValue(settings.allDebridToken, 3, 3); case "alldebrid-web": return "Browser-Login"; case "ddownload-login": return settings.ddownloadLogin.trim() ? maskValue(settings.ddownloadLogin.trim(), 2, 6) : "Login + Passwort"; case "onefichier-api": return maskValue(settings.oneFichierApiKey, 3, 3); case "debridlink-api": { const keys = (settings.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()); if (keys.length > 1) return `${keys.length} API-Keys`; return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt"; } case "linksnappy-login": return (settings.linkSnappyLogin || "").trim() ? maskValue((settings.linkSnappyLogin || "").trim(), 2, 4) : "Login + Passwort"; default: return "Konfiguriert"; } } function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] { if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) { const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); if (keys.length > 1) { return keys.map((entry) => `${entry.label}: ${entry.masked}`); } } if (kind === "megadebrid-api" || kind === "megadebrid-web") { const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); if (accounts.length > 1) { return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`); } } return [summarizeAccount(kind, settings)]; } function getStoredAccountUsername(kind: AccountKind, settings: AppSettings): string { switch (kind) { case "ddownload-login": return settings.ddownloadLogin; case "linksnappy-login": return settings.linkSnappyLogin; case "realdebrid-web": case "alldebrid-web": return "Browser-Login"; case "bestdebrid-web": return "Cookies.txt"; default: return "API-Account"; } } function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { const baseMega: Pick = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; if (!kind) { return { mode, kind: null, service: null, token: "", login: "", password: "", dailyLimitGb: "", keyDailyLimitGbById: {}, ...baseMega }; } const service = findAccountOption(kind).service; const provider = getAccountServiceProvider(service); const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider)); switch (kind) { case "realdebrid-api": return { mode, kind, service, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "realdebrid-web": return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "megadebrid-api": case "megadebrid-web": { let megaToken = (settings.megaCredentials || "").trim(); if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) { megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`; } const parsed = parseMegaDebridAccounts(megaToken); const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); const loadedIds = new Set(parsed.map((a) => a.id)); const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id)); return { mode, kind, service, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; } case "bestdebrid-api": return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "bestdebrid-web": return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "alldebrid-api": return { mode, kind, service, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "alldebrid-web": return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "ddownload-login": return { mode, kind, service, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "onefichier-api": return { mode, kind, service, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "debridlink-api": return { mode, kind, service, token: mode === "create" ? "" : settings.debridLinkApiKeys || "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: mode === "create" ? {} : buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings), ...baseMega }; case "linksnappy-login": return { mode, kind, service, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; default: return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; } } export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { if (!dialog.kind) { return settings; } const token = dialog.token.trim(); const login = dialog.login.trim(); const password = dialog.password; const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service); const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api" ? Object.fromEntries( parseDebridLinkApiKeys(dialog.token).flatMap((entry) => { const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || ""); return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : []; }) ) as Record : { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb); if (dailyLimitBytes && dailyLimitBytes > 0) { nextProviderDailyLimitBytes[provider] = dailyLimitBytes; } else { delete nextProviderDailyLimitBytes[provider]; } switch (dialog.kind) { case "realdebrid-api": return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "realdebrid-web": return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "megadebrid-api": { const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts); const megaParsed = parseMegaDebridAccounts(megaSerialized); const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : ""; const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : ""; const validIds = new Set(megaParsed.map((a) => a.id)); const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id)); return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes }; } case "megadebrid-web": { const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts); const megaParsed = parseMegaDebridAccounts(megaSerialized); const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : ""; const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : ""; const validIds = new Set(megaParsed.map((a) => a.id)); const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id)); return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes }; } case "bestdebrid-api": return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "bestdebrid-web": return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "alldebrid-api": return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "alldebrid-web": return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "ddownload-login": return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "onefichier-api": return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "debridlink-api": return { ...settings, debridLinkApiKeys: token, providerDailyLimitBytes: nextProviderDailyLimitBytes, debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes }; case "linksnappy-login": return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; default: return settings; } } function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings { const provider = getAccountServiceProvider(service); const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) }; const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }; delete nextProviderDailyLimitBytes[provider]; delete nextProviderDailyUsageBytes[provider]; if (service === "debridlink") { for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) { delete nextDebridLinkApiKeyDailyLimitBytes[key.id]; delete nextDebridLinkApiKeyDailyUsageBytes[key.id]; } } switch (service) { case "realdebrid": return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "megadebrid-api": return settings.megaDebridWebEnabled ? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "megadebrid-web": return settings.megaDebridApiEnabled ? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "bestdebrid": return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "alldebrid": return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "ddownload": return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "onefichier": return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "debridlink": return { ...settings, debridLinkApiKeys: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes, debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes }; case "linksnappy": return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; default: return settings; } } function validateAccountDialog(dialog: AccountDialogState): string | null { if (!dialog.kind) { return "Bitte zuerst einen Account-Typ auswählen."; } const option = findAccountOption(dialog.kind); if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") { if (dialog.megaAccounts.length === 0) { return `${option.title}: Mindestens einen Account hinzufügen.`; } } 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; } 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", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", debridLinkDisabledKeyIds: [], archivePasswordList: "", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, autoRename4sf4sj: false, 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", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyUrl: "", 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: [], megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, debridAccountStatuses: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 }, session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "", paused: false, running: false, updatedAt: Date.now() }, summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --", canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {} }); const cleanupLabels: Record = { never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" }; const historyRetentionLabels: Record = { 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 = itemCount >= 700 ? 0 : itemCount >= 250 ? 50 : 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: AppSettings): string { const base = providerLabels[provider]; if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") { return base; } const kind = getConfiguredAccountKind(settings, provider as AccountService); if (!kind) return base; const opt = ACCOUNT_OPTIONS.find((o) => o.kind === kind); return opt?.modeLabel ? `${base} (${opt.modeLabel})` : base; } function 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 formatCheckedAgo(checkedAt: number): string { const deltaMs = Date.now() - checkedAt; if (!Number.isFinite(deltaMs) || deltaMs < 0) return "gerade eben"; const min = Math.floor(deltaMs / 60000); if (min < 1) return "gerade eben"; if (min < 60) return `vor ${min} Min`; const hours = Math.floor(min / 60); if (hours < 24) return `vor ${hours} Std`; const days = Math.floor(hours / 24); return `vor ${days} Tag${days === 1 ? "" : "en"}`; } 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 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(null); const containerRef = useRef(null); const animationFrameRef = useRef(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 interval = setInterval(() => { drawChart(); }, 250); 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 (
); }); interface DownloadSpeedSparklineProps { speedBps: number; speedStateRef: React.MutableRefObject; hidden?: boolean; } const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement { const canvasRef = useRef(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 isDark = document.documentElement.getAttribute("data-theme") !== "light"; const accent = isDark ? "#f2942d" : "#c2701a"; const fill = isDark ? "rgba(242, 148, 45, 0.16)" : "rgba(194, 112, 26, 0.16)"; 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.fillStyle = fill; ctx.fill(); 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 = 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, 250); return () => window.clearInterval(id); }, []); return (
{speedBps > 0 ? formatSpeedMbps(speedBps) : "0 B/s"}
); }); 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, items: Record, 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, items: Record, 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, items: Record, 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): 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, apply: (result: UpdateCheckResult, generation: number) => Promise | void ): Promise { 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(emptySnapshot); const [appVersion, setAppVersion] = useState(""); const [tab, setTab] = useState("downloads"); const [statusToast, setStatusToast] = useState(""); const [availableUpdate, setAvailableUpdate] = useState(null); const [updateDialogOpen, setUpdateDialogOpen] = useState(false); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.theme); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); const [settingsDirty, setSettingsDirty] = useState(false); const [settingsSaveState, setSettingsSaveState] = useState("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 settingsDraftRevisionRef = useRef(0); useEffect(() => { const localizer = createUiLocalizer(document, normalizeLanguage(settingsDraft.language)); return () => localizer.disconnect(); }, [settingsDraft.language]); const panelDirtyRevisionRef = useRef(0); const latestStateRef = useRef(null); const masterSnapshotRef = useRef(null); const snapshotRef = useRef(snapshot); snapshotRef.current = snapshot; const tabRef = useRef(tab); const autoExpandedPkgsRef = useRef(new Set()); const manualCollapsedPkgsRef = useRef(new Set()); tabRef.current = tab; const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); const onImportDlcRef = useRef<() => Promise>(() => Promise.resolve()); const [dragOver, setDragOver] = useState(false); const [draggedProvider, setDraggedProvider] = useState(null); const [providerDropTarget, setProviderDropTarget] = useState(null); const [editingPackageId, setEditingPackageId] = useState(null); const [editingName, setEditingName] = useState(""); const [collectorTabs, setCollectorTabs] = useState([ { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } ]); const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const [collectorQuery, setCollectorQuery] = useState(""); const [selectedCollectorRowIds, setSelectedCollectorRowIds] = useState>(() => new Set()); const [collectorError, setCollectorError] = useState(""); const [collectorInput, setCollectorInput] = useState(null); const collectorTabsRef = useRef(collectorTabs); const activeCollectorTabRef = useRef(activeCollectorTab); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); const serverPackageOrderRef = useRef([]); const pendingPackageOrderRef = useRef(null); const pendingPackageOrderAtRef = useRef(0); const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); const [downloadDisplayMode, setDownloadDisplayMode] = useState("packages"); const [downloadFilter, setDownloadFilter] = useState("all"); const [downloadProviderFilter, setDownloadProviderFilter] = useState("all"); const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); const [accountCheckBusy, setAccountCheckBusy] = useState(false); const [megaCheckingIds, setMegaCheckingIds] = useState>(() => new Set()); const actionBusyRef = useRef(false); const actionUnlockTimerRef = useRef | null>(null); const mountedRef = useRef(true); const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); const [openMenu, setOpenMenu] = useState(null); const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview"); const [selectedAccountRowKey, setSelectedAccountRowKey] = useState(null); const [openSubmenu, setOpenSubmenu] = useState(null); const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); const [confirmPrompt, setConfirmPrompt] = useState(null); const [onlineBackupDialog, setOnlineBackupDialog] = useState(null); const [remoteDiag, setRemoteDiag] = useState(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 confirmQueueRef = useRef void }>>([]); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const [contextMenu, setContextMenu] = useState(null); const ctxMenuRef = useRef(null); const [accountContextMenu, setAccountContextMenu] = useState(null); const accountContextMenuRef = useRef(null); const [linkPopup, setLinkPopup] = useState(null); const [accountDialog, setAccountDialog] = useState(null); const [accountEditDialog, setAccountEditDialog] = useState(null); const [accountDialogSearch, setAccountDialogSearch] = useState(""); const [accountDialogModeFilter, setAccountDialogModeFilter] = useState("all"); const [keyStatsPopup, setKeyStatsPopup] = useState(null); const [debridLinkHostLimits, setDebridLinkHostLimits] = useState>({}); const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false); const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState(""); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set; dontAsk: boolean } | null>(null); const [columnOrder, setColumnOrder] = useState(() => DEFAULT_COLUMN_ORDER); const columnDragSessionRef = useRef(null); const columnDragSettleTimerRef = useRef(null); const suppressColumnSortRef = useRef(false); useEffect(() => () => { if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current); if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current); }, []); const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null); const colHeaderCtxRef = useRef(null); const [historyEntries, setHistoryEntries] = useState([]); const historyEntriesRef = useRef([]); const [historyExpandedIds, setHistoryExpandedIds] = useState>(() => new Set()); const [selectedHistoryIds, setSelectedHistoryIds] = useState>(new Set()); const [historyFilter, setHistoryFilter] = useState("all"); const [historyQuery, setHistoryQuery] = useState(""); const [statisticsRange, setStatisticsRange] = useState("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(null); const historyLoadGenerationRef = useRef(0); const historyVisibleIdsRef = useRef([]); const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); const allDebridHostRequestRef = useRef(0); const debridLinkHostLimitsRequestRef = useRef(0); const columnOrderKey = useMemo( () => (snapshot.settings.columnOrder || []).join("|"), [snapshot.settings.columnOrder] ); useEffect(() => { const order = snapshot.settings.columnOrder; if (order && order.length > 0) { setColumnOrder(order); } }, [columnOrderKey]); 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 ), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds]); 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); }, []); 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 => { const generation = ++historyLoadGenerationRef.current; setHistoryLoading(true); setHistoryError(""); try { const entries = await window.rd.getHistory(); if (!mountedRef.current || generation !== historyLoadGenerationRef.current) { return; } applyHistoryEntries(entries); } catch { if (mountedRef.current && generation === historyLoadGenerationRef.current) { setHistoryError("Verlauf konnte nicht geladen werden"); } } finally { if (mountedRef.current && generation === historyLoadGenerationRef.current) { setHistoryLoading(false); } } }, [applyHistoryEntries]); useEffect(() => { if (tab !== "history") { return; } void loadHistoryEntries(); return () => { historyLoadGenerationRef.current += 1; }; }, [loadHistoryEntries, tab]); const loadAllDebridHostInfo = useCallback(async (silent = false): Promise => { 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 => { const requestId = debridLinkHostLimitsRequestRef.current + 1; debridLinkHostLimitsRequestRef.current = requestId; setDebridLinkHostLimitsLoading(true); setDebridLinkHostLimitsError(""); setDebridLinkHostLimits({}); try { const apiKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); if (apiKeys.length === 0) { throw new Error("Debrid-Link ist nicht konfiguriert"); } 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); } } }, [settingsDraft.debridLinkApiKeys, showToast]); useEffect(() => { if (keyStatsPopup !== "debridlink") { setDebridLinkHostLimits({}); setDebridLinkHostLimitsError(""); setDebridLinkHostLimitsLoading(false); return; } void loadDebridLinkHostLimits(true); }, [keyStatsPopup, loadDebridLinkHostLimits]); const clearImportQueueFocusListener = useCallback((): void => { const handler = importQueueFocusHandlerRef.current; if (!handler) { return; } window.removeEventListener("focus", handler); importQueueFocusHandlerRef.current = null; }, []); useEffect(() => { document.title = `Multi Debrid Downloader${appVersion ? ` - v${appVersion}` : ""}`; }, [appVersion]); useEffect(() => { 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) { setColumnOrder(state.settings.columnOrder); } setSettingsDraft(state.settings); 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 = { ...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 = { ...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 (next.settings.columnOrder?.length > 0) { setColumnOrder(next.settings.columnOrder); } if (!settingsDirtyRef.current) { setSettingsDraft(next.settings); } latestStateRef.current = null; } }, flushDelay); }); unsubClipboard = window.rd.onClipboardDetected((links) => { showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); setCollectorTabs((prev) => { const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0]; if (!active) { return prev; } const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n"); return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t); }); }); unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { if (!mountedRef.current) { return; } setUpdateInstallProgress(progress); }); return () => { mountedRef.current = false; if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } clearImportQueueFocusListener(); if (startConflictResolverRef.current) { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; resolver(null); } if (confirmResolverRef.current) { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; resolver(false); } while (confirmQueueRef.current.length > 0) { const request = confirmQueueRef.current.shift(); request?.resolve(false); } 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(); } const map = new Map(); 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 = { ...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; } } for (const packageId of Array.from(manualCollapsedPkgsRef.current)) { if (!snapshot.session.packages[packageId]) { manualCollapsedPkgsRef.current.delete(packageId); } } 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 sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) ? snapshot.session.items : null; const visiblePackages = useMemo(() => { if (!sortRelevantItems) { return packages; } return sortPackagesForDisplay( packages, sortRelevantItems, true, true ); }, [packages, sortRelevantItems]); 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 = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin || snapshot.settings.allDebridToken !== settingsDraft.allDebridToken; useEffect(() => { if (!snapshot.session.running) { setShowAllPackages(false); } }, [snapshot.session.running]); useEffect(() => { if (settingsSubTab !== "accounts") { return; } if (!hasSavedAllDebridAccount) { setAllDebridHostInfo(null); setAllDebridHostLoading(false); return; } void loadAllDebridHostInfo(true); }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); useEffect(() => { const extractingPkgIds: string[] = []; const currentlyExtracting = new Set(); for (const pkg of packages) { const items = (pkg.itemIds ?? []).map((id) => snapshot.session.items[id]).filter(Boolean); const isExtracting = items.some((item) => item.fullStatus?.startsWith("Entpacken -") && !item.fullStatus?.includes("Done")); if (isExtracting) { currentlyExtracting.add(pkg.id); if (collapsedPackages[pkg.id] && !manualCollapsedPkgsRef.current.has(pkg.id) && !autoExpandedPkgsRef.current.has(pkg.id)) { extractingPkgIds.push(pkg.id); autoExpandedPkgsRef.current.add(pkg.id); } } } for (const id of autoExpandedPkgsRef.current) { if (!currentlyExtracting.has(id)) { autoExpandedPkgsRef.current.delete(id); } } if (extractingPkgIds.length > 0) { setCollapsedPackages((prev) => { const next = { ...prev }; for (const id of extractingPkgIds) next[id] = false; return next; }); } }, [packages, snapshot.session.items, collapsedPackages]); const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) ), [packages, collapsedPackages]); const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]); const hasDdownloadAccount = useMemo(() => Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()), [settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); const hasOneFichierAccount = useMemo(() => Boolean((settingsDraft.oneFichierApiKey || "").trim()), [settingsDraft.oneFichierApiKey]); const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0); 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, provider: DebridProvider): void => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", provider); setDraggedProvider(provider); setProviderDropTarget(provider); }, []); const onProviderDragOver = useCallback((event: DragEvent, provider: DebridProvider): void => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; if (providerDropTarget !== provider) { setProviderDropTarget(provider); } }, [providerDropTarget]); const onProviderDrop = useCallback((event: DragEvent, 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: AppSettings = useMemo(() => ({ ...settingsDraft, ...normalizeProviderSelectionForSettings(settingsDraft) }), [settingsDraft]); const configuredAccounts = useMemo(() => { const entries: ConfiguredAccountEntry[] = []; for (const service of ACCOUNT_SERVICES) { const kind = getConfiguredAccountKind(settingsDraft, service); if (!kind) { continue; } const option = findAccountOption(kind); let statusLabel = "Aktiviert"; let note = ""; if (kind === "megadebrid-api") { const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur API aktiv. Kein Web-Fallback."; } else if (kind === "megadebrid-web") { const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).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 = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").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" ? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => { const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id); const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id); const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes, providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, providerDailyUsageDay: snapshot.settings.providerDailyUsageDay }, key.id); return { id: key.id, label: key.label, token: key.token, masked: key.masked, disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id), dailyUsedBytes: keyDailyUsedBytes, totalUsedBytes: getDebridLinkApiKeyTotalUsageBytes(snapshot.settings, key.id), dailyLimitBytes: keyDailyLimitBytes, dailyRemainingBytes: keyDailyRemainingBytes, dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes }; }) : []; if (kind === "debridlink-api" && debridLinkKeys.length > 0) { const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length; const disabledKeyCount = debridLinkKeys.filter((entry) => entry.disabled).length; const keyNotes: string[] = []; if (limitedCount > 0) { keyNotes.push(`${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`); } if (disabledKeyCount > 0) { keyNotes.push(`${disabledKeyCount}/${debridLinkKeys.length} API-Keys deaktiviert.`); } if (keyNotes.length > 0) { const combinedKeyNote = keyNotes.join(" "); note = note ? `${combinedKeyNote} ${note}` : combinedKeyNote; } if (debridLinkKeys.every((entry) => entry.disabled || entry.dailyLimitReached)) { dailyLimitReached = true; } } if (dailyLimitReached) { note = note ? `Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster. ${note}` : "Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster."; } entries.push({ kind, service, provider, serviceLabel: option.serviceLabel, modeLabel: option.modeLabel, statusLabel: isDisabled ? "Deaktiviert" : statusLabel, summary: summarizeAccount(kind, settingsDraft), summaryLines: summarizeAccountLines(kind, settingsDraft), note, disabled: isDisabled, dailyUsedBytes, totalUsedBytes, dailyLimitBytes, dailyRemainingBytes, dailyLimitReached, debridLinkKeys }); } return entries; }, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); const accountRows = useMemo(() => { const rows: AccountTableRow[] = []; for (const entry of configuredAccounts) { if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || ""); for (const acc of accounts) { const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id); const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id); rows.push({ rowKey: `mega-${entry.kind}-${acc.id}`, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, username: acc.login, credentialLabel: "••••••", accountId: acc.id, checkable: true, disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id), dailyUsedBytes: used, dailyLimitBytes: limit, dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, totalUsedBytes: getMegaDebridAccountTotalUsageBytes(snapshot.settings, acc.id), toggleKind: "mega", megaLogin: acc.login, editTarget: { type: "mega", rowKey: `mega-${entry.kind}-${acc.id}`, kind: entry.kind, service: entry.service as "megadebrid-api" | "megadebrid-web", accountId: acc.id } }); } } 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: key.disabled, 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 { rows.push({ rowKey: `svc-${entry.service}`, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, username: getStoredAccountUsername(entry.kind, settingsDraft), credentialLabel: getAccountCredentialLabel(entry.kind), accountId: null, checkable: false, 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.settings]); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); useEffect(() => { setSelectedAccountRowKey((current) => pruneAccountRowSelection(current, accountRows.map((row) => row.rowKey))); }, [accountRows]); const availableAccountOptions = useMemo(() => ( ACCOUNT_OPTIONS.filter((option) => option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api" || !configuredAccountServices.has(option.service)) ), [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 accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase(); const filteredAccountDialogOptions = useMemo(() => ( accountDialogSelectableOptions.filter((option) => { const matchesMode = accountDialogModeFilter === "all" || (accountDialogModeFilter === "api" && option.modeLabel === "API") || (accountDialogModeFilter === "web" && (option.modeLabel.startsWith("Web") || option.kind === "ddownload-login" || option.kind === "linksnappy-login")); if (!matchesMode) { return false; } if (!accountDialogSearchQuery) { return true; } const haystack = [ option.title, option.serviceLabel, option.modeLabel, option.pickerDescription, getAccountPickerFunctionLabel(option) ].join(" ").toLowerCase(); return haystack.includes(accountDialogSearchQuery); }) ), [accountDialogModeFilter, accountDialogSearchQuery, accountDialogSelectableOptions]); const handleUpdateResult = async ( result: UpdateCheckResult, source: "manual" | "startup", generation: number ): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { await performQuickAction(async () => { await persistDraftSettings(); const count = await window.rd.importBestDebridCookies(); if (count > 0) { showToast(`${count} BestDebrid-Cookies importiert`, 2200); } else { showToast("Keine Cookie-Datei ausgewählt", 2200); } }, (error) => { showToast(`BestDebrid Cookie-Import fehlgeschlagen: ${String(error)}`, 2800); }); }; const applyPersistedSettings = (result: AppSettings): void => { setSettingsDraft(result); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); setSettingsSaveState("clean"); setSettingsThemeChoice((current) => current === "system" ? current : result.theme); applyTheme(result.theme); }; const syncLiveProviderUsageSettings = (result: AppSettings): void => { setSnapshot((prev) => ({ ...prev, settings: result })); if (!settingsDirtyRef.current) { applyPersistedSettings(result); return; } setSettingsDraft((prev) => ({ ...prev, totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime), providerDailyUsageDay: result.providerDailyUsageDay, providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) }, providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) }, debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) }, debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) }, megaDebridAccountDailyUsageBytes: { ...(result.megaDebridAccountDailyUsageBytes || {}) }, megaDebridAccountTotalUsageBytes: { ...(result.megaDebridAccountTotalUsageBytes || {}) } })); }; const persistSpecificSettings = async (nextDraft: AppSettings): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; const draftAtStart = settingsDraft; const normalizedDraft = { ...nextDraft, ...normalizeProviderSelectionForSettings(nextDraft) }; const result = await window.rd.updateSettings(normalizedDraft); 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): Promise => { switch (action) { case "realdebrid-login": await window.rd.openRealDebridLogin(); showToast("Real-Debrid Login-Fenster geöffnet", 2200); return; case "bestdebrid-cookies": { const count = await window.rd.importBestDebridCookies(); showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewählt", 2200); return; } case "alldebrid-login": await window.rd.openAllDebridLogin(); showToast("AllDebrid Login-Fenster geöffnet", 2200); return; case "alldebrid-status": await loadAllDebridHostInfo(false); return; default: return; } }; const checkAllAccounts = useCallback(async (): Promise => { setAccountCheckBusy(true); try { const statuses = await window.rd.checkDebridAccounts(); if (!statuses || statuses.length === 0) { showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200); } else { const valid = statuses.filter((st) => st.valid).length; const premium = statuses.filter((st) => st.isPremium).length; showToast(`Account-Check: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600); } } catch (error) { showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600); } finally { setAccountCheckBusy(false); } }, [showToast]); const runMegaAccountCheck = useCallback(async (login: string, password: string): Promise => { const trimmedLogin = login.trim(); const trimmedPassword = password.trim(); if (!trimmedLogin || !trimmedPassword) return; const accId = getMegaDebridAccountId(trimmedLogin); setMegaCheckingIds((prev) => { const next = new Set(prev); next.add(accId); return next; }); try { const status = await window.rd.checkMegaDebridAccount(trimmedLogin, trimmedPassword); if (status) { showToast(status.valid ? `Account geprüft — ${status.message}` : `Account ungültig — ${status.message}`, 3200); } } catch (error) { showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3200); } finally { setMegaCheckingIds((prev) => { const next = new Set(prev); next.delete(accId); return next; }); } }, [showToast]); const openCreateAccountDialog = (): void => { setAccountDialogSearch(""); setAccountDialogModeFilter("all"); setAccountDialog(createAccountDialogState("create", availableAccountOptions[0]?.kind ?? null, settingsDraft)); }; const openEditAccountDialog = (row: AccountTableRow): void => { try { setAccountEditDialog(createAccountEditState(row.editTarget, settingsDraft)); } 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(""); setAccountDialogModeFilter("all"); }, []); const closeAccountEditDialog = useCallback((): void => { setAccountEditDialog(null); }, []); const onSaveAccountEditDialog = async (quickAction?: AccountQuickAction): Promise => { if (!accountEditDialog) { return; } const editSnapshot = accountEditDialog; const validationError = validateAccountEdit(editSnapshot, settingsDraft); if (validationError) { showToast(validationError, 2800); return; } await performQuickAction(async () => { const nextDraft = applyAccountEdit(settingsDraft, editSnapshot); if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { const statuses = await window.rd.checkDebridAccounts(buildAccountEditCheckSettings(nextDraft, editSnapshot), true, getAccountEditExpectedStatusId(editSnapshot) || undefined); const statusError = validateAccountEditStatuses(editSnapshot, statuses); if (statusError) { showToast(`Prüfung fehlgeschlagen: ${statusError}`, 4200); return; } } await persistSpecificSettings(nextDraft); closeAccountEditDialog(); if (quickAction) { await runAccountQuickAction(quickAction); } 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 => { if (!accountDialog) { return; } let dialogSnapshot = accountDialog; let newIdentityId: string | null = null; let newMegaCredentials: { login: string; password: string } | null = null; let newDebridLinkToken = ""; if (dialogSnapshot.mode === "create" && (dialogSnapshot.kind === "megadebrid-api" || dialogSnapshot.kind === "megadebrid-web")) { const login = dialogSnapshot.megaNewLogin.trim(); const password = dialogSnapshot.megaNewPassword; if (!login || !password) { showToast("Mega-Debrid: Bitte Login und Passwort eintragen.", 2800); return; } if (dialogSnapshot.megaAccounts.some((account) => account.login.trim().toLowerCase() === login.toLowerCase())) { showToast("Dieser Mega-Debrid-Account ist bereits vorhanden.", 2800); return; } newIdentityId = getMegaDebridAccountId(login); newMegaCredentials = { login, password }; dialogSnapshot = { ...dialogSnapshot, megaAccounts: [...dialogSnapshot.megaAccounts, newMegaCredentials], megaNewLogin: "", megaNewPassword: "" }; } if (dialogSnapshot.mode === "create" && dialogSnapshot.kind === "debridlink-api") { const newKeys = parseDebridLinkApiKeys(dialogSnapshot.token); if (newKeys.length !== 1) { showToast("Debrid-Link: Bitte genau einen API-Key eintragen.", 2800); return; } const existingKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); if (existingKeys.some((key) => key.id === newKeys[0].id)) { showToast("Dieser Debrid-Link-Key ist bereits vorhanden.", 2800); return; } newIdentityId = newKeys[0].id; newDebridLinkToken = newKeys[0].token; dialogSnapshot = { ...dialogSnapshot, token: [...existingKeys.map((key) => key.token), newKeys[0].token].join("\n"), keyDailyLimitGbById: { ...buildDebridLinkKeyLimitInputs(settingsDraft.debridLinkApiKeys || "", undefined, settingsDraft), [newKeys[0].id]: accountDialog.dailyLimitGb } }; } const validationError = validateAccountDialog(dialogSnapshot); if (validationError) { showToast(validationError, 2800); return; } const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot); const targetedOption = selectedOption && newIdentityId ? { service: selectedOption.service === "megadebrid-web" ? "megadebrid-api" : selectedOption.service } as Pick : null; const targetedCheck = targetedOption && newIdentityId ? buildTargetedAccountCheck(targetedOption, newIdentityId) : null; if (targetedCheck) { const checkSettings = targetedCheck.service === "megadebrid-api" && newMegaCredentials ? { ...nextDraft, megaCredentials: serializeMegaDebridAccounts([newMegaCredentials]), megaLogin: newMegaCredentials.login, megaPassword: newMegaCredentials.password, debridLinkApiKeys: "" } : { ...nextDraft, megaCredentials: "", megaLogin: "", megaPassword: "", debridLinkApiKeys: newDebridLinkToken }; const statuses = await window.rd.checkDebridAccounts(checkSettings, true, targetedCheck.expectedStatusId); const status = statuses.length === 1 && statuses[0].accountId === targetedCheck.expectedStatusId ? statuses[0] : null; if (!status || !status.valid) { throw new Error(status?.message || "Die Prüfung hat nicht genau den neuen Account bestätigt."); } } await persistSpecificSettings(nextDraft); closeAccountDialog(); if (quickAction) { await runAccountQuickAction(quickAction); } else if (selectedOption) { showToast(`${selectedOption.title} gespeichert`, 2200); } void checkAllAccounts(); }, (error) => { showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); }); }; const onRemoveAccount = async (entry: ConfiguredAccountEntry): Promise => { const confirmed = await askConfirmPrompt({ title: `${entry.serviceLabel} entfernen`, message: `Soll ${entry.serviceLabel} wirklich aus der Accountliste entfernt werden?`, confirmLabel: "Entfernen", danger: true }); if (!confirmed) { return; } await performQuickAction(async () => { const nextDraft = clearAccountServiceFromSettings(settingsDraft, entry.service); await persistSpecificSettings(nextDraft); if (entry.service === "alldebrid") { setAllDebridHostInfo(null); } showToast(`${entry.serviceLabel} entfernt`, 2200); }, (error) => { showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200); }); }; const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise => { 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 => { await performQuickAction(async () => { const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId); syncLiveProviderUsageSettings(result); showToast(`${entry.serviceLabel} ${keyLabel}: Tageszähler zurückgesetzt`, 2200); }, (error) => { showToast(`${entry.serviceLabel} ${keyLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200); }); }; const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise => { await performQuickAction(async () => { const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || []; const nextDisabledIds = key.disabled ? currentDisabledIds.filter((existingId) => existingId !== key.id) : [...currentDisabledIds, key.id]; const nextDraft: AppSettings = { ...settingsDraft, debridLinkDisabledKeyIds: nextDisabledIds }; await persistSpecificSettings(nextDraft); showToast( key.disabled ? `${entry.serviceLabel} ${key.label} aktiviert` : `${entry.serviceLabel} ${key.label} deaktiviert`, 2200 ); }, (error) => { showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200); }); }; const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise => { const meta = getAccountQuickActionMeta(entry.kind); if (!meta) { return; } await performQuickAction(async () => { await runAccountQuickAction(meta.action); }, (error) => { showToast(`${entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200); }); }; const onToggleMegaAccountEnabled = async (login: string, currentlyDisabled: boolean): Promise => { const accId = getMegaDebridAccountId(login.trim()); await performQuickAction(async () => { const current = settingsDraft.megaDebridDisabledAccountIds || []; const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId]; await persistSpecificSettings({ ...settingsDraft, megaDebridDisabledAccountIds: next }); showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000); }, (error) => { showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); }); }; const onRemoveMegaAccount = async (login: string): Promise => { const confirmed = await askConfirmPrompt({ title: "Account entfernen", message: `Soll der Mega-Debrid-Account ${maskMegaDebridLogin(login)} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); if (!confirmed) return; await performQuickAction(async () => { const remaining = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "") .filter((a) => a.login.trim().toLowerCase() !== login.trim().toLowerCase()); const nextCreds = serializeMegaDebridAccounts(remaining.map((a) => ({ login: a.login, password: a.password }))); await persistSpecificSettings({ ...settingsDraft, megaCredentials: nextCreds, megaLogin: remaining[0]?.login ?? "", megaPassword: remaining[0]?.password ?? "" }); showToast("Account entfernt", 2000); }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { 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 () => { const remaining = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").filter((k) => k.id !== key.id); await persistSpecificSettings({ ...settingsDraft, debridLinkApiKeys: remaining.map((k) => k.token).join("\n") }); showToast("Key entfernt", 2000); }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise => { await performQuickAction(async () => { const provider = entry.service as DebridProvider; const current = settingsDraft.disabledProviders || []; const nextDisabledProviders = current.includes(provider) ? current.filter((existing) => existing !== provider) : [...current, provider]; const nextDraft: AppSettings = { ...settingsDraft, disabledProviders: nextDisabledProviders }; await persistSpecificSettings(nextDraft); showToast( nextDisabledProviders.includes(provider) ? `${entry.serviceLabel} deaktiviert` : `${entry.serviceLabel} aktiviert`, 2200 ); }, (error) => { showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200); }); }; const toggleAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); if (row.toggleKind === "mega" && row.megaLogin) { void onToggleMegaAccountEnabled(row.megaLogin, row.disabled); } else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); } else { void onToggleAccountEnabled(row.entry); } }; const setAllAccountsEnabled = async (enabled: boolean): Promise => { await performQuickAction(async () => { const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))]; const nextEnabledState = buildBulkAccountEnabledState( settingsDraft.disabledProviders || [], configuredProviderIds, accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string), accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string), enabled ); const nextDraft: AppSettings = { ...settingsDraft, ...nextEnabledState }; await persistSpecificSettings(nextDraft); showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200); }, (error) => { showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200); }); }; const removeAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); void (async () => { const username = resolveAccountUsername(row.username, row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId]?.email : undefined); const confirmed = await askConfirmPrompt({ title: `${row.hosterLabel} entfernen`, message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); if (!confirmed) { return; } await performQuickAction(async () => { await persistSpecificSettings(removeAccountTarget(settingsDraft, row.editTarget)); if (row.entry.service === "alldebrid") { setAllDebridHostInfo(null); } showToast(`${row.hosterLabel} entfernt`, 2200); }, (error) => { showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200); }); })(); }; const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); if (row.toggleKind === "mega" && row.megaLogin) { const account = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "") .find((entry) => entry.id === row.accountId); if (account) { void runMegaAccountCheck(account.login, account.password); } return; } if (row.toggleKind === "dl") { void checkAllAccounts(); return; } if (getAccountQuickActionMeta(row.entry.kind)) { void onAccountRowQuickAction(row.entry); return; } showToast("Für diesen Account ist keine direkte Statusprüfung verfügbar.", 2800); }; const onCheckUpdates = async (): Promise => { 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 => { const revisionAtStart = settingsDraftRevisionRef.current; const result = await window.rd.updateSettings(normalizedSettingsDraft); if (settingsDraftRevisionRef.current === revisionAtStart) { applyPersistedSettings(result); } return result; }; const closeStartConflictPrompt = (result: { policy: Extract; applyToAll: boolean } | null): void => { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; setStartConflictPrompt(null); if (resolver) { resolver(result); } }; const askStartConflictDecision = (entry: StartConflictEntry): Promise<{ policy: Extract; 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 => { return new Promise((resolve) => { confirmQueueRef.current.push({ prompt, resolve }); pumpConfirmQueue(); }); }, [pumpConfirmQueue]); const restoreHistoryEntries = useCallback(async (entryIds: string[]): Promise => { 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 => { 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 => { 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 => { 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(() => ({ 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 => { 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 | 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): Promise => { 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 => { 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 => { 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 => { 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 => { 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): Promise => { 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 => { 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 => { 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 AppSettings, 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 AppSettings, value: string): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setNum = (key: keyof AppSettings, 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, onError?: (error: unknown) => void ): Promise => { if (actionBusyRef.current) { return; } actionBusyRef.current = true; setActionBusy(true); try { await action(); } catch (error) { if (onError) { onError(error); } else { showToast(`Fehler: ${String(error)}`, 2600); } } finally { if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } actionUnlockTimerRef.current = setTimeout(() => { if (!mountedRef.current) { actionUnlockTimerRef.current = null; return; } actionBusyRef.current = false; setActionBusy(false); actionUnlockTimerRef.current = null; }, 80); } }; const movePackage = useCallback((packageId: string, direction: "up" | "down") => { const currentOrder = packageOrderRef.current; const order = [...currentOrder]; const idx = order.indexOf(packageId); if (idx < 0) { return; } const target = direction === "up" ? idx - 1 : idx + 1; if (target < 0 || target >= order.length) { return; } [order[idx], order[target]] = [order[target], order[idx]]; setDownloadsSortDescending(false); pendingPackageOrderRef.current = [...order]; pendingPackageOrderAtRef.current = Date.now(); packageOrderRef.current = [...order]; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; }); void window.rd.reorderPackages(order).catch((error) => { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = serverPackageOrderRef.current; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; }); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { const currentOrder = packageOrderRef.current; const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId); const unchanged = nextOrder.length === currentOrder.length && nextOrder.every((id, index) => id === currentOrder[index]); if (unchanged) { return; } setDownloadsSortDescending(false); pendingPackageOrderRef.current = [...nextOrder]; pendingPackageOrderAtRef.current = Date.now(); packageOrderRef.current = [...nextOrder]; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } }; }); void window.rd.reorderPackages(nextOrder).catch((error) => { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = serverPackageOrderRef.current; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; }); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const addCollectorTab = (): void => { const id = `tab-${nextCollectorId++}`; setCollectorTabs((prev) => { const name = `Tab ${prev.length + 1}`; return [...prev, { id, name, text: "" }]; }); setActiveCollectorTab(id); setSelectedCollectorRowIds(new Set()); setCollectorError(""); }; const removeCollectorTab = (id: string): void => { 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(); 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); } } 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 onPackageDragStart = useCallback((packageId: string) => { draggedPackageIdRef.current = packageId; }, []); const onPackageDrop = useCallback((targetPackageId: string) => { const draggedPackageId = draggedPackageIdRef.current; draggedPackageIdRef.current = null; if (!draggedPackageId || draggedPackageId === targetPackageId) { return; } reorderPackagesByDrop(draggedPackageId, targetPackageId); }, [reorderPackagesByDrop]); const onPackageDragEnd = useCallback(() => { draggedPackageIdRef.current = null; }, []); const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => { setEditingPackageId(packageId); setEditingName(packageName); }, []); const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => { let shouldRename = false; setEditingPackageId((prev) => { if (prev !== packageId) return prev; shouldRename = true; return null; }); if (shouldRename) { const normalized = nextName.trim(); if (normalized && normalized !== currentName.trim()) { void window.rd.renamePackage(packageId, normalized).catch((error) => { showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400); }); } } }, [showToast]); const onPackageToggleCollapse = useCallback((packageId: string): void => { setCollapsedPackages((prev) => { const nextCollapsed = !(prev[packageId] ?? false); if (nextCollapsed) { manualCollapsedPkgsRef.current.add(packageId); } else { manualCollapsedPkgsRef.current.delete(packageId); autoExpandedPkgsRef.current.delete(packageId); } 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 = 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({ history: [], display: 0 }); const dragSelectRef = useRef(false); const dragAnchorRef = useRef(null); const dragDidMoveRef = useRef(false); const lastClickedIdRef = useRef(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(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(); 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 = {}; 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): void => { const current = snapshotRef.current; const promises: Promise[] = []; 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 : ""; if (shouldClearDownloadSelectionOnEscape(target.tagName, inputType)) { if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; if (tabRef.current === "downloads") setSelectedIds(new Set()); else if (tabRef.current === "history") setSelectedHistoryIds(new Set()); } } if (e.key === "Delete" && tabRef.current === "downloads" && selectedIds.size > 0) { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; e.preventDefault(); requestDeleteSelection(); } }; const onDown = (e: MouseEvent): void => { const target = e.target as HTMLElement; if (!shouldClearDownloadSelection(target)) return; setSelectedIds(new Set()); }; window.addEventListener("keydown", onKey); window.addEventListener("mousedown", onDown); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); }; }, [selectedIds, requestDeleteSelection]); const onExportBackup = async (): Promise => { closeMenus(); await performQuickAction(async () => { const result = await window.rd.exportBackup(); if (result.saved) { showToast("Sicherung exportiert"); } }, (error) => { showToast(`Sicherung fehlgeschlagen: ${String(error)}`, 2600); }); }; const onImportBackup = async (): Promise => { closeMenus(); await performQuickAction(async () => { const result = await window.rd.importBackup(); 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); } } else if (result.message !== "Abgebrochen") { showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); } }, (error) => { showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600); }); }; const onCreateOnlineBackup = async (): Promise => { 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 => { 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); 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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(); 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 = {}; 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 downloadsViewModel = useMemo(() => ({ ...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", 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, hosters: providerStats.length, speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", eta: snapshot.etaText } }), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, 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.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); const downloadsActions: DownloadsViewActions = { 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; setCollapsedPackages((current) => { const next = { ...current }; for (const entry of packages) { next[entry.id] = targetState; if (targetState) manualCollapsedPkgsRef.current.add(entry.id); else { manualCollapsedPkgsRef.current.delete(entry.id); autoExpandedPkgsRef.current.delete(entry.id); } } return next; }); }, onShowAllPackages: () => setShowAllPackages(true), onPackageDragStart, onPackageDrop, onPackageDragEnd, 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; } if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current); columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX); }, 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; const next = settleDownloadColumnDrag(session, false); if (!next) { columnDragSessionRef.current = null; return; } suppressColumnSortRef.current = true; window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0); const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|"); columnDragSettleTimerRef.current = window.setTimeout(() => { if (changed) { flushSync(() => setColumnOrder(next)); void window.rd.updateSettings({ columnOrder: next }).catch(() => {}); } clearDownloadColumnDrag(session); if (columnDragSessionRef.current === session) columnDragSessionRef.current = null; columnDragSettleTimerRef.current = null; }, 220); }, onColumnPointerCancel: (_column, event) => { const session = columnDragSessionRef.current; if (!session || session.pointerId !== event.pointerId) return; settleDownloadColumnDrag(session, true); columnDragSettleTimerRef.current = window.setTimeout(() => { clearDownloadColumnDrag(session); if (columnDragSessionRef.current === session) columnDragSessionRef.current = null; columnDragSettleTimerRef.current = null; }, 220); }, onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y }) }; const statisticsActions: StatisticsViewActions = { onRangeChange: setStatisticsRange, onResetSession: () => { void window.rd.resetSessionStats().then(() => { showToast("Session-Statistik zurückgesetzt", 1800); }).catch((error) => { showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400); }); }, onResetAll: () => { void 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(() => 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(() => accountRows.map((row) => { const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; const checking = Boolean(row.accountId && megaCheckingIds.has(row.accountId)); const state: AccountRowSource["status"]["state"] = row.disabled ? "disabled" : checking ? "checking" : !checkedStatus ? "unchecked" : checkedStatus && !checkedStatus.valid ? "invalid" : checkedStatus && !checkedStatus.isPremium ? "free" : "premium"; 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, 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, megaCheckingIds, snapshot.settings.debridAccountStatuses]); const selectedAccountViewId = useMemo(() => { const selectedRow = selectedAccountRowKey ? accountRows.find((row) => row.rowKey === selectedAccountRowKey) : null; return selectedRow ? accountRowViewId(selectedRow) : null; }, [accountRows, selectedAccountRowKey]); const projectedAccountRows = useMemo(() => projectAccountRows( accountSources, selectedAccountViewId ? [selectedAccountViewId] : [], runtimeNow ), [accountSources, runtimeNow, selectedAccountViewId]); const visibleAccountRows = useMemo(() => accountStatusSort === "none" ? projectedAccountRows : sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]); 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: selectedAccountViewId ? [selectedAccountViewId] : [], busy: actionBusy || accountCheckBusy, allEnabled: accountRows.length > 0 && accountRows.some((row) => !row.disabled), statusSort: accountStatusSort, 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): 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) => setSelectedAccountRowKey(accountRowBindings.get(rowId)?.rowKey ?? null), 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 }); }, onAdd: openCreateAccountDialog, onRemoveSelected: () => { const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null; if (row) removeAccountTableRow(row); }, onCheckAll: () => { void checkAllAccounts(); }, onSetAllEnabled: (enabled) => { void setAllAccountsEnabled(enabled); }, 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 (typeof value === "boolean") { setBool(fieldId as keyof AppSettings, value); return; } const numericLimits: Partial> = { 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 AppSettings]; if (bounds) { const parsed = Number(value); setNum(fieldId as keyof AppSettings, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2]))); } else { setText(fieldId as keyof AppSettings, 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; } 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 === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", icon: ACCOUNT_SERVICE_ICONS[option.service] })); const accountAddFields: AccountDialogField[] = accountDialog && accountDialogOption ? [ ...((accountDialog.kind === "megadebrid-api" || accountDialog.kind === "megadebrid-web") ? [ { id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: accountDialog.megaNewLogin }, { id: "megaNewPassword", label: "Passwort", type: "password" as const, value: accountDialog.megaNewPassword } ] : accountDialogOption.needsCredentials ? [ { id: "login", label: "Login / E-Mail", type: "text" as const, value: accountDialog.login }, { id: "password", label: "Passwort", type: "password" as const, value: accountDialog.password } ] : []), ...(accountDialogOption.needsToken ? [ { id: "token", label: accountDialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: accountDialog.token } ] : []), { id: "dailyLimitGb", label: "Tageslimit (GB, optional)", type: "number" as const, value: accountDialog.dailyLimitGb, placeholder: "Kein Limit", help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt." } ] : []; const accountAddDialog = ( 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, filter: accountDialogModeFilter, options: accountAddOptions, selectedOptionId: accountDialog?.kind ?? null, fields: accountAddFields, error: "", busy: actionBusy }} /> ); 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 } ] : []), ...((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 } ] : []), { 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, settingsDraft); if (validationError) { showToast(validationError, 2800); return; } const editSnapshot = accountEditDialog; void performQuickAction(async () => { if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { const checkSettings = buildAccountEditCheckSettings(applyAccountEdit(settingsDraft, editSnapshot), editSnapshot); const statuses = await window.rd.checkDebridAccounts(checkSettings, true, getAccountEditExpectedStatusId(editSnapshot) || undefined); const statusError = validateAccountEditStatuses(editSnapshot, statuses); if (statusError) throw new Error(statusError); showToast("Account erfolgreich geprüft", 2200); return; } const quickAction = getAccountQuickActionMeta(editSnapshot.target.kind); if (quickAction) { await runAccountQuickAction(quickAction.action); } 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 ? ( 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); } }} model={{ open: true, hoster: accountEditOption.serviceLabel, mode: accountEditOption.modeLabel, identity: resolveAccountUsername(accountEditRow?.username || accountEditDialog.login, accountEditStatus?.email), enabled: !accountEditRow?.disabled, fields: accountEditFields, error: "", busy: actionBusy }} /> ) : null; return (
{ event.preventDefault(); if (draggedPackageIdRef.current) { return; } const hasFiles = event.dataTransfer.types.includes("Files"); const hasUri = event.dataTransfer.types.includes("text/uri-list"); if (!hasFiles && !hasUri) { return; } dragDepthRef.current += 1; if (!dragOverRef.current) { dragOverRef.current = true; setDragOver(true); } }} onDragOver={(e) => { e.preventDefault(); }} onDragLeave={() => { if (draggedPackageIdRef.current) { return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0 && dragOverRef.current) { dragOverRef.current = false; setDragOver(false); } }} onDrop={onDrop} > Paket- und Linkstatus werden laufend aktualisiert. Auswahl, Reihenfolge und aktive Filter bleiben beim Ansichtswechsel erhalten.
) : tab === "collector" ? (
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.
) : tab === "history" ? (
Abgeschlossene und gelöschte Pakete bleiben hier durchsuchbar. Details zeigen Zielordner, Provider und gespeicherte Linkadressen.
) : tab === "statistics" ? (
{statisticsViewModel.message} Für sieben und 30 Tage bleiben Kennzahlen leer, solange keine historischen Buckets gespeichert werden.
) : null} footer={tab === "downloads" ? : tab === "history" ? : null} headerActions={( <>