Files
Multi-Debrid-Downloader/src/renderer/views/settings/settings-model.ts
T

892 lines
35 KiB
TypeScript

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<AccountTableColumnId, number>;
const ACCOUNT_TABLE_COLUMN_LIMITS: Record<AccountTableColumnId, { initial: number; min: number; max: number }> = {
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<string, unknown> : {};
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<RendererSettings, "historyRetentionMode" | "historyMaxEntries"> {
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<AccountAddFilter, "all">;
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<Record<string, string>>;
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<AccountRowViewModel, "id">[]
): 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<AccountAddOption, "id">[]
): 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<AccountAddOption, "service">,
identityId: string
): TargetedAccountCheck | null {
if (option.service !== "megadebrid-api" && option.service !== "debridlink") {
return null;
}
return { service: option.service, expectedStatusId: identityId };
}