diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index cdeb457..fa75f99 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -81,9 +81,14 @@ function sanitizeSettingsPatch(partial: Partial): Partial; } -function settingsFingerprint(settings: AppSettings): string { - return JSON.stringify(normalizeSettings(settings)); -} +function settingsFingerprint(settings: AppSettings): string { + return JSON.stringify(normalizeSettings(settings)); +} + +type PendingRealDebridWebAccount = { + generation: number; + dailyLimitBytes: number; +}; export class AppController { private settings: AppSettings; @@ -94,7 +99,7 @@ export class AppController { private realDebridWebFallbacks = new Map(); - private pendingRealDebridWebAccountIds = new Map(); + private pendingRealDebridWebAccountIds = new Map(); private realDebridWebGenerations = new Map(); @@ -732,7 +737,10 @@ export class AppController { if (!existing) { const generation = (this.realDebridWebGenerations.get(accountId) || 0) + 1; this.realDebridWebGenerations.set(accountId, generation); - this.pendingRealDebridWebAccountIds.set(accountId, generation); + this.pendingRealDebridWebAccountIds.set(accountId, { + generation, + dailyLimitBytes: request.create ? Math.floor(request.dailyLimitBytes || 0) : 0 + }); } this.audit("INFO", "Real-Debrid Login-Fenster geöffnet", { accountId, create: !existing }); try { @@ -789,7 +797,8 @@ export class AppController { return; } if (!account) { - if (this.pendingRealDebridWebAccountIds.get(accountId) !== generation) { + const pending = this.pendingRealDebridWebAccountIds.get(accountId); + if (!pending || pending.generation !== generation) { return; } const applied = applyAccountCommand(this.settings, { @@ -797,7 +806,7 @@ export class AppController { kind: "realdebrid-web", identity: accountId, secret: "", - dailyLimitBytes: 0 + dailyLimitBytes: pending.dailyLimitBytes }); this.pendingRealDebridWebAccountIds.delete(accountId); this.updateSettings(applied.settings); diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index e5d9579..2c3ce8c 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -153,6 +153,10 @@ export function createRendererSettings(settings: AppSettings): RendererSettings return { language: settings.language, realDebridUseWebLogin: settings.realDebridUseWebLogin, + realDebridDisabledAccountIds: [...settings.realDebridDisabledAccountIds], + realDebridAccountDailyLimitBytes: { ...settings.realDebridAccountDailyLimitBytes }, + realDebridAccountDailyUsageBytes: { ...settings.realDebridAccountDailyUsageBytes }, + realDebridAccountTotalUsageBytes: { ...settings.realDebridAccountTotalUsageBytes }, megaDebridApiEnabled: settings.megaDebridApiEnabled, megaDebridWebEnabled: settings.megaDebridWebEnabled, megaDebridPreferApi: settings.megaDebridPreferApi, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 631084e..ddb5548 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,7 @@ import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import type { RealDebridLoginRequest } from "../shared/preload-api"; import type { AccountCreateCommand, AllDebridHostInfo, @@ -40,7 +41,7 @@ import { } from "../shared/provider-daily-limits"; import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order"; import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection"; -import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, filterAccountDialogOptions, getAccountDialogSelectableOptions, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate, updateAccountRowSelection } from "./account-ui"; +import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate, sortAccountServices, updateAccountRowSelection } from "./account-ui"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; @@ -308,7 +309,7 @@ interface AccountTableRow { dailyLimitBytes: number; dailyRemainingBytes: number; totalUsedBytes: number; - toggleKind: "mega" | "dl" | "single"; + toggleKind: "rd" | "mega" | "dl" | "single"; dlKey?: DebridLinkAccountKeyEntry; editTarget: AccountEditTarget; } @@ -793,6 +794,15 @@ export function buildAccountCreateCommand(dialog: AccountDialogState): AccountCr dailyLimitBytes: parseAccountDailyLimitInputBytes(dialog.dailyLimitGb) || 0 }; } + +export function buildRealDebridWebCreateLoginRequest(dialog: AccountDialogState, accountId: string): RealDebridLoginRequest | null { + if (dialog.kind !== "realdebrid-web") return null; + return { + accountId, + create: true, + dailyLimitBytes: parseAccountDailyLimitInputBytes(dialog.dailyLimitGb) || 0 + }; +} const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, @@ -810,7 +820,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - language: "en", realDebridUseWebLogin: false, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false, + language: "en", realDebridUseWebLogin: false, realDebridDisabledAccountIds: [], realDebridAccountDailyLimitBytes: {}, realDebridAccountDailyUsageBytes: {}, realDebridAccountTotalUsageBytes: {}, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false, debridLinkDisabledKeyIds: [], archivePasswordListConfigured: false, notifyUrlConfigured: false, rememberToken: true, configuredProviders: [], providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", @@ -2307,8 +2317,55 @@ export function App(): ReactElement { const accountRows = useMemo(() => { const rows: AccountTableRow[] = []; - for (const entry of configuredAccounts) { - if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { + for (const entry of configuredAccounts) { + if (entry.service === "realdebrid") { + const accounts = snapshot.accounts.filter((account): account is RendererAccount & { kind: "realdebrid-api" | "realdebrid-web" } => ( + account.provider === "realdebrid" && (account.kind === "realdebrid-api" || account.kind === "realdebrid-web") + )); + for (const account of accounts) { + const option = findAccountOption(account.kind); + const disabled = (settingsDraft.disabledProviders || []).includes("realdebrid") + || (settingsDraft.realDebridDisabledAccountIds || []).includes(account.accountId); + const rowEntry: ConfiguredAccountEntry = { + ...entry, + kind: account.kind, + modeLabel: option.modeLabel, + statusLabel: disabled ? "Deaktiviert" : "Aktiviert", + summary: account.maskedIdentity, + summaryLines: [account.maskedIdentity], + disabled, + dailyUsedBytes: account.dailyUsageBytes, + totalUsedBytes: account.totalUsageBytes, + dailyLimitBytes: account.dailyLimitBytes, + dailyRemainingBytes: account.dailyLimitBytes > 0 ? Math.max(0, account.dailyLimitBytes - account.dailyUsageBytes) : 0, + dailyLimitReached: account.dailyLimitBytes > 0 && account.dailyUsageBytes >= account.dailyLimitBytes + }; + rows.push({ + rowKey: `rd-${account.accountId}`, + entry: rowEntry, + hosterLabel: option.serviceLabel, + modeLabel: option.modeLabel, + username: account.identity, + credentialLabel: getAccountCredentialLabel(account.kind), + accountId: account.accountId, + checkable: true, + disabled, + dailyUsedBytes: account.dailyUsageBytes, + dailyLimitBytes: account.dailyLimitBytes, + dailyRemainingBytes: account.dailyLimitBytes > 0 ? Math.max(0, account.dailyLimitBytes - account.dailyUsageBytes) : 0, + totalUsedBytes: account.totalUsageBytes, + toggleKind: "rd", + editTarget: { + type: "single", + rowKey: `rd-${account.accountId}`, + kind: account.kind, + service: "realdebrid", + provider: "realdebrid", + accountId: account.accountId + } + }); + } + } else if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { const accounts = accountsOfKind(entry.kind, snapshot.accounts); for (const acc of accounts) { const used = acc.dailyUsageBytes; @@ -2367,7 +2424,7 @@ export function App(): ReactElement { }); } } else { - const serviceAccountId = entry.service === "realdebrid" ? "svc-realdebrid" : null; + const serviceAccountId = null; rows.push({ rowKey: `svc-${entry.service}`, entry, @@ -2410,10 +2467,7 @@ export function App(): ReactElement { }); }, [accountRows]); const availableAccountOptions = useMemo(() => ( - ACCOUNT_OPTIONS.filter((option) => option.kind === "megadebrid-api" - || option.kind === "megadebrid-web" - || option.kind === "debridlink-api" - || !configuredAccountServices.has(option.service)) + getAvailableAccountOptions(ACCOUNT_OPTIONS, [...configuredAccountServices]) ), [configuredAccountServices]); const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null; const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null; @@ -2432,7 +2486,7 @@ export function App(): ReactElement { ); }, [accountDialog, availableAccountOptions]); const accountDialogServiceFilters = useMemo(() => ( - [...new Set(accountDialogSelectableOptions.map((option) => option.serviceLabel))] + sortAccountServices(accountDialogSelectableOptions.map((option) => option.serviceLabel)) ), [accountDialogSelectableOptions]); const filteredAccountDialogOptions = useMemo(() => ( filterAccountDialogOptions(accountDialogSelectableOptions, accountDialogSearch, accountDialogServiceFilter) @@ -2625,11 +2679,12 @@ export function App(): ReactElement { return result; }; - const runAccountQuickAction = async (action: AccountQuickAction): Promise => { - switch (action) { - case "realdebrid-login": - await window.rd.openRealDebridLogin(); - showToast("Real-Debrid Login-Fenster geöffnet", 2200); + const runAccountQuickAction = async (action: AccountQuickAction, accountId?: string | null): Promise => { + switch (action) { + case "realdebrid-login": + if (!accountId) throw new Error("Der ausgewählte Real-Debrid-Account wurde nicht gefunden."); + await window.rd.openRealDebridLogin({ accountId }); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); return; case "bestdebrid-cookies": { const count = await window.rd.importBestDebridCookies(); @@ -2733,7 +2788,7 @@ export function App(): ReactElement { applyPersistedSettings(result.settings); closeAccountEditDialog(); if (quickAction) { - await runAccountQuickAction(quickAction); + await runAccountQuickAction(quickAction, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null); } else { showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200); } @@ -2758,14 +2813,23 @@ export function App(): ReactElement { } const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { + if (dialogSnapshot.kind === "realdebrid-web") { + const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`; + const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId); + if (!request) throw new Error("Account-Payload ist ungültig"); + await window.rd.openRealDebridLogin(request); + closeAccountDialog(); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); + return; + } const command = buildAccountCreateCommand(dialogSnapshot); if (!command) throw new Error("Account-Payload ist ungültig"); const result = await window.rd.createAccount(command); setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); applyPersistedSettings(result.settings); - closeAccountDialog(); - if (quickAction) { - await runAccountQuickAction(quickAction); + closeAccountDialog(); + if (quickAction) { + await runAccountQuickAction(quickAction, result.accountId); } else if (selectedOption) { showToast(`${selectedOption.title} gespeichert`, 2200); } @@ -2845,15 +2909,15 @@ export function App(): ReactElement { }); }; - const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise => { - const meta = getAccountQuickActionMeta(entry.kind); - if (!meta) { - return; - } - await performQuickAction(async () => { - await runAccountQuickAction(meta.action); - }, (error) => { - showToast(`${entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200); + const onAccountRowQuickAction = async (row: AccountTableRow): Promise => { + const meta = getAccountQuickActionMeta(row.entry.kind); + if (!meta) { + return; + } + await performQuickAction(async () => { + await runAccountQuickAction(meta.action, row.accountId); + }, (error) => { + showToast(`${row.entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200); }); }; @@ -2920,9 +2984,31 @@ export function App(): ReactElement { }); }; + const onToggleRealDebridAccountEnabled = async (accountId: string, enabled: boolean): Promise => { + await performQuickAction(async () => { + const nextState = buildScopedAccountEnabledState( + settingsDraft.disabledProviders || [], + ["realdebrid"], + settingsDraft.realDebridDisabledAccountIds || [], + accountId, + enabled + ); + await persistAccountToggle({ + ...settingsDraft, + disabledProviders: nextState.disabledProviders, + realDebridDisabledAccountIds: nextState.disabledAccountIds + }); + showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000); + }, (error) => { + showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + const toggleAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if (row.toggleKind === "mega" && row.accountId) { + if (row.toggleKind === "rd" && row.accountId) { + void onToggleRealDebridAccountEnabled(row.accountId, row.disabled); + } else if (row.toggleKind === "mega" && row.accountId) { void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled); } else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey, row.disabled); @@ -2975,12 +3061,21 @@ export function App(): ReactElement { const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); + if ((row.entry.kind === "realdebrid-api" || row.entry.kind === "realdebrid-web") && row.accountId) { + const kind = row.entry.kind; + const accountId = row.accountId; + void performQuickAction(async () => { + const status = await window.rd.checkAccountCredentials({ kind, accountId }); + showToast(status.valid ? "Account erfolgreich geprüft" : status.message || "Zugangsdaten ungültig", 2600); + }, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200)); + return; + } if (row.checkable) { void checkAccounts("all"); return; } if (getAccountQuickActionMeta(row.entry.kind)) { - void onAccountRowQuickAction(row.entry); + void onAccountRowQuickAction(row); return; } showToast("Für diesen Account ist keine direkte Statusprüfung verfügbar.", 2800); @@ -5161,7 +5256,7 @@ export function App(): ReactElement { description: option.pickerDescription, functionLabel: getAccountPickerFunctionLabel(option), filter: option.modeLabel === "API" ? "api" : "web", - multi: option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", + multi: option.kind === "realdebrid-api" || option.kind === "realdebrid-web" || option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", icon: ACCOUNT_SERVICE_ICONS[option.service] })); const accountAddFields = buildAccountAddFields(accountDialog); @@ -5278,11 +5373,17 @@ export function App(): ReactElement { } const editSnapshot = accountEditDialog; void performQuickAction(async () => { - if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { + if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink" + || editSnapshot.target.kind === "realdebrid-api" || editSnapshot.target.kind === "realdebrid-web") { const secret = editSnapshot.target.type === "mega" ? editSnapshot.password : editSnapshot.token; + const kind = editSnapshot.target.kind as "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api"; const status = await window.rd.checkAccountCredentials({ - kind: editSnapshot.target.kind, - accountId: editSnapshot.target.type === "mega" ? editSnapshot.target.accountId : editSnapshot.target.keyId, + kind, + accountId: editSnapshot.target.type === "mega" + ? editSnapshot.target.accountId + : editSnapshot.target.type === "debridlink" + ? editSnapshot.target.keyId + : editSnapshot.target.accountId, identity: secret ? editSnapshot.login : undefined, secret: secret || undefined }); @@ -5292,7 +5393,7 @@ export function App(): ReactElement { } const quickAction = getAccountQuickActionMeta(editSnapshot.target.kind); if (quickAction) { - await runAccountQuickAction(quickAction.action); + await runAccountQuickAction(quickAction.action, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null); } else { showToast("Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", 2800); } @@ -5977,7 +6078,7 @@ export function App(): ReactElement { Account prüfen {getAccountQuickActionMeta(activeAccountContextRow.entry.kind) && ( - )} diff --git a/src/renderer/account-ui.ts b/src/renderer/account-ui.ts index f27f2c8..8c28187 100644 --- a/src/renderer/account-ui.ts +++ b/src/renderer/account-ui.ts @@ -6,6 +6,22 @@ export interface AccountModeOption { modeLabel: string; } +export function sortAccountServices(labels: readonly string[]): string[] { + return [...new Set(labels)].sort((left, right) => left.localeCompare(right, "de-DE", { sensitivity: "base" })); +} + +export function getAvailableAccountOptions( + options: readonly T[], + configuredServices: readonly string[] +): T[] { + const configured = new Set(configuredServices); + return options.filter((option) => option.service === "realdebrid" + || option.service === "megadebrid-api" + || option.service === "megadebrid-web" + || option.service === "debridlink" + || !configured.has(option.service)); +} + export function matchesAccountModeFilter(option: AccountModeOption, filter: AccountModeFilter): boolean { if (filter === "all") { return true; @@ -34,7 +50,8 @@ export function filterAccountDialogOptions left.serviceLabel.localeCompare(right.serviceLabel, "de-DE", { sensitivity: "base" }) + || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" })); } export function buildConfiguredProviderOrder( diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 9a54ddf..44f54a7 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -76,7 +76,7 @@ const pairs = [ ["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."], ["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"], ["Dienst / Zugangstyp", "Service / access type"], ["Dienst", "Service"], ["Typ/Funktion", "Type/function"], ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Zugangstyp suchen, z. B. API oder Web", "Search access type, e.g. API or web"], ["Dienst filtern", "Filter service"], ["Alle Dienste", "All services"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."], - ["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"], + ["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"], ["Der ausgewählte Real-Debrid-Account wurde nicht gefunden.", "The selected Real-Debrid account was not found."], ["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"], ["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"], ["Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.", "The selected Mega-Debrid account was not found."], ["Der ausgewählte Debrid-Link-Key wurde nicht gefunden.", "The selected Debrid-Link key was not found."], diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 9095517..be22a78 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -37,6 +37,7 @@ import { isRealDebridWebAccountId } from "./real-debrid-accounts"; export interface RealDebridLoginRequest { accountId: string; create?: boolean; + dailyLimitBytes?: number; } export function validateRealDebridLoginRequest(value: unknown): Required { @@ -44,13 +45,22 @@ export function validateRealDebridLoginRequest(value: unknown): Required; - if (Object.keys(raw).some((key) => key !== "accountId" && key !== "create") + if (Object.keys(raw).some((key) => key !== "accountId" && key !== "create" && key !== "dailyLimitBytes") || typeof raw.accountId !== "string" || !isRealDebridWebAccountId(raw.accountId) - || (raw.create !== undefined && typeof raw.create !== "boolean")) { + || (raw.create !== undefined && typeof raw.create !== "boolean") + || (raw.dailyLimitBytes !== undefined && (typeof raw.dailyLimitBytes !== "number" + || !Number.isFinite(raw.dailyLimitBytes) + || raw.dailyLimitBytes < 0 + || raw.dailyLimitBytes > Number.MAX_SAFE_INTEGER))) { throw new Error("Account-Payload ist ungültig"); } - return { accountId: raw.accountId.trim(), create: raw.create === true }; + const create = raw.create === true; + const dailyLimitBytes = Math.floor(Number(raw.dailyLimitBytes) || 0); + if (!create && dailyLimitBytes !== 0) { + throw new Error("Account-Payload ist ungültig"); + } + return { accountId: raw.accountId.trim(), create, dailyLimitBytes: create ? dailyLimitBytes : 0 }; } export interface ElectronApi { diff --git a/src/shared/types.ts b/src/shared/types.ts index 6c00ca5..6c7ff25 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -237,6 +237,10 @@ export interface RendererAccount { export interface RendererSettings { language: AppLanguage; realDebridUseWebLogin: boolean; + realDebridDisabledAccountIds: string[]; + realDebridAccountDailyLimitBytes: Record; + realDebridAccountDailyUsageBytes: Record; + realDebridAccountTotalUsageBytes: Record; megaDebridApiEnabled: boolean; megaDebridWebEnabled: boolean; megaDebridPreferApi: boolean; diff --git a/tests/account-dialog-prefer-api.test.ts b/tests/account-dialog-prefer-api.test.ts index 520b5bb..70fe16d 100644 --- a/tests/account-dialog-prefer-api.test.ts +++ b/tests/account-dialog-prefer-api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildAccountCreateCommand, createAccountDialogState } from "../src/renderer/App"; +import { buildAccountCreateCommand, buildRealDebridWebCreateLoginRequest, createAccountDialogState } from "../src/renderer/App"; import { createRendererSettings } from "../src/main/renderer-state"; import { defaultSettings } from "../src/main/constants"; @@ -27,4 +27,18 @@ describe("account creation dialog", () => { expect(dialog.password).toBe(""); expect(dialog.megaAccounts).toEqual([]); }); + + it("forwards the selected daily limit with a newly reserved Real-Debrid Web account", () => { + const settings = createRendererSettings(defaultSettings()); + const dialog = { + ...createAccountDialogState("create", "realdebrid-web", settings), + dailyLimitGb: "2,5" + }; + + expect(buildRealDebridWebCreateLoginRequest(dialog, "rdw_reservedopaqueid")).toEqual({ + accountId: "rdw_reservedopaqueid", + create: true, + dailyLimitBytes: Math.floor(2.5 * 1024 * 1024 * 1024) + }); + }); }); diff --git a/tests/account-edit.test.ts b/tests/account-edit.test.ts index d61ff1d..ebc9782 100644 --- a/tests/account-edit.test.ts +++ b/tests/account-edit.test.ts @@ -22,6 +22,39 @@ function megaTarget(identity: string): AccountEditTarget { } describe("renderer-safe account editing", () => { + it("targets one concrete Real-Debrid API account for replace and delete", () => { + const settings = defaultSettings(); + settings.realDebridApiTokens = JSON.stringify({ + version: 1, + accounts: [ + { id: "rda_first", token: "fixture-token-first" }, + { id: "rda_second", token: "fixture-token-second" } + ] + }); + const renderer = createRendererState(settings); + const target: AccountEditTarget = { + type: "single", + rowKey: "rd-rda_second", + kind: "realdebrid-api", + service: "realdebrid", + provider: "realdebrid", + accountId: "rda_second" + }; + const edit = createAccountEditState(target, renderer.accounts); + + expect(buildAccountReplaceCommand({ ...edit, token: "replacement-token" })).toEqual(expect.objectContaining({ + action: "replace", + kind: "realdebrid-api", + accountId: "rda_second", + secret: "replacement-token" + })); + expect(buildAccountDeleteCommand(target)).toEqual({ + action: "delete", + kind: "realdebrid-api", + accountId: "rda_second" + }); + }); + it("opens an existing account without reading its stored secret", () => { const identity = "safe-edit@example.test"; const state = createRendererState({ diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index b736aa8..b0a8e2f 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -76,11 +76,11 @@ describe("account preload contract", () => { it("forwards account-bound existing and create browser logins", async () => { await electron.api?.openRealDebridLogin({ accountId: "rdw_existing" }); - await electron.api?.openRealDebridLogin({ accountId: "rdw_reserved", create: true }); + await electron.api?.openRealDebridLogin({ accountId: "rdw_reserved", create: true, dailyLimitBytes: 10_000 }); expect(electron.invoke.mock.calls).toEqual([ [IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_existing" }], - [IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_reserved", create: true }] + [IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_reserved", create: true, dailyLimitBytes: 10_000 }] ]); }); diff --git a/tests/account-ui.test.ts b/tests/account-ui.test.ts index 6502e8d..67d6ff1 100644 --- a/tests/account-ui.test.ts +++ b/tests/account-ui.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { buildConfiguredProviderOrder, filterAccountDialogOptions, + getAvailableAccountOptions, getAccountDialogSelectableOptions, isAccountRowSelectionKey, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, - resolveVisibleAccountKind + resolveVisibleAccountKind, + sortAccountServices } from "../src/renderer/account-ui"; import * as accountUi from "../src/renderer/account-ui"; @@ -32,7 +34,40 @@ describe("account dialog filter", () => { it("combines an exact service choice with an access-type search", () => { expect(filterAccountDialogOptions(options, "web", "Real-Debrid").map((option) => option.id)).toEqual(["rd-web"]); - expect(filterAccountDialogOptions(options, "api", "all").map((option) => option.id)).toEqual(["rd-api", "md-api"]); + expect(filterAccountDialogOptions(options, "api", "all").map((option) => option.id)).toEqual(["md-api", "rd-api"]); + }); + + it("sorts services alphabetically in German without duplicates", () => { + expect(sortAccountServices([ + "Real-Debrid", + "Mega-Debrid", + "BestDebrid", + "Debrid-Link", + "1Fichier", + "AllDebrid", + "DDownload", + "LinkSnappy", + "Mega-Debrid" + ])).toEqual([ + "1Fichier", + "AllDebrid", + "BestDebrid", + "DDownload", + "Debrid-Link", + "LinkSnappy", + "Mega-Debrid", + "Real-Debrid" + ]); + }); + + it("keeps both Real-Debrid access types addable after existing Real-Debrid accounts", () => { + const choices = [ + { kind: "realdebrid-api", service: "realdebrid" }, + { kind: "realdebrid-web", service: "realdebrid" }, + { kind: "bestdebrid-api", service: "bestdebrid" } + ]; + expect(getAvailableAccountOptions(choices, ["realdebrid", "bestdebrid"]).map((option) => option.kind)) + .toEqual(["realdebrid-api", "realdebrid-web"]); }); }); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index f923615..2554562 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -174,6 +174,7 @@ describe("renderer localization", () => { ["Schätzwert", "Estimate"], ["ist verfügbar. Installierte Version:", "is available. Installed version:"], ["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."], + ["Der ausgewählte Real-Debrid-Account wurde nicht gefunden.", "The selected Real-Debrid account was not found."], ["Nur lokal", "Local only"], ["Name kopiert", "Name copied"], ["Link kopiert", "Link copied"], diff --git a/tests/ipc-security.test.ts b/tests/ipc-security.test.ts index a781a6d..93b1c91 100644 --- a/tests/ipc-security.test.ts +++ b/tests/ipc-security.test.ts @@ -15,11 +15,14 @@ function eventFor(url: string) { describe("ipc-security", () => { it("accepts only opaque Real-Debrid browser account login requests", () => { - expect(validateRealDebridLoginRequest({ accountId: "rdw_existing" })).toEqual({ accountId: "rdw_existing", create: false }); - expect(validateRealDebridLoginRequest({ accountId: "rdw_reserved", create: true })).toEqual({ accountId: "rdw_reserved", create: true }); + expect(validateRealDebridLoginRequest({ accountId: "rdw_existing" })).toEqual({ accountId: "rdw_existing", create: false, dailyLimitBytes: 0 }); + expect(validateRealDebridLoginRequest({ accountId: "rdw_reserved", create: true, dailyLimitBytes: 12_345.9 })).toEqual({ accountId: "rdw_reserved", create: true, dailyLimitBytes: 12_345 }); expect(() => validateRealDebridLoginRequest({ accountId: "../shared", create: true })).toThrow(/Account-Payload/i); expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: "yes" })).toThrow(/Account-Payload/i); expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: false, token: "secret" })).toThrow(/Account-Payload/i); + expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: true, dailyLimitBytes: -1 })).toThrow(/Account-Payload/i); + expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: true, dailyLimitBytes: Number.MAX_SAFE_INTEGER + 1 })).toThrow(/Account-Payload/i); + expect(() => validateRealDebridLoginRequest({ accountId: "rdw_existing", dailyLimitBytes: 1 })).toThrow(/Account-Payload/i); }); it("accepts IPC from the configured Vite development renderer origin", () => { diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 2d5412d..883be55 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -255,7 +255,7 @@ describe("realdebrid-web", () => { dispose: vi.fn() }; controller.settings = defaultSettings(); - controller.pendingRealDebridWebAccountIds = new Map([["rdw_reserved", 0]]); + controller.pendingRealDebridWebAccountIds = new Map([["rdw_reserved", { generation: 0, dailyLimitBytes: 987_654 }]]); controller.realDebridWebGenerations = new Map([["rdw_reserved", 0]]); controller.realDebridWebFallbacks = new Map([["rdw_reserved", fallback]]); controller.manager = { applyDebridAccountStatuses: applyStatuses }; @@ -272,6 +272,7 @@ describe("realdebrid-web", () => { expect(updateSettings).toHaveBeenCalledTimes(1); expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_reserved"]); + expect(controller.settings.realDebridAccountDailyLimitBytes).toEqual({ rdw_reserved: 987_654 }); expect(applyStatuses).toHaveBeenCalledWith([ expect.objectContaining({ accountId: "rdw_reserved", valid: true, username: "fixture-user" }) ]); @@ -334,7 +335,7 @@ describe("realdebrid-web", () => { controller.realDebridWebAuthenticationTasks = new Map(); controller.audit = vi.fn(); - await expect(controller.openRealDebridLoginWindow({ accountId: "rdw_failed", create: true })).rejects.toThrow("load failed"); + await expect(controller.openRealDebridLoginWindow({ accountId: "rdw_failed", create: true, dailyLimitBytes: 123_456 })).rejects.toThrow("load failed"); expect(controller.pendingRealDebridWebAccountIds.size).toBe(0); expect(controller.realDebridWebFallbacks.size).toBe(0); @@ -353,11 +354,12 @@ describe("realdebrid-web", () => { controller.realDebridWebAuthenticationTasks = new Map(); controller.audit = vi.fn(); - await controller.openRealDebridLoginWindow({ accountId: "rdw_closed", create: true }); + await controller.openRealDebridLoginWindow({ accountId: "rdw_closed", create: true, dailyLimitBytes: 123_456 }); mockBrowserWindow.close(); await vi.waitFor(() => expect(controller.realDebridWebFallbacks.size).toBe(0)); expect(controller.pendingRealDebridWebAccountIds.size).toBe(0); + expect(controller.settings.realDebridAccountDailyLimitBytes).toEqual({}); expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_closed"); expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_closed"); }); @@ -404,13 +406,14 @@ describe("realdebrid-web", () => { }); controller.audit = vi.fn(); - await controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true }); + await controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true, dailyLimitBytes: 123_456 }); await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1)); mockBrowserWindow.close(); resolveClosingToken("close-time-token"); await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"])); expect(controller.pendingRealDebridWebAccountIds.size).toBe(0); + expect(controller.settings.realDebridAccountDailyLimitBytes).toEqual({ rdw_close_auth: 123_456 }); expect(mockFromPartition).not.toHaveBeenCalledWith("persist:realdebrid-web-rdw_close_auth"); expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([ expect.objectContaining({ accountId: "rdw_close_auth", valid: true, username: "close-user" }) diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts index ef60a1f..23d9aae 100644 --- a/tests/renderer-state.test.ts +++ b/tests/renderer-state.test.ts @@ -159,4 +159,25 @@ describe("renderer state serialization", () => { expect(serialized).not.toContain(secret); } }); + + it("exposes only non-secret Real-Debrid account controls to the renderer", () => { + const settings = { + ...defaultSettings(), + realDebridApiTokens: JSON.stringify({ version: 1, accounts: [{ id: "rda_visible", token: "fixture-rd-secret" }] }), + realDebridDisabledAccountIds: ["rda_visible"], + realDebridAccountDailyLimitBytes: { rda_visible: 10_000 }, + realDebridAccountDailyUsageBytes: { rda_visible: 2_000 }, + realDebridAccountTotalUsageBytes: { rda_visible: 8_000 } + }; + + const renderer = createRendererState(settings).settings; + + expect(renderer.realDebridDisabledAccountIds).toEqual(["rda_visible"]); + expect(renderer.realDebridAccountDailyLimitBytes).toEqual({ rda_visible: 10_000 }); + expect(renderer.realDebridAccountDailyUsageBytes).toEqual({ rda_visible: 2_000 }); + expect(renderer.realDebridAccountTotalUsageBytes).toEqual({ rda_visible: 8_000 }); + expect(JSON.stringify(renderer)).not.toContain("fixture-rd-secret"); + expect(renderer).not.toHaveProperty("realDebridApiTokens"); + expect(renderer).not.toHaveProperty("realDebridWebAccountIds"); + }); });