import type { RendererSettings } from "../../../shared/types"; import type { AccountService } from "../../account-edit"; import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons"; export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates"; export type SettingsSaveState = "clean" | "dirty" | "saving" | "saved" | "error"; export type SettingsThemeChoice = RendererSettings["themePreference"]; export const SETTINGS_SECTIONS: readonly { id: SettingsSection; label: string }[] = [ { id: "allgemein", label: "Allgemein" }, { id: "accounts", label: "Accounts" }, { id: "extract", label: "Entpacken" }, { id: "speed", label: "Geschwindigkeit" }, { id: "cleanup", label: "Bereinigung" }, { id: "updates", label: "Updates" } ]; export const ACCOUNT_COLUMNS = [ "Hoster", "Status", "Download-Traffic übrig", "Benutzername", "E-Mail", "Verfallsdatum", "Passwort/Zugang" ] as const; export const ACCOUNT_TABLE_COLUMN_IDS = [ "hoster", "status", "traffic", "username", "email", "expires", "credential" ] as const; export type AccountTableColumnId = typeof ACCOUNT_TABLE_COLUMN_IDS[number]; export type AccountTableColumnWidths = Record; const ACCOUNT_TABLE_COLUMN_LIMITS: Record = { hoster: { initial: 210, min: 140, max: 520 }, status: { initial: 250, min: 120, max: 520 }, traffic: { initial: 210, min: 150, max: 420 }, username: { initial: 170, min: 120, max: 420 }, email: { initial: 200, min: 140, max: 480 }, expires: { initial: 150, min: 120, max: 280 }, credential: { initial: 170, min: 130, max: 320 } }; export function createAccountTableColumnWidths(value?: unknown): AccountTableColumnWidths { const raw = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; return Object.fromEntries(ACCOUNT_TABLE_COLUMN_IDS.map((id) => { const limits = ACCOUNT_TABLE_COLUMN_LIMITS[id]; const candidate = typeof raw[id] === "number" && Number.isFinite(raw[id]) ? Math.round(raw[id]) : limits.initial; return [id, Math.max(limits.min, Math.min(limits.max, candidate))]; })) as AccountTableColumnWidths; } export function resizeAccountTableColumn( widths: AccountTableColumnWidths, column: AccountTableColumnId, delta: number ): AccountTableColumnWidths { return createAccountTableColumnWidths({ ...widths, [column]: widths[column] + delta }); } export function getAccountTableGridTemplate(widths: AccountTableColumnWidths): string { return `42px ${ACCOUNT_TABLE_COLUMN_IDS.map((id) => `${widths[id]}px`).join(" ")} minmax(64px, 1fr)`; } export function getAccountTableMinWidth(widths: AccountTableColumnWidths): number { return 42 + 64 + ACCOUNT_TABLE_COLUMN_IDS.reduce((sum, id) => sum + widths[id], 0); } export function formatAccountContextHeading(hoster: string, mode: string): string { return `${hoster} | ${mode}`; } export function getSettingsSaveLabel(state: SettingsSaveState): string { switch (state) { case "dirty": return "Ungespeicherte Änderungen"; case "saving": return "Wird gespeichert…"; case "error": return "Speichern fehlgeschlagen"; case "clean": case "saved": return "Gespeichert"; } } export function getSettingsSelectNavigationIndex(currentIndex: number, optionCount: number, key: string): number { if (optionCount <= 0) return -1; const current = Math.max(0, Math.min(optionCount - 1, currentIndex)); if (key === "Home") return 0; if (key === "End") return optionCount - 1; if (key === "ArrowDown") return (current + 1) % optionCount; if (key === "ArrowUp") return (current - 1 + optionCount) % optionCount; return current; } export function resolveHistoryRetentionSelection( currentMode: RendererSettings["historyRetentionMode"], currentMaxEntries: number, value: string ): Pick { const preset = /^permanent-(100|250)$/.exec(value); if (preset) { return { historyRetentionMode: "permanent", historyMaxEntries: Number(preset[1]) }; } if (value === "permanent") { return { historyRetentionMode: "permanent", historyMaxEntries: currentMode === "permanent" && (currentMaxEntries === 100 || currentMaxEntries === 250) ? 500 : currentMaxEntries }; } return { historyRetentionMode: value as RendererSettings["historyRetentionMode"], historyMaxEntries: currentMaxEntries }; } export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled"; export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled"; export interface AccountRowSource { identityId: string; service: AccountService; hoster: string; mode: string; icon?: string; enabled: boolean; status: { state: AccountStatusSourceState; message: string; premiumUntilMs: number | null; checkedAt?: number; username?: string; email?: string; }; dailyLimitBytes?: number; dailyUsageBytes?: number; totalUsageBytes?: number; username: string; credentialKind: "password" | "api-key" | "protected"; canCheck: boolean; } export interface AccountRowViewModel { id: string; service: AccountService; hoster: string; mode: string; icon: string; enabled: boolean; selected: boolean; status: { tone: AccountStatusTone; text: string; checkedAgo: string; }; traffic: string; username: string; email: string; expires: string; credential: string; canCheck: boolean; problem: boolean; premiumUntilMs: number | null; } export type AccountAddFilter = "all" | "api" | "web"; export interface AccountAddOption { id: string; service: AccountService; title: string; mode: string; description: string; functionLabel: string; filter: Exclude; multi: boolean; icon?: string; } export interface AccountAddDraft { selectedId: string | null; login: string; password: string; token: string; dailyLimitGb: string; } export interface TargetedAccountCheck { service: "megadebrid-api" | "debridlink"; expectedStatusId: string; } export interface SettingsFieldBase { id: string; label: string; help?: string; disabled?: boolean; } export interface SettingsTextFieldViewModel extends SettingsFieldBase { kind: "text" | "path" | "number" | "textarea"; value: string; placeholder?: string; inputMode?: "decimal" | "numeric" | "text" | "url"; min?: number; max?: number; step?: number; actionLabel?: string; commitOnBlur?: boolean; } export interface SettingsSelectFieldViewModel extends SettingsFieldBase { kind: "select"; value: string; options: readonly { value: string; label: string }[]; } export interface SettingsSwitchFieldViewModel extends SettingsFieldBase { kind: "switch"; value: boolean; } export interface SettingsThemeFieldViewModel extends SettingsFieldBase { kind: "theme"; value: SettingsThemeChoice; options: readonly { value: SettingsThemeChoice; label: string }[]; } export interface SettingsActionFieldViewModel extends SettingsFieldBase { kind: "action"; actionLabel: string; } export type SettingsFieldViewModel = | SettingsTextFieldViewModel | SettingsSelectFieldViewModel | SettingsSwitchFieldViewModel | SettingsThemeFieldViewModel | SettingsActionFieldViewModel; export interface SettingsFormGroupViewModel { id: string; title: string; description?: string; fields: readonly SettingsFieldViewModel[]; } export interface SettingsFormViewModel { title: string; description: string; groups: readonly SettingsFormGroupViewModel[]; } export interface SettingsFormProjectionInput { settings: RendererSettings & { archivePasswordList: string; notifyUrl: string }; section: SettingsSection; speedLimitInput: string; scheduleSpeedInputs: Readonly>; themeChoice?: SettingsThemeChoice; } const NOTIFICATION_NUMBER_LIMITS = { notifyRemainingThresholdGb: { min: 1, max: 100000, fallback: 50 }, notifyStallAfterSeconds: { min: 60, max: 3600, fallback: 90 }, notifyStallCooldownMinutes: { min: 5, max: 1440, fallback: 10 } } as const; export function normalizeNotificationNumberField(fieldId: string, value: unknown): number | undefined { const limits = NOTIFICATION_NUMBER_LIMITS[fieldId as keyof typeof NOTIFICATION_NUMBER_LIMITS]; if (!limits) { return undefined; } const parsed = Number(value); const normalized = Number.isFinite(parsed) ? Math.floor(parsed) : limits.fallback; return Math.max(limits.min, Math.min(limits.max, normalized)); } export function buildSettingsFormViewModel({ settings, section, speedLimitInput, scheduleSpeedInputs, themeChoice = settings.themePreference }: SettingsFormProjectionInput): SettingsFormViewModel { if (section === "extract") { return { title: "Entpacken", description: "Ablauf, Ziel, Tonspur, Ablageform und Leistung.", groups: [ { id: "extract-target", title: "Ziel und Ablauf", fields: [ { id: "extractDir", kind: "path", label: "Entpacken nach", value: settings.extractDir, actionLabel: "Wählen", help: "Zielordner für entpackte Dateien." }, { id: "autoExtract", kind: "switch", label: "Automatisch entpacken", value: settings.autoExtract }, { id: "autoSkipExtracted", kind: "switch", label: "Bereits Entpacktes überspringen", value: settings.autoSkipExtracted }, { id: "hideExtractedItems", kind: "switch", label: "Entpackte Einträge ausblenden", value: settings.hideExtractedItems }, { id: "autoExtractWhenStopped", kind: "switch", label: "Entpacken auch ohne laufende Sitzung", value: settings.autoExtractWhenStopped } ] }, { id: "extract-audio", title: "Deutsche Tonspur", fields: [ { id: "keepGermanAudioOnly", kind: "switch", label: "Nur deutsche Tonspur behalten", value: settings.keepGermanAudioOnly, help: "Benötigt ffmpeg." }, { id: "germanAudioMode", kind: "select", label: "Welche Tonspur behalten", value: settings.germanAudioMode, disabled: !settings.keepGermanAudioOnly, options: [ { value: "tag", label: "Deutsche Spur per Sprach-Tag" }, { value: "first", label: "Immer erste Tonspur" } ] } ] }, { id: "extract-layout", title: "Ablageform", fields: [ { id: "autoRename4sf4sj", kind: "switch", label: "Automatisch umbenennen", value: settings.autoRename4sf4sj }, { id: "createExtractSubfolder", kind: "switch", label: "In Paket-Unterordner ablegen", value: settings.createExtractSubfolder }, { id: "collectMkvToLibrary", kind: "switch", label: "Videos in Sammelordner verschieben", value: settings.collectMkvToLibrary }, { id: "mkvLibraryDir", kind: "path", label: "Video-Sammelordner", value: settings.mkvLibraryDir, disabled: !settings.collectMkvToLibrary, actionLabel: "Wählen" } ] }, { id: "extract-performance", title: "Leistung", fields: [ { id: "hybridExtract", kind: "switch", label: "Hybrid-Entpacken", value: settings.hybridExtract }, { id: "maxParallelExtract", kind: "number", label: "Gleichzeitige Entpackungen", value: String(settings.maxParallelExtract), min: 1, max: 8 }, { id: "extractCpuPriority", kind: "select", label: "CPU-Priorität beim Entpacken", value: settings.extractCpuPriority, options: [ { value: "high", label: "Hoch (80% CPU)" }, { value: "middle", label: "Mittel (50% CPU)" }, { value: "low", label: "Niedrig (25% CPU)" } ] } ] }, { id: "extract-passwords", title: "Passwörter", fields: [ { id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: settings.archivePasswordListConfigured ? "Gespeichert; leer lassen zum Beibehalten" : "Ein Passwort pro Zeile" } ] } ] }; } if (section === "speed") { const scheduleGroups: SettingsFormGroupViewModel[] = (settings.bandwidthSchedules || []).map((schedule, index) => { const scheduleKey = schedule.id || `schedule-${index}`; return { id: `schedule:${index}`, title: `Zeitregel ${index + 1}`, fields: [ { id: `schedule:${index}:startHour`, kind: "number", label: "Von (Stunde)", value: String(schedule.startHour), min: 0, max: 23 }, { id: `schedule:${index}:endHour`, kind: "number", label: "Bis (Stunde)", value: String(schedule.endHour), min: 0, max: 23 }, { id: `schedule:${index}:speedLimitMbps`, kind: "number", label: "Limit (MB/s)", value: scheduleSpeedInputs[scheduleKey] ?? String(schedule.speedLimitKbps / 1024), min: 0, step: 0.1, commitOnBlur: true }, { id: `schedule:${index}:enabled`, kind: "switch", label: "Zeitregel aktiviert", value: schedule.enabled }, { id: `schedule:${index}:remove`, kind: "action", label: "Zeitregel entfernen", actionLabel: "Entfernen" } ] }; }); return { title: "Geschwindigkeit", description: "Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.", groups: [ { id: "speed-limit", title: "Tempo-Begrenzung", fields: [ { id: "speedLimitEnabled", kind: "switch", label: "Geschwindigkeit begrenzen", value: settings.speedLimitEnabled }, { id: "speedLimitInput", kind: "number", label: "Höchstgeschwindigkeit (MB/s)", value: speedLimitInput, min: 0, step: 0.1, disabled: !settings.speedLimitEnabled, commitOnBlur: true }, { id: "speedLimitMode", kind: "select", label: "Limit gilt für", value: settings.speedLimitMode, disabled: !settings.speedLimitEnabled, options: [ { value: "global", label: "Global" }, { value: "per_download", label: "Pro Download" } ] } ] }, { id: "speed-connection", title: "Verbindung", fields: [ { id: "autoReconnect", kind: "switch", label: "Automatisch neu verbinden", value: settings.autoReconnect }, { id: "reconnectWaitSeconds", kind: "number", label: "Wartezeit vor neuem Versuch (Sek.)", value: String(settings.reconnectWaitSeconds), min: 10, max: 600 } ] }, { id: "speed-proxy", title: "Proxy-only", description: "Leitet API-, Login- und Link-Anfragen über einen festen HTTP-Proxy. Downloads verwenden zusätzlich mehrere Proxys parallel. Bei aktivem Modus gibt es keinen direkten Rückfall über deine echte Verbindung.", fields: [ { id: "proxyDownloadEnabled", kind: "switch", label: "Proxy-only aktivieren", value: settings.proxyDownloadEnabled, help: "Ist der feste API-Proxy nicht erreichbar oder ungültig, schlagen Netzwerkanfragen geschlossen fehl. Es wird nie ungefragt direkt verbunden." }, { id: "proxyListPath", kind: "path", label: "Proxy-Liste", value: settings.proxyListPath, placeholder: "Textdatei mit einem Proxy pro Zeile", actionLabel: "Datei wählen", disabled: !settings.proxyDownloadEnabled, help: "Unterstützt unter anderem Benutzer:Passwort@Host:Port. Zugangsdaten werden nicht protokolliert." }, { id: "proxyApiProxyIndex", kind: "number", label: "Fester API-Proxy (gültiger Listeneintrag)", value: String(settings.proxyApiProxyIndex), min: 1, max: 100000, disabled: !settings.proxyDownloadEnabled, help: "Dieser 1-basierte Eintrag bleibt für API, Login und Link-Auflösung fest. Ist er offline, wird nicht automatisch ein anderer Proxy oder die direkte Verbindung verwendet." }, { id: "proxyConnectionsPerDownload", kind: "number", label: "Proxy-Verbindungen insgesamt", value: String(settings.proxyConnectionsPerDownload), min: 2, max: 32, disabled: !settings.proxyDownloadEnabled, help: `Dieses Gesamtlimit wird zwischen allen gleichzeitigen Segmentdownloads geteilt. Bei ${settings.maxParallel} aktiven Downloads laufen zusammen höchstens ${settings.proxyConnectionsPerDownload} Segmentverbindungen. Kleine Dateien, Teil-Downloads und aktive Geschwindigkeitslimits laufen über den festen API-Proxy.` } ] }, ...scheduleGroups, { id: "speed-schedule-add", title: "Bandbreitenplanung", description: "Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.", fields: [{ id: "schedule:add", kind: "action", label: "Weitere Zeitregel", actionLabel: "Zeitregel hinzufügen" }] } ] }; } if (section === "cleanup") { return { title: "Bereinigung", description: "Integritätsprüfung und Aufräumen nach Downloads und Entpacken.", groups: [ { id: "cleanup-check", title: "Prüfung", fields: [{ id: "enableIntegrityCheck", kind: "switch", label: "Dateien auf Fehler prüfen", value: settings.enableIntegrityCheck }] }, { id: "cleanup-extract", title: "Nach dem Entpacken", fields: [ { id: "removeLinkFilesAfterExtract", kind: "switch", label: "Link-Dateien danach entfernen", value: settings.removeLinkFilesAfterExtract }, { id: "removeSamplesAfterExtract", kind: "switch", label: "Vorschau-Dateien danach entfernen", value: settings.removeSamplesAfterExtract }, { id: "cleanupMode", kind: "select", label: "Archive nach dem Entpacken", value: settings.cleanupMode, options: [ { value: "none", label: "Keine Archive löschen" }, { value: "trash", label: "Archive in Papierkorb" }, { value: "delete", label: "Archive löschen" } ] } ] }, { id: "cleanup-finished", title: "Fertige Downloads und Konflikte", fields: [ { id: "completedCleanupPolicy", kind: "select", label: "Fertige Downloads aus der Liste", value: settings.completedCleanupPolicy, options: [ { value: "never", label: "Nie" }, { value: "immediate", label: "Sofort" }, { value: "on_start", label: "Beim App-Start" }, { value: "package_done", label: "Sobald Paket fertig ist" } ] }, { id: "extractConflictMode", kind: "select", label: "Bei gleichnamigen Dateien", value: settings.extractConflictMode, options: [ { value: "overwrite", label: "Überschreiben" }, { value: "skip", label: "Überspringen" }, { value: "rename", label: "Umbenennen" }, { value: "ask", label: "Nachfragen" } ] } ] } ] }; } if (section === "updates") { return { title: "Updates", description: "Quelle und Zeitpunkt der Update-Prüfung.", groups: [ { id: "updates-main", title: "Aktualisierung", fields: [ { id: "autoUpdateCheck", kind: "switch", label: "Beim Start nach Updates suchen", value: settings.autoUpdateCheck }, { id: "updateRepo", kind: "text", label: "Update-Quelle", value: settings.updateRepo, help: "Quelle im Format Benutzer/Repository." }, { id: "update:check", kind: "action", label: "Jetzt nach einer neuen Version suchen", actionLabel: "Nach Updates suchen" } ] } ] }; } if (section === "accounts") { return { title: "Accounts", description: "Accounts und Verwendungsregeln.", groups: [] }; } return { title: "Allgemein", description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", groups: [ { id: "general-language", title: "Sprache", fields: [ { id: "language", kind: "select", label: "Sprache", value: settings.language, options: [ { value: "en", label: "English" }, { value: "de", label: "Deutsch" } ] } ] }, { id: "general-storage", title: "Speicherort", fields: [ { id: "outputDir", kind: "path", label: "Download-Ordner", value: settings.outputDir, actionLabel: "Wählen", help: "Zielordner für heruntergeladene Dateien." }, { id: "packageName", kind: "text", label: "Paketname (optional)", value: settings.packageName }, { id: "logStorageLocation", kind: "select", label: "Log-Speicherort", value: settings.logStorageLocation, options: [ { value: "appdata", label: "AppData (empfohlen)" }, { value: "desktop", label: "Desktop / Downloader Log" } ], help: "Beim Wechsel werden vorhandene Log-Dateien übernommen. Zugangsdaten und App-Konfiguration bleiben in AppData." }, { id: "logStorageDirectory", kind: "action", label: "Log-Ordner", actionLabel: "Ordner öffnen" } ] }, { id: "general-downloads", title: "Download-Verhalten", fields: [ { id: "maxParallel", kind: "number", label: "Max. gleichzeitige Downloads", value: String(settings.maxParallel), min: 1, max: 50 }, { id: "retryLimit", kind: "number", label: "Automatische Wiederholungen", value: String(settings.retryLimit), min: 0, max: 99 }, { id: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart }, { id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch } ] }, { id: "general-history", title: "Verlauf", fields: [ { id: "historyRetentionMode", kind: "select", label: "Verlauf speichern", value: settings.historyRetentionMode === "permanent" && (settings.historyMaxEntries === 100 || settings.historyMaxEntries === 250) ? `permanent-${settings.historyMaxEntries}` : settings.historyRetentionMode, options: [ { value: "never", label: "Nie" }, { value: "session", label: "Nur aktuelle Session" }, { value: "permanent-100", label: "Nur letzte 100 Einträge" }, { value: "permanent-250", label: "Nur letzte 250 Einträge" }, { value: "permanent", label: "Dauerhaft" } ] }, { id: "historyMaxEntries", kind: "number", label: "Maximale Verlauf-Einträge", value: String(settings.historyMaxEntries), min: 50, max: 100000, disabled: settings.historyRetentionMode !== "permanent" }, { id: "historyMaxAgeDays", kind: "number", label: "Einträge löschen älter als (Tage)", value: String(settings.historyMaxAgeDays), min: 0, max: 3650, disabled: settings.historyRetentionMode !== "permanent" } ] }, { id: "general-interface", title: "Oberfläche und Bedienung", fields: [ { id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages }, { id: "animatePackageDisclosure", kind: "switch", label: "Animationen", value: settings.animatePackageDisclosure }, { id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray }, { id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection }, { id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads }, { id: "backupIncludeRemoteDiagnostics", kind: "switch", label: "Ferndiagnose-Einstellungen mitsichern", value: settings.backupIncludeRemoteDiagnostics }, { id: "theme", kind: "theme", label: "Theme", value: themeChoice, options: [ { value: "light", label: "Hell" }, { value: "dark", label: "Dunkel" }, { value: "system", label: "System" } ] } ] }, { id: "general-notifications", title: "Discord-Benachrichtigungen", fields: [ { id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: settings.notifyUrlConfigured ? "Gespeichert; leer lassen zum Beibehalten" : "https://discord.com/api/webhooks/…", actionLabel: "Testen" }, { id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention }, { id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted }, { id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed }, { id: "notifyPackageSuccessMode", kind: "select", label: "Erfolgsmeldungen senden", value: settings.notifyPackageSuccessMode, disabled: !settings.notifyOnPackageCompleted, options: [ { value: "digest", label: "Gesammelt (alle 2 Minuten)" }, { value: "individual", label: "Jedes Paket einzeln" } ] }, { id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn der gesamte Lauf fertig ist", value: settings.notifyOnRunFinished }, { id: "notifyOnRemainingBelow", kind: "switch", label: "Melden, wenn die Restmenge unterschritten wird", value: settings.notifyOnRemainingBelow }, { id: "notifyRemainingThresholdGb", kind: "number", label: "Restmengenschwelle (GB)", value: String(settings.notifyRemainingThresholdGb), min: 1, max: 100000, disabled: !settings.notifyOnRemainingBelow }, { id: "notifyOnDownloadStall", kind: "switch", label: "Melden, wenn Downloads stillstehen", value: settings.notifyOnDownloadStall }, { id: "notifyStallAfterSeconds", kind: "number", label: "Stillstand bestätigen nach (Sek.)", value: String(settings.notifyStallAfterSeconds), min: 60, max: 3600, disabled: !settings.notifyOnDownloadStall }, { id: "notifyStallCooldownMinutes", kind: "number", label: "Frühestens erneut melden nach (Min.)", value: String(settings.notifyStallCooldownMinutes), min: 5, max: 1440, disabled: !settings.notifyOnDownloadStall }, { id: "notifyOnDownloadRecovery", kind: "switch", label: "Melden, wenn Downloads wieder laufen", value: settings.notifyOnDownloadRecovery, disabled: !settings.notifyOnDownloadStall } ] } ] }; } export function buildAccountRowId(service: AccountService, mode: string, identityId: string): string { return [service, mode, identityId].map((part) => encodeURIComponent(part)).join("::"); } function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { return "0 B"; } const units = ["B", "KiB", "MiB", "GiB", "TiB"]; const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const value = bytes / (1024 ** unitIndex); return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`; } function formatTraffic(limitBytes?: number, usageBytes?: number, totalUsageBytes?: number): string { const total = Number.isFinite(totalUsageBytes) && totalUsageBytes && totalUsageBytes > 0 ? ` · Gesamt ${formatBytes(totalUsageBytes)}` : ""; if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) { return `Unbeschränkt${total}`; } const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0; return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig${total}`; } function formatExpiry(premiumUntilMs: number | null): string { if (!premiumUntilMs || !Number.isFinite(premiumUntilMs) || premiumUntilMs <= 0) { return "—"; } return new Intl.DateTimeFormat("de-DE").format(new Date(premiumUntilMs)); } function formatCheckedAgo(checkedAt: number | undefined, nowMs: number): string { if (!checkedAt || !Number.isFinite(checkedAt) || checkedAt <= 0) { return ""; } const deltaMs = Math.max(0, nowMs - checkedAt); const minutes = Math.floor(deltaMs / 60_000); if (minutes < 1) { return "gerade eben"; } if (minutes < 60) { return `vor ${minutes} Min`; } const hours = Math.floor(minutes / 60); if (hours < 24) { return `vor ${hours} Std`; } const days = Math.floor(hours / 24); return `vor ${days} Tag${days === 1 ? "" : "en"}`; } function projectStatus(source: AccountRowSource): { tone: AccountStatusTone; text: string } { if (!source.enabled || source.status.state === "disabled") { return { tone: "disabled", text: "Deaktiviert" }; } switch (source.status.state) { case "premium": return { tone: "ok", text: source.status.message.trim() || "Premium Account" }; case "free": return { tone: "free", text: "Free Account" }; case "invalid": return { tone: "invalid", text: source.status.message.trim() || "Zugang ungültig" }; case "checking": return { tone: "unknown", text: "Prüft…" }; case "unchecked": return { tone: "unknown", text: "Noch nicht geprüft" }; } } function projectCredential(kind: AccountRowSource["credentialKind"]): string { if (kind === "api-key") { return "API-Key"; } return kind === "password" ? "••••••" : "Geschützter Zugang"; } function projectAccountIdentity(username: string, checkedUsername?: string, checkedEmail?: string): { username: string; email: string } { const verifiedUsername = checkedUsername?.trim() || ""; const stored = verifiedUsername || username.trim(); const verifiedEmail = checkedEmail?.trim() || ""; const storedIsEmail = stored.includes("@"); return { username: stored && !storedIsEmail ? stored : "—", email: verifiedEmail || (storedIsEmail ? stored : "—") }; } export function projectAccountRows( sources: readonly AccountRowSource[], selectedIds: readonly string[], nowMs: number = Date.now() ): AccountRowViewModel[] { const selected = new Set(selectedIds); return sources.map((source) => { const id = buildAccountRowId(source.service, source.mode, source.identityId); const status = projectStatus(source); const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs ? source.status.premiumUntilMs : null; const identity = projectAccountIdentity(source.username, source.status.username, source.status.email); return { id, service: source.service, hoster: source.hoster, mode: source.mode, icon: ACCOUNT_SERVICE_ICONS[source.service], enabled: source.enabled, selected: selected.has(id), status: { ...status, checkedAgo: formatCheckedAgo(source.status.checkedAt, nowMs) }, traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes, source.totalUsageBytes), username: identity.username, email: identity.email, expires: formatExpiry(source.status.premiumUntilMs), credential: projectCredential(source.credentialKind), canCheck: source.canCheck, problem: status.tone === "invalid", premiumUntilMs }; }); } export function sortAccountRows( rows: readonly AccountRowViewModel[], direction: "desc" | "asc" = "desc" ): AccountRowViewModel[] { return rows .map((row, index) => ({ row, index })) .sort((left, right) => { const leftUntil = left.row.premiumUntilMs ?? -1; const rightUntil = right.row.premiumUntilMs ?? -1; if (leftUntil > 0 && rightUntil > 0) { return (direction === "desc" ? rightUntil - leftUntil : leftUntil - rightUntil) || left.index - right.index; } if (leftUntil > 0) { return -1; } if (rightUntil > 0) { return 1; } return left.index - right.index; }) .map(({ row }) => row); } export function pruneAccountSelection( selectedIds: readonly string[], rows: readonly Pick[] ): string[] { const existing = new Set(rows.map((row) => row.id)); return [...new Set(selectedIds)].filter((id) => existing.has(id)); } export function filterAccountAddOptions( options: readonly AccountAddOption[], query: string, filter: AccountAddFilter, configuredServices: readonly string[] ): AccountAddOption[] { const normalizedQuery = query.trim().toLocaleLowerCase("de-DE"); const configured = new Set(configuredServices); return options.filter((option) => { if (!option.multi && configured.has(option.service)) { return false; } if (filter !== "all" && option.filter !== filter) { return false; } if (!normalizedQuery) { return true; } return [option.title, option.service, option.mode, option.description, option.functionLabel] .join(" ") .toLocaleLowerCase("de-DE") .includes(normalizedQuery); }); } export function reconcileAccountAddDraft( draft: AccountAddDraft, visibleOptions: readonly Pick[] ): AccountAddDraft { if (draft.selectedId && visibleOptions.some((option) => option.id === draft.selectedId)) { return draft; } return { selectedId: null, login: "", password: "", token: "", dailyLimitGb: "" }; } export function buildTargetedAccountCheck( option: Pick, identityId: string ): TargetedAccountCheck | null { if (option.service !== "megadebrid-api" && option.service !== "debridlink") { return null; } return { service: option.service, expectedStatusId: identityId }; }