From 56a05fa66f55cbd712ec3d8661e8db89c00af4b4 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 15 Aug 2026 19:55:19 +0200 Subject: [PATCH] feat(realdebrid): add multi-account settings model --- src/main/account-status-sanitizer.ts | 5 + src/main/constants.ts | 10 +- src/main/credential-protection.ts | 1 + src/main/storage.ts | 63 ++++++++-- src/shared/real-debrid-accounts.ts | 175 +++++++++++++++++++++++++++ src/shared/types.ts | 8 +- tests/credential-protection.test.ts | 23 ++++ tests/real-debrid-accounts.test.ts | 67 ++++++++++ tests/storage.test.ts | 173 +++++++++++++++++++++++++- tests/visual/fixtures.ts | 10 +- 10 files changed, 515 insertions(+), 20 deletions(-) create mode 100644 src/shared/real-debrid-accounts.ts create mode 100644 tests/real-debrid-accounts.test.ts diff --git a/src/main/account-status-sanitizer.ts b/src/main/account-status-sanitizer.ts index 8dd7128..100d78e 100644 --- a/src/main/account-status-sanitizer.ts +++ b/src/main/account-status-sanitizer.ts @@ -1,5 +1,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { parseRealDebridApiAccounts } from "../shared/real-debrid-accounts"; import type { AppSettings, DebridAccountStatus } from "../shared/types"; const REDACTED = "[geschützt]"; @@ -45,6 +46,7 @@ export function collectAccountStatusRedactionValues(settings?: AppSettings, inpu const values = new Set(); if (settings) { addRedaction(values, settings.token); + addRedaction(values, settings.realDebridApiTokens); addRedaction(values, settings.megaPassword); addRedaction(values, settings.megaCredentials); addRedaction(values, settings.megaDebridApiCredentials); @@ -66,6 +68,9 @@ export function collectAccountStatusRedactionValues(settings?: AppSettings, inpu for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys)) { addRedaction(values, key.token); } + for (const account of parseRealDebridApiAccounts(settings.realDebridApiTokens)) { + addRedaction(values, account.token); + } } const inputIdentity = getInputString(input, "identity"); const inputSecret = getInputString(input, "secret"); diff --git a/src/main/constants.ts b/src/main/constants.ts index c89a948..6c1c55d 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -44,8 +44,14 @@ export function defaultSettings(): AppSettings { const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader"); return { language: "en", - token: "", - realDebridUseWebLogin: false, + token: "", + realDebridUseWebLogin: false, + realDebridApiTokens: "", + realDebridWebAccountIds: [], + realDebridDisabledAccountIds: [], + realDebridAccountDailyLimitBytes: {}, + realDebridAccountDailyUsageBytes: {}, + realDebridAccountTotalUsageBytes: {}, megaLogin: "", megaPassword: "", megaCredentials: "", diff --git a/src/main/credential-protection.ts b/src/main/credential-protection.ts index ad2d225..9aa662c 100644 --- a/src/main/credential-protection.ts +++ b/src/main/credential-protection.ts @@ -10,6 +10,7 @@ export interface CredentialProtector { const MASKED_CREDENTIAL = "••••••••"; const CREDENTIAL_KEYS = [ "token", + "realDebridApiTokens", "megaLogin", "megaPassword", "megaCredentials", diff --git a/src/main/storage.ts b/src/main/storage.ts index 8cfeddb..6791449 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -6,6 +6,7 @@ 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"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; +import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts"; import { defaultSettings } from "./constants"; import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection"; import { logger } from "./logger"; @@ -270,15 +271,20 @@ function normalizeDebridAccountStatuses( value: unknown, megaIds: string[], debridLinkIds: string[], - realDebridConfigured: boolean + realDebridIds: string[], + legacyRealDebridTargetId: string | null ): Record { - const allowed = new Set([...megaIds, ...debridLinkIds, ...(realDebridConfigured ? ["svc-realdebrid"] : [])]); + const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]); const result: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) { - for (const [key, raw] of Object.entries(value as Record)) { - if (!allowed.has(key) || !raw || typeof raw !== "object") { - continue; - } + for (const [storedKey, raw] of Object.entries(value as Record)) { + const key = storedKey === "svc-realdebrid" ? legacyRealDebridTargetId : storedKey; + if (!key || !allowed.has(key) || !raw || typeof raw !== "object") { + continue; + } + if (storedKey === "svc-realdebrid" && result[key]) { + continue; + } const entry = raw as Partial; if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") { continue; @@ -295,7 +301,7 @@ function normalizeDebridAccountStatuses( email = undefined; } result[key] = { - accountId: entry.accountId, + accountId: key, provider, label: String(entry.label || ""), maskedLogin: String(entry.maskedLogin || ""), @@ -439,6 +445,28 @@ export function normalizeSettings(settings: AppSettings): AppSettings { ? normalizeStringList(settings.megaDebridWebDisabledAccountIds, megaDebridWebAccountIds) : legacyMegaDisabledAccountIds.filter((id) => megaDebridWebAccountIds.includes(id)); 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 hasRealDebridWebPool = Object.prototype.hasOwnProperty.call(settings, "realDebridWebAccountIds"); + let realDebridWebAccountIds = normalizeRealDebridWebAccountIds(settings.realDebridWebAccountIds); + if (!hasRealDebridWebPool && Boolean(settings.realDebridUseWebLogin)) { + realDebridWebAccountIds = ["rdw_legacy"]; + } + const realDebridAccountIds = getRealDebridAccountIds({ + realDebridApiTokens, + realDebridWebAccountIds, + realDebridDisabledAccountIds: [] + }); + const realDebridDisabledAccountIds = normalizeStringList(settings.realDebridDisabledAccountIds, realDebridAccountIds); + const legacyRealDebridTargetId = Boolean(settings.realDebridUseWebLogin) && realDebridWebAccountIds.includes("rdw_legacy") + ? "rdw_legacy" + : (parseRealDebridApiAccounts(realDebridApiTokens)[0]?.id || realDebridWebAccountIds[0] || null); const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay); const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw) ? providerDailyUsageDayRaw @@ -469,8 +497,16 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); const normalized: AppSettings = { language: settings.language === "de" ? "de" : "en", - token: asText(settings.token), - realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), + token: asText(settings.token), + realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), + realDebridApiTokens, + realDebridWebAccountIds, + realDebridDisabledAccountIds, + realDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.realDebridAccountDailyLimitBytes, realDebridAccountIds), + realDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay + ? normalizeNamedByteMap(settings.realDebridAccountDailyUsageBytes, realDebridAccountIds) + : {}, + realDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.realDebridAccountTotalUsageBytes, realDebridAccountIds), megaLogin, megaPassword, megaCredentials, @@ -588,7 +624,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds, - Boolean(settings.realDebridUseWebLogin || asText(settings.token)) + realDebridAccountIds, + legacyRealDebridTargetId ), providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) @@ -742,6 +779,12 @@ function readSettingsFile(filePath: string): LoadedSettingsFile | null { if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridWebDisabledAccountIds")) { delete (mergedInput as Partial).megaDebridWebDisabledAccountIds; } + if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridApiTokens")) { + delete (mergedInput as Partial).realDebridApiTokens; + } + if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridWebAccountIds")) { + delete (mergedInput as Partial).realDebridWebAccountIds; + } const merged = normalizeSettings(mergedInput); return { settings: merged, needsCredentialRewrite }; } catch (error) { diff --git a/src/shared/real-debrid-accounts.ts b/src/shared/real-debrid-accounts.ts new file mode 100644 index 0000000..dcb237b --- /dev/null +++ b/src/shared/real-debrid-accounts.ts @@ -0,0 +1,175 @@ +export interface RealDebridAccountBase { + id: string; + kind: "api" | "web"; + index: number; + label: string; + maskedLogin: string; + enabled: boolean; +} + +export interface RealDebridApiAccountEntry extends RealDebridAccountBase { + kind: "api"; + token: string; +} + +export interface RealDebridWebAccountEntry extends RealDebridAccountBase { + kind: "web"; +} + +export type RealDebridAccountEntry = RealDebridApiAccountEntry | RealDebridWebAccountEntry; + +export type RealDebridAccountSettings = { + realDebridApiTokens?: string; + realDebridWebAccountIds?: string[]; + realDebridDisabledAccountIds?: string[]; +}; + +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 isRealDebridWebAccountId(value: string): boolean { + return WEB_ACCOUNT_ID_RE.test(String(value || "").trim()); +} + +export function parseRealDebridApiAccounts(raw: string): RealDebridApiAccountEntry[] { + return normalizeTokens(String(raw || "").split(/[\n,]+/)).map((token, index) => ({ + id: getRealDebridApiAccountId(token), + kind: "api", + token, + index, + label: `API-Token ${index + 1}`, + maskedLogin: "Geschützter API-Token", + enabled: true + })); +} + +export function serializeRealDebridApiAccounts(tokens: readonly string[]): string { + return normalizeTokens(tokens).join("\n"); +} + +export function normalizeRealDebridWebAccountIds(raw: unknown): string[] { + if (!Array.isArray(raw)) { + return []; + } + const seen = new Set(); + const result: string[] = []; + for (const value of raw) { + const id = String(value || "").trim(); + if (!isRealDebridWebAccountId(id) || seen.has(id)) { + continue; + } + seen.add(id); + result.push(id); + } + return result; +} + +export function getRealDebridAccounts(settings: RealDebridAccountSettings): RealDebridAccountEntry[] { + const disabled = new Set(Array.isArray(settings.realDebridDisabledAccountIds) ? settings.realDebridDisabledAccountIds : []); + const apiAccounts = parseRealDebridApiAccounts(settings.realDebridApiTokens || "").map((entry) => ({ + ...entry, + enabled: !disabled.has(entry.id) + })); + const webAccounts = normalizeRealDebridWebAccountIds(settings.realDebridWebAccountIds).map((id, index) => ({ + id, + kind: "web" as const, + index, + label: `Browser-Login ${index + 1}`, + maskedLogin: "Geschützter Browser-Login", + enabled: !disabled.has(id) + })); + return [...apiAccounts, ...webAccounts]; +} + +export function getRealDebridAccountIds(settings: RealDebridAccountSettings): string[] { + return getRealDebridAccounts(settings).map((entry) => entry.id); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index fd3d981..6c00ca5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -95,7 +95,13 @@ export interface DebridAccountStatus { export interface AppSettings { language: AppLanguage; token: string; - realDebridUseWebLogin: boolean; + realDebridUseWebLogin: boolean; + realDebridApiTokens: string; + realDebridWebAccountIds: string[]; + realDebridDisabledAccountIds: string[]; + realDebridAccountDailyLimitBytes: Record; + realDebridAccountDailyUsageBytes: Record; + realDebridAccountTotalUsageBytes: Record; megaLogin: string; megaPassword: string; megaCredentials: string; diff --git a/tests/credential-protection.test.ts b/tests/credential-protection.test.ts index 0f64f8c..c3afc96 100644 --- a/tests/credential-protection.test.ts +++ b/tests/credential-protection.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultSettings } from "../src/main/constants"; import { logger } from "../src/main/logger"; +import { collectAccountStatusRedactionValues, sanitizeAccountStatusText } from "../src/main/account-status-sanitizer"; import { configureCredentialProtector, CredentialProtector, @@ -31,6 +32,7 @@ describe("credential protection", () => { ...defaultSettings(), rememberToken: true, token: "value-to-protect", + realDebridApiTokens: "first-real-debrid-token\nsecond-real-debrid-token", megaLogin: "account@example.invalid", megaPassword: "password-value" }; @@ -38,10 +40,13 @@ describe("credential protection", () => { const persisted = protectPersistedSettings(input); expect(persisted.token).not.toBe(input.token); + expect(persisted.realDebridApiTokens).not.toBe(input.realDebridApiTokens); expect(persisted.megaLogin).not.toBe(input.megaLogin); expect(JSON.stringify(persisted)).not.toContain(input.token); + expect(JSON.stringify(persisted)).not.toContain("first-real-debrid-token"); expect(restorePersistedSettings(persisted)).toMatchObject({ token: input.token, + realDebridApiTokens: input.realDebridApiTokens, megaLogin: input.megaLogin, megaPassword: input.megaPassword }); @@ -135,6 +140,7 @@ describe("credential protection", () => { ...defaultSettings(), rememberToken: true, token: "value-to-protect", + realDebridApiTokens: "pool-value-to-protect", megaDebridApiCredentials: "account@example.invalid:password-value", debridLinkApiKeys: "key-value" }; @@ -143,11 +149,28 @@ describe("credential protection", () => { const serialized = JSON.stringify(projected); expect(serialized).not.toContain(input.token); + expect(serialized).not.toContain(input.realDebridApiTokens); expect(serialized).not.toContain("account@example.invalid"); expect(serialized).not.toContain("password-value"); expect(serialized).not.toContain("key-value"); expect(projected.token).not.toBe(""); + expect(projected.realDebridApiTokens).not.toBe(""); expect(projected.megaDebridApiCredentials).not.toBe(""); expect(projected.debridLinkApiKeys).not.toBe(""); }); + + it("redacts every Real-Debrid pool token from account status text", () => { + const settings = { + ...defaultSettings(), + realDebridApiTokens: "first-private-token\nsecond-private-token" + }; + const redactions = collectAccountStatusRedactionValues(settings); + const sanitized = sanitizeAccountStatusText( + "Unrestrict first-private-token schlug fehl; Fallback second-private-token ebenfalls", + redactions + ); + + expect(sanitized).not.toContain("first-private-token"); + expect(sanitized).not.toContain("second-private-token"); + }); }); diff --git a/tests/real-debrid-accounts.test.ts b/tests/real-debrid-accounts.test.ts new file mode 100644 index 0000000..cd3fa1e --- /dev/null +++ b/tests/real-debrid-accounts.test.ts @@ -0,0 +1,67 @@ +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"); + + expect(accounts).toHaveLength(2); + 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", () => { + const token = "private-real-debrid-token"; + const first = getRealDebridApiAccountId(token); + const second = getRealDebridApiAccountId(` ${token} `); + + 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); + }); + + 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); + + 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"); + }); + + it("combines API and opaque Web accounts and marks disabled entries", () => { + const apiId = getRealDebridApiAccountId("api-secret"); + const settings = { + realDebridApiTokens: "api-secret", + realDebridWebAccountIds: ["rdw_legacy", "rdw_second"], + realDebridDisabledAccountIds: [apiId, "rdw_second"] + }; + + expect(getRealDebridAccounts(settings)).toEqual([ + expect.objectContaining({ id: apiId, 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"]); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 0c9471a..8137183 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; 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 { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import { getRealDebridApiAccountId } 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"; @@ -659,9 +660,168 @@ describe("settings storage", () => { expect(normalizedDisabled.realDebridUseWebLogin).toBe(false); }); - it("keeps the Real-Debrid service status while web login remains configured", () => { + 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, + debridAccountStatuses: { + "svc-realdebrid": { + accountId: "svc-realdebrid", + provider: "realdebrid", + label: "Real-Debrid", + maskedLogin: "API-Token", + valid: true, + isPremium: true, + premiumUntilMs: checkedAt + 1000, + username: "api-user", + message: "Premium aktiv", + checkedAt + } + } + } as Partial; + delete legacyApi.realDebridApiTokens; + delete legacyApi.realDebridWebAccountIds; + const normalizedApi = normalizeSettings(legacyApi as AppSettings); + + expect(normalizedApi.realDebridApiTokens).toBe(apiToken); + expect(normalizedApi.realDebridWebAccountIds).toEqual([]); + expect(normalizedApi.debridAccountStatuses[apiId]).toMatchObject({ + accountId: apiId, + provider: "realdebrid", + username: "api-user" + }); + expect(normalizedApi.debridAccountStatuses["svc-realdebrid"]).toBeUndefined(); + + const legacyWeb = { + ...defaultSettings(), + realDebridUseWebLogin: true, + debridAccountStatuses: { + "svc-realdebrid": { + accountId: "svc-realdebrid", + provider: "realdebrid", + label: "Real-Debrid", + maskedLogin: "Browser-Login", + valid: true, + isPremium: true, + premiumUntilMs: checkedAt + 1000, + username: "web-user", + message: "Premium aktiv", + checkedAt + } + } + } as Partial; + delete legacyWeb.realDebridApiTokens; + delete legacyWeb.realDebridWebAccountIds; + const normalizedWeb = normalizeSettings(legacyWeb as AppSettings); + + expect(normalizedWeb.realDebridApiTokens).toBe(""); + expect(normalizedWeb.realDebridWebAccountIds).toEqual(["rdw_legacy"]); + expect(normalizedWeb.debridAccountStatuses.rdw_legacy).toMatchObject({ + accountId: "rdw_legacy", + provider: "realdebrid", + username: "web-user" + }); + }); + + it("keeps authoritative empty Real-Debrid pools empty instead of restoring legacy accounts", () => { const normalized = normalizeSettings({ + ...defaultSettings(), + token: "deleted-legacy-api-token", + realDebridUseWebLogin: true, + realDebridApiTokens: "", + realDebridWebAccountIds: [] + }); + + expect(normalized.realDebridApiTokens).toBe(""); + expect(normalized.realDebridWebAccountIds).toEqual([]); + }); + + 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 checkedAt = Date.now(); + const legacyStatus = { + accountId: "svc-realdebrid", + provider: "realdebrid" as const, + label: "Legacy", + maskedLogin: "Legacy", + valid: false, + isPremium: false, + premiumUntilMs: null, + username: "legacy-user", + message: "Legacy status", + checkedAt: checkedAt - 1000 + }; + const concreteStatus = { + ...legacyStatus, + accountId, + label: "Concrete", + valid: true, + isPremium: true, + username: "concrete-user", + message: "Concrete status", + checkedAt + }; + const normalizeWithOrder = (entries: [string, typeof legacyStatus][]) => normalizeSettings({ + ...defaultSettings(), + realDebridApiTokens: token, + debridAccountStatuses: Object.fromEntries(entries) + }).debridAccountStatuses[accountId]; + + expect(normalizeWithOrder([["svc-realdebrid", legacyStatus], [accountId, concreteStatus]])).toMatchObject({ + valid: true, + username: "concrete-user" + }); + expect(normalizeWithOrder([[accountId, concreteStatus], ["svc-realdebrid", legacyStatus]])).toMatchObject({ + valid: true, + username: "concrete-user" + }); + }); + + it("normalizes the Real-Debrid pool idempotently and prunes stale account maps", () => { + const apiId = getRealDebridApiAccountId("api-token"); + const today = getProviderUsageDayKey(); + const once = normalizeSettings({ + ...defaultSettings(), + realDebridApiTokens: "api-token\napi-token\nsecond-token", + realDebridWebAccountIds: ["rdw_legacy", "rdw_second", "broken", "rdw_second"], + realDebridDisabledAccountIds: [apiId, "rdw_second", "stale"], + realDebridAccountDailyLimitBytes: { [apiId]: 1000, rdw_second: 2000, stale: 3000 }, + realDebridAccountDailyUsageBytes: { [apiId]: 4000, stale: 5000 }, + realDebridAccountTotalUsageBytes: { rdw_second: 6000, stale: 7000 }, + providerDailyUsageDay: today + }); + const twice = normalizeSettings(once); + + expect(once.realDebridApiTokens).toBe("api-token\nsecond-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 }); + expect(once.realDebridAccountDailyUsageBytes).toEqual({ [apiId]: 4000 }); + expect(once.realDebridAccountTotalUsageBytes).toEqual({ rdw_second: 6000 }); + expect(twice).toEqual(once); + }); + + it("resets stale per-account Real-Debrid daily usage", () => { + const apiId = getRealDebridApiAccountId("api-token"); + const normalized = normalizeSettings({ + ...defaultSettings(), + realDebridApiTokens: "api-token", + providerDailyUsageDay: "2001-01-01", + realDebridAccountDailyUsageBytes: { [apiId]: 4000 }, + realDebridAccountTotalUsageBytes: { [apiId]: 9000 } + }); + + expect(normalized.realDebridAccountDailyUsageBytes).toEqual({}); + expect(normalized.realDebridAccountTotalUsageBytes).toEqual({ [apiId]: 9000 }); + }); + + it("moves the Real-Debrid service status to the migrated Web account", () => { + const checkedAt = Date.now(); + const legacy = { ...defaultSettings(), realDebridUseWebLogin: true, debridAccountStatuses: { @@ -679,10 +839,13 @@ describe("settings storage", () => { checkedAt } } - }); + } as Partial; + delete legacy.realDebridApiTokens; + delete legacy.realDebridWebAccountIds; + const normalized = normalizeSettings(legacy as AppSettings); - expect(normalized.debridAccountStatuses["svc-realdebrid"]).toMatchObject({ - accountId: "svc-realdebrid", + expect(normalized.debridAccountStatuses.rdw_legacy).toMatchObject({ + accountId: "rdw_legacy", provider: "realdebrid", valid: true, username: "web-user", diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 3af8ac8..f19d910 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -55,8 +55,14 @@ function createSettings(): AppSettings { const megaAccountId = getMegaDebridAccountId(megaLogin); const debridLinkKeys = parseDebridLinkApiKeys(debridLinkApiKeys); return { - token: "visual-real-debrid-token", - realDebridUseWebLogin: false, + token: "visual-real-debrid-token", + realDebridUseWebLogin: false, + realDebridApiTokens: "visual-real-debrid-token", + realDebridWebAccountIds: [], + realDebridDisabledAccountIds: [], + realDebridAccountDailyLimitBytes: {}, + realDebridAccountDailyUsageBytes: {}, + realDebridAccountTotalUsageBytes: {}, megaLogin, megaPassword: "visual-password", language: "de",