Harden renderer account IPC boundary

Move secret-bearing account and settings state behind main-process boundaries for Task 1B. Renderer snapshots now expose RendererSettings plus safe account metadata instead of full AppSettings, with provider tokens, passwords, API keys, archive passwords, and notification URLs excluded from renderer-bound state. Add write-only account create, replace, update-secret, and delete IPC commands, validate renderer settings updates against the safe shape, and keep account command results limited to safe settings, safe accounts, and stable account IDs.

Preserve existing account behavior while removing renderer secret access: blank replace secrets retain stored main-process secrets, Mega-Debrid API/Web pools stay mode-specific, Debrid-Link key metadata migrates by stable key ID, and delete/enable operations target stable account or provider identities. Remove obsolete renderer-side account status helpers from the old settings snapshot flow.

Add focused coverage for all supported renderer account kinds, secret-free UiSnapshot serialization, preload account command forwarding, malformed payload error sanitization, Mega-Debrid preferApi preservation, Debrid-Link key metadata migration, account edit safety, settings UI, debug server settings payloads, link export, and visual fixtures.
This commit is contained in:
Sucukdeluxe
2026-08-11 23:22:44 +02:00
parent 1cb381fa50
commit 26d62a9337
24 changed files with 2240 additions and 1700 deletions
+466
View File
@@ -0,0 +1,466 @@
import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import {
getMegaDebridAccountId,
getMegaDebridAccountsForMode,
getMegaDebridCredentialsForMode,
getMegaDebridDisabledAccountIdsForMode,
mergeMegaDebridCredentialPools,
parseMegaDebridAccounts,
serializeMegaDebridAccounts,
type MegaDebridAccountMode
} from "../shared/mega-debrid-accounts";
import type { AccountCommand, AccountCredentialCheckInput, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types";
export interface AppliedAccountCommand {
settings: AppSettings;
response: { accountId: string | null };
}
const ACCOUNT_KINDS = new Set<RendererAccountKind>([
"realdebrid-api",
"realdebrid-web",
"megadebrid-api",
"megadebrid-web",
"bestdebrid-api",
"bestdebrid-web",
"alldebrid-api",
"alldebrid-web",
"ddownload-login",
"onefichier-api",
"debridlink-api",
"linksnappy-login"
]);
const ACTION_FIELDS: Record<AccountCommand["action"], ReadonlySet<string>> = {
create: new Set(["action", "kind", "identity", "secret", "dailyLimitBytes"]),
replace: new Set(["action", "kind", "accountId", "identity", "secret", "dailyLimitBytes"]),
"update-secret": new Set(["action", "kind", "accountId", "secret"]),
delete: new Set(["action", "kind", "accountId"])
};
function invalid(): never {
throw new Error("Account-Payload ist ungültig");
}
function optionalString(value: unknown, maxLength: number): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string" || value.length > maxLength) {
invalid();
}
return value;
}
function requiredString(value: unknown, maxLength: number): string {
const result = optionalString(value, maxLength);
if (result === undefined || !result.trim()) {
invalid();
}
return result;
}
function optionalLimit(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
invalid();
}
return value;
}
export function validateAccountCommand(value: unknown): AccountCommand {
if (!value || typeof value !== "object" || Array.isArray(value)) {
invalid();
}
const raw = value as Record<string, unknown>;
const action = raw.action;
if (action !== "create" && action !== "replace" && action !== "update-secret" && action !== "delete") {
invalid();
}
if (Object.keys(raw).some((key) => !ACTION_FIELDS[action].has(key))) {
invalid();
}
if (typeof raw.kind !== "string" || !ACCOUNT_KINDS.has(raw.kind as RendererAccountKind)) {
invalid();
}
const kind = raw.kind as RendererAccountKind;
if (action === "create") {
return {
action,
kind,
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000),
dailyLimitBytes: optionalLimit(raw.dailyLimitBytes)
};
}
const accountId = requiredString(raw.accountId, 256).trim();
if (action === "replace") {
return {
action,
kind,
accountId,
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000),
dailyLimitBytes: optionalLimit(raw.dailyLimitBytes)
};
}
if (action === "update-secret") {
return {
action,
kind,
accountId,
secret: requiredString(raw.secret, 100_000)
};
}
return { action, kind, accountId };
}
export function validateAccountCredentialCheckInput(value: unknown): AccountCredentialCheckInput {
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => !new Set(["kind", "accountId", "identity", "secret"]).has(key))) invalid();
if (raw.kind !== "megadebrid-api" && raw.kind !== "megadebrid-web" && raw.kind !== "debridlink-api") invalid();
return {
kind: raw.kind,
accountId: optionalString(raw.accountId, 256),
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000)
};
}
function withoutKeys<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
const removed = new Set(keys);
return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key)));
}
function migrateIdList(values: readonly string[], oldId: string, newId: string): string[] {
const retained = values.filter((value) => value !== oldId && value !== newId);
if (values.includes(oldId)) {
retained.push(newId);
}
return retained;
}
function setLimit(record: Record<string, number>, id: string, value: number | undefined): Record<string, number> {
const result = { ...record };
if (value === undefined) {
return result;
}
if (value > 0) {
result[id] = value;
} else {
delete result[id];
}
return result;
}
function migrateLimit(record: Record<string, number>, oldId: string, newId: string, value: number | undefined): Record<string, number> {
const result = withoutKeys(record, oldId, newId);
const resolved = value === undefined ? record[oldId] : value;
if (resolved && resolved > 0) {
result[newId] = resolved;
}
return result;
}
function validateIdentity(identity: string): string {
const trimmed = identity.trim();
if (!trimmed || /[:\r\n]/.test(trimmed)) {
invalid();
}
return trimmed;
}
function validateSecret(secret: string): string {
if (!secret.trim() || /[\r\n]/.test(secret)) {
invalid();
}
return secret;
}
function megaMode(kind: RendererAccountKind): MegaDebridAccountMode {
return kind === "megadebrid-web" ? "web" : "api";
}
function writeMegaPools(settings: AppSettings, apiCredentials: string, webCredentials: string): AppSettings {
const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials);
const first = parseMegaDebridAccounts(mergedCredentials)[0];
return {
...settings,
megaCredentials: mergedCredentials,
megaLogin: first?.login || "",
megaPassword: first?.password || "",
megaDebridApiCredentials: apiCredentials,
megaDebridWebCredentials: webCredentials,
megaDebridApiEnabled: settings.megaDebridApiEnabled && Boolean(apiCredentials),
megaDebridWebEnabled: settings.megaDebridWebEnabled && Boolean(webCredentials)
};
}
function createMega(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
const mode = megaMode(command.kind);
const identity = validateIdentity(command.identity || "");
const secret = validateSecret(command.secret || "");
const accounts = getMegaDebridAccountsForMode(settings, mode);
if (accounts.some((account) => account.login.toLowerCase() === identity.toLowerCase())) {
invalid();
}
const selectedCredentials = serializeMegaDebridAccounts([...accounts, { login: identity, password: secret }]);
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const accountId = getMegaDebridAccountId(identity);
const next = writeMegaPools(settings, apiCredentials, webCredentials);
if (mode === "api") {
next.megaDebridApiEnabled = true;
} else {
next.megaDebridWebEnabled = true;
}
next.megaDebridAccountDailyLimitBytes = setLimit(settings.megaDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes);
return { settings: next, response: { accountId } };
}
function replaceMega(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
const mode = megaMode(command.kind);
const accounts = getMegaDebridAccountsForMode(settings, mode);
const index = accounts.findIndex((account) => account.id === command.accountId);
if (index < 0) {
invalid();
}
const current = accounts[index];
const identity = command.identity === undefined ? current.login : validateIdentity(command.identity);
const secret = command.secret?.trim() ? validateSecret(command.secret) : current.password;
if (accounts.some((account, accountIndex) => accountIndex !== index && account.login.toLowerCase() === identity.toLowerCase())) {
invalid();
}
const accountId = getMegaDebridAccountId(identity);
const selectedCredentials = serializeMegaDebridAccounts(accounts.map((account, accountIndex) => accountIndex === index
? { login: identity, password: secret }
: { login: account.login, password: account.password }));
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const next = writeMegaPools(settings, apiCredentials, webCredentials);
const selectedDisabled = migrateIdList(getMegaDebridDisabledAccountIdsForMode(settings, mode), command.accountId, accountId);
if (mode === "api") {
next.megaDebridApiDisabledAccountIds = selectedDisabled;
} else {
next.megaDebridWebDisabledAccountIds = selectedDisabled;
}
next.megaDebridDisabledAccountIds = [...new Set([...next.megaDebridApiDisabledAccountIds, ...next.megaDebridWebDisabledAccountIds])];
next.megaDebridAccountDailyLimitBytes = migrateLimit(settings.megaDebridAccountDailyLimitBytes, command.accountId, accountId, command.dailyLimitBytes);
if (command.accountId !== accountId) {
next.megaDebridAccountDailyUsageBytes = withoutKeys(settings.megaDebridAccountDailyUsageBytes, command.accountId, accountId);
next.megaDebridAccountTotalUsageBytes = withoutKeys(settings.megaDebridAccountTotalUsageBytes, command.accountId, accountId);
next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId, accountId);
}
return { settings: next, response: { accountId } };
}
function deleteMega(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
const mode = megaMode(command.kind);
const selected = getMegaDebridAccountsForMode(settings, mode);
if (!selected.some((account) => account.id === command.accountId)) {
invalid();
}
const selectedCredentials = serializeMegaDebridAccounts(selected.filter((account) => account.id !== command.accountId));
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const accountStillUsed = parseMegaDebridAccounts(mode === "api" ? webCredentials : apiCredentials).some((account) => account.id === command.accountId);
const next = writeMegaPools(settings, apiCredentials, webCredentials);
next.megaDebridApiDisabledAccountIds = mode === "api"
? settings.megaDebridApiDisabledAccountIds.filter((id) => id !== command.accountId)
: [...settings.megaDebridApiDisabledAccountIds];
next.megaDebridWebDisabledAccountIds = mode === "web"
? settings.megaDebridWebDisabledAccountIds.filter((id) => id !== command.accountId)
: [...settings.megaDebridWebDisabledAccountIds];
next.megaDebridDisabledAccountIds = [...new Set([...next.megaDebridApiDisabledAccountIds, ...next.megaDebridWebDisabledAccountIds])];
if (!accountStillUsed) {
next.megaDebridAccountDailyLimitBytes = withoutKeys(settings.megaDebridAccountDailyLimitBytes, command.accountId);
next.megaDebridAccountDailyUsageBytes = withoutKeys(settings.megaDebridAccountDailyUsageBytes, command.accountId);
next.megaDebridAccountTotalUsageBytes = withoutKeys(settings.megaDebridAccountTotalUsageBytes, command.accountId);
next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId);
}
const remaining = getMegaDebridAccountsForMode(next, mode)[0]?.id || null;
return { settings: next, response: { accountId: remaining } };
}
function createDebridLink(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
const secret = validateSecret(command.secret || "");
if (/[,\r\n]/.test(secret)) {
invalid();
}
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
if (keys.some((key) => key.token === secret)) {
invalid();
}
const accountId = getDebridLinkApiKeyId(secret);
return {
settings: {
...settings,
debridLinkApiKeys: [...keys.map((key) => key.token), secret].join("\n"),
debridLinkApiKeyDailyLimitBytes: setLimit(settings.debridLinkApiKeyDailyLimitBytes, accountId, command.dailyLimitBytes)
},
response: { accountId }
};
}
function replaceDebridLink(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
const index = keys.findIndex((key) => key.id === command.accountId);
if (index < 0) {
invalid();
}
const secret = command.secret?.trim() ? validateSecret(command.secret) : keys[index].token;
if (/[,\r\n]/.test(secret) || keys.some((key, keyIndex) => keyIndex !== index && key.token === secret)) {
invalid();
}
const accountId = getDebridLinkApiKeyId(secret);
const tokens = keys.map((key, keyIndex) => keyIndex === index ? secret : key.token);
const idChanged = accountId !== command.accountId;
return {
settings: {
...settings,
debridLinkApiKeys: tokens.join("\n"),
debridLinkDisabledKeyIds: idChanged ? migrateIdList(settings.debridLinkDisabledKeyIds, command.accountId, accountId) : [...settings.debridLinkDisabledKeyIds],
debridLinkApiKeyDailyLimitBytes: migrateLimit(settings.debridLinkApiKeyDailyLimitBytes, command.accountId, accountId, command.dailyLimitBytes),
debridLinkApiKeyDailyUsageBytes: idChanged ? withoutKeys(settings.debridLinkApiKeyDailyUsageBytes, command.accountId, accountId) : { ...settings.debridLinkApiKeyDailyUsageBytes },
debridLinkApiKeyTotalUsageBytes: idChanged ? withoutKeys(settings.debridLinkApiKeyTotalUsageBytes, command.accountId, accountId) : { ...settings.debridLinkApiKeyTotalUsageBytes },
debridAccountStatuses: idChanged ? withoutKeys(settings.debridAccountStatuses, command.accountId, accountId) : { ...settings.debridAccountStatuses }
},
response: { accountId }
};
}
function deleteDebridLink(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
if (!keys.some((key) => key.id === command.accountId)) {
invalid();
}
const remaining = keys.filter((key) => key.id !== command.accountId);
return {
settings: {
...settings,
debridLinkApiKeys: remaining.map((key) => key.token).join("\n"),
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds.filter((id) => id !== command.accountId),
debridLinkApiKeyDailyLimitBytes: withoutKeys(settings.debridLinkApiKeyDailyLimitBytes, command.accountId),
debridLinkApiKeyDailyUsageBytes: withoutKeys(settings.debridLinkApiKeyDailyUsageBytes, command.accountId),
debridLinkApiKeyTotalUsageBytes: withoutKeys(settings.debridLinkApiKeyTotalUsageBytes, command.accountId),
debridAccountStatuses: withoutKeys(settings.debridAccountStatuses, command.accountId)
},
response: { accountId: remaining[0]?.id || null }
};
}
function singleProvider(kind: RendererAccountKind): DebridProvider {
if (kind.startsWith("realdebrid")) return "realdebrid";
if (kind.startsWith("bestdebrid")) return "bestdebrid";
if (kind.startsWith("alldebrid")) return "alldebrid";
if (kind === "ddownload-login") return "ddownload";
if (kind === "onefichier-api") return "onefichier";
if (kind === "linksnappy-login") return "linksnappy";
invalid();
}
function singleConfigured(settings: AppSettings, kind: RendererAccountKind): boolean {
if (kind === "realdebrid-api") return Boolean(settings.token.trim());
if (kind === "realdebrid-web") return settings.realDebridUseWebLogin;
if (kind === "bestdebrid-api") return Boolean(settings.bestToken.trim());
if (kind === "bestdebrid-web") return settings.bestDebridUseWebLogin;
if (kind === "alldebrid-api") return Boolean(settings.allDebridToken.trim());
if (kind === "alldebrid-web") return settings.allDebridUseWebLogin;
if (kind === "ddownload-login") return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword);
if (kind === "onefichier-api") return Boolean(settings.oneFichierApiKey.trim());
if (kind === "linksnappy-login") return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword);
return false;
}
function setSingle(settings: AppSettings, kind: RendererAccountKind, identity: string | undefined, secret: string | undefined): AppSettings {
if (kind === "realdebrid-api") return { ...settings, token: validateSecret(secret || ""), realDebridUseWebLogin: false };
if (kind === "realdebrid-web") return { ...settings, token: "", realDebridUseWebLogin: true };
if (kind === "bestdebrid-api") return { ...settings, bestToken: validateSecret(secret || ""), bestDebridUseWebLogin: false };
if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true };
if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false };
if (kind === "alldebrid-web") return { ...settings, allDebridToken: "", allDebridUseWebLogin: true };
if (kind === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") };
if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") };
if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") };
invalid();
}
function replaceSingle(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
if (!singleConfigured(settings, command.kind) || command.accountId !== `svc-${singleProvider(command.kind)}`) {
invalid();
}
let identity = command.identity;
let secret = command.secret?.trim() ? command.secret : undefined;
if (command.kind === "realdebrid-api") secret ||= settings.token;
if (command.kind === "bestdebrid-api") secret ||= settings.bestToken;
if (command.kind === "alldebrid-api") secret ||= settings.allDebridToken;
if (command.kind === "ddownload-login") {
identity = identity?.trim() ? identity : settings.ddownloadLogin;
secret ||= settings.ddownloadPassword;
}
if (command.kind === "onefichier-api") secret ||= settings.oneFichierApiKey;
if (command.kind === "linksnappy-login") {
identity = identity?.trim() ? identity : settings.linkSnappyLogin;
secret ||= settings.linkSnappyPassword;
}
const provider = singleProvider(command.kind);
const next = setSingle(settings, command.kind, identity, secret);
next.providerDailyLimitBytes = setLimit(settings.providerDailyLimitBytes as Record<string, number>, provider, command.dailyLimitBytes);
return { settings: next, response: { accountId: command.accountId } };
}
function deleteSingle(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
const provider = singleProvider(command.kind);
if (!singleConfigured(settings, command.kind) || command.accountId !== `svc-${provider}`) {
invalid();
}
let next = { ...settings };
if (provider === "realdebrid") next = { ...next, token: "", realDebridUseWebLogin: false };
if (provider === "bestdebrid") next = { ...next, bestToken: "", bestDebridUseWebLogin: false };
if (provider === "alldebrid") next = { ...next, allDebridToken: "", allDebridUseWebLogin: false };
if (provider === "ddownload") next = { ...next, ddownloadLogin: "", ddownloadPassword: "" };
if (provider === "onefichier") next = { ...next, oneFichierApiKey: "" };
if (provider === "linksnappy") next = { ...next, linkSnappyLogin: "", linkSnappyPassword: "" };
next.providerDailyLimitBytes = withoutKeys(settings.providerDailyLimitBytes as Record<string, number>, provider);
next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record<string, number>, provider);
next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record<string, number>, provider);
return { settings: next, response: { accountId: null } };
}
function createSingle(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
if (singleConfigured(settings, command.kind)) {
invalid();
}
const provider = singleProvider(command.kind);
const next = setSingle(settings, command.kind, command.identity, command.secret);
next.providerDailyLimitBytes = setLimit(settings.providerDailyLimitBytes as Record<string, number>, provider, command.dailyLimitBytes);
return { settings: next, response: { accountId: `svc-${provider}` } };
}
export function applyAccountCommand(settings: AppSettings, command: AccountCommand): AppliedAccountCommand {
if (command.action === "update-secret") {
const replace: Extract<AccountCommand, { action: "replace" }> = {
action: "replace",
kind: command.kind,
accountId: command.accountId,
secret: command.secret
};
return applyAccountCommand(settings, replace);
}
if (command.kind === "megadebrid-api" || command.kind === "megadebrid-web") {
if (command.action === "create") return createMega(settings, command);
if (command.action === "replace") return replaceMega(settings, command);
return deleteMega(settings, command);
}
if (command.kind === "debridlink-api") {
if (command.action === "create") return createDebridLink(settings, command);
if (command.action === "replace") return replaceDebridLink(settings, command);
return deleteDebridLink(settings, command);
}
if (command.action === "create") return createSingle(settings, command);
if (command.action === "replace") return replaceSingle(settings, command);
return deleteSingle(settings, command);
}
+64 -29
View File
@@ -5,7 +5,10 @@ import { app } from "electron";
import {
AddLinksPayload,
AllDebridHostInfo,
AppSettings,
AppSettings,
AccountCommand,
AccountCommandResult,
AccountCredentialCheckInput,
DebridAccountStatus,
DebridProvider,
DuplicatePolicy,
@@ -27,8 +30,12 @@ import { importDlcContainers } from "./container";
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount } from "./account-check";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { applyAccountCommand } from "./account-commands";
import { createRendererState } from "./renderer-state";
import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web";
@@ -59,7 +66,6 @@ import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceE
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
import { overlayLiveUsageCounters } from "./settings-live-overlay";
import { canPersistExpectedAccountStatus } from "./account-status-persistence";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -433,7 +439,7 @@ export class AppController {
}
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings;
const nextSettings = normalizeSettings({
@@ -472,8 +478,52 @@ export class AppController {
logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
});
}
return this.settings;
}
return this.settings;
}
public async executeAccountCommand(command: AccountCommand): Promise<AccountCommandResult> {
const applied = applyAccountCommand(this.settings, command);
let checkedStatus: DebridAccountStatus | null = null;
if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) {
const mode = command.kind === "megadebrid-web" ? "web" : "api";
const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId);
if (!account) throw new Error("Account-Payload ist ungültig");
checkedStatus = await checkMegaDebridAccount(account);
}
if (command.action !== "delete" && applied.response.accountId && command.kind === "debridlink-api") {
const key = parseDebridLinkApiKeys(applied.settings.debridLinkApiKeys).find((entry) => entry.id === applied.response.accountId);
if (!key) throw new Error("Account-Payload ist ungültig");
checkedStatus = await checkDebridLinkKey(key);
}
if (checkedStatus && !checkedStatus.valid) {
const submittedSecret = "secret" in command ? command.secret : undefined;
const safeMessage = submittedSecret ? checkedStatus.message.split(submittedSecret).join("[geschützt]") : checkedStatus.message;
throw new Error(safeMessage || "Zugangsdaten ungültig");
}
this.updateSettings(applied.settings);
if (checkedStatus) this.manager.applyDebridAccountStatuses([checkedStatus]);
const state = createRendererState(this.settings);
this.audit("INFO", "Account aktualisiert", { action: command.action, kind: command.kind, accountId: applied.response.accountId });
return { ...applied.response, ...state };
}
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
const mode = input.kind === "megadebrid-web" ? "web" : "api";
const account = input.identity?.trim() && input.secret
? parseMegaDebridAccounts(`${input.identity.trim()}:${input.secret}`)[0]
: getMegaDebridAccountsForMode(this.settings, mode).find((entry) => entry.id === input.accountId);
if (!account) throw new Error("Account-Payload ist ungültig");
const status = await checkMegaDebridAccount(account);
return { ...status, message: input.secret ? status.message.split(input.secret).join("[geschützt]") : status.message };
}
const key = input.secret?.trim()
? parseDebridLinkApiKeys(input.secret)[0]
: parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId);
if (!key) throw new Error("Account-Payload ist ungültig");
const status = await checkDebridLinkKey(key);
return { ...status, message: input.secret ? status.message.split(input.secret).join("[geschützt]") : status.message };
}
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
const liveSettings = this.manager.getSettings();
@@ -535,31 +585,16 @@ export class AppController {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
}
public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(settingsOverride ? normalizeSettings(settingsOverride) : this.settings);
if (!settingsOverride || (persistValidOverride && canPersistExpectedAccountStatus(statuses, expectedAccountId))) {
this.manager.applyDebridAccountStatuses(statuses);
}
if (!settingsOverride) {
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
}
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
return statuses;
}
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
if (!entry) {
return null;
}
const status = await checkMegaDebridAccount(entry);
this.manager.applyDebridAccountStatuses([status]);
this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium });
return status;
}
public async checkUpdates(): Promise<UpdateCheckResult> {
const result = await checkGitHubUpdate(this.settings.updateRepo);
if (!result.error) {
+13 -24
View File
@@ -71,6 +71,7 @@ import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerificat
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
import { mergeKnownTotalBytes } from "./download-size";
import { createRendererState } from "./renderer-state";
type ActiveTask = {
itemId: string;
@@ -407,20 +408,7 @@ function cloneSession(session: SessionState): SessionState {
};
}
function cloneSettings(settings: AppSettings): AppSettings {
return {
...settings,
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }
};
}
type ParsedContentRange = {
type ParsedContentRange = {
start: number;
end: number;
total: number | null;
@@ -1782,7 +1770,7 @@ export class DownloadManager extends EventEmitter {
private statsCacheAt = 0;
private settingsSnapshotCache: AppSettings | null = null;
private settingsSnapshotCache: ReturnType<typeof createRendererState> | null = null;
private settingsSnapshotCacheAt = 0;
private invalidateSettingsSnapshotCache(): void {
this.settingsSnapshotCache = null;
@@ -2490,21 +2478,22 @@ export class DownloadManager extends EventEmitter {
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
const snapshotSession = cloneSession(this.session);
let snapshotSettings: AppSettings;
if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) {
snapshotSettings = this.settingsSnapshotCache;
} else {
snapshotSettings = cloneSettings(this.settings);
this.settingsSnapshotCache = snapshotSettings;
this.settingsSnapshotCacheAt = now;
}
let rendererState: ReturnType<typeof createRendererState>;
if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) {
rendererState = this.settingsSnapshotCache;
} else {
rendererState = createRendererState(this.settings);
this.settingsSnapshotCache = rendererState;
this.settingsSnapshotCacheAt = now;
}
const snapshotSummary = this.summary
? { ...this.summary }
: null;
return {
rotationEvents: getRecentRotationEvents(40),
settings: snapshotSettings,
settings: rendererState.settings,
accounts: rendererState.accounts,
session: snapshotSession,
summary: snapshotSummary,
stats: this.getStats(now),
+45 -18
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, safeStorage, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
import { getLogFilePath, logger } from "./logger";
@@ -15,6 +15,9 @@ import { DEV_SERVER_URL } from "./dev-server-url";
import { resolveAppIconPath } from "./app-icon";
import { configureCredentialProtector } from "./credential-protection";
import { isMdd2Backup } from "./backup-crypto";
import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands";
import { createRendererSettings } from "./renderer-state";
import { validateRendererSettingsUpdate } from "./renderer-settings";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -358,28 +361,52 @@ function registerIpcHandlers(): void {
return false;
}
});
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
const validated = validatePlainObject(partial ?? {}, "partial");
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
return result;
});
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
return createRendererSettings(result);
});
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
const validatedProvider = validateString(provider, "provider") as DebridProvider;
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
throw new Error("provider ist ungültig");
}
return controller.resetProviderDailyUsage(validatedProvider);
return createRendererSettings(controller.resetProviderDailyUsage(validatedProvider));
});
ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => {
const validatedKeyId = validateString(keyId, "keyId").trim();
if (!validatedKeyId) {
throw new Error("keyId ist ungültig");
}
return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId);
});
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
});
ipcMain.handle(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
});
ipcMain.handle(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
});
ipcMain.handle(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
});
ipcMain.handle(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
});
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
validatePlainObject(payload ?? {}, "payload");
validateString(payload?.rawText, "rawText");
@@ -761,13 +788,13 @@ function registerIpcHandlers(): void {
return controller.getDebridLinkHostLimits();
});
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string) => {
return controller.checkDebridAccounts(settings, persistValidOverride === true, expectedAccountId);
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
return controller.checkDebridAccounts();
});
ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, async (_event, rawInput: unknown) => {
return controller.checkAccountCredentials(validateAccountCredentialCheckInput(rawInput));
});
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
});
ipcMain.handle(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => {
pendingBackupImport = null;
+83
View File
@@ -0,0 +1,83 @@
import type { AppSettings, RendererSettingsUpdate } from "../shared/types";
import { createRendererSettings } from "./renderer-state";
const DERIVED_KEYS = new Set(["archivePasswordListConfigured", "notifyUrlConfigured", "configuredProviders"]);
const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]);
const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000;
function invalid(): never {
throw new Error("Settings-Payload ist ungültig");
}
function validateJsonValue(value: unknown, depth = 0): void {
if (depth > 8) {
invalid();
}
if (value === null || typeof value === "string" || typeof value === "boolean") {
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) invalid();
return;
}
if (Array.isArray(value)) {
if (value.length > 10_000) invalid();
value.forEach((entry) => validateJsonValue(entry, depth + 1));
return;
}
if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) {
invalid();
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length > 10_000) invalid();
entries.forEach(([, entry]) => validateJsonValue(entry, depth + 1));
}
function validateTopLevelType(value: unknown, expected: unknown): void {
if (Array.isArray(expected)) {
if (!Array.isArray(value)) invalid();
return;
}
if (expected && typeof expected === "object") {
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) invalid();
return;
}
if (typeof value !== typeof expected) {
invalid();
}
}
export function validateRendererSettingsUpdate(value: unknown, current: AppSettings): RendererSettingsUpdate {
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) {
invalid();
}
let serialized = "";
try {
serialized = JSON.stringify(value);
} catch {
invalid();
}
if (Buffer.byteLength(serialized, "utf8") > MAX_SETTINGS_PAYLOAD_BYTES) {
invalid();
}
const safe = createRendererSettings(current) as unknown as Record<string, unknown>;
const input = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(input)) {
if (DERIVED_KEYS.has(key)) {
continue;
}
if (WRITE_ONLY_KEYS.has(key)) {
if (typeof entry !== "string" || entry.length > 100_000) invalid();
output[key] = entry;
continue;
}
if (!(key in safe)) {
invalid();
}
validateTopLevelType(entry, safe[key]);
validateJsonValue(entry);
output[key] = entry;
}
return output as RendererSettingsUpdate;
}
+264
View File
@@ -0,0 +1,264 @@
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
function maskValue(value: string, keepStart = 3, keepEnd = 3): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
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 getSecrets(settings: AppSettings): string[] {
const megaSecrets = [settings.megaCredentials, settings.megaDebridApiCredentials, settings.megaDebridWebCredentials]
.flatMap((credentials) => parseMegaDebridAccounts(credentials, settings.megaPassword))
.map((account) => account.password);
const debridLinkSecrets = parseDebridLinkApiKeys(settings.debridLinkApiKeys).map((key) => key.token);
return [...new Set([
settings.token,
settings.megaPassword,
settings.megaCredentials,
settings.megaDebridApiCredentials,
settings.megaDebridWebCredentials,
...megaSecrets,
settings.bestToken,
settings.allDebridToken,
settings.ddownloadPassword,
settings.oneFichierApiKey,
settings.debridLinkApiKeys,
...debridLinkSecrets,
settings.linkSnappyPassword,
settings.archivePasswordList,
settings.notifyUrl
].map((value) => value.trim()).filter(Boolean))].sort((left, right) => right.length - left.length);
}
function redact(value: string, secrets: readonly string[]): string {
return secrets.reduce((result, secret) => result.split(secret).join("[geschützt]"), value);
}
function safeStatus(status: DebridAccountStatus | undefined, secrets: readonly string[]): DebridAccountStatus | null {
if (!status) {
return null;
}
return {
...status,
label: redact(status.label, secrets),
maskedLogin: redact(status.maskedLogin, secrets),
email: status.email ? redact(status.email, secrets) : undefined,
message: redact(status.message, secrets)
};
}
function providerEnabled(settings: AppSettings, provider: DebridProvider): boolean {
return !settings.disabledProviders.includes(provider);
}
function singleAccount(
settings: AppSettings,
kind: RendererAccountKind,
provider: DebridProvider,
identity: string,
maskedIdentity: string,
hasSecret: boolean
): RendererAccount {
return {
accountId: `svc-${provider}`,
kind,
provider,
identity,
maskedIdentity,
hasSecret,
enabled: providerEnabled(settings, provider),
dailyLimitBytes: settings.providerDailyLimitBytes[provider] || 0,
dailyUsageBytes: settings.providerDailyUsageBytes[provider] || 0,
totalUsageBytes: settings.providerTotalUsageBytes[provider] || 0,
status: null
};
}
export function createRendererAccounts(settings: AppSettings): RendererAccount[] {
const accounts: RendererAccount[] = [];
const secrets = getSecrets(settings);
if (settings.realDebridUseWebLogin || settings.token.trim()) {
accounts.push(singleAccount(
settings,
settings.realDebridUseWebLogin ? "realdebrid-web" : "realdebrid-api",
"realdebrid",
"",
settings.realDebridUseWebLogin ? "Browser-Login" : maskValue(settings.token),
true
));
}
for (const mode of ["api", "web"] as const) {
const kind: RendererAccountKind = mode === "api" ? "megadebrid-api" : "megadebrid-web";
const provider: DebridProvider = kind;
const modeEnabled = mode === "api" ? settings.megaDebridApiEnabled : settings.megaDebridWebEnabled;
const disabledIds = new Set(getMegaDebridDisabledAccountIdsForMode(settings, mode));
for (const account of getMegaDebridAccountsForMode(settings, mode)) {
accounts.push({
accountId: account.id,
kind,
provider,
identity: account.login,
maskedIdentity: account.maskedLogin,
hasSecret: Boolean(account.password),
enabled: modeEnabled && providerEnabled(settings, provider) && !disabledIds.has(account.id),
dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0,
dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[account.id] || 0,
totalUsageBytes: settings.megaDebridAccountTotalUsageBytes[account.id] || 0,
status: safeStatus(settings.debridAccountStatuses[account.id], secrets)
});
}
}
if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) {
accounts.push(singleAccount(
settings,
settings.bestDebridUseWebLogin ? "bestdebrid-web" : "bestdebrid-api",
"bestdebrid",
"",
settings.bestDebridUseWebLogin ? "Cookie-Import" : maskValue(settings.bestToken),
true
));
}
if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) {
accounts.push(singleAccount(
settings,
settings.allDebridUseWebLogin ? "alldebrid-web" : "alldebrid-api",
"alldebrid",
"",
settings.allDebridUseWebLogin ? "Browser-Login" : maskValue(settings.allDebridToken),
true
));
}
if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) {
accounts.push(singleAccount(settings, "ddownload-login", "ddownload", settings.ddownloadLogin.trim(), maskValue(settings.ddownloadLogin, 2, 4), true));
}
if (settings.oneFichierApiKey.trim()) {
accounts.push(singleAccount(settings, "onefichier-api", "onefichier", "", maskValue(settings.oneFichierApiKey), true));
}
for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys)) {
accounts.push({
accountId: key.id,
kind: "debridlink-api",
provider: "debridlink",
identity: "",
maskedIdentity: key.masked,
hasSecret: true,
enabled: providerEnabled(settings, "debridlink") && !settings.debridLinkDisabledKeyIds.includes(key.id),
dailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes[key.id] || 0,
dailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes[key.id] || 0,
totalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes[key.id] || 0,
status: safeStatus(settings.debridAccountStatuses[key.id], secrets)
});
}
if (settings.linkSnappyLogin.trim() && settings.linkSnappyPassword) {
accounts.push(singleAccount(settings, "linksnappy-login", "linksnappy", settings.linkSnappyLogin.trim(), maskValue(settings.linkSnappyLogin, 2, 4), true));
}
return accounts;
}
export function createRendererSettings(settings: AppSettings): RendererSettings {
const configuredProviders = [...new Set(createRendererAccounts(settings).map((account) => account.provider))];
return {
language: settings.language,
realDebridUseWebLogin: settings.realDebridUseWebLogin,
megaDebridApiEnabled: settings.megaDebridApiEnabled,
megaDebridWebEnabled: settings.megaDebridWebEnabled,
megaDebridPreferApi: settings.megaDebridPreferApi,
bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
allDebridUseWebLogin: settings.allDebridUseWebLogin,
debridLinkDisabledKeyIds: [...settings.debridLinkDisabledKeyIds],
rememberToken: settings.rememberToken,
configuredProviders,
providerOrder: [...settings.providerOrder],
providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary,
providerTertiary: settings.providerTertiary,
autoProviderFallback: settings.autoProviderFallback,
outputDir: settings.outputDir,
packageName: settings.packageName,
autoExtract: settings.autoExtract,
autoRename4sf4sj: settings.autoRename4sf4sj,
keepGermanAudioOnly: settings.keepGermanAudioOnly,
germanAudioMode: settings.germanAudioMode,
extractDir: settings.extractDir,
collectMkvToLibrary: settings.collectMkvToLibrary,
mkvLibraryDir: settings.mkvLibraryDir,
createExtractSubfolder: settings.createExtractSubfolder,
hybridExtract: settings.hybridExtract,
cleanupMode: settings.cleanupMode,
extractConflictMode: settings.extractConflictMode,
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
enableIntegrityCheck: settings.enableIntegrityCheck,
autoResumeOnStart: settings.autoResumeOnStart,
autoReconnect: settings.autoReconnect,
reconnectWaitSeconds: settings.reconnectWaitSeconds,
completedCleanupPolicy: settings.completedCleanupPolicy,
maxParallel: settings.maxParallel,
maxParallelExtract: settings.maxParallelExtract,
retryLimit: settings.retryLimit,
speedLimitEnabled: settings.speedLimitEnabled,
speedLimitKbps: settings.speedLimitKbps,
speedLimitMode: settings.speedLimitMode,
updateRepo: settings.updateRepo,
autoUpdateCheck: settings.autoUpdateCheck,
clipboardWatch: settings.clipboardWatch,
minimizeToTray: settings.minimizeToTray,
theme: settings.theme,
collapseNewPackages: settings.collapseNewPackages,
historyRetentionMode: settings.historyRetentionMode,
historyMaxEntries: settings.historyMaxEntries,
historyMaxAgeDays: settings.historyMaxAgeDays,
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys,
autoSortPackagesByProgress: settings.autoSortPackagesByProgress,
autoSkipExtracted: settings.autoSkipExtracted,
hideExtractedItems: settings.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads,
backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics,
archivePasswordListConfigured: Boolean(settings.archivePasswordList.trim()),
notifyUrlConfigured: Boolean(settings.notifyUrl.trim()),
notifyMention: settings.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
notifyOnPackageFailed: settings.notifyOnPackageFailed,
notifyOnRunFinished: settings.notifyOnRunFinished,
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
bandwidthSchedules: settings.bandwidthSchedules.map((entry) => ({ ...entry })),
columnOrder: [...settings.columnOrder],
columnOrderVersion: settings.columnOrderVersion,
extractCpuPriority: settings.extractCpuPriority,
autoExtractWhenStopped: settings.autoExtractWhenStopped,
disabledProviders: [...settings.disabledProviders],
hosterRouting: { ...settings.hosterRouting },
providerDailyLimitBytes: { ...settings.providerDailyLimitBytes },
providerDailyUsageBytes: { ...settings.providerDailyUsageBytes },
providerTotalUsageBytes: { ...settings.providerTotalUsageBytes },
debridLinkApiKeyDailyLimitBytes: { ...settings.debridLinkApiKeyDailyLimitBytes },
debridLinkApiKeyDailyUsageBytes: { ...settings.debridLinkApiKeyDailyUsageBytes },
debridLinkApiKeyTotalUsageBytes: { ...settings.debridLinkApiKeyTotalUsageBytes },
megaDebridDisabledAccountIds: [...settings.megaDebridDisabledAccountIds],
megaDebridApiDisabledAccountIds: [...settings.megaDebridApiDisabledAccountIds],
megaDebridWebDisabledAccountIds: [...settings.megaDebridWebDisabledAccountIds],
megaDebridAccountDailyLimitBytes: { ...settings.megaDebridAccountDailyLimitBytes },
megaDebridAccountDailyUsageBytes: { ...settings.megaDebridAccountDailyUsageBytes },
megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes },
debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, getSecrets(settings))])),
providerDailyUsageDay: settings.providerDailyUsageDay,
scheduledStartEpochMs: settings.scheduledStartEpochMs
} as RendererSettings;
}
export function createRendererState(settings: AppSettings): { settings: RendererSettings; accounts: RendererAccount[] } {
return {
settings: createRendererSettings(settings),
accounts: createRendererAccounts(settings)
};
}
+21 -10
View File
@@ -1,8 +1,13 @@
import { contextBridge, ipcRenderer } from "electron";
import {
AddLinksPayload,
AddLinksPayload,
AccountCommandResult,
AccountCredentialCheckInput,
AccountCreateCommand,
AccountDeleteCommand,
AccountReplaceCommand,
AccountUpdateSecretCommand,
AllDebridHostInfo,
AppSettings,
DebridAccountStatus,
DebridLinkHostLimitInfo,
DebridProvider,
@@ -11,7 +16,9 @@ import {
HistoryEntry,
HistoryRevealResult,
PackagePriority,
RemoteDiagnosticsInfo,
RemoteDiagnosticsInfo,
RendererSettings,
RendererSettingsUpdate,
RendererErrorReport,
SessionStats,
StartConflictEntry,
@@ -29,9 +36,13 @@ const api: ElectronApi = {
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
updateSettings: (settings: RendererSettingsUpdate): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
resetProviderDailyUsage: (provider: DebridProvider): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
createAccount: (command: AccountCreateCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.CREATE_ACCOUNT, command),
replaceAccount: (command: AccountReplaceCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_ACCOUNT, command),
updateAccountSecret: (command: AccountUpdateSecretCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, command),
deleteAccount: (command: AccountDeleteCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.DELETE_ACCOUNT, command),
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
@@ -89,10 +100,10 @@ const api: ElectronApi = {
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
checkDebridAccounts: (settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, settings, persistValidOverride, expectedAccountId),
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
checkAccountCredentials: (input: AccountCredentialCheckInput): Promise<DebridAccountStatus> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
+251 -658
View File
File diff suppressed because it is too large Load Diff
+89 -495
View File
@@ -1,505 +1,99 @@
import { parseDebridLinkApiKeys, getDebridLinkApiKeyId } from "../shared/debrid-link-keys";
import { getMegaDebridAccountId, getMegaDebridAccountsForMode, getMegaDebridCredentialsForMode, getMegaDebridDisabledAccountIdsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, serializeMegaDebridAccounts, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts";
import type { AppSettings, DebridAccountStatus, DebridProvider } from "../shared/types";
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
export type AccountKind =
| "realdebrid-api"
| "realdebrid-web"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid-api"
| "bestdebrid-web"
| "alldebrid-api"
| "alldebrid-web"
| "ddownload-login"
| "onefichier-api"
| "debridlink-api"
| "linksnappy-login";
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
export type AccountEditTarget =
| {
type: "single";
rowKey: string;
kind: SingleAccountKind;
service: AccountService;
provider: DebridProvider;
}
| {
type: "mega";
rowKey: string;
kind: "megadebrid-api" | "megadebrid-web";
service: "megadebrid-api" | "megadebrid-web";
accountId: string;
}
| {
type: "debridlink";
rowKey: string;
kind: "debridlink-api";
service: "debridlink";
keyId: string;
};
export interface AccountEditState {
target: AccountEditTarget;
login: string;
password: string;
token: string;
dailyLimitGb: string;
originalDailyLimitBytes: number;
}
import type { AccountDeleteCommand, AccountReplaceCommand, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types";
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
export type AccountKind = RendererAccountKind;
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
export type AccountEditTarget =
| { type: "single"; rowKey: string; kind: SingleAccountKind; service: AccountService; provider: DebridProvider }
| { type: "mega"; rowKey: string; kind: "megadebrid-api" | "megadebrid-web"; service: "megadebrid-api" | "megadebrid-web"; accountId: string }
| { type: "debridlink"; rowKey: string; kind: "debridlink-api"; service: "debridlink"; keyId: string };
export interface AccountEditState {
target: AccountEditTarget;
login: string;
password: string;
token: string;
dailyLimitGb: string;
originalDailyLimitBytes: number;
}
const BYTES_PER_GIB = 1024 * 1024 * 1024;
function getMegaTargetMode(target: Extract<AccountEditTarget, { type: "mega" }>): MegaDebridAccountMode {
return target.kind === "megadebrid-web" ? "web" : "api";
function formatDailyLimit(limitBytes: number): string {
if (!Number.isFinite(limitBytes) || limitBytes <= 0) return "";
const gib = limitBytes / BYTES_PER_GIB;
const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2;
return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function formatDailyLimit(limitBytes: number): string {
if (!Number.isFinite(limitBytes) || limitBytes <= 0) {
return "";
}
const gib = limitBytes / BYTES_PER_GIB;
const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2;
return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function parseDailyLimit(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 * BYTES_PER_GIB);
}
function withoutRecordKeys<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
const blocked = new Set(keys);
return Object.fromEntries(Object.entries(record || {}).filter(([key]) => !blocked.has(key)));
}
function resolveDailyLimit(value: string, originalBytes: number): number | null {
return value === formatDailyLimit(originalBytes) ? (originalBytes > 0 ? originalBytes : null) : parseDailyLimit(value);
}
function updateTargetLimit(record: Record<string, number>, oldId: string, newId: string, value: string, originalBytes: number): Record<string, number> {
const next = withoutRecordKeys(record || {}, oldId, newId);
const limit = resolveDailyLimit(value, originalBytes);
if (limit && limit > 0) {
next[newId] = limit;
}
return next;
}
function migrateDisabledId(ids: readonly string[], oldId: string, newId: string): string[] {
const wasDisabled = ids.includes(oldId);
const next = ids.filter((id) => id !== oldId && id !== newId);
if (wasDisabled) {
next.push(newId);
}
return next;
}
function updateProviderLimit(settings: AppSettings, provider: DebridProvider, value: string, originalBytes: number): AppSettings["providerDailyLimitBytes"] {
const next = { ...(settings.providerDailyLimitBytes || {}) };
const limit = resolveDailyLimit(value, originalBytes);
if (limit && limit > 0) {
next[provider] = limit;
} else {
delete next[provider];
}
return next;
}
function createSingleEditState(target: Extract<AccountEditTarget, { type: "single" }>, settings: AppSettings): AccountEditState {
const originalDailyLimitBytes = settings.providerDailyLimitBytes?.[target.provider] || 0;
const base = {
target,
login: "",
password: "",
token: "",
dailyLimitGb: formatDailyLimit(originalDailyLimitBytes),
originalDailyLimitBytes
};
switch (target.kind) {
case "realdebrid-api":
return { ...base, token: settings.token };
case "bestdebrid-api":
return { ...base, token: settings.bestToken };
case "alldebrid-api":
return { ...base, token: settings.allDebridToken };
case "onefichier-api":
return { ...base, token: settings.oneFichierApiKey };
case "ddownload-login":
return { ...base, login: settings.ddownloadLogin, password: settings.ddownloadPassword };
case "linksnappy-login":
return { ...base, login: settings.linkSnappyLogin, password: settings.linkSnappyPassword };
default:
return base;
}
}
export function createAccountEditState(target: AccountEditTarget, settings: AppSettings): AccountEditState {
if (target.type === "single") {
return createSingleEditState(target, settings);
}
if (target.type === "mega") {
const account = getMegaDebridAccountsForMode(settings, getMegaTargetMode(target))
.find((entry) => entry.id === target.accountId);
if (!account) {
throw new Error("Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.");
}
return {
target,
login: account.login,
password: account.password,
token: "",
dailyLimitGb: formatDailyLimit(settings.megaDebridAccountDailyLimitBytes?.[account.id] || 0),
originalDailyLimitBytes: settings.megaDebridAccountDailyLimitBytes?.[account.id] || 0
};
}
const key = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "").find((entry) => entry.id === target.keyId);
if (!key) {
throw new Error("Der ausgewählte Debrid-Link-Key wurde nicht gefunden.");
}
return {
target,
login: "",
password: "",
token: key.token,
dailyLimitGb: formatDailyLimit(settings.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0),
originalDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0
};
}
function validateDailyLimit(value: string): string | null {
const normalized = value.trim().replace(",", ".");
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed >= 0 ? null : "Das Tageslimit muss eine positive Zahl oder 0 sein.";
}
export function validateAccountEdit(state: AccountEditState, settings: AppSettings): string | null {
const limitError = validateDailyLimit(state.dailyLimitGb);
if (limitError) {
return limitError;
}
if (state.target.type === "mega") {
const target = state.target;
const login = state.login.trim();
if (!login || !state.password.trim()) {
return "Login und Passwort werden benötigt.";
}
if (/[:\r\n]/.test(login)) {
return "Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten.";
}
if (/[\r\n]/.test(state.password)) {
return "Das Passwort darf keinen Zeilenumbruch enthalten.";
}
const accounts = getMegaDebridAccountsForMode(settings, getMegaTargetMode(target));
if (!accounts.some((entry) => entry.id === target.accountId)) {
return "Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.";
}
if (accounts.some((entry) => entry.id !== target.accountId && entry.login.toLowerCase() === login.toLowerCase())) {
return "Dieser Mega-Debrid-Login ist bereits vorhanden.";
}
return null;
}
if (state.target.type === "debridlink") {
const target = state.target;
const token = state.token.trim();
if (!token) {
return "Der API-Key wird benötigt.";
}
if (/[,\r\n]/.test(token)) {
return "Beim Bearbeiten ist genau ein API-Key erlaubt.";
}
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
if (!keys.some((entry) => entry.id === target.keyId)) {
return "Der ausgewählte Debrid-Link-Key wurde nicht gefunden.";
}
if (keys.some((entry) => entry.id !== target.keyId && entry.token === token)) {
return "Dieser Debrid-Link-Key ist bereits vorhanden.";
}
return null;
}
if (["realdebrid-api", "bestdebrid-api", "alldebrid-api", "onefichier-api"].includes(state.target.kind) && !state.token.trim()) {
return "Der Zugangstoken wird benötigt.";
}
if (["ddownload-login", "linksnappy-login"].includes(state.target.kind)) {
if (!state.login.trim() || !state.password.trim()) {
return "Login und Passwort werden benötigt.";
}
}
return null;
}
function applySingleEdit(settings: AppSettings, state: AccountEditState & { target: Extract<AccountEditTarget, { type: "single" }> }): AppSettings {
const providerDailyLimitBytes = updateProviderLimit(settings, state.target.provider, state.dailyLimitGb, state.originalDailyLimitBytes);
const token = state.token.trim();
const login = state.login.trim();
switch (state.target.kind) {
case "realdebrid-api":
return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes };
case "realdebrid-web":
return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes };
case "bestdebrid-api":
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes };
case "bestdebrid-web":
return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes };
case "alldebrid-api":
return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes };
case "alldebrid-web":
return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes };
case "ddownload-login":
return { ...settings, ddownloadLogin: login, ddownloadPassword: state.password, providerDailyLimitBytes };
case "onefichier-api":
return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes };
case "linksnappy-login":
return { ...settings, linkSnappyLogin: login, linkSnappyPassword: state.password, providerDailyLimitBytes };
}
}
function applyMegaEdit(settings: AppSettings, state: AccountEditState & { target: Extract<AccountEditTarget, { type: "mega" }> }): AppSettings {
const mode = getMegaTargetMode(state.target);
const otherMode: MegaDebridAccountMode = mode === "api" ? "web" : "api";
const accounts = getMegaDebridAccountsForMode(settings, mode);
const index = accounts.findIndex((entry) => entry.id === state.target.accountId);
if (index < 0) {
throw new Error("Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.");
}
const oldId = state.target.accountId;
const login = state.login.trim();
const newId = getMegaDebridAccountId(login);
const nextAccounts = accounts.map((entry, accountIndex) => accountIndex === index
? { login, password: state.password }
: { login: entry.login, password: entry.password });
const idChanged = oldId !== newId;
const selectedCredentials = serializeMegaDebridAccounts(nextAccounts);
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials);
const first = parseMegaDebridAccounts(mergedCredentials)[0];
const oldIdStillUsed = idChanged && getMegaDebridAccountsForMode(settings, otherMode).some((entry) => entry.id === oldId);
const selectedDisabledIds = idChanged
? migrateDisabledId(getMegaDebridDisabledAccountIdsForMode(settings, mode), oldId, newId)
: getMegaDebridDisabledAccountIdsForMode(settings, mode);
const otherDisabledIds = getMegaDebridDisabledAccountIdsForMode(settings, otherMode);
const apiDisabledIds = mode === "api" ? selectedDisabledIds : otherDisabledIds;
const webDisabledIds = mode === "web" ? selectedDisabledIds : otherDisabledIds;
const nextDailyLimits = updateTargetLimit(settings.megaDebridAccountDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes);
if (oldIdStillUsed && settings.megaDebridAccountDailyLimitBytes?.[oldId]) {
nextDailyLimits[oldId] = settings.megaDebridAccountDailyLimitBytes[oldId];
function parseDailyLimit(value: string, originalBytes: number): number {
if (value === formatDailyLimit(originalBytes)) return originalBytes;
const normalized = value.trim().replace(",", ".");
if (!normalized) return 0;
return Math.floor(Number(normalized) * BYTES_PER_GIB);
}
function targetAccountId(target: AccountEditTarget): string {
if (target.type === "mega") return target.accountId;
if (target.type === "debridlink") return target.keyId;
return `svc-${target.provider}`;
}
export function createAccountEditState(target: AccountEditTarget, accounts: readonly RendererAccount[]): AccountEditState {
const account = accounts.find((entry) => entry.accountId === targetAccountId(target) && entry.kind === target.kind);
if (!account) {
throw new Error("Der ausgewählte Account wurde nicht gefunden.");
}
const retainOldMetadata = <T,>(record: Record<string, T>): Record<string, T> => idChanged
? oldIdStillUsed ? withoutRecordKeys(record, newId) : withoutRecordKeys(record, oldId, newId)
: { ...record };
return {
...settings,
megaCredentials: mergedCredentials,
megaLogin: first?.login || "",
megaPassword: first?.password || "",
megaDebridApiCredentials: apiCredentials,
megaDebridWebCredentials: webCredentials,
megaDebridApiEnabled: settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0,
megaDebridWebEnabled: settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0,
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
megaDebridApiDisabledAccountIds: apiDisabledIds,
megaDebridWebDisabledAccountIds: webDisabledIds,
megaDebridAccountDailyLimitBytes: nextDailyLimits,
megaDebridAccountDailyUsageBytes: retainOldMetadata(settings.megaDebridAccountDailyUsageBytes || {}),
megaDebridAccountTotalUsageBytes: retainOldMetadata(settings.megaDebridAccountTotalUsageBytes || {}),
debridAccountStatuses: retainOldMetadata(settings.debridAccountStatuses || {})
target,
login: account.identity,
password: "",
token: "",
dailyLimitGb: formatDailyLimit(account.dailyLimitBytes),
originalDailyLimitBytes: account.dailyLimitBytes
};
}
function applyDebridLinkEdit(settings: AppSettings, state: AccountEditState & { target: Extract<AccountEditTarget, { type: "debridlink" }> }): AppSettings {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
const index = keys.findIndex((entry) => entry.id === state.target.keyId);
if (index < 0) {
throw new Error("Der ausgewählte Debrid-Link-Key wurde nicht gefunden.");
}
const oldId = state.target.keyId;
const token = state.token.trim();
const newId = getDebridLinkApiKeyId(token);
const tokens = keys.map((entry, keyIndex) => keyIndex === index ? token : entry.token);
const idChanged = oldId !== newId;
return {
...settings,
debridLinkApiKeys: tokens.join("\n"),
debridLinkDisabledKeyIds: idChanged
? migrateDisabledId(settings.debridLinkDisabledKeyIds || [], oldId, newId)
: [...(settings.debridLinkDisabledKeyIds || [])],
debridLinkApiKeyDailyLimitBytes: updateTargetLimit(settings.debridLinkApiKeyDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes),
debridLinkApiKeyDailyUsageBytes: idChanged
? withoutRecordKeys(settings.debridLinkApiKeyDailyUsageBytes || {}, oldId, newId)
: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: idChanged
? withoutRecordKeys(settings.debridLinkApiKeyTotalUsageBytes || {}, oldId, newId)
: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) },
debridAccountStatuses: idChanged
? withoutRecordKeys(settings.debridAccountStatuses || {}, oldId, newId)
: { ...(settings.debridAccountStatuses || {}) }
};
}
export function applyAccountEdit(settings: AppSettings, state: AccountEditState): AppSettings {
if (state.target.type === "single") {
return applySingleEdit(settings, state as AccountEditState & { target: Extract<AccountEditTarget, { type: "single" }> });
}
if (state.target.type === "mega") {
return applyMegaEdit(settings, state as AccountEditState & { target: Extract<AccountEditTarget, { type: "mega" }> });
}
return applyDebridLinkEdit(settings, state as AccountEditState & { target: Extract<AccountEditTarget, { type: "debridlink" }> });
}
function clearSingleAccount(settings: AppSettings, target: Extract<AccountEditTarget, { type: "single" }>): AppSettings {
const providerDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) };
const providerDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) };
const providerTotalUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
delete providerDailyLimitBytes[target.provider];
delete providerDailyUsageBytes[target.provider];
delete providerTotalUsageBytes[target.provider];
const base = { ...settings, providerDailyLimitBytes, providerDailyUsageBytes, providerTotalUsageBytes };
switch (target.service) {
case "realdebrid":
return { ...base, token: "", realDebridUseWebLogin: false };
case "bestdebrid":
return { ...base, bestToken: "", bestDebridUseWebLogin: false };
case "alldebrid":
return { ...base, allDebridToken: "", allDebridUseWebLogin: false };
case "ddownload":
return { ...base, ddownloadLogin: "", ddownloadPassword: "" };
case "onefichier":
return { ...base, oneFichierApiKey: "" };
case "linksnappy":
return { ...base, linkSnappyLogin: "", linkSnappyPassword: "" };
default:
return base;
}
}
export function removeAccountTarget(settings: AppSettings, target: AccountEditTarget): AppSettings {
if (target.type === "single") {
return clearSingleAccount(settings, target);
export function validateAccountEdit(state: AccountEditState, accounts: readonly RendererAccount[]): string | null {
const normalizedLimit = state.dailyLimitGb.trim().replace(",", ".");
if (normalizedLimit && (!Number.isFinite(Number(normalizedLimit)) || Number(normalizedLimit) < 0)) {
return "Das Tageslimit muss eine positive Zahl oder 0 sein.";
}
const accountId = targetAccountId(state.target);
if (!accounts.some((entry) => entry.accountId === accountId && entry.kind === state.target.kind)) {
return "Der ausgewählte Account wurde nicht gefunden.";
}
if (target.type === "mega") {
const mode = getMegaTargetMode(target);
const selectedAccounts = getMegaDebridAccountsForMode(settings, mode)
.filter((entry) => entry.id !== target.accountId)
.map((entry) => ({ login: entry.login, password: entry.password }));
const selectedCredentials = serializeMegaDebridAccounts(selectedAccounts);
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials);
const first = parseMegaDebridAccounts(mergedCredentials)[0];
const accountStillUsed = parseMegaDebridAccounts(mode === "api" ? webCredentials : apiCredentials).some((entry) => entry.id === target.accountId);
const apiDisabledIds = (mode === "api" ? getMegaDebridDisabledAccountIdsForMode(settings, "api").filter((id) => id !== target.accountId) : getMegaDebridDisabledAccountIdsForMode(settings, "api"));
const webDisabledIds = (mode === "web" ? getMegaDebridDisabledAccountIdsForMode(settings, "web").filter((id) => id !== target.accountId) : getMegaDebridDisabledAccountIdsForMode(settings, "web"));
const keepOrRemoveMetadata = <T,>(record: Record<string, T>): Record<string, T> => accountStillUsed ? { ...record } : withoutRecordKeys(record, target.accountId);
return {
...settings,
megaCredentials: mergedCredentials,
megaLogin: first?.login || "",
megaPassword: first?.password || "",
megaDebridApiCredentials: apiCredentials,
megaDebridWebCredentials: webCredentials,
megaDebridApiEnabled: parseMegaDebridAccounts(apiCredentials).length > 0 && settings.megaDebridApiEnabled,
megaDebridWebEnabled: parseMegaDebridAccounts(webCredentials).length > 0 && settings.megaDebridWebEnabled,
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
megaDebridApiDisabledAccountIds: apiDisabledIds,
megaDebridWebDisabledAccountIds: webDisabledIds,
megaDebridAccountDailyLimitBytes: keepOrRemoveMetadata(settings.megaDebridAccountDailyLimitBytes || {}),
megaDebridAccountDailyUsageBytes: keepOrRemoveMetadata(settings.megaDebridAccountDailyUsageBytes || {}),
megaDebridAccountTotalUsageBytes: keepOrRemoveMetadata(settings.megaDebridAccountTotalUsageBytes || {}),
debridAccountStatuses: keepOrRemoveMetadata(settings.debridAccountStatuses || {})
};
}
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "").filter((entry) => entry.id !== target.keyId);
return {
...settings,
debridLinkApiKeys: keys.map((entry) => entry.token).join("\n"),
debridLinkDisabledKeyIds: (settings.debridLinkDisabledKeyIds || []).filter((id) => id !== target.keyId),
debridLinkApiKeyDailyLimitBytes: withoutRecordKeys(settings.debridLinkApiKeyDailyLimitBytes || {}, target.keyId),
debridLinkApiKeyDailyUsageBytes: withoutRecordKeys(settings.debridLinkApiKeyDailyUsageBytes || {}, target.keyId),
debridLinkApiKeyTotalUsageBytes: withoutRecordKeys(settings.debridLinkApiKeyTotalUsageBytes || {}, target.keyId),
debridAccountStatuses: withoutRecordKeys(settings.debridAccountStatuses || {}, target.keyId)
};
}
export function buildAccountEditCheckSettings(settings: AppSettings, state: AccountEditState): AppSettings {
if (state.target.type === "mega") {
const login = state.login.trim();
const credentials = serializeMegaDebridAccounts([{ login, password: state.password }]);
const mode = getMegaTargetMode(state.target);
return {
...settings,
megaCredentials: credentials,
megaLogin: login,
megaPassword: state.password,
megaDebridApiCredentials: mode === "api" ? credentials : "",
megaDebridWebCredentials: mode === "web" ? credentials : "",
megaDebridApiEnabled: mode === "api",
megaDebridWebEnabled: mode === "web",
megaDebridApiDisabledAccountIds: [],
megaDebridWebDisabledAccountIds: [],
debridLinkApiKeys: ""
};
}
if (state.target.type === "debridlink") {
return {
...settings,
megaCredentials: "",
megaLogin: "",
megaPassword: "",
megaDebridApiCredentials: "",
megaDebridWebCredentials: "",
debridLinkApiKeys: state.token.trim()
};
}
return {
...settings,
megaCredentials: "",
megaLogin: "",
megaPassword: "",
megaDebridApiCredentials: "",
megaDebridWebCredentials: "",
debridLinkApiKeys: ""
};
}
export function validateAccountEditStatuses(state: AccountEditState, statuses: readonly DebridAccountStatus[]): string | null {
if (state.target.type === "single") {
return null;
}
if (statuses.length === 0) {
return "Die Prüfung hat keinen Account zurückgegeben.";
}
if (statuses.length !== 1) {
return "Die Prüfung hat mehr als den ausgewählten Account zurückgegeben.";
}
const expectedId = getAccountEditExpectedStatusId(state);
const status = statuses[0];
if (status.accountId !== expectedId) {
return "Die Prüfung hat den falschen Account zurückgegeben.";
}
return status.valid ? null : status.message || "Zugangsdaten ungültig";
}
export function getAccountEditExpectedStatusId(state: AccountEditState): string | null {
if (state.target.type === "mega") {
return getMegaDebridAccountId(state.login);
}
if (state.target.type === "debridlink") {
return getDebridLinkApiKeyId(state.token);
}
return null;
}
if (!login) return "Der Login wird benötigt.";
if (/[:\r\n]/.test(login)) return "Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten.";
if (/[\r\n]/.test(state.password)) return "Das Passwort darf keinen Zeilenumbruch enthalten.";
if (accounts.some((entry) => entry.kind === state.target.kind && entry.accountId !== accountId && entry.identity.toLowerCase() === login.toLowerCase())) {
return "Dieser Mega-Debrid-Login ist bereits vorhanden.";
}
}
if (state.target.type === "debridlink" && /[,\r\n]/.test(state.token)) {
return "Beim Bearbeiten ist genau ein API-Key erlaubt.";
}
if (state.target.type === "single" && (state.target.kind === "ddownload-login" || state.target.kind === "linksnappy-login")) {
if (!state.login.trim()) return "Der Login wird benötigt.";
if (/[\r\n]/.test(state.password)) return "Das Passwort darf keinen Zeilenumbruch enthalten.";
}
return null;
}
export function buildAccountReplaceCommand(state: AccountEditState): AccountReplaceCommand {
return {
action: "replace",
kind: state.target.kind,
accountId: targetAccountId(state.target),
identity: state.target.type === "debridlink" ? undefined : state.login.trim(),
secret: state.target.type === "debridlink" ? state.token : state.target.type === "single" && !["ddownload-login", "linksnappy-login"].includes(state.target.kind) ? state.token : state.password,
dailyLimitBytes: parseDailyLimit(state.dailyLimitGb, state.originalDailyLimitBytes)
};
}
export function buildAccountDeleteCommand(target: AccountEditTarget): AccountDeleteCommand {
return { action: "delete", kind: target.kind, accountId: targetAccountId(target) };
}
@@ -1,4 +1,4 @@
import type { AppSettings } from "../../../shared/types";
import type { RendererSettings } from "../../../shared/types";
import type { AccountService } from "../../account-edit";
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
@@ -49,10 +49,10 @@ export function getSettingsSelectNavigationIndex(currentIndex: number, optionCou
}
export function resolveHistoryRetentionSelection(
currentMode: AppSettings["historyRetentionMode"],
currentMode: RendererSettings["historyRetentionMode"],
currentMaxEntries: number,
value: string
): Pick<AppSettings, "historyRetentionMode" | "historyMaxEntries"> {
): Pick<RendererSettings, "historyRetentionMode" | "historyMaxEntries"> {
const preset = /^permanent-(100|250)$/.exec(value);
if (preset) {
return {
@@ -69,7 +69,7 @@ export function resolveHistoryRetentionSelection(
};
}
return {
historyRetentionMode: value as AppSettings["historyRetentionMode"],
historyRetentionMode: value as RendererSettings["historyRetentionMode"],
historyMaxEntries: currentMaxEntries
};
}
@@ -208,7 +208,7 @@ export interface SettingsFormViewModel {
}
export interface SettingsFormProjectionInput {
settings: AppSettings;
settings: RendererSettings & { archivePasswordList: string; notifyUrl: string };
section: SettingsSection;
speedLimitInput: string;
scheduleSpeedInputs: Readonly<Record<string, string>>;
@@ -289,7 +289,7 @@ export function buildSettingsFormViewModel({
id: "extract-passwords",
title: "Passwörter",
fields: [
{ id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: "Ein Passwort pro Zeile" }
{ id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: settings.archivePasswordListConfigured ? "Gespeichert; leer lassen zum Beibehalten" : "Ein Passwort pro Zeile" }
]
}
]
@@ -528,7 +528,7 @@ export function buildSettingsFormViewModel({
id: "general-notifications",
title: "Discord-Benachrichtigungen",
fields: [
{ id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: "https://discord.com/api/webhooks/…", actionLabel: "Testen" },
{ 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 },
+9 -5
View File
@@ -5,7 +5,11 @@ export const IPC_CHANNELS = {
INSTALL_UPDATE: "app:install-update",
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
OPEN_EXTERNAL: "app:open-external",
UPDATE_SETTINGS: "app:update-settings",
UPDATE_SETTINGS: "app:update-settings",
CREATE_ACCOUNT: "app:create-account",
REPLACE_ACCOUNT: "app:replace-account",
UPDATE_ACCOUNT_SECRET: "app:update-account-secret",
DELETE_ACCOUNT: "app:delete-account",
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
ADD_LINKS: "queue:add-links",
@@ -64,10 +68,10 @@ export const IPC_CHANNELS = {
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
RETRY_EXTRACTION: "queue:retry-extraction",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
CHECK_ACCOUNT_CREDENTIALS: "app:check-account-credentials",
RETRY_EXTRACTION: "queue:retry-extraction",
EXTRACT_NOW: "queue:extract-now",
RESET_PACKAGE: "queue:reset-package",
GET_HISTORY: "history:get",
+21 -10
View File
@@ -1,7 +1,12 @@
import type {
AddLinksPayload,
AddLinksPayload,
AccountCommandResult,
AccountCredentialCheckInput,
AccountCreateCommand,
AccountDeleteCommand,
AccountReplaceCommand,
AccountUpdateSecretCommand,
AllDebridHostInfo,
AppSettings,
DebridAccountStatus,
DebugSetupCheckResult,
DebridLinkHostLimitInfo,
@@ -11,7 +16,9 @@ import type {
HistoryEntry,
HistoryRevealResult,
PackagePriority,
RemoteDiagnosticsInfo,
RemoteDiagnosticsInfo,
RendererSettings,
RendererSettingsUpdate,
RendererErrorReport,
SessionStats,
StartConflictEntry,
@@ -29,9 +36,13 @@ export interface ElectronApi {
checkUpdates: () => Promise<UpdateCheckResult>;
installUpdate: () => Promise<UpdateInstallResult>;
openExternal: (url: string) => Promise<boolean>;
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
updateSettings: (settings: RendererSettingsUpdate) => Promise<RendererSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<RendererSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<RendererSettings>;
createAccount: (command: AccountCreateCommand) => Promise<AccountCommandResult>;
replaceAccount: (command: AccountReplaceCommand) => Promise<AccountCommandResult>;
updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise<AccountCommandResult>;
deleteAccount: (command: AccountDeleteCommand) => Promise<AccountCommandResult>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>;
@@ -86,10 +97,10 @@ export interface ElectronApi {
openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: (settings?: AppSettings, persistValidOverride?: boolean, expectedAccountId?: string) => Promise<DebridAccountStatus[]>;
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
retryExtraction: (packageId: string) => Promise<void>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise<DebridAccountStatus>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>;
+174 -4
View File
@@ -168,8 +168,177 @@ export interface AppSettings {
megaDebridAccountTotalUsageBytes: Record<string, number>;
debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
}
scheduledStartEpochMs: number;
}
export type RendererAccountKind =
| "realdebrid-api"
| "realdebrid-web"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid-api"
| "bestdebrid-web"
| "alldebrid-api"
| "alldebrid-web"
| "ddownload-login"
| "onefichier-api"
| "debridlink-api"
| "linksnappy-login";
export interface RendererAccount {
accountId: string;
kind: RendererAccountKind;
provider: DebridProvider;
identity: string;
maskedIdentity: string;
hasSecret: boolean;
enabled: boolean;
dailyLimitBytes: number;
dailyUsageBytes: number;
totalUsageBytes: number;
status: DebridAccountStatus | null;
}
export interface RendererSettings {
language: AppLanguage;
realDebridUseWebLogin: boolean;
megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean;
megaDebridPreferApi: boolean;
bestDebridUseWebLogin: boolean;
allDebridUseWebLogin: boolean;
debridLinkDisabledKeyIds: string[];
rememberToken: boolean;
configuredProviders: DebridProvider[];
providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider;
providerTertiary: DebridFallbackProvider;
autoProviderFallback: boolean;
outputDir: string;
packageName: string;
autoExtract: boolean;
autoRename4sf4sj: boolean;
keepGermanAudioOnly: boolean;
germanAudioMode: "tag" | "first";
extractDir: string;
collectMkvToLibrary: boolean;
mkvLibraryDir: string;
createExtractSubfolder: boolean;
hybridExtract: boolean;
cleanupMode: CleanupMode;
extractConflictMode: ConflictMode;
removeLinkFilesAfterExtract: boolean;
removeSamplesAfterExtract: boolean;
enableIntegrityCheck: boolean;
autoResumeOnStart: boolean;
autoReconnect: boolean;
reconnectWaitSeconds: number;
completedCleanupPolicy: FinishedCleanupPolicy;
maxParallel: number;
maxParallelExtract: number;
retryLimit: number;
speedLimitEnabled: boolean;
speedLimitKbps: number;
speedLimitMode: SpeedMode;
updateRepo: string;
autoUpdateCheck: boolean;
clipboardWatch: boolean;
minimizeToTray: boolean;
theme: AppTheme;
collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number;
historyMaxAgeDays: number;
accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean;
hideExtractedItems: boolean;
confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
backupIncludeRemoteDiagnostics: boolean;
archivePasswordListConfigured: boolean;
notifyUrlConfigured: boolean;
notifyMention: string;
notifyOnPackageCompleted: boolean;
notifyOnPackageFailed: boolean;
notifyOnRunFinished: boolean;
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
columnOrderVersion?: number;
extractCpuPriority: ExtractCpuPriority;
autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
megaDebridDisabledAccountIds: string[];
megaDebridApiDisabledAccountIds: string[];
megaDebridWebDisabledAccountIds: string[];
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>;
debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
}
export type RendererSettingsUpdate = Partial<RendererSettings> & {
archivePasswordList?: string;
notifyUrl?: string;
};
export interface AccountCreateCommand {
action: "create";
kind: RendererAccountKind;
identity?: string;
secret?: string;
dailyLimitBytes?: number;
}
export interface AccountReplaceCommand {
action: "replace";
kind: RendererAccountKind;
accountId: string;
identity?: string;
secret?: string;
dailyLimitBytes?: number;
}
export interface AccountUpdateSecretCommand {
action: "update-secret";
kind: RendererAccountKind;
accountId: string;
secret: string;
}
export interface AccountDeleteCommand {
action: "delete";
kind: RendererAccountKind;
accountId: string;
}
export type AccountCommand = AccountCreateCommand | AccountReplaceCommand | AccountUpdateSecretCommand | AccountDeleteCommand;
export interface AccountCommandResult {
accountId: string | null;
settings: RendererSettings;
accounts: RendererAccount[];
}
export interface AccountCredentialCheckInput {
kind: "megadebrid-api" | "megadebrid-web" | "debridlink-api";
accountId?: string;
identity?: string;
secret?: string;
}
export interface DownloadItem {
id: string;
@@ -287,8 +456,9 @@ export interface RotationEvent {
next?: string;
}
export interface UiSnapshot {
settings: AppSettings;
export interface UiSnapshot {
settings: RendererSettings;
accounts: RendererAccount[];
session: SessionState;
summary: DownloadSummary | null;
stats: DownloadStats;