diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts new file mode 100644 index 0000000..68e8140 --- /dev/null +++ b/src/main/account-commands.ts @@ -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([ + "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> = { + 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; + 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; + 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(record: Record, ...keys: string[]): Record { + 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, id: string, value: number | undefined): Record { + const result = { ...record }; + if (value === undefined) { + return result; + } + if (value > 0) { + result[id] = value; + } else { + delete result[id]; + } + return result; +} + +function migrateLimit(record: Record, oldId: string, newId: string, value: number | undefined): Record { + 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): 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): 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): 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): 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): 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): 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): 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, provider, command.dailyLimitBytes); + return { settings: next, response: { accountId: command.accountId } }; +} + +function deleteSingle(settings: AppSettings, command: Extract): 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, provider); + next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record, provider); + next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record, provider); + return { settings: next, response: { accountId: null } }; +} + +function createSingle(settings: AppSettings, command: Extract): 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, 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 = { + 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); +} diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 95e0ea8..71f07fc 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -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): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -433,7 +439,7 @@ export class AppController { } } - public updateSettings(partial: Partial): AppSettings { + public updateSettings(partial: Partial): 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 { + 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 { + 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 { - 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 { + 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 { - 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 { const result = await checkGitHubUpdate(this.settings.updateRepo); if (!result.error) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 28d9fd5..0582dea 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -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 | 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; + 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), diff --git a/src/main/main.ts b/src/main/main.ts index 190482d..2654223 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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) => { - const validated = validatePlainObject(partial ?? {}, "partial"); - const result = controller.updateSettings(validated as Partial); - 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); + 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; diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts new file mode 100644 index 0000000..4aa517a --- /dev/null +++ b/src/main/renderer-settings.ts @@ -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); + 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; + const input = value as Record; + const output: Record = {}; + 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; +} diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts new file mode 100644 index 0000000..a574046 --- /dev/null +++ b/src/main/renderer-state.ts @@ -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) + }; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 546f1b2..0369141 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -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 => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES), installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), - updateSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), - resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), - resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + updateSettings: (settings: RendererSettingsUpdate): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), + resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), + resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + createAccount: (command: AccountCreateCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.CREATE_ACCOUNT, command), + replaceAccount: (command: AccountReplaceCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_ACCOUNT, command), + updateAccountSecret: (command: AccountUpdateSecretCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, command), + deleteAccount: (command: AccountDeleteCommand): Promise => 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 => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), - getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), - checkDebridAccounts: (settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, settings, persistValidOverride, expectedAccountId), - checkMegaDebridAccount: (login: string, password: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password), - retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), + getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), + checkDebridAccounts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS), + checkAccountCredentials: (input: AccountCredentialCheckInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input), + retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9f5b763..f52a2da 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,11 +1,10 @@ import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountId, getMegaDebridAccountsForMode, getMegaDebridCredentialsForMode, getMegaDebridDisabledAccountIdsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, serializeMegaDebridAccounts, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; -import type { - AllDebridHostInfo, - AppSettings, - AppTheme, +import type { + AccountCreateCommand, + AllDebridHostInfo, + AppTheme, BandwidthScheduleEntry, DebugSetupCheckResult, DebridFallbackProvider, @@ -16,7 +15,10 @@ import type { DuplicatePolicy, HistoryEntry, PackageEntry, - RemoteDiagnosticsInfo, + RemoteDiagnosticsInfo, + RendererAccount, + RendererSettings, + RendererSettingsUpdate, StartConflictEntry, UiSnapshot, UpdateCheckResult, @@ -40,7 +42,7 @@ import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./packag import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; import type { AccountModeFilter } from "./account-ui"; -import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit"; +import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state"; @@ -306,7 +308,6 @@ interface AccountTableRow { dailyRemainingBytes: number; totalUsedBytes: number; toggleKind: "mega" | "dl" | "single"; - megaLogin?: string; dlKey?: DebridLinkAccountKeyEntry; editTarget: AccountEditTarget; } @@ -319,23 +320,36 @@ interface AccountContextMenuState { type SettingsThemeChoice = AppTheme | "system"; +interface RendererSettingsDraft extends RendererSettings { + archivePasswordList: string; + notifyUrl: string; +} + +function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft { + return { + ...settings, + archivePasswordList: current?.archivePasswordList || "", + notifyUrl: current?.notifyUrl || "" + }; +} + function settingsValueEqual(left: unknown, right: unknown): boolean { return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); } export function mergeConcurrentSpecificSettings( - base: AppSettings, - requested: AppSettings, - persisted: AppSettings, - current: AppSettings -): AppSettings { + base: RendererSettingsDraft, + requested: RendererSettingsDraft, + persisted: RendererSettings, + current: RendererSettingsDraft +): RendererSettingsDraft { const merged = { ...current } as Record; - for (const key of Object.keys(requested) as Array) { - if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key])) { - merged[key] = persisted[key]; + for (const key of Object.keys(requested) as Array) { + if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key]) && key in persisted) { + merged[key] = persisted[key as keyof RendererSettings]; } } - return merged as unknown as AppSettings; + return merged as unknown as RendererSettingsDraft; } export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme { @@ -545,16 +559,7 @@ function getAccountServiceProvider(service: AccountService): DebridProvider { return service as DebridProvider; } -function formatAccountDailyLimitInput(limitBytes: number): string { - if (limitBytes <= 0) { - return ""; - } - const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB; - const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; - return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); -} - -function parseAccountDailyLimitInputBytes(value: string): number | null { +function parseAccountDailyLimitInputBytes(value: string): number | null { const normalized = value.trim().replace(",", "."); if (!normalized) { return null; @@ -566,15 +571,6 @@ function parseAccountDailyLimitInputBytes(value: string): number | null { return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB); } -function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record, settings?: AppSettings): Record { - const next: Record = {}; - for (const key of parseDebridLinkApiKeys(rawKeys)) { - next[key.id] = values?.[key.id] - ?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0); - } - return next; -} - function getAccountPickerFunctionLabel(option: AccountOption): string { switch (option.kind) { case "realdebrid-api": @@ -621,54 +617,25 @@ function getAccountCredentialLabel(kind: AccountKind): string { } } -function hasMegaDebridCredentials(settings: AppSettings, mode?: MegaDebridAccountMode): boolean { - if (mode) { - return getMegaDebridAccountsForMode(settings, mode).length > 0; - } - return getMegaDebridAccountsForMode(settings, "api").length > 0 || getMegaDebridAccountsForMode(settings, "web").length > 0; -} - -function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] { - const list: DebridProvider[] = []; - if (settings.token.trim() || settings.realDebridUseWebLogin) { - list.push("realdebrid"); - } - if (hasMegaDebridCredentials(settings, "api") && settings.megaDebridApiEnabled) { - list.push("megadebrid-api"); - } - if (hasMegaDebridCredentials(settings, "web") && settings.megaDebridWebEnabled) { - list.push("megadebrid-web"); - } - if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) { - list.push("bestdebrid"); - } - if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) { - list.push("alldebrid"); - } - if ((settings.debridLinkApiKeys || "").trim()) { - list.push("debridlink"); - } - if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) { - list.push("linksnappy"); - } - return list; +function getConfiguredProvidersFromSettings(settings: RendererSettings): DebridProvider[] { + return [...settings.configuredProviders]; } -function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] { +function getActiveProvidersFromSettings(settings: RendererSettings): DebridProvider[] { const disabled = new Set(settings.disabledProviders || []); return getConfiguredProvidersFromSettings(settings).filter((provider) => !disabled.has(provider)); } const DIRECT_HOSTERS: ReadonlySet = new Set(["onefichier", "ddownload"]); -function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] { +function normalizeProviderOrderForSettings(settings: RendererSettings): DebridProvider[] { const configured = getConfiguredProvidersFromSettings(settings).filter((provider) => !DIRECT_HOSTERS.has(provider)); return buildConfiguredProviderOrder(settings.providerOrder || [], configured); } function normalizeProviderSelectionForSettings( - settings: AppSettings -): Pick { + settings: RendererSettings +): Pick { const providerOrder = normalizeProviderOrderForSettings(settings); return { providerOrder, @@ -678,174 +645,70 @@ function normalizeProviderSelectionForSettings( }; } -function getConfiguredAccountKind(settings: AppSettings, service: AccountService): AccountKind | null { - switch (service) { - case "realdebrid": - if (settings.realDebridUseWebLogin) return "realdebrid-web"; - return settings.token.trim() ? "realdebrid-api" : null; - case "megadebrid-api": - return hasMegaDebridCredentials(settings, "api") && settings.megaDebridApiEnabled ? "megadebrid-api" : null; - case "megadebrid-web": - return hasMegaDebridCredentials(settings, "web") && settings.megaDebridWebEnabled ? "megadebrid-web" : null; - case "bestdebrid": - if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; - return settings.bestToken.trim() ? "bestdebrid-api" : null; - case "alldebrid": - if (settings.allDebridUseWebLogin) return "alldebrid-web"; - return settings.allDebridToken.trim() ? "alldebrid-api" : null; - case "ddownload": - return settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim() ? "ddownload-login" : null; - case "onefichier": - return settings.oneFichierApiKey.trim() ? "onefichier-api" : null; - case "debridlink": - return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null; - case "linksnappy": - return (settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim() ? "linksnappy-login" : null; +function getConfiguredAccountKind(settings: RendererSettings, service: AccountService): AccountKind | null { + const configured = new Set(settings.configuredProviders); + switch (service) { + case "realdebrid": + if (settings.realDebridUseWebLogin) return "realdebrid-web"; + return configured.has("realdebrid") ? "realdebrid-api" : null; + case "megadebrid-api": + return configured.has("megadebrid-api") ? "megadebrid-api" : null; + case "megadebrid-web": + return configured.has("megadebrid-web") ? "megadebrid-web" : null; + case "bestdebrid": + if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; + return configured.has("bestdebrid") ? "bestdebrid-api" : null; + case "alldebrid": + if (settings.allDebridUseWebLogin) return "alldebrid-web"; + return configured.has("alldebrid") ? "alldebrid-api" : null; + case "ddownload": + return configured.has("ddownload") ? "ddownload-login" : null; + case "onefichier": + return configured.has("onefichier") ? "onefichier-api" : null; + case "debridlink": + return configured.has("debridlink") ? "debridlink-api" : null; + case "linksnappy": + return configured.has("linksnappy") ? "linksnappy-login" : null; default: return null; } } -function maskValue(value: string, keepStart = 2, keepEnd = 2): string { - const trimmed = value.trim(); - if (!trimmed) return "Nicht hinterlegt"; - if (trimmed.length <= keepStart + keepEnd) { - return "*".repeat(trimmed.length); - } - return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`; -} - -function summarizeAccount(kind: AccountKind, settings: AppSettings): string { - switch (kind) { - case "realdebrid-api": - return maskValue(settings.token, 3, 3); - case "realdebrid-web": - return "Browser-Login"; - case "megadebrid-api": - case "megadebrid-web": { - const mode = kind === "megadebrid-web" ? "web" : "api"; - const megaAccounts = getMegaDebridAccountsForMode(settings, mode); - if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`; - if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin; - return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt"; - } - case "bestdebrid-api": - return maskValue(settings.bestToken, 3, 3); - case "bestdebrid-web": - return "Cookie-Import"; - case "alldebrid-api": - return maskValue(settings.allDebridToken, 3, 3); - case "alldebrid-web": - return "Browser-Login"; - case "ddownload-login": - return settings.ddownloadLogin.trim() ? maskValue(settings.ddownloadLogin.trim(), 2, 6) : "Login + Passwort"; - case "onefichier-api": - return maskValue(settings.oneFichierApiKey, 3, 3); - case "debridlink-api": { - const keys = (settings.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()); - if (keys.length > 1) return `${keys.length} API-Keys`; - return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt"; - } - case "linksnappy-login": - return (settings.linkSnappyLogin || "").trim() ? maskValue((settings.linkSnappyLogin || "").trim(), 2, 4) : "Login + Passwort"; - default: - return "Konfiguriert"; - } -} - -function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] { - if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) { - const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); - if (keys.length > 1) { - return keys.map((entry) => `${entry.label}: ${entry.masked}`); - } - } - if (kind === "megadebrid-api" || kind === "megadebrid-web") { - const accounts = getMegaDebridAccountsForMode(settings, kind === "megadebrid-web" ? "web" : "api"); - if (accounts.length > 1) { - return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`); - } - } - return [summarizeAccount(kind, settings)]; +function accountsOfKind(kind: AccountKind, accounts: readonly RendererAccount[]): RendererAccount[] { + return accounts.filter((account) => account.kind === kind); } -function getStoredAccountUsername(kind: AccountKind, settings: AppSettings): string { - switch (kind) { - case "ddownload-login": - return settings.ddownloadLogin; - case "linksnappy-login": - return settings.linkSnappyLogin; - case "realdebrid-web": - case "alldebrid-web": - return "Browser-Login"; - case "bestdebrid-web": - return "Cookies.txt"; - default: - return "API-Account"; +function summarizeAccount(kind: AccountKind, accounts: readonly RendererAccount[]): string { + const matching = accountsOfKind(kind, accounts); + if (matching.length > 1) return kind === "debridlink-api" ? `${matching.length} API-Keys` : `${matching.length} Accounts`; + return matching[0]?.maskedIdentity || "Konfiguriert"; +} + +function summarizeAccountLines(kind: AccountKind, accounts: readonly RendererAccount[], detailed: boolean): string[] { + const matching = accountsOfKind(kind, accounts); + if (matching.length > 1 && (kind !== "debridlink-api" || detailed)) { + return matching.map((entry, index) => `${kind === "debridlink-api" ? "Key" : "Account"} ${index + 1}: ${entry.maskedIdentity}`); } + return [summarizeAccount(kind, accounts)]; +} + +function getStoredAccountUsername(kind: AccountKind, accounts: readonly RendererAccount[]): string { + return accountsOfKind(kind, accounts)[0]?.identity || ""; } -export function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { +export function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, _settings: RendererSettings): AccountDialogState { const baseMega: Pick = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; - if (!kind) { - return { - mode, - kind: null, - service: null, - token: "", - login: "", - password: "", - dailyLimitGb: "", - keyDailyLimitGbById: {}, - ...baseMega - }; - } - const service = findAccountOption(kind).service; - const provider = getAccountServiceProvider(service); - const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider)); - switch (kind) { - case "realdebrid-api": - return { mode, kind, service, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "realdebrid-web": - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "megadebrid-api": - case "megadebrid-web": { - const megaMode = kind === "megadebrid-web" ? "web" : "api"; - const parsed = getMegaDebridAccountsForMode(settings, megaMode); - const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); - const loadedIds = new Set(parsed.map((a) => a.id)); - const megaDisabledIds = getMegaDebridDisabledAccountIdsForMode(settings, megaMode).filter((id) => loadedIds.has(id)); - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; - } - case "bestdebrid-api": - return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "bestdebrid-web": - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "alldebrid-api": - return { mode, kind, service, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "alldebrid-web": - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "ddownload-login": - return { mode, kind, service, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "onefichier-api": - return { mode, kind, service, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "debridlink-api": - return { - mode, - kind, - service, - token: mode === "create" ? "" : settings.debridLinkApiKeys || "", - login: "", - password: "", - dailyLimitGb, - keyDailyLimitGbById: mode === "create" ? {} : buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings), - ...baseMega - }; - case "linksnappy-login": - return { mode, kind, service, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - default: - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - } + return { + mode, + kind, + service: kind ? findAccountOption(kind).service : null, + token: "", + login: "", + password: "", + dailyLimitGb: "", + keyDailyLimitGbById: {}, + ...baseMega + }; } export function buildAccountAddFields(dialog: AccountDialogState | null): AccountDialogField[] { @@ -875,170 +738,14 @@ export function buildAccountAddFields(dialog: AccountDialogState | null): Accoun ]; } -function applyMegaDebridDialogToSettings(settings: AppSettings, dialog: AccountDialogState, mode: MegaDebridAccountMode, providerDailyLimitBytes: AppSettings["providerDailyLimitBytes"]): AppSettings { - const selectedCredentials = serializeMegaDebridAccounts(dialog.megaAccounts); - 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 selectedIds = new Set(parseMegaDebridAccounts(selectedCredentials).map((account) => account.id)); - const selectedDisabledIds = (dialog.megaDisabledIds || []).filter((id) => selectedIds.has(id)); - const apiDisabledIds = mode === "api" ? selectedDisabledIds : getMegaDebridDisabledAccountIdsForMode(settings, "api"); - const webDisabledIds = mode === "web" ? selectedDisabledIds : getMegaDebridDisabledAccountIdsForMode(settings, "web"); - return { - ...settings, - megaCredentials: mergedCredentials, - megaLogin: first?.login || "", - megaPassword: first?.password || "", - megaDebridApiCredentials: apiCredentials, - megaDebridWebCredentials: webCredentials, - megaDebridApiEnabled: mode === "api" ? selectedIds.size > 0 : settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, - megaDebridWebEnabled: mode === "web" ? selectedIds.size > 0 : settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds, - providerDailyLimitBytes - }; -} - -function clearMegaDebridModeFromSettings(settings: AppSettings, mode: MegaDebridAccountMode, providerDailyLimitBytes: AppSettings["providerDailyLimitBytes"], providerDailyUsageBytes: AppSettings["providerDailyUsageBytes"]): AppSettings { - const apiCredentials = mode === "api" ? "" : getMegaDebridCredentialsForMode(settings, "api"); - const webCredentials = mode === "web" ? "" : getMegaDebridCredentialsForMode(settings, "web"); - const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); - const first = parseMegaDebridAccounts(mergedCredentials)[0]; - const apiDisabledIds = mode === "api" ? [] : getMegaDebridDisabledAccountIdsForMode(settings, "api"); - const webDisabledIds = mode === "web" ? [] : getMegaDebridDisabledAccountIdsForMode(settings, "web"); - return { - ...settings, - megaCredentials: mergedCredentials, - megaLogin: first?.login || "", - megaPassword: first?.password || "", - megaDebridApiCredentials: apiCredentials, - megaDebridWebCredentials: webCredentials, - megaDebridApiEnabled: mode === "api" ? false : settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, - megaDebridWebEnabled: mode === "web" ? false : settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds, - providerDailyLimitBytes, - providerDailyUsageBytes - }; -} - -export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { - if (!dialog.kind) { - return settings; - } - const token = dialog.token.trim(); - const login = dialog.login.trim(); - const password = dialog.password; - const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service); - const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; - const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api" - ? Object.fromEntries( - parseDebridLinkApiKeys(dialog.token).flatMap((entry) => { - const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || ""); - return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : []; - }) - ) as Record - : { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; - const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb); - if (dailyLimitBytes && dailyLimitBytes > 0) { - nextProviderDailyLimitBytes[provider] = dailyLimitBytes; - } else { - delete nextProviderDailyLimitBytes[provider]; - } - switch (dialog.kind) { - case "realdebrid-api": - return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "realdebrid-web": - return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "megadebrid-api": { - return applyMegaDebridDialogToSettings(settings, dialog, "api", nextProviderDailyLimitBytes); - } - case "megadebrid-web": { - return applyMegaDebridDialogToSettings(settings, dialog, "web", nextProviderDailyLimitBytes); - } - case "bestdebrid-api": - return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "bestdebrid-web": - return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "alldebrid-api": - return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "alldebrid-web": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "ddownload-login": - return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "onefichier-api": - return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "debridlink-api": - return { - ...settings, - debridLinkApiKeys: token, - providerDailyLimitBytes: nextProviderDailyLimitBytes, - debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes - }; - case "linksnappy-login": - return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - default: - return settings; - } -} - -function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings { - const provider = getAccountServiceProvider(service); - const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; - const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) }; - const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; - const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }; - delete nextProviderDailyLimitBytes[provider]; - delete nextProviderDailyUsageBytes[provider]; - if (service === "debridlink") { - for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) { - delete nextDebridLinkApiKeyDailyLimitBytes[key.id]; - delete nextDebridLinkApiKeyDailyUsageBytes[key.id]; - } - } - switch (service) { - case "realdebrid": - return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "megadebrid-api": - return clearMegaDebridModeFromSettings(settings, "api", nextProviderDailyLimitBytes, nextProviderDailyUsageBytes); - case "megadebrid-web": - return clearMegaDebridModeFromSettings(settings, "web", nextProviderDailyLimitBytes, nextProviderDailyUsageBytes); - case "bestdebrid": - return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "alldebrid": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "ddownload": - return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "onefichier": - return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "debridlink": - return { - ...settings, - debridLinkApiKeys: "", - providerDailyLimitBytes: nextProviderDailyLimitBytes, - providerDailyUsageBytes: nextProviderDailyUsageBytes, - debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes, - debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes - }; - case "linksnappy": - return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - default: - return settings; - } -} - -function validateAccountDialog(dialog: AccountDialogState): string | null { +function validateAccountDialog(dialog: AccountDialogState): string | null { if (!dialog.kind) { return "Bitte zuerst einen Account-Typ auswählen."; } - const option = findAccountOption(dialog.kind); - if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") { - if (dialog.megaAccounts.length === 0) { - return `${option.title}: Mindestens einen Account hinzufügen.`; - } + const option = findAccountOption(dialog.kind); + if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") { + if (!dialog.megaNewLogin.trim()) return `${option.title}: Bitte Login oder E-Mail eintragen.`; + if (!dialog.megaNewPassword) return `${option.title}: Bitte Passwort eintragen.`; } else if (option.needsToken && !dialog.token.trim()) { return `${option.title}: Bitte Zugangstoken eintragen.`; } @@ -1071,8 +778,20 @@ function validateAccountDialog(dialog: AccountDialogState): string | null { } } } - return null; -} + return null; +} + +export function buildAccountCreateCommand(dialog: AccountDialogState): AccountCreateCommand | null { + if (!dialog.kind) return null; + const option = findAccountOption(dialog.kind); + return { + action: "create", + kind: dialog.kind, + identity: dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web" ? dialog.megaNewLogin.trim() : dialog.login.trim(), + secret: dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web" ? dialog.megaNewPassword : option.needsToken ? dialog.token : dialog.password, + dailyLimitBytes: parseAccountDailyLimitInputBytes(dialog.dailyLimitGb) || 0 + }; +} const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, @@ -1090,10 +809,10 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiCredentials: "", megaDebridWebCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", - debridLinkDisabledKeyIds: [], - archivePasswordList: "", - rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", + language: "en", realDebridUseWebLogin: false, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false, + debridLinkDisabledKeyIds: [], + archivePasswordListConfigured: false, notifyUrlConfigured: false, + rememberToken: true, configuredProviders: [], providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, collectMkvToLibrary: false, mkvLibraryDir: "", @@ -1103,7 +822,7 @@ const emptySnapshot = (): UiSnapshot => ({ maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, - notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, + notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"], @@ -1126,7 +845,8 @@ const emptySnapshot = (): UiSnapshot => ({ debridAccountStatuses: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 - }, + }, + accounts: [], session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "", @@ -1140,7 +860,7 @@ const cleanupLabels: Record = { never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" }; -const historyRetentionLabels: Record = { +const historyRetentionLabels: Record = { never: "Nie", session: "Nur aktuelle Session", permanent: "Dauerhaft" @@ -1185,7 +905,7 @@ const KNOWN_HOSTERS: { id: string; label: string }[] = [ { id: "isra", label: "Isra.cloud" } ]; -function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string { +function providerLabelWithMode(provider: DebridProvider, settings: RendererSettings): string { const base = providerLabels[provider]; if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") { return base; @@ -1792,7 +1512,7 @@ export function App(): ReactElement { const [availableUpdate, setAvailableUpdate] = useState(null); const [updateDialogOpen, setUpdateDialogOpen] = useState(false); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); - const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const [settingsDraft, setSettingsDraft] = useState(() => createSettingsDraft(emptySnapshot().settings)); const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.theme); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); @@ -1803,7 +1523,8 @@ export function App(): ReactElement { const [scheduleCountdown, setScheduleCountdown] = useState(""); const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); const updateCheckGenerationRef = useRef(0); - const settingsDirtyRef = useRef(false); + const settingsDirtyRef = useRef(false); + const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">()); const settingsDraftRevisionRef = useRef(0); useEffect(() => { @@ -1847,11 +1568,10 @@ export function App(): ReactElement { const [downloadProviderFilter, setDownloadProviderFilter] = useState("all"); const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); - const [showAllPackages, setShowAllPackages] = useState(false); - const [actionBusy, setActionBusy] = useState(false); - const [accountCheckBusy, setAccountCheckBusy] = useState(false); - const [megaCheckingIds, setMegaCheckingIds] = useState>(() => new Set()); - const actionBusyRef = useRef(false); + const [showAllPackages, setShowAllPackages] = useState(false); + const [actionBusy, setActionBusy] = useState(false); + const [accountCheckBusy, setAccountCheckBusy] = useState(false); + const actionBusyRef = useRef(false); const actionUnlockTimerRef = useRef | null>(null); const mountedRef = useRef(true); const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); @@ -2108,7 +1828,7 @@ export function App(): ReactElement { setDebridLinkHostLimitsError(""); setDebridLinkHostLimits({}); try { - const apiKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); + const apiKeys = snapshot.accounts.filter((account) => account.kind === "debridlink-api"); if (apiKeys.length === 0) { throw new Error("Debrid-Link ist nicht konfiguriert"); } @@ -2133,7 +1853,7 @@ export function App(): ReactElement { setDebridLinkHostLimitsLoading(false); } } - }, [settingsDraft.debridLinkApiKeys, showToast]); + }, [snapshot.accounts, showToast]); useEffect(() => { if (keyStatsPopup !== "debridlink") { @@ -2178,7 +1898,8 @@ export function App(): ReactElement { if (state.settings.columnOrder?.length > 0) { setColumnOrder(state.settings.columnOrder); } - setSettingsDraft(state.settings); + setSettingsDraft(createSettingsDraft(state.settings)); + writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); @@ -2234,7 +1955,7 @@ export function App(): ReactElement { setColumnOrder(next.settings.columnOrder); } if (!settingsDirtyRef.current) { - setSettingsDraft(next.settings); + setSettingsDraft(createSettingsDraft(next.settings)); } latestStateRef.current = null; } @@ -2368,9 +2089,8 @@ export function App(): ReactElement { renderLimit: AUTO_RENDER_PACKAGE_LIMIT }), [collapsedPackages, deferredDownloadSearch, downloadDisplayMode, downloadFilter, downloadProviderFilter, selectedIds, showAllPackages, snapshot.session.items, snapshot.session.packages, snapshot.session.running, snapshot.settings.hideExtractedItems, visiblePackages]); - const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); - const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin - || snapshot.settings.allDebridToken !== settingsDraft.allDebridToken; + const hasSavedAllDebridAccount = snapshot.accounts.some((account) => account.provider === "alldebrid"); + const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin; useEffect(() => { if (!snapshot.session.running) { @@ -2388,7 +2108,7 @@ export function App(): ReactElement { return; } void loadAllDebridHostInfo(true); - }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); + }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) @@ -2396,15 +2116,7 @@ export function App(): ReactElement { const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]); - const hasDdownloadAccount = useMemo(() => - Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()), - [settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); - - const hasOneFichierAccount = useMemo(() => - Boolean((settingsDraft.oneFichierApiKey || "").trim()), - [settingsDraft.oneFichierApiKey]); - - const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0); + const totalConfiguredAccounts = snapshot.accounts.length; const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]); @@ -2460,7 +2172,7 @@ export function App(): ReactElement { setProviderDropTarget(null); }, []); - const normalizedSettingsDraft: AppSettings = useMemo(() => ({ + const normalizedSettingsDraft: RendererSettingsDraft = useMemo(() => ({ ...settingsDraft, ...normalizeProviderSelectionForSettings(settingsDraft) }), [settingsDraft]); @@ -2473,14 +2185,14 @@ export function App(): ReactElement { continue; } const option = findAccountOption(kind); - let statusLabel = "Aktiviert"; - let note = ""; + let statusLabel = "Aktiviert"; + let note = ""; if (kind === "megadebrid-api") { - const megaAccountCount = getMegaDebridAccountsForMode(settingsDraft, "api").length; + const megaAccountCount = accountsOfKind(kind, snapshot.accounts).length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur API aktiv. Kein Web-Fallback."; } else if (kind === "megadebrid-web") { - const megaAccountCount = getMegaDebridAccountsForMode(settingsDraft, "web").length; + const megaAccountCount = accountsOfKind(kind, snapshot.accounts).length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur Web aktiv. Kein API-Fallback."; } else if (kind === "realdebrid-web") { @@ -2500,9 +2212,9 @@ export function App(): ReactElement { if (allDebridSettingsDirty && hasSavedAllDebridAccount) { note = "Status basiert auf den zuletzt gespeicherten AllDebrid-Daten."; } - } - if (kind === "debridlink-api") { - const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length; + } + if (kind === "debridlink-api") { + const keyCount = accountsOfKind(kind, snapshot.accounts).length; statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Aktiviert"; } const provider = getAccountServiceProvider(service); @@ -2515,26 +2227,26 @@ export function App(): ReactElement { providerDailyUsageDay: snapshot.settings.providerDailyUsageDay }, provider); let dailyLimitReached = dailyLimitBytes > 0 && dailyUsedBytes >= dailyLimitBytes; - const isDisabled = (settingsDraft.disabledProviders || []).includes(provider); - const debridLinkKeys = kind === "debridlink-api" - ? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => { - const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id); - const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id); - const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ + const isDisabled = (settingsDraft.disabledProviders || []).includes(provider); + const debridLinkKeys = kind === "debridlink-api" + ? accountsOfKind(kind, snapshot.accounts).map((account, index) => { + const keyDailyUsedBytes = account.dailyUsageBytes; + const keyDailyLimitBytes = account.dailyLimitBytes; + const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes, providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, providerDailyUsageDay: snapshot.settings.providerDailyUsageDay - }, key.id); - return { - id: key.id, - label: key.label, - token: key.token, - masked: key.masked, - disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id), - dailyUsedBytes: keyDailyUsedBytes, - totalUsedBytes: getDebridLinkApiKeyTotalUsageBytes(snapshot.settings, key.id), + }, account.accountId); + return { + id: account.accountId, + label: `Key ${index + 1}`, + token: "", + masked: account.maskedIdentity, + disabled: !account.enabled, + dailyUsedBytes: keyDailyUsedBytes, + totalUsedBytes: account.totalUsageBytes, dailyLimitBytes: keyDailyLimitBytes, dailyRemainingBytes: keyDailyRemainingBytes, dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes @@ -2571,8 +2283,8 @@ export function App(): ReactElement { serviceLabel: option.serviceLabel, modeLabel: option.modeLabel, statusLabel: isDisabled ? "Deaktiviert" : statusLabel, - summary: summarizeAccount(kind, settingsDraft), - summaryLines: summarizeAccountLines(kind, settingsDraft), + summary: summarizeAccount(kind, snapshot.accounts), + summaryLines: summarizeAccountLines(kind, snapshot.accounts, settingsDraft.accountListShowDetailedDebridLinkKeys), note, disabled: isDisabled, dailyUsedBytes, @@ -2584,7 +2296,7 @@ export function App(): ReactElement { }); } return entries; - }, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); + }, [settingsDraft, snapshot.accounts, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); @@ -2592,34 +2304,31 @@ export function App(): ReactElement { const rows: AccountTableRow[] = []; for (const entry of configuredAccounts) { if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { - const mode = entry.kind === "megadebrid-web" ? "web" : "api"; - const accounts = getMegaDebridAccountsForMode(settingsDraft, mode); - const disabledAccountIds = getMegaDebridDisabledAccountIdsForMode(settingsDraft, mode); - for (const acc of accounts) { - const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id); - const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id); - rows.push({ - rowKey: `mega-${entry.kind}-${acc.id}`, + const accounts = accountsOfKind(entry.kind, snapshot.accounts); + for (const acc of accounts) { + const used = acc.dailyUsageBytes; + const limit = acc.dailyLimitBytes; + rows.push({ + rowKey: `mega-${entry.kind}-${acc.accountId}`, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, - username: acc.login, + username: acc.identity, credentialLabel: "••••••", - accountId: acc.id, - checkable: true, - disabled: disabledAccountIds.includes(acc.id), + accountId: acc.accountId, + checkable: true, + disabled: !acc.enabled, dailyUsedBytes: used, - dailyLimitBytes: limit, - dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, - totalUsedBytes: getMegaDebridAccountTotalUsageBytes(snapshot.settings, acc.id), + dailyLimitBytes: limit, + dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, + totalUsedBytes: acc.totalUsageBytes, toggleKind: "mega", - megaLogin: acc.login, editTarget: { type: "mega", - rowKey: `mega-${entry.kind}-${acc.id}`, + rowKey: `mega-${entry.kind}-${acc.accountId}`, kind: entry.kind, service: entry.service as "megadebrid-api" | "megadebrid-web", - accountId: acc.id + accountId: acc.accountId } }); } @@ -2656,7 +2365,7 @@ export function App(): ReactElement { entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, - username: getStoredAccountUsername(entry.kind, settingsDraft), + username: getStoredAccountUsername(entry.kind, snapshot.accounts), credentialLabel: getAccountCredentialLabel(entry.kind), accountId: null, checkable: false, @@ -2677,7 +2386,7 @@ export function App(): ReactElement { } } return rows; - }, [configuredAccounts, settingsDraft, snapshot.settings]); + }, [configuredAccounts, snapshot.accounts]); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); @@ -2865,8 +2574,9 @@ export function App(): ReactElement { }); }; - const applyPersistedSettings = (result: AppSettings): void => { - setSettingsDraft(result); + const applyPersistedSettings = (result: RendererSettings): void => { + setSettingsDraft(createSettingsDraft(result)); + writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); @@ -2875,7 +2585,7 @@ export function App(): ReactElement { applyTheme(result.theme); }; - const syncLiveProviderUsageSettings = (result: AppSettings): void => { + const syncLiveProviderUsageSettings = (result: RendererSettings): void => { setSnapshot((prev) => ({ ...prev, settings: result })); if (!settingsDirtyRef.current) { applyPersistedSettings(result); @@ -2894,14 +2604,17 @@ export function App(): ReactElement { })); }; - const persistSpecificSettings = async (nextDraft: AppSettings): Promise => { + const persistSpecificSettings = async (nextDraft: RendererSettingsDraft): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; const draftAtStart = settingsDraft; const normalizedDraft = { ...nextDraft, ...normalizeProviderSelectionForSettings(nextDraft) }; - const result = await window.rd.updateSettings(normalizedDraft); + const update: RendererSettingsUpdate = { ...normalizedDraft }; + if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; + if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; + const result = await window.rd.updateSettings(update); if (settingsDraftRevisionRef.current === revisionAtStart) { applyPersistedSettings(result); } else { @@ -2954,24 +2667,6 @@ export function App(): ReactElement { } }, [showToast]); - const runMegaAccountCheck = useCallback(async (login: string, password: string): Promise => { - const trimmedLogin = login.trim(); - const trimmedPassword = password.trim(); - if (!trimmedLogin || !trimmedPassword) return; - const accId = getMegaDebridAccountId(trimmedLogin); - setMegaCheckingIds((prev) => { const next = new Set(prev); next.add(accId); return next; }); - try { - const status = await window.rd.checkMegaDebridAccount(trimmedLogin, trimmedPassword); - if (status) { - showToast(status.valid ? `Account geprüft — ${status.message}` : `Account ungültig — ${status.message}`, 3200); - } - } catch (error) { - showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3200); - } finally { - setMegaCheckingIds((prev) => { const next = new Set(prev); next.delete(accId); return next; }); - } - }, [showToast]); - const openCreateAccountDialog = (): void => { setAccountDialogSearch(""); setAccountDialogModeFilter("all"); @@ -2980,7 +2675,7 @@ export function App(): ReactElement { const openEditAccountDialog = (row: AccountTableRow): void => { try { - setAccountEditDialog(createAccountEditState(row.editTarget, settingsDraft)); + setAccountEditDialog(createAccountEditState(row.editTarget, snapshot.accounts)); } catch (error) { showToast(String(error), 3200); } @@ -3023,22 +2718,15 @@ export function App(): ReactElement { return; } const editSnapshot = accountEditDialog; - const validationError = validateAccountEdit(editSnapshot, settingsDraft); + const validationError = validateAccountEdit(editSnapshot, snapshot.accounts); if (validationError) { showToast(validationError, 2800); return; } await performQuickAction(async () => { - const nextDraft = applyAccountEdit(settingsDraft, editSnapshot); - if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { - const statuses = await window.rd.checkDebridAccounts(buildAccountEditCheckSettings(nextDraft, editSnapshot), true, getAccountEditExpectedStatusId(editSnapshot) || undefined); - const statusError = validateAccountEditStatuses(editSnapshot, statuses); - if (statusError) { - showToast(`Prüfung fehlgeschlagen: ${statusError}`, 4200); - return; - } - } - await persistSpecificSettings(nextDraft); + const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot)); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); closeAccountEditDialog(); if (quickAction) { await runAccountQuickAction(quickAction); @@ -3054,51 +2742,10 @@ export function App(): ReactElement { if (!accountDialog) { return; } - let dialogSnapshot = accountDialog; - let newIdentityId: string | null = null; - let newMegaCredentials: { login: string; password: string } | null = null; - let newDebridLinkToken = ""; - if (dialogSnapshot.mode === "create" && (dialogSnapshot.kind === "megadebrid-api" || dialogSnapshot.kind === "megadebrid-web")) { - const login = dialogSnapshot.megaNewLogin.trim(); - const password = dialogSnapshot.megaNewPassword; - if (!login || !password) { - showToast("Mega-Debrid: Bitte Login und Passwort eintragen.", 2800); - return; - } - if (dialogSnapshot.megaAccounts.some((account) => account.login.trim().toLowerCase() === login.toLowerCase())) { - showToast("Dieser Mega-Debrid-Account ist bereits vorhanden.", 2800); - return; - } - newIdentityId = getMegaDebridAccountId(login); - newMegaCredentials = { login, password }; - dialogSnapshot = { - ...dialogSnapshot, - megaAccounts: [...dialogSnapshot.megaAccounts, newMegaCredentials], - megaNewLogin: "", - megaNewPassword: "" - }; - } - if (dialogSnapshot.mode === "create" && dialogSnapshot.kind === "debridlink-api") { - const newKeys = parseDebridLinkApiKeys(dialogSnapshot.token); - if (newKeys.length !== 1) { + const dialogSnapshot = accountDialog; + if (dialogSnapshot.kind === "debridlink-api" && parseDebridLinkApiKeys(dialogSnapshot.token).length !== 1) { showToast("Debrid-Link: Bitte genau einen API-Key eintragen.", 2800); return; - } - const existingKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); - if (existingKeys.some((key) => key.id === newKeys[0].id)) { - showToast("Dieser Debrid-Link-Key ist bereits vorhanden.", 2800); - return; - } - newIdentityId = newKeys[0].id; - newDebridLinkToken = newKeys[0].token; - dialogSnapshot = { - ...dialogSnapshot, - token: [...existingKeys.map((key) => key.token), newKeys[0].token].join("\n"), - keyDailyLimitGbById: { - ...buildDebridLinkKeyLimitInputs(settingsDraft.debridLinkApiKeys || "", undefined, settingsDraft), - [newKeys[0].id]: accountDialog.dailyLimitGb - } - }; } const validationError = validateAccountDialog(dialogSnapshot); if (validationError) { @@ -3107,42 +2754,11 @@ export function App(): ReactElement { } const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { - const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot); - const targetedOption = selectedOption && newIdentityId ? { - service: selectedOption.service === "megadebrid-web" ? "megadebrid-api" : selectedOption.service - } as Pick : null; - const targetedCheck = targetedOption && newIdentityId ? buildTargetedAccountCheck(targetedOption, newIdentityId) : null; - if (targetedCheck) { - const checkSettings = targetedCheck.service === "megadebrid-api" && newMegaCredentials - ? { - ...nextDraft, - megaCredentials: serializeMegaDebridAccounts([newMegaCredentials]), - megaLogin: newMegaCredentials.login, - megaPassword: newMegaCredentials.password, - megaDebridApiCredentials: selectedOption?.service === "megadebrid-web" ? "" : serializeMegaDebridAccounts([newMegaCredentials]), - megaDebridWebCredentials: selectedOption?.service === "megadebrid-web" ? serializeMegaDebridAccounts([newMegaCredentials]) : "", - megaDebridApiEnabled: selectedOption?.service !== "megadebrid-web", - megaDebridWebEnabled: selectedOption?.service === "megadebrid-web", - debridLinkApiKeys: "" - } - : { - ...nextDraft, - megaCredentials: "", - megaLogin: "", - megaPassword: "", - megaDebridApiCredentials: "", - megaDebridWebCredentials: "", - megaDebridApiEnabled: false, - megaDebridWebEnabled: false, - debridLinkApiKeys: newDebridLinkToken - }; - const statuses = await window.rd.checkDebridAccounts(checkSettings, true, targetedCheck.expectedStatusId); - const status = statuses.length === 1 && statuses[0].accountId === targetedCheck.expectedStatusId ? statuses[0] : null; - if (!status || !status.valid) { - throw new Error(status?.message || "Die Prüfung hat nicht genau den neuen Account bestätigt."); - } - } - await persistSpecificSettings(nextDraft); + const command = buildAccountCreateCommand(dialogSnapshot); + if (!command) throw new Error("Account-Payload ist ungültig"); + const result = await window.rd.createAccount(command); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); closeAccountDialog(); if (quickAction) { await runAccountQuickAction(quickAction); @@ -3155,28 +2771,6 @@ export function App(): ReactElement { }); }; - const onRemoveAccount = async (entry: ConfiguredAccountEntry): Promise => { - const confirmed = await askConfirmPrompt({ - title: `${entry.serviceLabel} entfernen`, - message: `Soll ${entry.serviceLabel} wirklich aus der Accountliste entfernt werden?`, - confirmLabel: "Entfernen", - danger: true - }); - if (!confirmed) { - return; - } - await performQuickAction(async () => { - const nextDraft = clearAccountServiceFromSettings(settingsDraft, entry.service); - await persistSpecificSettings(nextDraft); - if (entry.service === "alldebrid") { - setAllDebridHostInfo(null); - } - showToast(`${entry.serviceLabel} entfernt`, 2200); - }, (error) => { - showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200); - }); - }; - const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise => { await performQuickAction(async () => { const result = await window.rd.resetProviderDailyUsage(getAccountServiceProvider(entry.service)); @@ -3203,7 +2797,7 @@ export function App(): ReactElement { const nextDisabledIds = key.disabled ? currentDisabledIds.filter((existingId) => existingId !== key.id) : [...currentDisabledIds, key.id]; - const nextDraft: AppSettings = { + const nextDraft: RendererSettingsDraft = { ...settingsDraft, debridLinkDisabledKeyIds: nextDisabledIds }; @@ -3231,14 +2825,13 @@ export function App(): ReactElement { }); }; - const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", login: string, currentlyDisabled: boolean): Promise => { - const accId = getMegaDebridAccountId(login.trim()); + const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, currentlyDisabled: boolean): Promise => { await performQuickAction(async () => { const mode = kind === "megadebrid-web" ? "web" : "api"; - const current = getMegaDebridDisabledAccountIdsForMode(settingsDraft, mode); - const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId]; - const apiDisabledIds = mode === "api" ? next : getMegaDebridDisabledAccountIdsForMode(settingsDraft, "api"); - const webDisabledIds = mode === "web" ? next : getMegaDebridDisabledAccountIdsForMode(settingsDraft, "web"); + const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds; + const next = currentlyDisabled ? current.filter((id) => id !== accountId) : [...current, accountId]; + const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds; + const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds; await persistSpecificSettings({ ...settingsDraft, megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], @@ -3251,12 +2844,13 @@ export function App(): ReactElement { }); }; - const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { + const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); - if (!confirmed) return; - await performQuickAction(async () => { - const remaining = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").filter((k) => k.id !== key.id); - await persistSpecificSettings({ ...settingsDraft, debridLinkApiKeys: remaining.map((k) => k.token).join("\n") }); + if (!confirmed) return; + await performQuickAction(async () => { + const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id }); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); showToast("Key entfernt", 2000); }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; @@ -3268,7 +2862,7 @@ export function App(): ReactElement { const nextDisabledProviders = current.includes(provider) ? current.filter((existing) => existing !== provider) : [...current, provider]; - const nextDraft: AppSettings = { + const nextDraft: RendererSettingsDraft = { ...settingsDraft, disabledProviders: nextDisabledProviders }; @@ -3286,8 +2880,8 @@ export function App(): ReactElement { const toggleAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if (row.toggleKind === "mega" && row.megaLogin) { - void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.megaLogin, row.disabled); + if (row.toggleKind === "mega" && row.accountId) { + void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled); } else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); } else { @@ -3305,7 +2899,7 @@ export function App(): ReactElement { accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string), enabled ); - const nextDraft: AppSettings = { + const nextDraft: RendererSettingsDraft = { ...settingsDraft, ...nextEnabledState, megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), @@ -3332,7 +2926,9 @@ export function App(): ReactElement { return; } await performQuickAction(async () => { - await persistSpecificSettings(removeAccountTarget(settingsDraft, row.editTarget)); + const result = await window.rd.deleteAccount(buildAccountDeleteCommand(row.editTarget)); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); if (row.entry.service === "alldebrid") { setAllDebridHostInfo(null); } @@ -3345,15 +2941,7 @@ export function App(): ReactElement { const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if (row.toggleKind === "mega" && row.megaLogin) { - const account = getMegaDebridAccountsForMode(settingsDraft, row.entry.kind === "megadebrid-web" ? "web" : "api") - .find((entry) => entry.id === row.accountId); - if (account) { - void runMegaAccountCheck(account.login, account.password); - } - return; - } - if (row.toggleKind === "dl") { + if (row.toggleKind === "mega" || row.toggleKind === "dl") { void checkAllAccounts(); return; } @@ -3374,9 +2962,12 @@ export function App(): ReactElement { }); }; - const persistDraftSettings = async (): Promise => { - const revisionAtStart = settingsDraftRevisionRef.current; - const result = await window.rd.updateSettings(normalizedSettingsDraft); + const persistDraftSettings = async (): Promise => { + const revisionAtStart = settingsDraftRevisionRef.current; + const update: RendererSettingsUpdate = { ...normalizedSettingsDraft }; + if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; + if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; + const result = await window.rd.updateSettings(update); if (settingsDraftRevisionRef.current === revisionAtStart) { applyPersistedSettings(result); } @@ -3839,7 +3430,7 @@ export function App(): ReactElement { input.click(); }; - const setBool = (key: keyof AppSettings, value: boolean): void => { + const setBool = (key: keyof RendererSettingsDraft, value: boolean): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -3847,7 +3438,8 @@ export function App(): ReactElement { setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setText = (key: keyof AppSettings, value: string): void => { + const setText = (key: keyof RendererSettingsDraft, value: string): void => { + if (key === "archivePasswordList" || key === "notifyUrl") writeOnlySettingsDirtyRef.current.add(key); settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -3855,7 +3447,7 @@ export function App(): ReactElement { setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setNum = (key: keyof AppSettings, value: number): void => { + const setNum = (key: keyof RendererSettingsDraft, value: number): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -5230,18 +4822,15 @@ export function App(): ReactElement { : null; const accountSources = useMemo(() => accountRows.map((row) => { const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; - const checking = Boolean(row.accountId && megaCheckingIds.has(row.accountId)); const state: AccountRowSource["status"]["state"] = row.disabled ? "disabled" - : checking - ? "checking" - : !checkedStatus - ? "unchecked" - : checkedStatus && !checkedStatus.valid - ? "invalid" - : checkedStatus && !checkedStatus.isPremium - ? "free" - : "premium"; + : !checkedStatus + ? "unchecked" + : checkedStatus && !checkedStatus.valid + ? "invalid" + : checkedStatus && !checkedStatus.isPremium + ? "free" + : "premium"; return { identityId: row.accountId || row.rowKey, service: row.entry.service, @@ -5261,7 +4850,7 @@ export function App(): ReactElement { credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected", canCheck: row.checkable }; - }), [accountRows, megaCheckingIds, snapshot.settings.debridAccountStatuses]); + }), [accountRows, snapshot.settings.debridAccountStatuses]); const selectedAccountViewId = useMemo(() => { const selectedRow = selectedAccountRowKey ? accountRows.find((row) => row.rowKey === selectedAccountRowKey) : null; return selectedRow ? accountRowViewId(selectedRow) : null; @@ -5419,10 +5008,10 @@ export function App(): ReactElement { return; } if (typeof value === "boolean") { - setBool(fieldId as keyof AppSettings, value); + setBool(fieldId as keyof RendererSettingsDraft, value); return; } - const numericLimits: Partial> = { + const numericLimits: Partial> = { maxParallel: [1, 50, 1], retryLimit: [0, 99, 0], historyMaxEntries: [50, 100000, 500], @@ -5430,12 +5019,12 @@ export function App(): ReactElement { maxParallelExtract: [1, 8, 2], reconnectWaitSeconds: [10, 600, 45] }; - const bounds = numericLimits[fieldId as keyof AppSettings]; + const bounds = numericLimits[fieldId as keyof RendererSettingsDraft]; if (bounds) { const parsed = Number(value); - setNum(fieldId as keyof AppSettings, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2]))); + setNum(fieldId as keyof RendererSettingsDraft, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2]))); } else { - setText(fieldId as keyof AppSettings, String(value)); + setText(fieldId as keyof RendererSettingsDraft, String(value)); } }, onCommit: (fieldId, value) => { @@ -5563,7 +5152,7 @@ export function App(): ReactElement { ] : []; const checkAccountEditDialog = (): void => { if (!accountEditDialog) return; - const validationError = validateAccountEdit(accountEditDialog, settingsDraft); + const validationError = validateAccountEdit(accountEditDialog, snapshot.accounts); if (validationError) { showToast(validationError, 2800); return; @@ -5571,10 +5160,14 @@ export function App(): ReactElement { const editSnapshot = accountEditDialog; void performQuickAction(async () => { if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { - const checkSettings = buildAccountEditCheckSettings(applyAccountEdit(settingsDraft, editSnapshot), editSnapshot); - const statuses = await window.rd.checkDebridAccounts(checkSettings, true, getAccountEditExpectedStatusId(editSnapshot) || undefined); - const statusError = validateAccountEditStatuses(editSnapshot, statuses); - if (statusError) throw new Error(statusError); + const secret = editSnapshot.target.type === "mega" ? editSnapshot.password : editSnapshot.token; + const status = await window.rd.checkAccountCredentials({ + kind: editSnapshot.target.kind, + accountId: editSnapshot.target.type === "mega" ? editSnapshot.target.accountId : editSnapshot.target.keyId, + identity: secret ? editSnapshot.login : undefined, + secret: secret || undefined + }); + if (!status.valid) throw new Error(status.message || "Zugangsdaten ungültig"); showToast("Account erfolgreich geprüft", 2200); return; } diff --git a/src/renderer/account-edit.ts b/src/renderer/account-edit.ts index 6e61548..fbdb30d 100644 --- a/src/renderer/account-edit.ts +++ b/src/renderer/account-edit.ts @@ -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; - -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; + +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): 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(record: Record, ...keys: string[]): Record { - 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, oldId: string, newId: string, value: string, originalBytes: number): Record { - 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, 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 }): 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 }): 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 = (record: Record): Record => 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 }): 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 }); - } - if (state.target.type === "mega") { - return applyMegaEdit(settings, state as AccountEditState & { target: Extract }); - } - return applyDebridLinkEdit(settings, state as AccountEditState & { target: Extract }); -} - -function clearSingleAccount(settings: AppSettings, target: Extract): 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 = (record: Record): Record => 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) }; +} diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 1d099e8..0042936 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -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 { +): Pick { 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>; @@ -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 }, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index fc503ff..2457feb 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index d8f2748..5093a22 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -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; installUpdate: () => Promise; openExternal: (url: string) => Promise; - updateSettings: (settings: Partial) => Promise; - resetProviderDailyUsage: (provider: DebridProvider) => Promise; - resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; + updateSettings: (settings: RendererSettingsUpdate) => Promise; + resetProviderDailyUsage: (provider: DebridProvider) => Promise; + resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; + createAccount: (command: AccountCreateCommand) => Promise; + replaceAccount: (command: AccountReplaceCommand) => Promise; + updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise; + deleteAccount: (command: AccountDeleteCommand) => Promise; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; getStartConflicts: () => Promise; @@ -86,10 +97,10 @@ export interface ElectronApi { openAllDebridLogin: () => Promise; importBestDebridCookies: () => Promise; getAllDebridHostInfo: () => Promise; - getDebridLinkHostLimits: () => Promise; - checkDebridAccounts: (settings?: AppSettings, persistValidOverride?: boolean, expectedAccountId?: string) => Promise; - checkMegaDebridAccount: (login: string, password: string) => Promise; - retryExtraction: (packageId: string) => Promise; + getDebridLinkHostLimits: () => Promise; + checkDebridAccounts: () => Promise; + checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise; + retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; getHistory: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 2b1c355..aba5ea9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -168,8 +168,177 @@ export interface AppSettings { megaDebridAccountTotalUsageBytes: Record; debridAccountStatuses: Record; 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; + providerDailyLimitBytes: Partial>; + providerDailyUsageBytes: Partial>; + providerTotalUsageBytes: Partial>; + debridLinkApiKeyDailyLimitBytes: Record; + debridLinkApiKeyDailyUsageBytes: Record; + debridLinkApiKeyTotalUsageBytes: Record; + megaDebridDisabledAccountIds: string[]; + megaDebridApiDisabledAccountIds: string[]; + megaDebridWebDisabledAccountIds: string[]; + megaDebridAccountDailyLimitBytes: Record; + megaDebridAccountDailyUsageBytes: Record; + megaDebridAccountTotalUsageBytes: Record; + debridAccountStatuses: Record; + providerDailyUsageDay: string; + scheduledStartEpochMs: number; +} + +export type RendererSettingsUpdate = Partial & { + 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; diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts new file mode 100644 index 0000000..c68102c --- /dev/null +++ b/tests/account-commands.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; +import { applyAccountCommand, validateAccountCommand } from "../src/main/account-commands"; +import { defaultSettings } from "../src/main/constants"; +import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import type { AppSettings, RendererAccountKind } from "../src/shared/types"; + +const ORIGINAL_SECRET = "fixture-original-secret-4qV8"; +const REPLACEMENT_SECRET = "fixture-replacement-secret-6nC2"; +const GIB = 1024 * 1024 * 1024; + +const SECRET_RETAIN_CASES: Array<{ + kind: RendererAccountKind; + identity: string; + secret: string; + retained: (settings: AppSettings) => boolean; +}> = [ + { kind: "realdebrid-api", identity: "", secret: "fixture-retain-rd-1aC3", retained: (settings) => settings.token === "fixture-retain-rd-1aC3" }, + { kind: "megadebrid-api", identity: "retain-mega-api@example.test", secret: "fixture-retain-mega-api-2bD4", retained: (settings) => settings.megaDebridApiCredentials === "retain-mega-api@example.test:fixture-retain-mega-api-2bD4" }, + { kind: "megadebrid-web", identity: "retain-mega-web@example.test", secret: "fixture-retain-mega-web-3cE5", retained: (settings) => settings.megaDebridWebCredentials === "retain-mega-web@example.test:fixture-retain-mega-web-3cE5" }, + { kind: "bestdebrid-api", identity: "", secret: "fixture-retain-best-4dF6", retained: (settings) => settings.bestToken === "fixture-retain-best-4dF6" }, + { kind: "alldebrid-api", identity: "", secret: "fixture-retain-all-5eG7", retained: (settings) => settings.allDebridToken === "fixture-retain-all-5eG7" }, + { kind: "ddownload-login", identity: "retain-dd@example.test", secret: "fixture-retain-dd-6fH8", retained: (settings) => settings.ddownloadPassword === "fixture-retain-dd-6fH8" }, + { kind: "onefichier-api", identity: "", secret: "fixture-retain-one-7gJ9", retained: (settings) => settings.oneFichierApiKey === "fixture-retain-one-7gJ9" }, + { kind: "debridlink-api", identity: "", secret: "fixture-retain-dl-8hK1", retained: (settings) => settings.debridLinkApiKeys === "fixture-retain-dl-8hK1" }, + { kind: "linksnappy-login", identity: "retain-ls@example.test", secret: "fixture-retain-ls-9jL2", retained: (settings) => settings.linkSnappyPassword === "fixture-retain-ls-9jL2" } +]; + +const ACCOUNT_KINDS: 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" +]; + +describe("write-only account commands", () => { + it.each([ + ["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"], + ["realdebrid-web", "", "", "realDebridUseWebLogin"], + ["bestdebrid-api", "", "fixture-best-provider-secret-2gB5", "bestToken"], + ["bestdebrid-web", "", "", "bestDebridUseWebLogin"], + ["alldebrid-api", "", "fixture-ad-provider-secret-3hC6", "allDebridToken"], + ["alldebrid-web", "", "", "allDebridUseWebLogin"], + ["ddownload-login", "dd-safe@example.test", "fixture-dd-provider-secret-4jD7", "ddownloadPassword"], + ["onefichier-api", "", "fixture-one-provider-secret-5kE8", "oneFichierApiKey"], + ["linksnappy-login", "ls-safe@example.test", "fixture-ls-provider-secret-6mF9", "linkSnappyPassword"] + ] as const)("preserves create and delete behavior for %s", (kind, identity, secret, configuredKey) => { + const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ + action: "create", + kind, + identity, + secret, + dailyLimitBytes: 4_294_967_296 + })); + + expect(created.settings[configuredKey]).toBe(secret || true); + expect(JSON.stringify(created.response)).not.toContain(secret || "fixture-never-present"); + + const deleted = applyAccountCommand(created.settings, validateAccountCommand({ + action: "delete", + kind, + accountId: created.response.accountId + })); + + expect(deleted.settings[configuredKey]).toBe(secret ? "" : false); + }); + + it("creates an account without returning submitted secrets", () => { + const command = validateAccountCommand({ + action: "create", + kind: "megadebrid-api", + identity: "new-account@example.test", + secret: ORIGINAL_SECRET, + dailyLimitBytes: 12_884_901_888 + }); + const result = applyAccountCommand(defaultSettings(), command); + + expect(result.settings.megaDebridApiCredentials).toContain(ORIGINAL_SECRET); + expect(JSON.stringify(result.response)).not.toContain(ORIGINAL_SECRET); + expect(result.response.accountId).toBe(getMegaDebridAccountId("new-account@example.test")); + }); + + it("adds a Web Mega-Debrid account without copying it into the API pool or changing preferApi", () => { + const result = applyAccountCommand({ + ...defaultSettings(), + megaCredentials: "api@example.test:fixture-api-secret-1aM2", + megaDebridApiCredentials: "api@example.test:fixture-api-secret-1aM2", + megaDebridWebCredentials: "", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: false + }, validateAccountCommand({ + action: "create", + kind: "megadebrid-web", + identity: "web@example.test", + secret: "fixture-web-secret-3bN4", + dailyLimitBytes: 0 + })); + + expect(result.settings.megaDebridApiCredentials).toBe("api@example.test:fixture-api-secret-1aM2"); + expect(result.settings.megaDebridWebCredentials).toBe("web@example.test:fixture-web-secret-3bN4"); + expect(result.settings.megaDebridApiEnabled).toBe(true); + expect(result.settings.megaDebridWebEnabled).toBe(true); + expect(result.settings.megaDebridPreferApi).toBe(false); + expect(JSON.stringify(result.response)).not.toContain("fixture-web-secret-3bN4"); + }); + + it("retains a stored secret when replace receives a blank secret", () => { + const identity = "existing-account@example.test"; + const accountId = getMegaDebridAccountId(identity); + const settings = { + ...defaultSettings(), + megaCredentials: `${identity}:${ORIGINAL_SECRET}`, + megaDebridApiCredentials: `${identity}:${ORIGINAL_SECRET}`, + megaDebridApiEnabled: true + }; + const command = validateAccountCommand({ + action: "replace", + kind: "megadebrid-api", + accountId, + identity: "renamed-account@example.test", + secret: "", + dailyLimitBytes: 8_589_934_592 + }); + const result = applyAccountCommand(settings, command); + + expect(result.settings.megaDebridApiCredentials).toBe(`renamed-account@example.test:${ORIGINAL_SECRET}`); + expect(JSON.stringify(result.response)).not.toContain(ORIGINAL_SECRET); + }); + + it.each(SECRET_RETAIN_CASES)("retains the stored $kind secret when replace receives a blank secret", ({ kind, identity, secret, retained }) => { + const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ + action: "create", + kind, + identity, + secret, + dailyLimitBytes: 1 + })); + const replaced = applyAccountCommand(created.settings, validateAccountCommand({ + action: "replace", + kind, + accountId: created.response.accountId, + identity, + secret: "", + dailyLimitBytes: 1 + })); + + expect(retained(replaced.settings)).toBe(true); + expect(JSON.stringify(replaced.response)).not.toContain(secret); + }); + + it("replaces a Mega-Debrid account while preserving sibling accounts and mode-specific state", () => { + const firstId = getMegaDebridAccountId("first@example.test"); + const oldId = getMegaDebridAccountId("second@example.test"); + const newId = getMegaDebridAccountId("renamed@example.test"); + const webId = getMegaDebridAccountId("web@example.test"); + const settings = { + ...defaultSettings(), + megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret\nweb@example.test:web-secret", + megaDebridApiCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret", + megaDebridWebCredentials: "web@example.test:web-secret", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridPreferApi: false, + megaDebridDisabledAccountIds: [oldId, firstId, webId], + megaDebridApiDisabledAccountIds: [oldId, firstId], + megaDebridWebDisabledAccountIds: [webId], + megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB }, + megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB }, + megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB }, + debridAccountStatuses: { + [oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***st", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }, + [firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***st", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } + } + }; + const result = applyAccountCommand(settings, validateAccountCommand({ + action: "replace", + kind: "megadebrid-api", + accountId: oldId, + identity: "renamed@example.test", + secret: "renamed-secret", + dailyLimitBytes: Math.floor(25.5 * GIB) + })); + + expect(result.settings.megaDebridApiCredentials).toBe("first@example.test:first-secret\nrenamed@example.test:renamed-secret"); + expect(result.settings.megaDebridWebCredentials).toBe("web@example.test:web-secret"); + expect(result.settings.megaDebridPreferApi).toBe(false); + expect(result.settings.megaDebridDisabledAccountIds).toEqual([firstId, newId, webId]); + expect(result.settings.megaDebridApiDisabledAccountIds).toEqual([firstId, newId]); + expect(result.settings.megaDebridWebDisabledAccountIds).toEqual([webId]); + expect(result.settings.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) }); + expect(result.settings.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB }); + expect(result.settings.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB }); + expect(result.settings.debridAccountStatuses).toEqual({ + [firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***st", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } + }); + expect(JSON.stringify(result.response)).not.toContain("renamed-secret"); + }); + + it("replaces only the selected Debrid-Link key and migrates its own metadata", () => { + const keyA = "fixture-dl-key-a-1aB2"; + const keyB = "fixture-dl-key-b-3cD4"; + const keyC = "fixture-dl-key-c-5eF6"; + const newKey = "fixture-dl-key-b-new-7gH8"; + const idA = getDebridLinkApiKeyId(keyA); + const idB = getDebridLinkApiKeyId(keyB); + const idNew = getDebridLinkApiKeyId(newKey); + const result = applyAccountCommand({ + ...defaultSettings(), + debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`, + debridLinkDisabledKeyIds: [idB, idA], + debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB }, + debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB }, + debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB } + }, validateAccountCommand({ + action: "replace", + kind: "debridlink-api", + accountId: idB, + secret: newKey, + dailyLimitBytes: 12 * GIB + })); + + expect(result.settings.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`); + expect(result.settings.debridLinkDisabledKeyIds).toEqual([idA, idNew]); + expect(result.settings.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB }); + expect(result.settings.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB }); + expect(result.settings.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB }); + expect(JSON.stringify(result.response)).not.toContain(newKey); + }); + + it("keeps a matching Web identity disabled when its API identity is deleted", () => { + const identity = "shared-mode@example.test"; + const accountId = getMegaDebridAccountId(identity); + const settings = { + ...defaultSettings(), + megaCredentials: `${identity}:fixture-api-mode-secret-1mN3`, + megaDebridApiCredentials: `${identity}:fixture-api-mode-secret-1mN3`, + megaDebridWebCredentials: `${identity}:fixture-web-mode-secret-2nP4`, + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridDisabledAccountIds: [accountId], + megaDebridApiDisabledAccountIds: [accountId], + megaDebridWebDisabledAccountIds: [accountId] + }; + + const deleted = applyAccountCommand(settings, validateAccountCommand({ + action: "delete", + kind: "megadebrid-api", + accountId + })); + + expect(deleted.settings.megaDebridApiCredentials).toBe(""); + expect(deleted.settings.megaDebridWebCredentials).toBe(`${identity}:fixture-web-mode-secret-2nP4`); + expect(deleted.settings.megaDebridApiDisabledAccountIds).toEqual([]); + expect(deleted.settings.megaDebridWebDisabledAccountIds).toEqual([accountId]); + expect(deleted.settings.megaDebridDisabledAccountIds).toEqual([accountId]); + }); + + it("updates only the selected secret and deletes only the selected account", () => { + const firstIdentity = "first-account@example.test"; + const secondIdentity = "second-account@example.test"; + const firstId = getMegaDebridAccountId(firstIdentity); + const secondId = getMegaDebridAccountId(secondIdentity); + const settings = { + ...defaultSettings(), + megaCredentials: `${firstIdentity}:${ORIGINAL_SECRET}\n${secondIdentity}:fixture-sibling-secret-3wH7`, + megaDebridApiCredentials: `${firstIdentity}:${ORIGINAL_SECRET}\n${secondIdentity}:fixture-sibling-secret-3wH7`, + megaDebridApiEnabled: true + }; + const updated = applyAccountCommand(settings, validateAccountCommand({ + action: "update-secret", + kind: "megadebrid-api", + accountId: firstId, + secret: REPLACEMENT_SECRET + })); + const deleted = applyAccountCommand(updated.settings, validateAccountCommand({ + action: "delete", + kind: "megadebrid-api", + accountId: firstId + })); + + expect(updated.settings.megaDebridApiCredentials).toContain(`${firstIdentity}:${REPLACEMENT_SECRET}`); + expect(deleted.settings.megaDebridApiCredentials).toBe(`${secondIdentity}:fixture-sibling-secret-3wH7`); + expect(deleted.response.accountId).toBe(secondId); + expect(JSON.stringify([updated.response, deleted.response])).not.toContain(REPLACEMENT_SECRET); + }); + + it("creates and deletes a Debrid-Link API key by stable key identity", () => { + const secret = "fixture-dl-create-delete-1pQ4"; + const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ + action: "create", + kind: "debridlink-api", + secret, + dailyLimitBytes: 3 * GIB + })); + + expect(created.response.accountId).toBe(getDebridLinkApiKeyId(secret)); + expect(created.settings.debridLinkApiKeys).toBe(secret); + expect(created.settings.debridLinkApiKeyDailyLimitBytes).toEqual({ [getDebridLinkApiKeyId(secret)]: 3 * GIB }); + expect(JSON.stringify(created.response)).not.toContain(secret); + + const deleted = applyAccountCommand(created.settings, validateAccountCommand({ + action: "delete", + kind: "debridlink-api", + accountId: created.response.accountId + })); + + expect(deleted.settings.debridLinkApiKeys).toBe(""); + expect(deleted.settings.debridLinkApiKeyDailyLimitBytes).toEqual({}); + expect(deleted.response.accountId).toBeNull(); + }); + + it("rejects malformed payloads without echoing submitted secrets", () => { + let errorText = ""; + try { + validateAccountCommand({ + action: "replace", + kind: "megadebrid-api", + accountId: 42, + secret: REPLACEMENT_SECRET + }); + } catch (error) { + errorText = String(error); + } + + expect(errorText).toMatch(/ungültig/i); + expect(errorText).not.toContain(REPLACEMENT_SECRET); + }); + + it.each(ACCOUNT_KINDS)("rejects malformed %s payloads without echoing their submitted secret", (kind) => { + const secret = `fixture-malformed-${kind}-3qR5`; + let errorText = ""; + try { + validateAccountCommand({ action: "replace", kind, accountId: 42, secret }); + } catch (error) { + errorText = String(error); + } + + expect(errorText).toMatch(/ungültig/i); + expect(errorText).not.toContain(secret); + }); +}); diff --git a/tests/account-dialog-prefer-api.test.ts b/tests/account-dialog-prefer-api.test.ts index b00b946..520b5bb 100644 --- a/tests/account-dialog-prefer-api.test.ts +++ b/tests/account-dialog-prefer-api.test.ts @@ -1,81 +1,30 @@ -import { describe, expect, it } from "vitest"; -import { applyAccountDialogToSettings, createAccountDialogState, AccountDialogState } from "../src/renderer/App"; +import { describe, expect, it } from "vitest"; +import { buildAccountCreateCommand, createAccountDialogState } from "../src/renderer/App"; +import { createRendererSettings } from "../src/main/renderer-state"; import { defaultSettings } from "../src/main/constants"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; -import { isMegaDebridAccountDisabled } from "../src/shared/provider-daily-limits"; - -function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState { - return { - mode: "edit", - kind, - service: kind, - token: "", - login: "", - password: "", - dailyLimitGb: "", - keyDailyLimitGbById: {}, - megaAccounts: [{ login: "user@x", password: "pw" }], - megaNewLogin: "", - megaNewPassword: "", - megaDisabledIds: [] - }; -} - -describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => { - it("does not flip megaDebridPreferApi to true when editing the API account", () => { - const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false }; - const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api")); - expect(next.megaDebridApiEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(false); - }); - - it("does not flip megaDebridPreferApi to false when editing the Web account", () => { - const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true }; - const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web")); - expect(next.megaDebridWebEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(true); + +describe("account creation dialog", () => { + it("creates a mode-specific Mega-Debrid command without existing credentials", () => { + const settings = createRendererSettings(defaultSettings()); + const dialog = { + ...createAccountDialogState("create", "megadebrid-web", settings), + megaNewLogin: "web-safe@example.test", + megaNewPassword: "fixture-dialog-secret-7pL2" + }; + + expect(buildAccountCreateCommand(dialog)).toEqual({ + action: "create", + kind: "megadebrid-web", + identity: "web-safe@example.test", + secret: "fixture-dialog-secret-7pL2", + dailyLimitBytes: 0 + }); }); - it("adds a Web account without copying it into the API account pool", () => { - const settings = { - ...defaultSettings(), - megaCredentials: "api@example.test:api-pass", - megaDebridApiCredentials: "api@example.test:api-pass", - megaDebridWebCredentials: "", - megaDebridApiEnabled: true, - megaDebridWebEnabled: false - }; - const dialog = createAccountDialogState("create", "megadebrid-web", settings); - + it("keeps all account fields blank before user input", () => { + const dialog = createAccountDialogState("create", "debridlink-api", createRendererSettings(defaultSettings())); + expect(dialog.token).toBe(""); + expect(dialog.password).toBe(""); expect(dialog.megaAccounts).toEqual([]); - - const next = applyAccountDialogToSettings(settings, { - ...dialog, - megaAccounts: [{ login: "web@example.test", password: "web-pass" }] - }); - - expect(next.megaDebridApiCredentials).toBe("api@example.test:api-pass"); - expect(next.megaDebridWebCredentials).toBe("web@example.test:web-pass"); - expect(next.megaDebridApiEnabled).toBe(true); - expect(next.megaDebridWebEnabled).toBe(true); - }); - - it("disables an API account without disabling the matching Web account", () => { - const accountId = getMegaDebridAccountId("shared@example.test"); - const settings = { - ...defaultSettings(), - megaCredentials: "shared@example.test:api-pass", - megaDebridApiCredentials: "shared@example.test:api-pass", - megaDebridWebCredentials: "shared@example.test:web-pass", - megaDebridApiEnabled: true, - megaDebridWebEnabled: true - }; - const next = applyAccountDialogToSettings(settings, { - ...createAccountDialogState("edit", "megadebrid-api", settings), - megaDisabledIds: [accountId] - }); - - expect(isMegaDebridAccountDisabled(next, accountId, "api")).toBe(true); - expect(isMegaDebridAccountDisabled(next, accountId, "web")).toBe(false); }); }); diff --git a/tests/account-edit.test.ts b/tests/account-edit.test.ts index c5bb1d1..d61ff1d 100644 --- a/tests/account-edit.test.ts +++ b/tests/account-edit.test.ts @@ -1,305 +1,82 @@ -import { describe, expect, it } from "vitest"; -import { defaultSettings } from "../src/main/constants"; -import { - applyAccountEdit, - buildAccountEditCheckSettings, - createAccountEditState, - removeAccountTarget, - validateAccountEdit, - validateAccountEditStatuses, - type AccountEditTarget -} from "../src/renderer/account-edit"; -import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; - -const GIB = 1024 * 1024 * 1024; - -function megaTarget(login: string): AccountEditTarget { - return { - type: "mega", - rowKey: `mega-${getMegaDebridAccountId(login)}`, - kind: "megadebrid-api", - service: "megadebrid-api", - accountId: getMegaDebridAccountId(login) - }; -} - -function debridLinkTarget(token: string): AccountEditTarget { - return { - type: "debridlink", - rowKey: `dl-${getDebridLinkApiKeyId(token)}`, - kind: "debridlink-api", - service: "debridlink", - keyId: getDebridLinkApiKeyId(token) - }; -} - -describe("account-specific editing", () => { - it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => { - const oldId = getMegaDebridAccountId("second@example.com"); - const newId = getMegaDebridAccountId("renamed@example.com"); - const firstId = getMegaDebridAccountId("first@example.com"); - const webId = getMegaDebridAccountId("web@example.com"); - const settings = { +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createRendererState } from "../src/main/renderer-state"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { + buildAccountDeleteCommand, + buildAccountReplaceCommand, + createAccountEditState, + validateAccountEdit, + type AccountEditTarget +} from "../src/renderer/account-edit"; + +function megaTarget(identity: string): AccountEditTarget { + const accountId = getMegaDebridAccountId(identity); + return { + type: "mega", + rowKey: `mega-megadebrid-api-${accountId}`, + kind: "megadebrid-api", + service: "megadebrid-api", + accountId + }; +} + +describe("renderer-safe account editing", () => { + it("opens an existing account without reading its stored secret", () => { + const identity = "safe-edit@example.test"; + const state = createRendererState({ ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass\nweb@example.com:web-pass", - megaDebridApiCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass", - megaDebridWebCredentials: "web@example.com:web-pass", - megaLogin: "first@example.com", - megaPassword: "first-pass", - megaDebridApiEnabled: true, - megaDebridWebEnabled: true, - megaDebridPreferApi: false, - megaDebridDisabledAccountIds: [oldId, firstId, webId], - megaDebridApiDisabledAccountIds: [oldId, firstId], - megaDebridWebDisabledAccountIds: [webId], - megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB }, - megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB }, - megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB }, - debridAccountStatuses: { - [oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }, - [firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - } - }; - const state = { - ...createAccountEditState(megaTarget("second@example.com"), settings), - login: "renamed@example.com", - password: "new-pass", - dailyLimitGb: "25,5" - }; - - expect(validateAccountEdit(state, settings)).toBeNull(); - const next = applyAccountEdit(settings, state); - - expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass\nweb@example.com:web-pass"); - expect(next.megaDebridApiCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass"); - expect(next.megaDebridWebCredentials).toBe("web@example.com:web-pass"); - expect(next.megaLogin).toBe("first@example.com"); - expect(next.megaPassword).toBe("first-pass"); - expect(next.megaDebridApiEnabled).toBe(true); - expect(next.megaDebridWebEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(false); - expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId, webId]); - expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) }); - expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB }); - expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB }); - expect(next.debridAccountStatuses).toEqual({ - [firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - }); - }); - - it("keeps Mega-Debrid usage when only the password changes", () => { - const id = getMegaDebridAccountId("user@example.com"); - const settings = { - ...defaultSettings(), - megaCredentials: "user@example.com:old-pass", - megaLogin: "user@example.com", - megaPassword: "old-pass", - megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB }, - megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB }, - megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB } - }; - const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" }; - const next = applyAccountEdit(settings, state); - - expect(next.megaCredentials).toBe("user@example.com:new-pass"); - expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB }); - expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB }); - }); - - it("preserves exact account limits when the rounded display value is left unchanged", () => { - const megaLogin = "user@example.com"; - const megaId = getMegaDebridAccountId(megaLogin); - const key = "debrid-link-token"; - const keyId = getDebridLinkApiKeyId(key); - const exactLimit = Math.floor(10.05 * GIB); - const settings = { - ...defaultSettings(), - megaCredentials: `${megaLogin}:pass`, - megaLogin, - megaPassword: "pass", - megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit }, - debridLinkApiKeys: key, - debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit } - }; - - const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings)); - const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings)); - - expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit); - expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit); - }); - - it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => { - const login = "user@example.com"; - const settings = { - ...defaultSettings(), - megaCredentials: `${login}:pass`, - megaLogin: login, - megaPassword: "pass" - }; - const state = { ...createAccountEditState(megaTarget(login), settings), password: " " }; - - expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i); - expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i); - expect(validateAccountEditStatuses(state, [{ - accountId: "mda_other", - provider: "megadebrid", - label: "Account 1", - maskedLogin: "ot***er", - valid: true, - isPremium: true, - premiumUntilMs: null, - message: "OK", - checkedAt: 1 - }])).toMatch(/falschen Account/i); - }); - - it("rejects duplicate Mega-Debrid logins and missing row targets", () => { - const settings = { - ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass" - }; - const duplicate = { - ...createAccountEditState(megaTarget("second@example.com"), settings), - login: "first@example.com" - }; - - expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i); - expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i); - }); - - it("replaces only the selected Debrid-Link key and migrates its own metadata", () => { - const keyA = "token-a"; - const keyB = "token-b"; - const keyC = "token-c"; - const newKey = "token-b-new"; - const idA = getDebridLinkApiKeyId(keyA); - const idB = getDebridLinkApiKeyId(keyB); - const idNew = getDebridLinkApiKeyId(newKey); - const settings = { - ...defaultSettings(), - debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`, - debridLinkDisabledKeyIds: [idB, idA], - debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB }, - debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB }, - debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB } - }; - const state = { - ...createAccountEditState(debridLinkTarget(keyB), settings), - token: newKey, - dailyLimitGb: "12" - }; - const next = applyAccountEdit(settings, state); - - expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`); - expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]); - expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB }); - expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB }); - expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB }); - }); - - it("edits a single login without changing unrelated credentials", () => { - const settings = { - ...defaultSettings(), - ddownloadLogin: "old@example.com", - ddownloadPassword: "old-pass", - linkSnappyLogin: "keep@example.com", - linkSnappyPassword: "keep-pass" - }; - const target: AccountEditTarget = { - type: "single", - rowKey: "svc-ddownload", - kind: "ddownload-login", - service: "ddownload", - provider: "ddownload" - }; - const state = { - ...createAccountEditState(target, settings), - login: "new@example.com", - password: "new-pass", - dailyLimitGb: "7" - }; - const next = applyAccountEdit(settings, state); - - expect(next.ddownloadLogin).toBe("new@example.com"); - expect(next.ddownloadPassword).toBe("new-pass"); - expect(next.linkSnappyLogin).toBe("keep@example.com"); - expect(next.linkSnappyPassword).toBe("keep-pass"); - expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB); - }); - - it("rejects whitespace-only passwords for direct login accounts", () => { - const settings = { - ...defaultSettings(), - ddownloadLogin: "user@example.com", - ddownloadPassword: "password", - linkSnappyLogin: "member@example.com", - linkSnappyPassword: "secret" - }; - const ddownloadTarget: AccountEditTarget = { - type: "single", - rowKey: "svc-ddownload", - kind: "ddownload-login", - service: "ddownload", - provider: "ddownload" - }; - const linkSnappyTarget: AccountEditTarget = { - type: "single", - rowKey: "svc-linksnappy", - kind: "linksnappy-login", - service: "linksnappy", - provider: "linksnappy" - }; - - expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i); - expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i); - }); - - it("builds a targeted check snapshot without invalid sibling accounts", () => { - const settings = { - ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass", - megaLogin: "first@example.com", - megaPassword: "first-pass", - debridLinkApiKeys: "one\ntwo" - }; - const target = megaTarget("second@example.com"); - const state = createAccountEditState(target, settings); - const checkSettings = buildAccountEditCheckSettings(settings, state); - - expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass"); - expect(checkSettings.megaLogin).toBe("second@example.com"); - expect(checkSettings.megaPassword).toBe("second-pass"); - expect(checkSettings.debridLinkApiKeys).toBe(""); - }); - - it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => { - const removeId = getMegaDebridAccountId("second@example.com"); - const keepId = getMegaDebridAccountId("first@example.com"); - const settings = { - ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass", - megaLogin: "first@example.com", - megaPassword: "first-pass", - megaDebridDisabledAccountIds: [removeId, keepId], - megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 }, - megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 }, - megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 }, - debridAccountStatuses: { - [removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }, - [keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - } - }; - const next = removeAccountTarget(settings, megaTarget("second@example.com")); - - expect(next.megaCredentials).toBe("first@example.com:first-pass"); - expect(next.megaDebridDisabledAccountIds).toEqual([keepId]); - expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 }); - expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 }); - expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 }); - expect(next.debridAccountStatuses).toEqual({ - [keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - }); - }); -}); + megaCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiEnabled: true + }); + + const edit = createAccountEditState(megaTarget(identity), state.accounts); + + expect(edit.login).toBe(identity); + expect(edit.password).toBe(""); + expect(JSON.stringify(edit)).not.toContain("fixture-stored-secret-4dH8"); + }); + + it("builds a replace command whose blank secret retains the main-process value", () => { + const identity = "safe-edit@example.test"; + const renderer = createRendererState({ + ...defaultSettings(), + megaCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiEnabled: true + }); + const edit = createAccountEditState(megaTarget(identity), renderer.accounts); + + expect(validateAccountEdit(edit, renderer.accounts)).toBeNull(); + expect(buildAccountReplaceCommand(edit)).toEqual(expect.objectContaining({ + action: "replace", + accountId: getMegaDebridAccountId(identity), + identity, + secret: "" + })); + }); + + it("rejects duplicate identities using safe account metadata", () => { + const first = "first-safe@example.test"; + const second = "second-safe@example.test"; + const renderer = createRendererState({ + ...defaultSettings(), + megaCredentials: `${first}:fixture-first-secret-1aB2\n${second}:fixture-second-secret-3cD4`, + megaDebridApiCredentials: `${first}:fixture-first-secret-1aB2\n${second}:fixture-second-secret-3cD4`, + megaDebridApiEnabled: true + }); + const edit = { ...createAccountEditState(megaTarget(second), renderer.accounts), login: first }; + + expect(validateAccountEdit(edit, renderer.accounts)).toMatch(/bereits vorhanden/i); + }); + + it("builds an identity-only delete command", () => { + const target = megaTarget("delete-safe@example.test"); + expect(buildAccountDeleteCommand(target)).toEqual({ + action: "delete", + kind: "megadebrid-api", + accountId: target.type === "mega" ? target.accountId : "" + }); + }); +}); diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts new file mode 100644 index 0000000..984903e --- /dev/null +++ b/tests/account-preload.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { IPC_CHANNELS } from "../src/shared/ipc"; +import type { ElectronApi } from "../src/shared/preload-api"; + +const electron = vi.hoisted(() => ({ + api: undefined as ElectronApi | undefined, + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined) +})); + +vi.mock("electron", () => ({ + contextBridge: { + exposeInMainWorld: (_name: string, api: ElectronApi) => { + electron.api = api; + } + }, + ipcRenderer: { + invoke: electron.invoke, + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn() + } +})); + +describe("account preload contract", () => { + beforeAll(async () => { + await import("../src/preload/preload"); + }); + + beforeEach(() => { + electron.invoke.mockClear(); + }); + + it("forwards submitted secrets only in write-only account commands", async () => { + const secret = "fixture-preload-secret-5zK1"; + electron.invoke.mockResolvedValueOnce({ accountId: "mda_fixture", settings: { language: "de" }, accounts: [] }); + const result = await electron.api?.createAccount({ + action: "create", + kind: "megadebrid-api", + identity: "preload-account@example.test", + secret, + dailyLimitBytes: 0 + }); + + expect(electron.invoke).toHaveBeenCalledWith( + IPC_CHANNELS.CREATE_ACCOUNT, + expect.objectContaining({ secret }) + ); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("exposes separate replace, update-secret and delete channels", async () => { + electron.invoke.mockResolvedValue({ accountId: "mda_fixture", settings: { language: "de" }, accounts: [] }); + + await electron.api?.replaceAccount({ action: "replace", kind: "megadebrid-api", accountId: "mda_fixture", identity: "account@example.test", secret: "", dailyLimitBytes: 0 }); + await electron.api?.updateAccountSecret({ action: "update-secret", kind: "megadebrid-api", accountId: "mda_fixture", secret: "fixture-new-secret-8sP4" }); + await electron.api?.deleteAccount({ action: "delete", kind: "megadebrid-api", accountId: "mda_fixture" }); + + expect(electron.invoke.mock.calls.map((call) => call[0])).toEqual([ + IPC_CHANNELS.REPLACE_ACCOUNT, + IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, + IPC_CHANNELS.DELETE_ACCOUNT + ]); + }); +}); diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 17b4039..7d231c9 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { once } from "node:events"; import AdmZip from "adm-zip"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../src/main/windows-host-diagnostics", () => ({ getWindowsHostDiagnostics: () => ({ @@ -40,7 +40,9 @@ vi.mock("../src/main/windows-host-diagnostics", () => ({ }) })); -import { defaultSettings } from "../src/main/constants"; +import { defaultSettings } from "../src/main/constants"; +import { configureCredentialProtector } from "../src/main/credential-protection"; +import { createRendererState } from "../src/main/renderer-state"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; @@ -70,6 +72,14 @@ const forbiddenSupportMarkers = [ ["M", "C", "P"].join(""), ["K", "I"].join("") ]; + +beforeEach(() => { + configureCredentialProtector({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value, "utf8").reverse(), + decryptString: (value) => Buffer.from(value).reverse().toString("utf8") + }); +}); async function getFreePort(): Promise { const probe = http.createServer(); @@ -99,15 +109,16 @@ async function waitForReady(url: string): Promise { throw new Error(`debug server not ready: ${url}`); } -function buildSnapshot(baseDir: string): UiSnapshot { +function buildSnapshot(baseDir: string): UiSnapshot { const settings = { ...defaultSettings(), outputDir: path.join(baseDir, "downloads"), extractDir: path.join(baseDir, "extract") }; - return { - settings, + const renderer = createRendererState(settings); + return { + ...renderer, session: { version: 1, packageOrder: ["pkg-1"], @@ -220,8 +231,10 @@ async function createFixture() { const debridLinkApiKeys = "key-a\nkey-b"; const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys); - saveSettings(storagePaths, { - ...snapshot.settings, + saveSettings(storagePaths, { + ...defaultSettings(), + outputDir: snapshot.settings.outputDir, + extractDir: snapshot.settings.extractDir, token: "rd-secret-token", realDebridUseWebLogin: true, debridLinkApiKeys, diff --git a/tests/link-export.test.ts b/tests/link-export.test.ts index 7195178..63d4b4b 100644 --- a/tests/link-export.test.ts +++ b/tests/link-export.test.ts @@ -4,8 +4,9 @@ import { parseCollectorInput } from "../src/main/link-parser"; import type { UiSnapshot } from "../src/shared/types"; function buildSnapshot(): UiSnapshot { - return { - settings: {} as UiSnapshot["settings"], + return { + settings: {} as UiSnapshot["settings"], + accounts: [], session: { version: 1, packageOrder: ["pkg-1", "pkg-2"], diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts new file mode 100644 index 0000000..91061f7 --- /dev/null +++ b/tests/renderer-state.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createRendererState } from "../src/main/renderer-state"; +import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import type { AppSettings, RendererAccountKind } from "../src/shared/types"; + +const SECRETS = { + token: "fixture-rd-token-7vQ2", + megaPassword: "fixture-mega-password-8kM3", + bestToken: "fixture-best-token-5xL9", + allDebridToken: "fixture-ad-token-4pR6", + ddownloadPassword: "fixture-dd-password-2tN8", + oneFichierApiKey: "fixture-onefichier-key-6cW1", + debridLinkApiKey: "fixture-debridlink-key-9hS4", + linkSnappyPassword: "fixture-linksnappy-password-3mB7", + archivePassword: "fixture-archive-password-1jD5", + notifyUrl: "https://notify.example.test/hooks/fixture-notify-secret-0fA2" +} as const; + +const ACCOUNT_FIXTURES: Array<{ + kind: RendererAccountKind; + secret: string; + settings: Partial; +}> = [ + { kind: "realdebrid-api", secret: "fixture-rd-api-secret-1aK4", settings: { token: "fixture-rd-api-secret-1aK4" } }, + { kind: "realdebrid-web", secret: "fixture-rd-web-session-2bL5", settings: { token: "fixture-rd-web-session-2bL5", realDebridUseWebLogin: true } }, + { kind: "megadebrid-api", secret: "fixture-mega-api-secret-3cM6", settings: { megaCredentials: "mega-api@example.test:fixture-mega-api-secret-3cM6", megaDebridApiCredentials: "mega-api@example.test:fixture-mega-api-secret-3cM6", megaDebridApiEnabled: true } }, + { kind: "megadebrid-web", secret: "fixture-mega-web-secret-4dN7", settings: { megaCredentials: "mega-web@example.test:fixture-mega-web-secret-4dN7", megaDebridWebCredentials: "mega-web@example.test:fixture-mega-web-secret-4dN7", megaDebridWebEnabled: true } }, + { kind: "bestdebrid-api", secret: "fixture-best-api-secret-5eP8", settings: { bestToken: "fixture-best-api-secret-5eP8" } }, + { kind: "bestdebrid-web", secret: "fixture-best-web-session-6fQ9", settings: { bestToken: "fixture-best-web-session-6fQ9", bestDebridUseWebLogin: true } }, + { kind: "alldebrid-api", secret: "fixture-all-api-secret-7gR1", settings: { allDebridToken: "fixture-all-api-secret-7gR1" } }, + { kind: "alldebrid-web", secret: "fixture-all-web-session-8hS2", settings: { allDebridToken: "fixture-all-web-session-8hS2", allDebridUseWebLogin: true } }, + { kind: "ddownload-login", secret: "fixture-dd-secret-9jT3", settings: { ddownloadLogin: "dd@example.test", ddownloadPassword: "fixture-dd-secret-9jT3" } }, + { kind: "onefichier-api", secret: "fixture-one-secret-0kU4", settings: { oneFichierApiKey: "fixture-one-secret-0kU4" } }, + { kind: "debridlink-api", secret: "fixture-dl-secret-1mV5", settings: { debridLinkApiKeys: "fixture-dl-secret-1mV5" } }, + { kind: "linksnappy-login", secret: "fixture-ls-secret-2nW6", settings: { linkSnappyLogin: "ls@example.test", linkSnappyPassword: "fixture-ls-secret-2nW6" } } +]; + +describe("renderer state serialization", () => { + it.each(ACCOUNT_FIXTURES)("serializes $kind without its representative secret", ({ kind, secret, settings }) => { + const state = createRendererState({ ...defaultSettings(), ...settings }); + + expect(state.accounts).toEqual(expect.arrayContaining([expect.objectContaining({ kind, hasSecret: true })])); + expect(JSON.stringify(state)).not.toContain(secret); + expect(JSON.stringify(state.settings)).not.toContain(secret); + }); + + it("excludes every provider and settings secret while preserving safe account metadata", () => { + const megaLogin = "renderer-fixture@example.test"; + const settings = { + ...defaultSettings(), + token: SECRETS.token, + megaLogin, + megaPassword: SECRETS.megaPassword, + megaCredentials: `${megaLogin}:${SECRETS.megaPassword}`, + megaDebridApiCredentials: `${megaLogin}:${SECRETS.megaPassword}`, + megaDebridApiEnabled: true, + bestToken: SECRETS.bestToken, + allDebridToken: SECRETS.allDebridToken, + ddownloadLogin: "renderer-dd@example.test", + ddownloadPassword: SECRETS.ddownloadPassword, + oneFichierApiKey: SECRETS.oneFichierApiKey, + debridLinkApiKeys: SECRETS.debridLinkApiKey, + linkSnappyLogin: "renderer-linksnappy@example.test", + linkSnappyPassword: SECRETS.linkSnappyPassword, + archivePasswordList: SECRETS.archivePassword, + notifyUrl: SECRETS.notifyUrl + }; + + const state = createRendererState(settings); + const serialized = JSON.stringify(state); + + for (const secret of Object.values(SECRETS)) { + expect(serialized).not.toContain(secret); + } + expect(state.settings.archivePasswordListConfigured).toBe(true); + expect(state.settings.notifyUrlConfigured).toBe(true); + expect(state.accounts).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "realdebrid-api", hasSecret: true }), + expect.objectContaining({ kind: "megadebrid-api", identity: megaLogin, hasSecret: true }), + expect.objectContaining({ kind: "debridlink-api", hasSecret: true }) + ])); + }); + + it("redacts individual secrets embedded in status metadata for multi-account pools", () => { + const firstMegaSecret = "fixture-first-mega-pool-secret-3pX7"; + const secondMegaSecret = "fixture-second-mega-pool-secret-4qY8"; + const firstKey = "fixture-first-debridlink-pool-secret-5rZ9"; + const secondKey = "fixture-second-debridlink-pool-secret-6sA1"; + const secondMegaId = getMegaDebridAccountId("second-pool@example.test"); + const secondKeyId = getDebridLinkApiKeyId(secondKey); + const settings = { + ...defaultSettings(), + megaCredentials: `first-pool@example.test:${firstMegaSecret}\nsecond-pool@example.test:${secondMegaSecret}`, + megaDebridApiCredentials: `first-pool@example.test:${firstMegaSecret}\nsecond-pool@example.test:${secondMegaSecret}`, + megaDebridApiEnabled: true, + debridLinkApiKeys: `${firstKey}\n${secondKey}`, + debridAccountStatuses: { + [secondMegaId]: { + accountId: secondMegaId, + provider: "megadebrid" as const, + label: "Account 2", + maskedLogin: "se***st", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: `Rejected ${secondMegaSecret}`, + checkedAt: 1 + }, + [secondKeyId]: { + accountId: secondKeyId, + provider: "debridlink" as const, + label: "Key 2", + maskedLogin: "fi***A1", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: `Rejected ${secondKey}`, + checkedAt: 1 + } + } + }; + + const serialized = JSON.stringify(createRendererState(settings)); + + for (const secret of [firstMegaSecret, secondMegaSecret, firstKey, secondKey]) { + expect(serialized).not.toContain(secret); + } + }); +}); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 7f71ae7..4aeca1f 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -3,12 +3,9 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { defaultSettings } from "../src/main/constants"; +import { createRendererSettings, createRendererState } from "../src/main/renderer-state"; import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App"; -import { - applyAccountEdit, - createAccountEditState, - type AccountEditTarget -} from "../src/renderer/account-edit"; +import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder @@ -416,7 +413,6 @@ describe("settings model", () => { const login = "member@example.test"; const oldId = getMegaDebridAccountId(login); const newLogin = "renamed@example.test"; - const newId = getMegaDebridAccountId(newLogin); const exactLimit = Math.floor(10.05 * GIB); const settings = { ...defaultSettings(), @@ -435,17 +431,17 @@ describe("settings model", () => { service: "megadebrid-api", accountId: oldId }; - const unchanged = applyAccountEdit(settings, createAccountEditState(target, settings)); - const renamed = applyAccountEdit(settings, { - ...createAccountEditState(target, settings), + const renderer = createRendererState(settings); + const unchanged = buildAccountReplaceCommand(createAccountEditState(target, renderer.accounts)); + const renamed = buildAccountReplaceCommand({ + ...createAccountEditState(target, renderer.accounts), login: newLogin }); - expect(unchanged.megaDebridAccountDailyLimitBytes[oldId]).toBe(exactLimit); - expect(renamed.megaDebridDisabledAccountIds).toEqual([newId]); - expect(renamed.megaDebridAccountDailyLimitBytes[newId]).toBe(exactLimit); - expect(renamed.megaDebridAccountDailyUsageBytes[newId]).toBeUndefined(); - expect(renamed.megaDebridAccountTotalUsageBytes[newId]).toBeUndefined(); + expect(unchanged.dailyLimitBytes).toBe(exactLimit); + expect(unchanged.secret).toBe(""); + expect(renamed.identity).toBe(newLogin); + expect(renamed.secret).toBe(""); }); }); @@ -468,7 +464,7 @@ describe("settings views", () => { it("offers animated language and bounded history retention choices", () => { const form = buildSettingsFormViewModel({ - settings: defaultSettings(), + settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" }, section: "allgemein", speedLimitInput: "0", scheduleSpeedInputs: {} @@ -912,9 +908,10 @@ describe("settings App integration", () => { megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret", debridLinkApiKeys: "existing-debrid-link-key" }; - const megaDialog = createAccountDialogState("create", "megadebrid-api", settings); + const rendererSettings = createRendererSettings(settings); + const megaDialog = createAccountDialogState("create", "megadebrid-api", rendererSettings); const megaFields = buildAccountAddFields(megaDialog); - const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", settings)); + const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", rendererSettings)); expect(megaDialog.megaNewLogin).toBe(""); expect(megaDialog.megaNewPassword).toBe(""); diff --git a/tests/visual-fixtures.test.ts b/tests/visual-fixtures.test.ts index 8ad283e..8ccc1c1 100644 --- a/tests/visual-fixtures.test.ts +++ b/tests/visual-fixtures.test.ts @@ -1,8 +1,6 @@ import React, { type ReactElement } from "react"; import { describe, expect, it } from "vitest"; import { App } from "../src/renderer/App"; -import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import type { ElectronApi } from "../src/shared/preload-api"; import * as visualFixtures from "./visual/fixtures"; import * as visualMain from "./visual/main"; @@ -136,11 +134,12 @@ describe("visual fixtures", () => { it("aligns dense account table values with credential-derived account IDs", async () => { const dense = createVisualFixture("dense"); const settings = dense.snapshot.settings; - const megaAccountId = getMegaDebridAccountId(settings.megaLogin); - const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + const megaAccount = dense.snapshot.accounts.find((account) => account.kind === "megadebrid-api"); + const debridLinkKeys = dense.snapshot.accounts.filter((account) => account.kind === "debridlink-api"); + const megaAccountId = megaAccount?.accountId || ""; expect(megaAccountId).toBe("mda_2f92guyzhdf6j"); - expect(debridLinkKeys.map((entry) => entry.id)).toEqual([ + expect(debridLinkKeys.map((entry) => entry.accountId)).toEqual([ "dlk_1ix5qlyx6mtm1", "dlk_1ix5pfvlg4nkg" ]); @@ -152,23 +151,23 @@ describe("visual fixtures", () => { ); for (const entry of debridLinkKeys) { - expect(settings.debridAccountStatuses[entry.id]?.valid).toBe(true); - expect(settings.debridLinkApiKeyDailyLimitBytes[entry.id]).toBeGreaterThan(0); - expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeGreaterThan(0); - expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeLessThan( - settings.debridLinkApiKeyDailyLimitBytes[entry.id] + expect(settings.debridAccountStatuses[entry.accountId]?.valid).toBe(true); + expect(settings.debridLinkApiKeyDailyLimitBytes[entry.accountId]).toBeGreaterThan(0); + expect(settings.debridLinkApiKeyDailyUsageBytes[entry.accountId]).toBeGreaterThan(0); + expect(settings.debridLinkApiKeyDailyUsageBytes[entry.accountId]).toBeLessThan( + settings.debridLinkApiKeyDailyLimitBytes[entry.accountId] ); - expect(settings.debridLinkApiKeyTotalUsageBytes[entry.id]).toBeGreaterThan( - settings.debridLinkApiKeyDailyUsageBytes[entry.id] + expect(settings.debridLinkApiKeyTotalUsageBytes[entry.accountId]).toBeGreaterThan( + settings.debridLinkApiKeyDailyUsageBytes[entry.accountId] ); } const debridLinkItem = Object.values(dense.snapshot.session.items).find( (item) => item.provider === "debridlink" ); - expect(debridLinkItem?.providerAccountId).toBe(debridLinkKeys[0].id); + expect(debridLinkItem?.providerAccountId).toBe(debridLinkKeys[0].accountId); const hostLimits = await createVisualElectronApi(dense).getDebridLinkHostLimits(); - expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].id); + expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].accountId); }); it("stores every mutable bridge state inside the visual fixture", async () => { diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 74f3e97..d7f7922 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -7,7 +7,8 @@ import type { UpdateCheckResult } from "../../src/shared/types"; import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts"; +import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts"; +import { createRendererState } from "../../src/main/renderer-state"; export const VISUAL_SCENARIOS = ["empty", "dense", "update"] as const; @@ -228,9 +229,10 @@ function createSettings(): AppSettings { }; } -function createEmptySnapshot(): UiSnapshot { - return { - settings: createSettings(), +function createEmptySnapshot(): UiSnapshot { + const renderer = createRendererState(createSettings()); + return { + ...renderer, session: { version: 1, packageOrder: [], @@ -274,9 +276,9 @@ function createEmptySnapshot(): UiSnapshot { }; } -function createDenseSnapshot(): UiSnapshot { - const snapshot = createEmptySnapshot(); - const debridLinkKeys = parseDebridLinkApiKeys(snapshot.settings.debridLinkApiKeys); +function createDenseSnapshot(): UiSnapshot { + const snapshot = createEmptySnapshot(); + const debridLinkKeys = snapshot.accounts.filter((account) => account.kind === "debridlink-api"); snapshot.session = { version: 1, packageOrder: ["visual-package-active", "visual-package-complete", "visual-package-failed"], @@ -359,7 +361,7 @@ function createDenseSnapshot(): UiSnapshot { url: "https://ddownload.com/visual-active-2", provider: "debridlink", providerLabel: "Debrid-Link", - providerAccountId: debridLinkKeys[0].id, + providerAccountId: debridLinkKeys[0].accountId, providerAccountLabel: "Debrid-Link Key 1", status: "queued", retries: 0, diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index c568bfc..252e3ca 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -1,6 +1,5 @@ import type { ElectronApi } from "../../src/shared/preload-api"; -import type { AppSettings, HistoryEntry } from "../../src/shared/types"; -import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys"; +import type { HistoryEntry, RendererSettings, RendererSettingsUpdate } from "../../src/shared/types"; import type { VisualFixture } from "./fixtures"; const stableNoopUnsubscribe = (): void => {}; @@ -15,8 +14,9 @@ export function createVisualElectronApi( ): ElectronApi { const historyState = new URLSearchParams(search).get("history-state"); let historyRequestCount = 0; - const updateSettings = (settings: Partial): AppSettings => { - Object.assign(fixture.snapshot.settings, settings); + const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => { + const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings; + Object.assign(fixture.snapshot.settings, safe); return clone(fixture.snapshot.settings); }; @@ -35,6 +35,10 @@ export function createVisualElectronApi( fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0; return clone(fixture.snapshot.settings); }, + createAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), + replaceAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), + updateAccountSecret: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), + deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }), addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }), getStartConflicts: async () => [], @@ -291,10 +295,11 @@ export function createVisualElectronApi( note: "Visual host status" }), getDebridLinkHostLimits: async () => { - const primaryKey = parseDebridLinkApiKeys(fixture.snapshot.settings.debridLinkApiKeys)[0]; + const primaryKey = fixture.snapshot.accounts.find((account) => account.kind === "debridlink-api"); + if (!primaryKey) return []; return [{ - keyId: primaryKey.id, - keyLabel: primaryKey.label, + keyId: primaryKey.accountId, + keyLabel: "Key 1", host: "ddownload.com", fetchedAt: 1786312800000, trafficCurrentBytes: 53687091200, @@ -314,12 +319,17 @@ export function createVisualElectronApi( }]; }, checkDebridAccounts: async () => clone(Object.values(fixture.snapshot.settings.debridAccountStatuses)), - checkMegaDebridAccount: async () => { - const status = Object.values(fixture.snapshot.settings.debridAccountStatuses).find( - (entry) => entry.provider === "megadebrid" - ); - return status ? clone(status) : null; - }, + checkAccountCredentials: async (input) => clone(fixture.snapshot.accounts.find((account) => account.accountId === input.accountId)?.status || { + accountId: input.accountId || "visual-account", + provider: input.kind === "debridlink-api" ? "debridlink" : "megadebrid", + label: "Visual Account", + maskedLogin: "vi***al", + valid: true, + isPremium: true, + premiumUntilMs: null, + message: "Premium aktiv", + checkedAt: 1786312800000 + }), retryExtraction: async (packageId) => { const entry = fixture.snapshot.session.packages[packageId]; if (entry) {