From af710610fdc30aa1b2422d292c79145e189f3964 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 15 Aug 2026 20:08:33 +0200 Subject: [PATCH] feat(accounts): manage Real-Debrid account pool --- src/main/account-check.ts | 68 ++++++++++--- src/main/account-commands.ts | 132 +++++++++++++++++++++++- src/main/app-controller.ts | 40 ++++++-- src/main/renderer-state.ts | 20 +++- src/main/settings-live-overlay.ts | 15 ++- src/main/storage.ts | 16 +-- src/renderer/account-edit.ts | 4 +- src/shared/real-debrid-accounts.ts | 153 +++++++++++----------------- tests/account-check.test.ts | 40 +++++++- tests/account-commands.test.ts | 65 +++++++++++- tests/real-debrid-accounts.test.ts | 60 +++++------ tests/renderer-state.test.ts | 31 ++++++ tests/settings-live-overlay.test.ts | 22 ++++ tests/storage.test.ts | 51 ++++++++-- 14 files changed, 539 insertions(+), 178 deletions(-) diff --git a/src/main/account-check.ts b/src/main/account-check.ts index b191dc6..cd6694e 100644 --- a/src/main/account-check.ts +++ b/src/main/account-check.ts @@ -1,6 +1,7 @@ import type { AccountCheckScope, AppSettings, DebridAccountStatus, DebridProvider } from "../shared/types"; import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; -import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys"; +import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys"; +import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts"; import { logger } from "./logger"; import { compactErrorText } from "./utils"; @@ -23,6 +24,15 @@ export interface RealDebridSessionProbeResult { } export type RealDebridSessionProbe = (signal?: AbortSignal) => Promise; +export type RealDebridSessionProbeResolver = (accountId: string, signal?: AbortSignal) => Promise; + +export function retainConfiguredRealDebridStatuses(settings: AppSettings, statuses: readonly DebridAccountStatus[]): DebridAccountStatus[] { + const accountIds = new Set(getRealDebridAccounts(settings).map((account) => account.id)); + if (accountIds.size === 0 && (settings.realDebridUseWebLogin || settings.token.trim())) { + accountIds.add(REAL_DEBRID_STATUS_ID); + } + return statuses.filter((status) => status.provider !== "realdebrid" || accountIds.has(status.accountId)); +} function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal { const timeout = AbortSignal.timeout(ms); @@ -69,18 +79,30 @@ function maskSecret(value: string): string { } export async function checkRealDebridAccount( - settings: AppSettings, + accountOrSettings: RealDebridAccountEntry | AppSettings, signal?: AbortSignal, now = Date.now(), probeWebSession?: RealDebridSessionProbe ): Promise { - const token = String(settings.token || "").trim(); - const useWebLogin = Boolean(settings.realDebridUseWebLogin); + const isAccount = typeof (accountOrSettings as RealDebridAccountEntry).kind === "string"; + const account = isAccount + ? accountOrSettings as RealDebridAccountEntry + : { + id: REAL_DEBRID_STATUS_ID, + kind: (accountOrSettings as AppSettings).realDebridUseWebLogin ? "web" as const : "api" as const, + index: 0, + label: "Real-Debrid", + maskedLogin: (accountOrSettings as AppSettings).realDebridUseWebLogin ? "Browser-Login" : maskSecret(String((accountOrSettings as AppSettings).token || "")), + enabled: true, + ...((accountOrSettings as AppSettings).realDebridUseWebLogin ? {} : { token: String((accountOrSettings as AppSettings).token || "").trim() }) + } as RealDebridAccountEntry; + const token = account.kind === "api" ? account.token.trim() : ""; + const useWebLogin = account.kind === "web"; const base: DebridAccountStatus = { - accountId: REAL_DEBRID_STATUS_ID, + accountId: account.id, provider: "realdebrid", - label: "Real-Debrid", - maskedLogin: useWebLogin ? "Browser-Login" : maskSecret(token), + label: account.label, + maskedLogin: account.maskedLogin, valid: false, isPremium: false, premiumUntilMs: null, @@ -274,7 +296,7 @@ export async function checkDebridLinkKey( export async function checkAllDebridAccounts( settings: AppSettings, signal?: AbortSignal, - probeRealDebridWebSession?: RealDebridSessionProbe, + probeRealDebridWebSession?: RealDebridSessionProbeResolver, scope: AccountCheckScope = "all" ): Promise { const now = Date.now(); @@ -298,13 +320,33 @@ export async function checkAllDebridAccounts( : providerEnabled("debridlink") ? configuredDebridLinkKeys.filter((key) => !(settings.debridLinkDisabledKeyIds || []).includes(key.id)) : []; - const checkRealDebrid = Boolean(settings.realDebridUseWebLogin || String(settings.token || "").trim()) - && (scope === "all" || providerEnabled("realdebrid")); + const configuredRealDebridAccounts = getRealDebridAccounts(settings); + const legacyRealDebridAccounts: RealDebridAccountEntry[] = configuredRealDebridAccounts.length === 0 + && (settings.realDebridUseWebLogin || String(settings.token || "").trim()) + ? [{ + id: REAL_DEBRID_STATUS_ID, + kind: settings.realDebridUseWebLogin ? "web" : "api", + index: 0, + label: "Real-Debrid", + maskedLogin: settings.realDebridUseWebLogin ? "Browser-Login" : maskSecret(settings.token), + enabled: true, + ...(settings.realDebridUseWebLogin ? {} : { token: settings.token.trim() }) + } as RealDebridAccountEntry] + : []; + const allRealDebridAccounts = configuredRealDebridAccounts.length > 0 ? configuredRealDebridAccounts : legacyRealDebridAccounts; + const realDebridAccounts = scope === "all" + ? allRealDebridAccounts + : providerEnabled("realdebrid") ? allRealDebridAccounts.filter((account) => account.enabled) : []; const taskFns: Array<() => Promise> = [ - ...(checkRealDebrid - ? [() => checkRealDebridAccount(settings, signal, now, probeRealDebridWebSession)] - : []), + ...realDebridAccounts.map((account) => () => checkRealDebridAccount( + account, + signal, + now, + account.kind === "web" && probeRealDebridWebSession + ? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal) + : undefined + )), ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), ...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)) ]; diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts index c23cae0..c143e90 100644 --- a/src/main/account-commands.ts +++ b/src/main/account-commands.ts @@ -1,4 +1,5 @@ import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { randomUUID } from "node:crypto"; import { getMegaDebridAccountId, getMegaDebridAccountsForMode, @@ -9,6 +10,7 @@ import { serializeMegaDebridAccounts, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; +import { getRealDebridAccounts, isRealDebridWebAccountId, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts"; import type { AccountCommand, AccountCredentialCheckInput, AccountSecretRequest, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types"; export interface AppliedAccountCommand { @@ -150,6 +152,13 @@ function storedSecretMissing(): never { } export function resolveStoredAccountSecret(settings: AppSettings, request: AccountSecretRequest): string { + if (request.kind === "realdebrid-api") { + const accounts = parseRealDebridApiAccounts(settings.realDebridApiTokens); + const account = accounts.find((entry) => entry.id === request.accountId); + if (account?.token) return account.token; + if (accounts.length === 0 && request.accountId === REAL_DEBRID_LEGACY_ID && settings.token) return settings.token; + return storedSecretMissing(); + } if (request.kind === "megadebrid-api" || request.kind === "megadebrid-web") { const mode = request.kind === "megadebrid-web" ? "web" : "api"; const account = getMegaDebridAccountsForMode(settings, mode).find((entry) => entry.id === request.accountId); @@ -163,7 +172,6 @@ export function resolveStoredAccountSecret(settings: AppSettings, request: Accou } const provider = singleProvider(request.kind); if (request.accountId !== `svc-${provider}` || !singleConfigured(settings, request.kind)) storedSecretMissing(); - if (request.kind === "realdebrid-api" && settings.token) return settings.token; if (request.kind === "bestdebrid-api" && settings.bestToken) return settings.bestToken; if (request.kind === "alldebrid-api" && settings.allDebridToken) return settings.allDebridToken; if (request.kind === "ddownload-login" && settings.ddownloadPassword) return settings.ddownloadPassword; @@ -172,6 +180,122 @@ export function resolveStoredAccountSecret(settings: AppSettings, request: Accou return storedSecretMissing(); } +const REAL_DEBRID_LEGACY_ID = "svc-realdebrid"; + +function syncRealDebridLegacyFields(settings: AppSettings): AppSettings { + const firstApi = parseRealDebridApiAccounts(settings.realDebridApiTokens)[0]; + return { + ...settings, + token: firstApi?.token || "", + realDebridUseWebLogin: settings.realDebridWebAccountIds.length > 0 + }; +} + +function normalizeRealDebridCommandSettings(settings: AppSettings): AppSettings { + const rawPool = settings.realDebridApiTokens.trim() || (settings.realDebridUseWebLogin ? "" : settings.token.trim()); + const apiAccounts = parseRealDebridApiAccounts(rawPool).map((account) => ({ + id: account.id.startsWith("rda_legacy_") ? `rda_${randomUUID().replace(/-/g, "")}` : account.id, + token: account.token + })); + return { + ...settings, + realDebridApiTokens: serializeRealDebridApiAccounts(apiAccounts), + realDebridWebAccountIds: settings.realDebridWebAccountIds.length > 0 + ? [...settings.realDebridWebAccountIds] + : settings.realDebridUseWebLogin ? ["rdw_legacy"] : [] + }; +} + +export function setRealDebridAccountEnabled(settings: AppSettings, accountId: string, enabled: boolean): AppSettings { + const normalized = normalizeRealDebridCommandSettings(settings); + if (!getRealDebridAccounts(normalized).some((account) => account.id === accountId)) invalid(); + return { + ...normalized, + realDebridDisabledAccountIds: enabled + ? normalized.realDebridDisabledAccountIds.filter((id) => id !== accountId) + : [...new Set([...normalized.realDebridDisabledAccountIds, accountId])] + }; +} + +function migrateRealDebridMetadata(settings: AppSettings, oldId: string, newId: string, limit: number | undefined): AppSettings { + const idChanged = oldId !== newId; + return { + ...settings, + realDebridDisabledAccountIds: idChanged + ? migrateIdList(settings.realDebridDisabledAccountIds, oldId, newId) + : [...settings.realDebridDisabledAccountIds], + realDebridAccountDailyLimitBytes: migrateLimit(settings.realDebridAccountDailyLimitBytes, oldId, newId, limit), + realDebridAccountDailyUsageBytes: idChanged ? withoutKeys(settings.realDebridAccountDailyUsageBytes, oldId, newId) : { ...settings.realDebridAccountDailyUsageBytes }, + realDebridAccountTotalUsageBytes: idChanged ? withoutKeys(settings.realDebridAccountTotalUsageBytes, oldId, newId) : { ...settings.realDebridAccountTotalUsageBytes }, + debridAccountStatuses: idChanged ? withoutKeys(settings.debridAccountStatuses, oldId, newId) : { ...settings.debridAccountStatuses } + }; +} + +function createRealDebrid(settings: AppSettings, command: Extract): AppliedAccountCommand { + if (command.kind === "realdebrid-api") { + const token = validateSecret(command.secret || ""); + const accounts = parseRealDebridApiAccounts(settings.realDebridApiTokens); + if (accounts.some((account) => account.token === token)) invalid(); + const accountId = `rda_${randomUUID().replace(/-/g, "")}`; + const next = syncRealDebridLegacyFields({ + ...settings, + realDebridApiTokens: serializeRealDebridApiAccounts([...accounts, { id: accountId, token }]), + realDebridDisabledAccountIds: settings.realDebridDisabledAccountIds.filter((id) => id !== accountId), + realDebridAccountDailyLimitBytes: setLimit(settings.realDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes) + }); + return { settings: next, response: { accountId } }; + } + const requestedId = String(command.identity || "").trim(); + const accountId = requestedId && isRealDebridWebAccountId(requestedId) ? requestedId : `rdw_${randomUUID().replace(/-/g, "")}`; + if (settings.realDebridWebAccountIds.includes(accountId)) invalid(); + const next = syncRealDebridLegacyFields({ + ...settings, + realDebridWebAccountIds: [...settings.realDebridWebAccountIds, accountId], + realDebridDisabledAccountIds: settings.realDebridDisabledAccountIds.filter((id) => id !== accountId), + realDebridAccountDailyLimitBytes: setLimit(settings.realDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes) + }); + return { settings: next, response: { accountId } }; +} + +function replaceRealDebrid(settings: AppSettings, command: Extract): AppliedAccountCommand { + const accounts = getRealDebridAccounts(settings); + const current = accounts.find((account) => account.id === command.accountId); + if (!current || (current.kind === "api") !== (command.kind === "realdebrid-api")) invalid(); + if (current.kind === "web") { + const next = syncRealDebridLegacyFields(migrateRealDebridMetadata(settings, current.id, current.id, command.dailyLimitBytes)); + return { settings: next, response: { accountId: current.id } }; + } + const token = command.secret?.trim() ? validateSecret(command.secret) : current.token; + if (accounts.some((account) => account.id !== current.id && account.kind === "api" && account.token === token)) invalid(); + const credentials = parseRealDebridApiAccounts(settings.realDebridApiTokens).map((account) => ({ + id: account.id, + token: account.id === current.id ? token : account.token + })); + const next = syncRealDebridLegacyFields(migrateRealDebridMetadata({ + ...settings, + realDebridApiTokens: serializeRealDebridApiAccounts(credentials) + }, current.id, current.id, command.dailyLimitBytes)); + return { settings: next, response: { accountId: current.id } }; +} + +function deleteRealDebrid(settings: AppSettings, command: Extract): AppliedAccountCommand { + const account = getRealDebridAccounts(settings).find((entry) => entry.id === command.accountId); + if (!account || (account.kind === "api") !== (command.kind === "realdebrid-api")) invalid(); + const next = syncRealDebridLegacyFields({ + ...settings, + realDebridApiTokens: account.kind === "api" + ? serializeRealDebridApiAccounts(parseRealDebridApiAccounts(settings.realDebridApiTokens).filter((entry) => entry.id !== account.id)) + : settings.realDebridApiTokens, + realDebridWebAccountIds: account.kind === "web" ? settings.realDebridWebAccountIds.filter((id) => id !== account.id) : [...settings.realDebridWebAccountIds], + realDebridDisabledAccountIds: settings.realDebridDisabledAccountIds.filter((id) => id !== account.id), + realDebridAccountDailyLimitBytes: withoutKeys(settings.realDebridAccountDailyLimitBytes, account.id), + realDebridAccountDailyUsageBytes: withoutKeys(settings.realDebridAccountDailyUsageBytes, account.id), + realDebridAccountTotalUsageBytes: withoutKeys(settings.realDebridAccountTotalUsageBytes, account.id), + debridAccountStatuses: withoutKeys(settings.debridAccountStatuses, account.id) + }); + return { settings: next, response: { accountId: getRealDebridAccounts(next)[0]?.id || null } }; +} + function withoutKeys(record: Record, ...keys: string[]): Record { const removed = new Set(keys); return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key))); @@ -492,6 +616,12 @@ export function applyAccountCommand(settings: AppSettings, command: AccountComma }; return applyAccountCommand(settings, replace); } + if (command.kind === "realdebrid-api" || command.kind === "realdebrid-web") { + const normalizedSettings = normalizeRealDebridCommandSettings(settings); + if (command.action === "create") return createRealDebrid(normalizedSettings, command); + if (command.action === "replace") return replaceRealDebrid(normalizedSettings, command); + return deleteRealDebrid(normalizedSettings, command); + } 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); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 04e6650..fd4646e 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,6 +1,7 @@ import path from "node:path"; import os from "node:os"; -import v8 from "node:v8"; +import v8 from "node:v8"; +import { randomUUID } from "node:crypto"; import { app } from "electron"; import { AddLinksPayload, @@ -33,10 +34,11 @@ 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, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount } from "./account-check"; +import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } 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 { getRealDebridAccounts } from "../shared/real-debrid-accounts"; import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; import { createRendererState } from "./renderer-state"; @@ -524,6 +526,11 @@ export class AppController { if (!key) throw new Error("Account-Payload ist ungültig"); checkedStatus = await checkDebridLinkKey(key); } + if (command.action !== "delete" && applied.response.accountId && command.kind === "realdebrid-api") { + const account = getRealDebridAccounts(applied.settings).find((entry) => entry.id === applied.response.accountId && entry.kind === "api"); + if (!account) throw new Error("Account-Payload ist ungültig"); + checkedStatus = await checkRealDebridAccount(account); + } if (checkedStatus) { checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions); } @@ -547,19 +554,28 @@ export class AppController { const redactions = collectAccountStatusRedactionValues(this.settings, input); if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") { const useWebLogin = input.kind === "realdebrid-web"; - const settings = input.secret?.trim() - ? { ...this.settings, token: input.secret.trim(), realDebridUseWebLogin: useWebLogin } - : { ...this.settings, realDebridUseWebLogin: useWebLogin }; + const account = input.secret?.trim() && !useWebLogin + ? { + id: `rda_${randomUUID().replace(/-/g, "")}`, + kind: "api" as const, + index: 0, + label: "Real-Debrid", + maskedLogin: "Geschützter API-Token", + enabled: true, + token: input.secret.trim() + } + : getRealDebridAccounts(this.settings).find((entry) => entry.id === input.accountId && entry.kind === (useWebLogin ? "web" : "api")); + if (!account) throw new Error("Account-Payload ist ungültig"); const status = sanitizeDebridAccountStatus( await checkRealDebridAccount( - settings, + account, undefined, Date.now(), useWebLogin ? (signal) => this.realDebridWebFallback.probeLoginState(signal) : undefined ), redactions ); - if (!input.secret && useWebLogin === this.settings.realDebridUseWebLogin) { + if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) { this.manager.applyDebridAccountStatuses([status]); } return status; @@ -613,12 +629,13 @@ export class AppController { } private async refreshRealDebridWebStatus(): Promise { - if (!this.settings.realDebridUseWebLogin) { + const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web"); + if (!account) { return; } const status = sanitizeDebridAccountStatus( await checkRealDebridAccount( - this.settings, + account, undefined, Date.now(), (signal) => this.realDebridWebFallback.probeLoginState(signal) @@ -658,15 +675,16 @@ export class AppController { } public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise { - const statuses = sanitizeDebridAccountStatuses( + const checkedStatuses = sanitizeDebridAccountStatuses( await checkAllDebridAccounts( this.settings, undefined, - (signal) => this.realDebridWebFallback.probeLoginState(signal), + (_accountId, signal) => this.realDebridWebFallback.probeLoginState(signal), scope ), collectAccountStatusRedactionValues(this.settings) ); + const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses); this.manager.applyDebridAccountStatuses(statuses); this.audit("INFO", "Debrid-Accounts geprueft", { total: statuses.length, diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 25ce8c4..e5d9579 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -1,5 +1,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts"; +import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer"; @@ -51,7 +52,24 @@ function singleAccount( export function createRendererAccounts(settings: AppSettings): RendererAccount[] { const accounts: RendererAccount[] = []; const redactions = collectAccountStatusRedactionValues(settings); - if (settings.realDebridUseWebLogin || settings.token.trim()) { + const realDebridAccounts = getRealDebridAccounts(settings); + for (const account of realDebridAccounts) { + const status = safeStatus(settings.debridAccountStatuses[account.id], redactions); + accounts.push({ + accountId: account.id, + kind: account.kind === "web" ? "realdebrid-web" : "realdebrid-api", + provider: "realdebrid", + identity: status?.username || "", + maskedIdentity: account.maskedLogin, + hasSecret: true, + enabled: providerEnabled(settings, "realdebrid") && account.enabled, + dailyLimitBytes: settings.realDebridAccountDailyLimitBytes[account.id] || 0, + dailyUsageBytes: settings.realDebridAccountDailyUsageBytes[account.id] || 0, + totalUsageBytes: settings.realDebridAccountTotalUsageBytes[account.id] || 0, + status + }); + } + if (realDebridAccounts.length === 0 && (settings.realDebridUseWebLogin || settings.token.trim())) { accounts.push(singleAccount( settings, settings.realDebridUseWebLogin ? "realdebrid-web" : "realdebrid-api", diff --git a/src/main/settings-live-overlay.ts b/src/main/settings-live-overlay.ts index 995f6b1..d584fe6 100644 --- a/src/main/settings-live-overlay.ts +++ b/src/main/settings-live-overlay.ts @@ -1,14 +1,17 @@ import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts"; +import { getRealDebridAccountIds } from "../shared/real-debrid-accounts"; import type { AppSettings } from "../shared/types"; export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void { const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys)); const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || "")); + const realDebridAccountIds = new Set(getRealDebridAccountIds(target)); const validAccountIds = new Set([ ...debridLinkKeyIds, ...megaAccountIds, - ...(target.realDebridUseWebLogin || target.token.trim() ? ["svc-realdebrid"] : []) + ...realDebridAccountIds, + ...(realDebridAccountIds.size === 0 && (target.realDebridUseWebLogin || target.token.trim()) ? ["svc-realdebrid"] : []) ]); target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); @@ -25,9 +28,15 @@ export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppS target.megaDebridAccountDailyUsageBytes = Object.fromEntries( Object.entries(liveSettings.megaDebridAccountDailyUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId)) ); - target.megaDebridAccountTotalUsageBytes = Object.fromEntries( + target.megaDebridAccountTotalUsageBytes = Object.fromEntries( Object.entries(liveSettings.megaDebridAccountTotalUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId)) - ); + ); + target.realDebridAccountDailyUsageBytes = Object.fromEntries( + Object.entries(liveSettings.realDebridAccountDailyUsageBytes || {}).filter(([accountId]) => realDebridAccountIds.has(accountId)) + ); + target.realDebridAccountTotalUsageBytes = Object.fromEntries( + Object.entries(liveSettings.realDebridAccountTotalUsageBytes || {}).filter(([accountId]) => realDebridAccountIds.has(accountId)) + ); target.debridAccountStatuses = Object.fromEntries( Object.entries(liveSettings.debridAccountStatuses || {}).filter(([accountId]) => validAccountIds.has(accountId)) ); diff --git a/src/main/storage.ts b/src/main/storage.ts index 6791449..86f8595 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; -import path from "node:path"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types"; @@ -447,12 +448,13 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const megaDebridDisabledAccountIds = [...new Set([...megaDebridApiDisabledAccountIds, ...megaDebridWebDisabledAccountIds])]; const legacyRealDebridToken = asText(settings.token); const hasRealDebridApiPool = Object.prototype.hasOwnProperty.call(settings, "realDebridApiTokens"); - const storedRealDebridApiTokens = serializeRealDebridApiAccounts( - parseRealDebridApiAccounts(String(settings.realDebridApiTokens ?? "")).map((entry) => entry.token) - ); - const realDebridApiTokens = hasRealDebridApiPool - ? storedRealDebridApiTokens - : serializeRealDebridApiAccounts([legacyRealDebridToken]); + const rawRealDebridApiAccounts = hasRealDebridApiPool + ? parseRealDebridApiAccounts(String(settings.realDebridApiTokens ?? "")) + : legacyRealDebridToken ? parseRealDebridApiAccounts(legacyRealDebridToken) : []; + const realDebridApiTokens = serializeRealDebridApiAccounts(rawRealDebridApiAccounts.map((account) => ({ + id: account.id.startsWith("rda_legacy_") ? `rda_${randomUUID().replace(/-/g, "")}` : account.id, + token: account.token + }))); const hasRealDebridWebPool = Object.prototype.hasOwnProperty.call(settings, "realDebridWebAccountIds"); let realDebridWebAccountIds = normalizeRealDebridWebAccountIds(settings.realDebridWebAccountIds); if (!hasRealDebridWebPool && Boolean(settings.realDebridUseWebLogin)) { diff --git a/src/renderer/account-edit.ts b/src/renderer/account-edit.ts index f013f1b..351e399 100644 --- a/src/renderer/account-edit.ts +++ b/src/renderer/account-edit.ts @@ -5,7 +5,7 @@ export type AccountKind = RendererAccountKind; export type SingleAccountKind = Exclude; export type AccountEditTarget = - | { type: "single"; rowKey: string; kind: SingleAccountKind; service: AccountService; provider: DebridProvider } + | { type: "single"; rowKey: string; kind: SingleAccountKind; service: AccountService; provider: DebridProvider; accountId?: string } | { 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 }; @@ -37,7 +37,7 @@ function parseDailyLimit(value: string, originalBytes: number): number { function targetAccountId(target: AccountEditTarget): string { if (target.type === "mega") return target.accountId; if (target.type === "debridlink") return target.keyId; - return `svc-${target.provider}`; + return target.accountId || `svc-${target.provider}`; } export function createAccountEditState(target: AccountEditTarget, accounts: readonly RendererAccount[]): AccountEditState { diff --git a/src/shared/real-debrid-accounts.ts b/src/shared/real-debrid-accounts.ts index dcb237b..aa34821 100644 --- a/src/shared/real-debrid-accounts.ts +++ b/src/shared/real-debrid-accounts.ts @@ -18,113 +18,79 @@ export interface RealDebridWebAccountEntry extends RealDebridAccountBase { export type RealDebridAccountEntry = RealDebridApiAccountEntry | RealDebridWebAccountEntry; +export type RealDebridApiCredential = { + id: string; + token: string; +}; + export type RealDebridAccountSettings = { realDebridApiTokens?: string; realDebridWebAccountIds?: string[]; realDebridDisabledAccountIds?: string[]; }; +const API_ACCOUNT_ID_RE = /^rda_[A-Za-z0-9_-]{1,96}$/; const WEB_ACCOUNT_ID_RE = /^rdw_[A-Za-z0-9_-]{1,96}$/; -const SHA256_INITIAL = [ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, - 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 -] as const; -const SHA256_CONSTANTS = [ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 -] as const; -function rotateRight(value: number, count: number): number { - return (value >>> count) | (value << (32 - count)); -} - -function sha256(text: string): string { - const bytes = new TextEncoder().encode(text); - const paddingLength = (64 - ((bytes.length + 1 + 8) % 64)) % 64; - const data = new Uint8Array(bytes.length + 1 + paddingLength + 8); - data.set(bytes); - data[bytes.length] = 0x80; - let bitLength = BigInt(bytes.length) * 8n; - for (let index = 0; index < 8; index += 1) { - data[data.length - 1 - index] = Number(bitLength & 0xffn); - bitLength >>= 8n; - } - const hash: number[] = [...SHA256_INITIAL]; - const words = new Uint32Array(64); - for (let offset = 0; offset < data.length; offset += 64) { - const view = new DataView(data.buffer, data.byteOffset + offset, 64); - for (let index = 0; index < 16; index += 1) { - words[index] = view.getUint32(index * 4, false); - } - for (let index = 16; index < 64; index += 1) { - const previous15 = words[index - 15]; - const previous2 = words[index - 2]; - const sigma0 = rotateRight(previous15, 7) ^ rotateRight(previous15, 18) ^ (previous15 >>> 3); - const sigma1 = rotateRight(previous2, 17) ^ rotateRight(previous2, 19) ^ (previous2 >>> 10); - words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0; - } - let [a, b, c, d, e, f, g, h] = hash; - for (let index = 0; index < 64; index += 1) { - const upperSigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); - const choice = (e & f) ^ (~e & g); - const temporary1 = (h + upperSigma1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0; - const upperSigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); - const majority = (a & b) ^ (a & c) ^ (b & c); - const temporary2 = (upperSigma0 + majority) >>> 0; - h = g; - g = f; - f = e; - e = (d + temporary1) >>> 0; - d = c; - c = b; - b = a; - a = (temporary1 + temporary2) >>> 0; - } - hash[0] = (hash[0] + a) >>> 0; - hash[1] = (hash[1] + b) >>> 0; - hash[2] = (hash[2] + c) >>> 0; - hash[3] = (hash[3] + d) >>> 0; - hash[4] = (hash[4] + e) >>> 0; - hash[5] = (hash[5] + f) >>> 0; - hash[6] = (hash[6] + g) >>> 0; - hash[7] = (hash[7] + h) >>> 0; - } - return hash.map((value) => value.toString(16).padStart(8, "0")).join(""); -} - -function normalizeTokens(values: readonly string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - const token = String(value || "").trim(); - if (!token || seen.has(token)) { - continue; - } - seen.add(token); - result.push(token); - } - return result; -} - -export function getRealDebridApiAccountId(token: string): string { - return `rda_${sha256(String(token || "").trim()).slice(0, 32)}`; +export function isRealDebridApiAccountId(value: string): boolean { + return API_ACCOUNT_ID_RE.test(String(value || "").trim()); } export function isRealDebridWebAccountId(value: string): boolean { return WEB_ACCOUNT_ID_RE.test(String(value || "").trim()); } +function normalizeCredentials(values: readonly RealDebridApiCredential[]): RealDebridApiCredential[] { + const ids = new Set(); + const tokens = new Set(); + const result: RealDebridApiCredential[] = []; + for (const value of values) { + const id = String(value?.id || "").trim(); + const token = String(value?.token || "").trim(); + if (!isRealDebridApiAccountId(id) || !token || /[\r\n]/.test(token) || ids.has(id) || tokens.has(token)) { + continue; + } + ids.add(id); + tokens.add(token); + result.push({ id, token }); + } + return result; +} + +function parsePersistedCredentials(raw: string): RealDebridApiCredential[] | null { + try { + const parsed = JSON.parse(raw) as { version?: unknown; accounts?: unknown }; + if (parsed?.version !== 1 || !Array.isArray(parsed.accounts)) { + return null; + } + return normalizeCredentials(parsed.accounts as RealDebridApiCredential[]); + } catch { + return null; + } +} + +function parseLegacyCredentials(raw: string): RealDebridApiCredential[] { + const tokens = new Set(); + const result: RealDebridApiCredential[] = []; + for (const value of raw.split(/[\n,]+/)) { + const token = value.trim(); + if (!token || /\r/.test(token) || tokens.has(token)) { + continue; + } + tokens.add(token); + result.push({ id: `rda_legacy_${result.length + 1}`, token }); + } + return result; +} + export function parseRealDebridApiAccounts(raw: string): RealDebridApiAccountEntry[] { - return normalizeTokens(String(raw || "").split(/[\n,]+/)).map((token, index) => ({ - id: getRealDebridApiAccountId(token), + const normalizedRaw = String(raw || "").trim(); + const credentials = normalizedRaw + ? parsePersistedCredentials(normalizedRaw) ?? parseLegacyCredentials(normalizedRaw) + : []; + return credentials.map((entry, index) => ({ + ...entry, kind: "api", - token, index, label: `API-Token ${index + 1}`, maskedLogin: "Geschützter API-Token", @@ -132,8 +98,9 @@ export function parseRealDebridApiAccounts(raw: string): RealDebridApiAccountEnt })); } -export function serializeRealDebridApiAccounts(tokens: readonly string[]): string { - return normalizeTokens(tokens).join("\n"); +export function serializeRealDebridApiAccounts(accounts: readonly RealDebridApiCredential[]): string { + const normalized = normalizeCredentials(accounts); + return normalized.length > 0 ? JSON.stringify({ version: 1, accounts: normalized }) : ""; } export function normalizeRealDebridWebAccountIds(raw: unknown): string[] { diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts index 74e26f3..b492e36 100644 --- a/tests/account-check.test.ts +++ b/tests/account-check.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID } from "../src/main/account-check"; +import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check"; import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts"; import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys"; import type { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { getRealDebridAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; function megaAccount(login = "user@example.com"): MegaDebridAccountEntry { return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" }; @@ -122,6 +123,13 @@ describe("checkDebridLinkKey", () => { }); describe("checkRealDebridAccount", () => { + it("keys every API account status by its concrete pool identity", async () => { + const token = "rd-api-pool-token"; + mockFetchOnce(200, { username: "api-user", type: "premium", expiration: new Date(NOW + 100_000).toISOString() }); + const account = getRealDebridAccounts({ realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_checkOpaque", token }]) })[0]; + const status = await checkRealDebridAccount(account, undefined, NOW); + expect(status.accountId).toBe("rda_checkOpaque"); + }); it("keeps browser-session username and email in separate status fields", async () => { const premiumUntilMs = NOW + 30 * 24 * 60 * 60 * 1000; const probe = vi.fn(async () => ({ @@ -173,6 +181,36 @@ describe("checkRealDebridAccount", () => { }); describe("checkAllDebridAccounts", () => { + it("discards a late Real-Debrid result after its account was removed", () => { + const removedId = "rda_removedAfterCheck"; + const lateStatus = { accountId: removedId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: NOW }; + expect(retainConfiguredRealDebridStatuses(defaultSettings(), [lateStatus])).toEqual([]); + }); + it("checks only enabled Real-Debrid pool entries in active scope and all entries in all scope", async () => { + const firstToken = "rd-pool-active"; + const secondToken = "rd-pool-disabled"; + const activeId = "rda_poolActive"; + const disabledId = "rda_poolDisabled"; + const settings = { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: activeId, token: firstToken }, { id: disabledId, token: secondToken }]), + realDebridWebAccountIds: ["rdw_first", "rdw_second"], + realDebridDisabledAccountIds: [disabledId, "rdw_second"] + }; + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ username: "api", type: "premium" }) })) as unknown as typeof fetch); + const probedAccountIds: string[] = []; + const probe = vi.fn(async (accountId: string) => { + probedAccountIds.push(accountId); + return { valid: true, isPremium: true, username: "web" }; + }); + + const active = await checkAllDebridAccounts(settings, undefined, probe, "active"); + const all = await checkAllDebridAccounts(settings, undefined, probe, "all"); + + expect(active.map((status) => status.accountId)).toEqual([activeId, "rdw_first"]); + expect(all.map((status) => status.accountId)).toEqual([activeId, disabledId, "rdw_first", "rdw_second"]); + expect(probedAccountIds).toEqual(["rdw_first", "rdw_first", "rdw_second"]); + }); it("returns empty array when nothing configured", async () => { const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings; const result = await checkAllDebridAccounts(settings); diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts index d63c935..7ddbb7c 100644 --- a/tests/account-commands.test.ts +++ b/tests/account-commands.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import { applyAccountCommand, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands"; +import { applyAccountCommand, resolveStoredAccountSecret, setRealDebridAccountEnabled, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands"; import * as accountCommands 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 { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import type { AppSettings, RendererAccountKind } from "../src/shared/types"; const ORIGINAL_SECRET = "fixture-original-secret-4qV8"; @@ -43,6 +44,63 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [ ]; describe("write-only account commands", () => { + it("manages multiple Real-Debrid API accounts without touching siblings", () => { + const firstToken = "fixture-rd-pool-first-1aB2"; + const secondToken = "fixture-rd-pool-second-3cD4"; + const replacementToken = "fixture-rd-pool-replacement-5eF6"; + const first = applyAccountCommand(defaultSettings(), validateAccountCommand({ action: "create", kind: "realdebrid-api", secret: firstToken, dailyLimitBytes: 2 * GIB })); + const second = applyAccountCommand(first.settings, validateAccountCommand({ action: "create", kind: "realdebrid-api", secret: secondToken, dailyLimitBytes: 3 * GIB })); + + expect(parseRealDebridApiAccounts(second.settings.realDebridApiTokens).map((entry) => entry.token)).toEqual([firstToken, secondToken]); + expect(second.response.accountId).toMatch(/^rda_[A-Za-z0-9_-]+$/); + expect(() => applyAccountCommand(second.settings, validateAccountCommand({ action: "create", kind: "realdebrid-api", secret: secondToken }))).toThrow(/ungültig/i); + expect(resolveStoredAccountSecret(second.settings, { kind: "realdebrid-api", accountId: first.response.accountId! })).toBe(firstToken); + expect(resolveStoredAccountSecret(second.settings, { kind: "realdebrid-api", accountId: second.response.accountId! })).toBe(secondToken); + + const replaced = applyAccountCommand(second.settings, validateAccountCommand({ + action: "replace", + kind: "realdebrid-api", + accountId: second.response.accountId, + secret: replacementToken, + dailyLimitBytes: 4 * GIB + })); + const replacementId = second.response.accountId!; + expect(parseRealDebridApiAccounts(replaced.settings.realDebridApiTokens).map((entry) => entry.token)).toEqual([firstToken, replacementToken]); + expect(replaced.settings.realDebridAccountDailyLimitBytes).toEqual({ [first.response.accountId!]: 2 * GIB, [replacementId]: 4 * GIB }); + + const deleted = applyAccountCommand(replaced.settings, validateAccountCommand({ action: "delete", kind: "realdebrid-api", accountId: first.response.accountId })); + expect(parseRealDebridApiAccounts(deleted.settings.realDebridApiTokens).map((entry) => entry.token)).toEqual([replacementToken]); + expect(deleted.response.accountId).toBe(replacementId); + }); + + it("creates, updates and deletes one Real-Debrid Web account by opaque ID", () => { + const first = applyAccountCommand(defaultSettings(), validateAccountCommand({ action: "create", kind: "realdebrid-web", identity: "rdw_first", dailyLimitBytes: GIB })); + const second = applyAccountCommand(first.settings, validateAccountCommand({ action: "create", kind: "realdebrid-web", identity: "rdw_second", dailyLimitBytes: 2 * GIB })); + expect(second.settings.realDebridWebAccountIds).toEqual(["rdw_first", "rdw_second"]); + expect(() => applyAccountCommand(second.settings, validateAccountCommand({ action: "create", kind: "realdebrid-web", identity: "rdw_second" }))).toThrow(/ungültig/i); + + const replaced = applyAccountCommand(second.settings, validateAccountCommand({ action: "replace", kind: "realdebrid-web", accountId: "rdw_second", dailyLimitBytes: 3 * GIB })); + expect(replaced.settings.realDebridAccountDailyLimitBytes).toEqual({ rdw_first: GIB, rdw_second: 3 * GIB }); + + const deleted = applyAccountCommand(replaced.settings, validateAccountCommand({ action: "delete", kind: "realdebrid-web", accountId: "rdw_first" })); + expect(deleted.settings.realDebridWebAccountIds).toEqual(["rdw_second"]); + expect(deleted.settings.realDebridAccountDailyLimitBytes).toEqual({ rdw_second: 3 * GIB }); + }); + + it("toggles only the selected Real-Debrid account ID", () => { + const firstToken = "fixture-rd-toggle-first"; + const secondToken = "fixture-rd-toggle-second"; + const firstId = "rda_toggleFirst"; + const secondId = "rda_toggleSecond"; + const settings = { ...defaultSettings(), realDebridApiTokens: serializeRealDebridApiAccounts([{ id: firstId, token: firstToken }, { id: secondId, token: secondToken }]) }; + + const disabled = setRealDebridAccountEnabled(settings, secondId, false); + expect(disabled.realDebridDisabledAccountIds).toEqual([secondId]); + expect(parseRealDebridApiAccounts(disabled.realDebridApiTokens).find((account) => account.id === firstId)?.enabled).toBe(true); + + const enabled = setRealDebridAccountEnabled(disabled, secondId, true); + expect(enabled.realDebridDisabledAccountIds).toEqual([]); + }); it("reveals only the exact explicitly requested stored account secret", () => { const api = accountCommands as typeof accountCommands & { resolveStoredAccountSecret?: (settings: AppSettings, request: { kind: RendererAccountKind; accountId: string }) => string; @@ -67,6 +125,11 @@ describe("write-only account commands", () => { expect(api.resolveStoredAccountSecret).toBeTypeOf("function"); expect(api.validateAccountSecretRequest).toBeTypeOf("function"); expect(api.resolveStoredAccountSecret?.(settings, { kind: "realdebrid-api", accountId: "svc-realdebrid" })).toBe("fixture-reveal-rd-1aB2"); + const pooled = { + ...settings, + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_revealOpaque", token: "fixture-reveal-rd-1aB2" }]) + }; + expect(() => api.resolveStoredAccountSecret?.(pooled, { kind: "realdebrid-api", accountId: "svc-realdebrid" })).toThrow(/nicht gefunden/i); expect(api.resolveStoredAccountSecret?.(settings, { kind: "megadebrid-api", accountId: megaId })).toBe("fixture-reveal-mega-3cD4"); expect(api.resolveStoredAccountSecret?.(settings, { kind: "debridlink-api", accountId: getDebridLinkApiKeyId("fixture-reveal-dl-5eF6") })).toBe("fixture-reveal-dl-5eF6"); expect(api.resolveStoredAccountSecret?.(settings, { kind: "bestdebrid-api", accountId: "svc-bestdebrid" })).toBe("fixture-reveal-best-7gH8"); diff --git a/tests/real-debrid-accounts.test.ts b/tests/real-debrid-accounts.test.ts index cd3fa1e..79b70e6 100644 --- a/tests/real-debrid-accounts.test.ts +++ b/tests/real-debrid-accounts.test.ts @@ -1,67 +1,57 @@ -import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; import { getRealDebridAccountIds, getRealDebridAccounts, - getRealDebridApiAccountId, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; describe("Real-Debrid account pool", () => { - it("parses distinct API tokens and removes exact duplicates", () => { - const accounts = parseRealDebridApiAccounts("first-secret-token\r\nsecond-secret-token\nfirst-secret-token"); + it("reads persisted opaque API identities and removes duplicate tokens", () => { + const raw = serializeRealDebridApiAccounts([ + { id: "rda_firstOpaque", token: "first-secret-token" }, + { id: "rda_secondOpaque", token: "second-secret-token" }, + { id: "rda_duplicateOpaque", token: "first-secret-token" } + ]); + const accounts = parseRealDebridApiAccounts(raw); expect(accounts).toHaveLength(2); + expect(accounts.map((entry) => entry.id)).toEqual(["rda_firstOpaque", "rda_secondOpaque"]); expect(accounts.map((entry) => entry.token)).toEqual(["first-secret-token", "second-secret-token"]); - expect(accounts.map((entry) => entry.label)).toEqual(["API-Token 1", "API-Token 2"]); }); - it("creates stable opaque IDs without secret material", () => { + it("keeps opaque IDs independent from token material", () => { const token = "private-real-debrid-token"; - const first = getRealDebridApiAccountId(token); - const second = getRealDebridApiAccountId(` ${token} `); + const raw = serializeRealDebridApiAccounts([{ id: "rda_opaqueAccount42", token }]); + const account = parseRealDebridApiAccounts(raw)[0]; - expect(first).toBe(second); - expect(first).toMatch(/^rda_[a-z0-9]+$/); - expect(first).not.toContain(token); - expect(parseRealDebridApiAccounts(token)[0]).toMatchObject({ id: first, kind: "api" }); - expect(parseRealDebridApiAccounts(token)[0].label).not.toContain(token); - expect(parseRealDebridApiAccounts(token)[0].maskedLogin).not.toContain(token); + expect(account.id).toBe("rda_opaqueAccount42"); + expect(account.id).not.toContain(token); + expect(account.label).not.toContain(token); + expect(account.maskedLogin).not.toContain(token); }); - it.each([ - "cryptographic-real-debrid-token", - "üñîçødé-real-debrid-token", - "x".repeat(160) - ])("derives API account IDs from a cryptographic SHA-256 fingerprint", (token) => { - const digest = crypto.createHash("sha256").update(token, "utf8").digest("hex").slice(0, 32); + it("serializes the versioned pool idempotently", () => { + const serialized = serializeRealDebridApiAccounts([ + { id: "rda_firstOpaque", token: " first-secret-token " }, + { id: "rda_secondOpaque", token: "second-secret-token" } + ]); - expect(getRealDebridApiAccountId(token)).toBe(`rda_${digest}`); - }); - - it("serializes normalized unique API tokens one per line", () => { - expect(serializeRealDebridApiAccounts([ - " first-secret-token ", - "", - "second-secret-token", - "first-secret-token" - ])).toBe("first-secret-token\nsecond-secret-token"); + expect(serializeRealDebridApiAccounts(parseRealDebridApiAccounts(serialized))).toBe(serialized); }); it("combines API and opaque Web accounts and marks disabled entries", () => { - const apiId = getRealDebridApiAccountId("api-secret"); const settings = { - realDebridApiTokens: "api-secret", + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_apiOpaque", token: "api-secret" }]), realDebridWebAccountIds: ["rdw_legacy", "rdw_second"], - realDebridDisabledAccountIds: [apiId, "rdw_second"] + realDebridDisabledAccountIds: ["rda_apiOpaque", "rdw_second"] }; expect(getRealDebridAccounts(settings)).toEqual([ - expect.objectContaining({ id: apiId, kind: "api", enabled: false }), + expect.objectContaining({ id: "rda_apiOpaque", kind: "api", enabled: false }), expect.objectContaining({ id: "rdw_legacy", kind: "web", enabled: true }), expect.objectContaining({ id: "rdw_second", kind: "web", enabled: false }) ]); - expect(getRealDebridAccountIds(settings)).toEqual([apiId, "rdw_legacy", "rdw_second"]); + expect(getRealDebridAccountIds(settings)).toEqual(["rda_apiOpaque", "rdw_legacy", "rdw_second"]); }); }); diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts index 91061f7..ef60a1f 100644 --- a/tests/renderer-state.test.ts +++ b/tests/renderer-state.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; +import crypto from "node:crypto"; 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 { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import type { AppSettings, RendererAccountKind } from "../src/shared/types"; const SECRETS = { @@ -38,6 +40,35 @@ const ACCOUNT_FIXTURES: Array<{ ]; describe("renderer state serialization", () => { + it("projects distinct Real-Debrid API and Web rows with per-account state", () => { + const firstToken = "fixture-renderer-rd-first-1aB2"; + const secondToken = "fixture-renderer-rd-second-3cD4"; + const firstId = "rda_rendererFirst"; + const secondId = "rda_rendererSecond"; + const settings = { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: firstId, token: firstToken }, { id: secondId, token: secondToken }]), + realDebridWebAccountIds: ["rdw_first"], + realDebridDisabledAccountIds: [secondId], + realDebridAccountDailyLimitBytes: { [firstId]: 100 }, + realDebridAccountDailyUsageBytes: { [firstId]: 25 }, + realDebridAccountTotalUsageBytes: { [firstId]: 500 }, + debridAccountStatuses: { + [firstId]: { accountId: firstId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützter API-Token", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: 1 } + } + }; + const state = createRendererState(settings); + const realDebridRows = state.accounts.filter((account) => account.provider === "realdebrid"); + + expect(realDebridRows).toEqual([ + expect.objectContaining({ accountId: firstId, kind: "realdebrid-api", enabled: true, dailyLimitBytes: 100, dailyUsageBytes: 25, totalUsageBytes: 500, status: expect.objectContaining({ accountId: firstId }) }), + expect.objectContaining({ accountId: secondId, kind: "realdebrid-api", enabled: false }), + expect.objectContaining({ accountId: "rdw_first", kind: "realdebrid-web", enabled: true }) + ]); + expect(JSON.stringify(state)).not.toContain(firstToken); + expect(JSON.stringify(state)).not.toContain(secondToken); + expect(realDebridRows[0].accountId).not.toBe(`rda_${crypto.createHash("sha256").update(firstToken).digest("hex").slice(0, 32)}`); + }); it.each(ACCOUNT_FIXTURES)("serializes $kind without its representative secret", ({ kind, secret, settings }) => { const state = createRendererState({ ...defaultSettings(), ...settings }); diff --git a/tests/settings-live-overlay.test.ts b/tests/settings-live-overlay.test.ts index 7895cc0..e131be3 100644 --- a/tests/settings-live-overlay.test.ts +++ b/tests/settings-live-overlay.test.ts @@ -3,8 +3,30 @@ import { defaultSettings } from "../src/main/constants"; import { overlayLiveUsageCounters } from "../src/main/settings-live-overlay"; import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; describe("live settings overlay", () => { + it("keeps only live Real-Debrid status and usage for accounts still configured", () => { + const keepToken = "fixture-overlay-rd-keep"; + const removedToken = "fixture-overlay-rd-removed"; + const keepId = "rda_overlayKeep"; + const removedId = "rda_overlayRemoved"; + const target = { ...defaultSettings(), realDebridApiTokens: serializeRealDebridApiAccounts([{ id: keepId, token: keepToken }]) }; + const status = (accountId: string) => ({ accountId, provider: "realdebrid" as const, label: "Real-Debrid", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: 1 }); + const live = { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: keepId, token: keepToken }, { id: removedId, token: removedToken }]), + realDebridAccountDailyUsageBytes: { [keepId]: 10, [removedId]: 20 }, + realDebridAccountTotalUsageBytes: { [keepId]: 100, [removedId]: 200 }, + debridAccountStatuses: { [keepId]: status(keepId), [removedId]: status(removedId) } + }; + + overlayLiveUsageCounters(target, live, 1); + + expect(target.realDebridAccountDailyUsageBytes).toEqual({ [keepId]: 10 }); + expect(target.realDebridAccountTotalUsageBytes).toEqual({ [keepId]: 100 }); + expect(target.debridAccountStatuses).toEqual({ [keepId]: status(keepId) }); + }); it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => { const keepMegaId = getMegaDebridAccountId("keep@example.com"); const removedMegaId = getMegaDebridAccountId("removed@example.com"); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 8137183..2749e3c 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; -import { getRealDebridApiAccountId } from "../src/shared/real-debrid-accounts"; +import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; import { configureCredentialProtector } from "../src/main/credential-protection"; @@ -663,7 +663,6 @@ describe("settings storage", () => { it("migrates legacy Real-Debrid API and Web accounts with their existing status", () => { const checkedAt = Date.now(); const apiToken = "legacy-real-debrid-token"; - const apiId = getRealDebridApiAccountId(apiToken); const legacyApi = { ...defaultSettings(), token: apiToken, @@ -685,8 +684,11 @@ describe("settings storage", () => { delete legacyApi.realDebridApiTokens; delete legacyApi.realDebridWebAccountIds; const normalizedApi = normalizeSettings(legacyApi as AppSettings); + const [migratedApiAccount] = parseRealDebridApiAccounts(normalizedApi.realDebridApiTokens); + const apiId = migratedApiAccount.id; - expect(normalizedApi.realDebridApiTokens).toBe(apiToken); + expect(migratedApiAccount.token).toBe(apiToken); + expect(apiId).toMatch(/^rda_[A-Za-z0-9_-]+$/); expect(normalizedApi.realDebridWebAccountIds).toEqual([]); expect(normalizedApi.debridAccountStatuses[apiId]).toMatchObject({ accountId: apiId, @@ -739,9 +741,30 @@ describe("settings storage", () => { expect(normalized.realDebridWebAccountIds).toEqual([]); }); + it("keeps two opaque Real-Debrid API IDs across normalize, save and load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-opaque-rd-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const expected = [ + { id: "rda_persistedFirst", token: "persisted-first-token" }, + { id: "rda_persistedSecond", token: "persisted-second-token" } + ]; + const normalized = normalizeSettings({ + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts(expected) + }); + + saveSettings(paths, normalized); + const loaded = loadSettings(paths); + + expect(parseRealDebridApiAccounts(normalized.realDebridApiTokens).map(({ id, token }) => ({ id, token }))).toEqual(expected); + expect(parseRealDebridApiAccounts(loaded.realDebridApiTokens).map(({ id, token }) => ({ id, token }))).toEqual(expected); + expect(normalizeSettings(loaded)).toEqual(loaded); + }); + it("prefers a concrete Real-Debrid status over the legacy status regardless of object order", () => { const token = "status-order-token"; - const accountId = getRealDebridApiAccountId(token); + const accountId = "rda_statusOrder"; const checkedAt = Date.now(); const legacyStatus = { accountId: "svc-realdebrid", @@ -767,7 +790,7 @@ describe("settings storage", () => { }; const normalizeWithOrder = (entries: [string, typeof legacyStatus][]) => normalizeSettings({ ...defaultSettings(), - realDebridApiTokens: token, + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token }]), debridAccountStatuses: Object.fromEntries(entries) }).debridAccountStatuses[accountId]; @@ -782,11 +805,16 @@ describe("settings storage", () => { }); it("normalizes the Real-Debrid pool idempotently and prunes stale account maps", () => { - const apiId = getRealDebridApiAccountId("api-token"); + const apiId = "rda_apiPrimary"; + const secondApiId = "rda_apiSecond"; const today = getProviderUsageDayKey(); const once = normalizeSettings({ ...defaultSettings(), - realDebridApiTokens: "api-token\napi-token\nsecond-token", + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: apiId, token: "api-token" }, + { id: "rda_duplicate", token: "api-token" }, + { id: secondApiId, token: "second-token" } + ]), realDebridWebAccountIds: ["rdw_legacy", "rdw_second", "broken", "rdw_second"], realDebridDisabledAccountIds: [apiId, "rdw_second", "stale"], realDebridAccountDailyLimitBytes: { [apiId]: 1000, rdw_second: 2000, stale: 3000 }, @@ -796,7 +824,10 @@ describe("settings storage", () => { }); const twice = normalizeSettings(once); - expect(once.realDebridApiTokens).toBe("api-token\nsecond-token"); + expect(parseRealDebridApiAccounts(once.realDebridApiTokens).map((account) => ({ id: account.id, token: account.token }))).toEqual([ + { id: apiId, token: "api-token" }, + { id: secondApiId, token: "second-token" } + ]); expect(once.realDebridWebAccountIds).toEqual(["rdw_legacy", "rdw_second"]); expect(once.realDebridDisabledAccountIds).toEqual([apiId, "rdw_second"]); expect(once.realDebridAccountDailyLimitBytes).toEqual({ [apiId]: 1000, rdw_second: 2000 }); @@ -806,10 +837,10 @@ describe("settings storage", () => { }); it("resets stale per-account Real-Debrid daily usage", () => { - const apiId = getRealDebridApiAccountId("api-token"); + const apiId = "rda_staleUsage"; const normalized = normalizeSettings({ ...defaultSettings(), - realDebridApiTokens: "api-token", + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: apiId, token: "api-token" }]), providerDailyUsageDay: "2001-01-01", realDebridAccountDailyUsageBytes: { [apiId]: 4000 }, realDebridAccountTotalUsageBytes: { [apiId]: 9000 }