From 0119534e079569dec2568149cea8eaaa30fe997a Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 15 Aug 2026 01:14:54 +0200 Subject: [PATCH] fix(accounts): refresh active logins and toggle instantly Split bulk account checks into active-only and all-configured scopes so disabled providers, Mega-Debrid accounts, and Debrid-Link keys no longer distort the normal result count. Preserve explicit full checks for diagnosing disabled credentials and surface their failed status. Render account enablement from the optimistic settings draft, persist it with revision-safe rollback, and apply the same immediate behavior to individual and bulk switches. Extend the IPC contract, translations, changelog, and regression coverage. --- CHANGELOG.md | 8 ++ src/main/account-check.ts | 39 ++++-- src/main/app-controller.ts | 11 +- src/main/main.ts | 8 +- src/preload/preload.ts | 3 +- src/renderer/App.tsx | 121 ++++++++++-------- src/renderer/account-ui.ts | 30 +++++ src/renderer/i18n.ts | 8 +- .../views/settings/AccountWorkspace.tsx | 10 +- src/shared/preload-api.ts | 3 +- src/shared/types.ts | 2 + tests/account-check.test.ts | 52 +++++++- tests/account-preload.test.ts | 12 ++ tests/i18n.test.ts | 2 + tests/settings-view.test.tsx | 44 ++++++- 15 files changed, 272 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c784d..fb2e090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [Unreleased] + +### Accounts + +- Split account refresh into active-account and all-account checks with matching result counts. +- Show failed check results for disabled accounts when all configured accounts are refreshed. +- Apply individual and bulk account enablement changes immediately while settings are saved, with automatic rollback after a failed save. + ## [2.0.40] - 2026-08-15 ### Interface diff --git a/src/main/account-check.ts b/src/main/account-check.ts index 392eeed..7c716fe 100644 --- a/src/main/account-check.ts +++ b/src/main/account-check.ts @@ -1,5 +1,5 @@ -import type { AppSettings, DebridAccountStatus } from "../shared/types"; -import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +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 { logger } from "./logger"; import { compactErrorText } from "./utils"; @@ -270,14 +270,35 @@ export async function checkDebridLinkKey( export async function checkAllDebridAccounts( settings: AppSettings, signal?: AbortSignal, - probeRealDebridWebSession?: RealDebridSessionProbe + probeRealDebridWebSession?: RealDebridSessionProbe, + scope: AccountCheckScope = "all" ): Promise { - const now = Date.now(); - const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); - const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); - + const now = Date.now(); + const providerEnabled = (provider: DebridProvider): boolean => !(settings.disabledProviders || []).includes(provider); + const configuredMegaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); + const activeMegaAccounts = (["api", "web"] as const).flatMap((mode) => { + const provider: DebridProvider = mode === "api" ? "megadebrid-api" : "megadebrid-web"; + const modeEnabled = mode === "api" ? settings.megaDebridApiEnabled : settings.megaDebridWebEnabled; + if (!modeEnabled || !providerEnabled(provider) || !providerEnabled("megadebrid")) { + return []; + } + const disabledIds = new Set(getMegaDebridDisabledAccountIdsForMode(settings, mode)); + return getMegaDebridAccountsForMode(settings, mode).filter((account) => !disabledIds.has(account.id)); + }); + const megaAccounts = scope === "all" + ? configuredMegaAccounts + : [...new Map(activeMegaAccounts.map((account) => [account.id, account])).values()]; + const configuredDebridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); + const debridLinkKeys = scope === "all" + ? configuredDebridLinkKeys + : providerEnabled("debridlink") + ? configuredDebridLinkKeys.filter((key) => !(settings.debridLinkDisabledKeyIds || []).includes(key.id)) + : []; + const checkRealDebrid = Boolean(settings.realDebridUseWebLogin || String(settings.token || "").trim()) + && (scope === "all" || providerEnabled("realdebrid")); + const taskFns: Array<() => Promise> = [ - ...(settings.realDebridUseWebLogin || String(settings.token || "").trim() + ...(checkRealDebrid ? [() => checkRealDebridAccount(settings, signal, now, probeRealDebridWebSession)] : []), ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), @@ -286,7 +307,7 @@ export async function checkAllDebridAccounts( const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY); logger.info( - `Account-Check abgeschlossen: ${results.length} Accounts geprueft ` + + `Account-Check abgeschlossen: scope=${scope}, ${results.length} Accounts geprueft ` + `(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)` ); return results; diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 72d934d..8e9f1a1 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -3,8 +3,9 @@ import os from "node:os"; import v8 from "node:v8"; import { app } from "electron"; import { - AddLinksPayload, - AllDebridHostInfo, + AddLinksPayload, + AccountCheckScope, + AllDebridHostInfo, AppSettings, AccountCommand, AccountCommandResult, @@ -647,18 +648,20 @@ export class AppController { return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); } -public async checkDebridAccounts(): Promise { + public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise { const statuses = sanitizeDebridAccountStatuses( await checkAllDebridAccounts( this.settings, undefined, - (signal) => this.realDebridWebFallback.probeLoginState(signal) + (signal) => this.realDebridWebFallback.probeLoginState(signal), + scope ), collectAccountStatusRedactionValues(this.settings) ); this.manager.applyDebridAccountStatuses(statuses); this.audit("INFO", "Debrid-Accounts geprueft", { total: statuses.length, + scope, valid: statuses.filter((s) => s.valid).length, premium: statuses.filter((s) => s.isPremium).length }); diff --git a/src/main/main.ts b/src/main/main.ts index 1dd3608..8e8e8c8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -843,8 +843,12 @@ function registerIpcHandlers(): void { return controller.getDebridLinkHostLimits(); }); - handleTrusted(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => { - return controller.checkDebridAccounts(); + handleTrusted(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, rawScope: unknown) => { + const scope = rawScope === undefined ? "active" : validateString(rawScope, "scope"); + if (scope !== "active" && scope !== "all") { + throw new Error("scope ist ungültig"); + } + return controller.checkDebridAccounts(scope); }); handleTrusted(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, async (_event, rawInput: unknown) => { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index f32207f..7c10974 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; import { AddLinksPayload, + AccountCheckScope, AccountCommandResult, AccountCredentialCheckInput, AccountCreateCommand, @@ -103,7 +104,7 @@ const api: ElectronApi = { importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), - checkDebridAccounts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS), + checkDebridAccounts: (scope: AccountCheckScope = "active"): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, scope), checkAccountCredentials: (input: AccountCredentialCheckInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9a3556c..8c7a92a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -40,7 +40,7 @@ import { } from "../shared/provider-daily-limits"; import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order"; import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; -import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; +import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate } from "./account-ui"; import type { AccountModeFilter } from "./account-ui"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; @@ -2245,7 +2245,7 @@ export function App(): ReactElement { label: `Key ${index + 1}`, token: "", masked: account.maskedIdentity, - disabled: !account.enabled, + disabled: settingsDraft.debridLinkDisabledKeyIds.includes(account.accountId), dailyUsedBytes: keyDailyUsedBytes, totalUsedBytes: account.totalUsageBytes, dailyLimitBytes: keyDailyLimitBytes, @@ -2318,7 +2318,9 @@ export function App(): ReactElement { credentialLabel: "••••••", accountId: acc.accountId, checkable: true, - disabled: !acc.enabled, + disabled: entry.disabled || (entry.kind === "megadebrid-api" + ? settingsDraft.megaDebridApiDisabledAccountIds.includes(acc.accountId) + : settingsDraft.megaDebridWebDisabledAccountIds.includes(acc.accountId)), dailyUsedBytes: used, dailyLimitBytes: limit, dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, @@ -2344,7 +2346,7 @@ export function App(): ReactElement { credentialLabel: "API-Key", accountId: key.id, checkable: true, - disabled: key.disabled, + disabled: entry.disabled || settingsDraft.debridLinkDisabledKeyIds.includes(key.id), dailyUsedBytes: key.dailyUsedBytes, dailyLimitBytes: key.dailyLimitBytes, dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0, @@ -2388,7 +2390,7 @@ export function App(): ReactElement { } } return rows; - }, [configuredAccounts, snapshot.accounts]); + }, [configuredAccounts, settingsDraft, snapshot.accounts]); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); @@ -2651,16 +2653,17 @@ export function App(): ReactElement { } }; - const checkAllAccounts = useCallback(async (): Promise => { - setAccountCheckBusy(true); - try { - const statuses = await window.rd.checkDebridAccounts(); + const checkAccounts = useCallback(async (scope: "active" | "all"): Promise => { + setAccountCheckBusy(true); + try { + const statuses = await window.rd.checkDebridAccounts(scope); if (!statuses || statuses.length === 0) { - showToast("Keine prüfbaren Accounts konfiguriert.", 3200); - } else { - const valid = statuses.filter((st) => st.valid).length; - const premium = statuses.filter((st) => st.isPremium).length; - showToast(`Account-Check: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600); + showToast(scope === "active" ? "Keine aktiven prüfbaren Accounts konfiguriert." : "Keine prüfbaren Accounts konfiguriert.", 3200); + } else { + const valid = statuses.filter((st) => st.valid).length; + const premium = statuses.filter((st) => st.isPremium).length; + const label = scope === "active" ? "Aktive Accounts" : "Alle Accounts"; + showToast(`${label}: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600); } } catch (error) { showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600); @@ -2767,7 +2770,7 @@ export function App(): ReactElement { } else if (selectedOption) { showToast(`${selectedOption.title} gespeichert`, 2200); } - void checkAllAccounts(); + void checkAccounts("active"); }, (error) => { showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); }); @@ -2783,31 +2786,56 @@ export function App(): ReactElement { }); }; - const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise => { + const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise => { await performQuickAction(async () => { const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId); syncLiveProviderUsageSettings(result); showToast(`${entry.serviceLabel} ${keyLabel}: Tageszähler zurückgesetzt`, 2200); }, (error) => { showToast(`${entry.serviceLabel} ${keyLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200); - }); - }; - - const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise => { - await performQuickAction(async () => { - const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || []; - const nextDisabledIds = key.disabled - ? currentDisabledIds.filter((existingId) => existingId !== key.id) - : [...currentDisabledIds, key.id]; + }); + }; + + const persistAccountToggle = async (nextDraft: RendererSettingsDraft): Promise => { + const previousDraft = settingsDraft; + const previousDirty = settingsDirtyRef.current; + const previousSaveState = settingsSaveState; + const revision = ++settingsDraftRevisionRef.current; + return runOptimisticAccountUpdate( + () => { + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("saving"); + setSettingsDraft(nextDraft); + }, + () => persistSpecificSettings(nextDraft), + () => { + if (settingsDraftRevisionRef.current !== revision) return; + settingsDraftRevisionRef.current += 1; + settingsDirtyRef.current = previousDirty; + setSettingsDirty(previousDirty); + setSettingsSaveState(previousSaveState); + setSettingsDraft(previousDraft); + } + ); + }; + + const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise => { + await performQuickAction(async () => { + const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || []; + const currentlyDisabled = currentDisabledIds.includes(key.id); + const nextDisabledIds = currentlyDisabled + ? currentDisabledIds.filter((existingId) => existingId !== key.id) + : [...currentDisabledIds, key.id]; const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - debridLinkDisabledKeyIds: nextDisabledIds - }; - await persistSpecificSettings(nextDraft); - showToast( - key.disabled - ? `${entry.serviceLabel} ${key.label} aktiviert` - : `${entry.serviceLabel} ${key.label} deaktiviert`, + ...settingsDraft, + debridLinkDisabledKeyIds: nextDisabledIds + }; + await persistAccountToggle(nextDraft); + showToast( + currentlyDisabled + ? `${entry.serviceLabel} ${key.label} aktiviert` + : `${entry.serviceLabel} ${key.label} deaktiviert`, 2200 ); }, (error) => { @@ -2834,7 +2862,7 @@ export function App(): ReactElement { const next = currentlyDisabled ? current.filter((id) => id !== accountId) : [...current, accountId]; const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds; const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds; - await persistSpecificSettings({ + await persistAccountToggle({ ...settingsDraft, megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], megaDebridApiDisabledAccountIds: apiDisabledIds, @@ -2865,10 +2893,10 @@ export function App(): ReactElement { ? current.filter((existing) => existing !== provider) : [...current, provider]; const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - disabledProviders: nextDisabledProviders - }; - await persistSpecificSettings(nextDraft); + ...settingsDraft, + disabledProviders: nextDisabledProviders + }; + await persistAccountToggle(nextDraft); showToast( nextDisabledProviders.includes(provider) ? `${entry.serviceLabel} deaktiviert` @@ -2907,7 +2935,7 @@ export function App(): ReactElement { megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string) }; - await persistSpecificSettings(nextDraft); + await persistAccountToggle(nextDraft); showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200); }, (error) => { showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200); @@ -2944,7 +2972,7 @@ export function App(): ReactElement { const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); if (row.checkable) { - void checkAllAccounts(); + void checkAccounts("all"); return; } if (getAccountQuickActionMeta(row.entry.kind)) { @@ -4831,15 +4859,7 @@ export function App(): ReactElement { : null; const accountSources = useMemo(() => accountRows.map((row) => { const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; - const state: AccountRowSource["status"]["state"] = row.disabled - ? "disabled" - : !checkedStatus - ? "unchecked" - : checkedStatus && !checkedStatus.valid - ? "invalid" - : checkedStatus && !checkedStatus.isPremium - ? "free" - : "premium"; + const state: AccountRowSource["status"]["state"] = resolveAccountStatusState(row.disabled, checkedStatus); return { identityId: row.accountId || row.rowKey, service: row.entry.service, @@ -4940,7 +4960,8 @@ export function App(): ReactElement { const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null; if (row) removeAccountTableRow(row); }, - onCheckAll: () => { void checkAllAccounts(); }, + onCheckActive: () => { void checkAccounts("active"); }, + onCheckAll: () => { void checkAccounts("all"); }, onSetAllEnabled: (enabled) => { void setAllAccountsEnabled(enabled); }, onStatusSort: cycleAccountStatusSort, onMoveProvider: (index, direction) => { diff --git a/src/renderer/account-ui.ts b/src/renderer/account-ui.ts index 6c278a2..c3603f9 100644 --- a/src/renderer/account-ui.ts +++ b/src/renderer/account-ui.ts @@ -59,6 +59,36 @@ export function resolveAccountUsername(storedUsername: string, checkedEmail?: st return checkedEmail?.trim() || storedUsername.trim() || "—"; } +export function resolveAccountStatusState( + disabled: boolean, + checkedStatus?: { valid: boolean; isPremium: boolean } +): "disabled" | "unchecked" | "invalid" | "free" | "premium" { + if (checkedStatus && !checkedStatus.valid) { + return "invalid"; + } + if (disabled) { + return "disabled"; + } + if (!checkedStatus) { + return "unchecked"; + } + return checkedStatus.isPremium ? "premium" : "free"; +} + +export async function runOptimisticAccountUpdate( + apply: () => void, + persist: () => Promise, + rollback: () => void +): Promise { + apply(); + try { + return await persist(); + } catch (error) { + rollback(); + throw error; + } +} + export function buildBulkAccountEnabledState( currentDisabledProviders: DebridProvider[], configuredProviders: DebridProvider[], diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index bd55832..678d219 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -47,7 +47,7 @@ const pairs = [ ["Integritätsprüfung und Aufräumen nach Downloads und Entpacken.", "Integrity checks and cleanup after downloading and extraction."], ["Prüfung", "Verification"], ["Dateien auf Fehler prüfen", "Check files for errors"], ["Nach dem Entpacken", "After extraction"], ["Link-Dateien danach entfernen", "Remove link files afterward"], ["Vorschau-Dateien danach entfernen", "Remove sample files afterward"], ["Archive nach dem Entpacken", "Archives after extraction"], ["Fertige Downloads und Konflikte", "Completed downloads and conflicts"], ["Fertige Downloads aus der Liste", "Completed downloads in the list"], ["Bei gleichnamigen Dateien", "When files have the same name"], - ["Hinzufügen", "Add"], ["Aktualisieren", "Refresh"], ["+ Hinzufügen", "+ Add"], ["− Entfernen", "− Remove"], ["↻ Aktualisieren", "↻ Refresh"], ["Provider-Reihenfolge", "Provider order"], ["Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.", "Choose the order in which available providers are used."], + ["Hinzufügen", "Add"], ["Aktualisieren", "Refresh"], ["+ Hinzufügen", "+ Add"], ["− Entfernen", "− Remove"], ["↻ Aktualisieren", "↻ Refresh"], ["↻ Aktive aktualisieren", "↻ Refresh active"], ["↻ Alle aktualisieren", "↻ Refresh all"], ["Prüft nur aktivierte Accounts.", "Checks enabled accounts only."], ["Prüft alle angelegten Accounts, auch deaktivierte.", "Checks all configured accounts, including disabled accounts."], ["Provider-Reihenfolge", "Provider order"], ["Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.", "Choose the order in which available providers are used."], ["Automatischer Fallback", "Automatic fallback"], ["Zugangsdaten lokal speichern", "Store credentials locally"], ["Hoster-Routing", "Hoster routing"], ["Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.", "Custom assignments override the default order for each hoster."], ["Rotations-Verlauf", "Rotation history"], ["Sammlung", "Collection"], ["URL oder Rohzeile", "URL or raw line"], ["Zeile", "Line"], ["Paket / Datei", "Package / file"], ["Größe", "Size"], ["Gestartet", "Started"], ["Beendet", "Finished"], ["Alle Einträge", "All entries"], ["Heute", "Today"], ["Letzte 7 Tage", "Last 7 days"], ["Älter", "Older"], ["Gelöscht", "Deleted"], ["Fehlgeschlagen", "Failed"], @@ -122,7 +122,7 @@ const pairs = [ ["Rapidgator-Status kann direkt aus der Liste geladen werden.", "RapidGator status can be loaded directly from the list."], ["Status basiert auf den zuletzt gespeicherten AllDebrid-Daten.", "Status is based on the last saved AllDebrid data."], ["Update wird vorbereitet", "Preparing update"], ["Stilles Update gestartet - App wird neu gestartet", "Silent update started - the app will restart"], ["Einstellungen gespeichert", "Settings saved"], ["Real-Debrid Login-Fenster geöffnet", "Real-Debrid login window opened"], ["AllDebrid Login-Fenster geöffnet", "AllDebrid login window opened"], ["Keine Cookie-Datei ausgewählt", "No cookie file selected"], - ["Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", "No Mega-Debrid or Debrid-Link accounts configured for checking."], ["Account aktiviert", "Account enabled"], ["Account deaktiviert", "Account disabled"], + ["Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", "No Mega-Debrid or Debrid-Link accounts configured for checking."], ["Keine prüfbaren Accounts konfiguriert.", "No checkable accounts configured."], ["Keine aktiven prüfbaren Accounts konfiguriert.", "No active checkable accounts configured."], ["Account aktiviert", "Account enabled"], ["Account deaktiviert", "Account disabled"], ["Account entfernen", "Remove account"], ["Account entfernt", "Account removed"], ["Key entfernen", "Remove key"], ["Key entfernt", "Key removed"], ["Accounts aktiviert", "Accounts enabled"], ["Accounts deaktiviert", "Accounts disabled"], ["Für diesen Account ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this account."], ["Keine gespeicherten Links vorhanden", "No saved links available"], ["Keine Links hinzugefügt", "No links added"], ["Fehler beim Hinzufügen", "Error while adding"], ["Verlaufseintrag entfernen", "Remove history entry"], ["Verlaufseinträge entfernen", "Remove history entries"], ["Diesen Eintrag aus dem Verlauf entfernen?", "Remove this entry from history?"], @@ -288,6 +288,8 @@ function translateDynamic(value: string, language: AppLanguage): string { if (disabledKeys) return `${disabledKeys[1]} API keys disabled.`; const accountCheck = value.match(/^Account-Check: (\d+\/\d+) Login gültig, (\d+) mit Premium\.$/); if (accountCheck) return `Account check: ${accountCheck[1]} logins valid, ${accountCheck[2]} with premium.`; + const scopedAccountCheck = value.match(/^(Aktive|Alle) Accounts: (\d+\/\d+) Login gültig, (\d+) mit Premium\.$/); + if (scopedAccountCheck) return `${scopedAccountCheck[1] === "Aktive" ? "Active" : "All"} accounts: ${scopedAccountCheck[2]} logins valid, ${scopedAccountCheck[3]} with premium.`; const removeAccount = value.match(/^Soll (.+) wirklich aus der Accountliste entfernt werden\?$/); if (removeAccount) return `Remove ${removeAccount[1]} from the account list?`; const resolved = value.match(/^Konflikte gelöst: (\d+) überschrieben, (\d+) übersprungen$/); @@ -441,6 +443,8 @@ function translateDynamic(value: string, language: AppLanguage): string { if (disabledKeys) return `${disabledKeys[1]} API-Keys deaktiviert.`; const accountCheck = value.match(/^Account check: (\d+\/\d+) logins valid, (\d+) with premium\.$/); if (accountCheck) return `Account-Check: ${accountCheck[1]} Login gültig, ${accountCheck[2]} mit Premium.`; + const scopedAccountCheck = value.match(/^(Active|All) accounts: (\d+\/\d+) logins valid, (\d+) with premium\.$/); + if (scopedAccountCheck) return `${scopedAccountCheck[1] === "Active" ? "Aktive" : "Alle"} Accounts: ${scopedAccountCheck[2]} Login gültig, ${scopedAccountCheck[3]} mit Premium.`; const removeAccount = value.match(/^Remove (.+) from the account list\?$/); if (removeAccount) return `Soll ${removeAccount[1]} wirklich aus der Accountliste entfernt werden?`; const resolved = value.match(/^Conflicts resolved: (\d+) overwritten, (\d+) skipped$/); diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index b4ac392..99b4c68 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -75,9 +75,10 @@ export interface AccountWorkspaceActions { onEdit: (rowId: string) => void; onContextMenu: (rowId: string, x: number, y: number) => void; onCopyIdentity: (label: "Benutzername" | "E-Mail", value: string) => void; - onAdd: () => void; - onRemoveSelected: () => void; - onCheckAll: () => void; + onAdd: () => void; + onRemoveSelected: () => void; + onCheckActive: () => void; + onCheckAll: () => void; onSetAllEnabled?: (enabled: boolean) => void; onStatusSort?: () => void; onMoveProvider?: (index: number, direction: -1 | 1) => void; @@ -336,7 +337,8 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
- + +
{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"} diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 1e9f433..9fa43cd 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -1,5 +1,6 @@ import type { AddLinksPayload, + AccountCheckScope, AccountCommandResult, AccountCredentialCheckInput, AccountCreateCommand, @@ -100,7 +101,7 @@ export interface ElectronApi { importBestDebridCookies: () => Promise; getAllDebridHostInfo: () => Promise; getDebridLinkHostLimits: () => Promise; - checkDebridAccounts: () => Promise; + checkDebridAccounts: (scope?: AccountCheckScope) => Promise; checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index b2e7c84..b7f7bcf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -174,6 +174,8 @@ export interface AppSettings { scheduledStartEpochMs: number; } +export type AccountCheckScope = "active" | "all"; + export type RendererAccountKind = | "realdebrid-api" | "realdebrid-web" diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts index e3ec3df..06d7ccd 100644 --- a/tests/account-check.test.ts +++ b/tests/account-check.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID } from "../src/main/account-check"; import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts"; -import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys"; -import type { AppSettings } from "../src/shared/types"; +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"; function megaAccount(login = "user@example.com"): MegaDebridAccountEntry { return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" }; @@ -137,7 +139,7 @@ describe("checkRealDebridAccount", () => { }); }); -describe("checkAllDebridAccounts", () => { +describe("checkAllDebridAccounts", () => { it("returns empty array when nothing configured", async () => { const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings; const result = await checkAllDebridAccounts(settings); @@ -164,7 +166,7 @@ describe("checkAllDebridAccounts", () => { }); }); - it("checks every configured mega account + debrid-link key", async () => { + it("checks every configured mega account + debrid-link key", async () => { const futureSec = Math.floor(Date.now() / 1000) + 1000; vi.stubGlobal("fetch", vi.fn(async (url: string) => { if (String(url).includes("mega-debrid")) { @@ -183,8 +185,46 @@ describe("checkAllDebridAccounts", () => { expect(result).toHaveLength(5); expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2); expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3); - expect(result.every((r) => r.valid)).toBe(true); - }); + expect(result.every((r) => r.valid)).toBe(true); + }); + + it("checks only enabled accounts in active scope and every account in all scope", async () => { + const megaCredentials = [ + "one@example.test:pw1", + "two@example.test:pw2", + "three@example.test:pw3", + "four@example.test:pw4" + ].join("\n"); + const settings: AppSettings = { + ...defaultSettings(), + realDebridUseWebLogin: true, + megaCredentials, + megaDebridWebCredentials: megaCredentials, + megaDebridWebEnabled: true, + debridLinkApiKeys: "disabled-debrid-link-key", + debridLinkDisabledKeyIds: [getDebridLinkApiKeyId("disabled-debrid-link-key")], + megaDebridWebDisabledAccountIds: [ + getMegaDebridAccountId("one@example.test"), + getMegaDebridAccountId("two@example.test"), + getMegaDebridAccountId("three@example.test"), + getMegaDebridAccountId("four@example.test") + ] + }; + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: "4102444800" }) + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + const probe = vi.fn(async () => ({ valid: true, isPremium: true, username: "rd-user" })); + + const active = await checkAllDebridAccounts(settings, undefined, probe, "active"); + const all = await checkAllDebridAccounts(settings, undefined, probe, "all"); + + expect(active.map((status) => status.accountId)).toEqual([REAL_DEBRID_STATUS_ID]); + expect(all).toHaveLength(6); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); it("caps concurrency (never more than 4 in flight) and preserves result order", async () => { let inFlight = 0; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 984903e..76d34af 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -61,4 +61,16 @@ describe("account preload contract", () => { IPC_CHANNELS.DELETE_ACCOUNT ]); }); + + it("forwards the selected bulk account-check scope", async () => { + electron.invoke.mockResolvedValue([]); + + await electron.api?.checkDebridAccounts("active"); + await electron.api?.checkDebridAccounts("all"); + + expect(electron.invoke.mock.calls).toEqual([ + [IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, "active"], + [IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, "all"] + ]); + }); }); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 506d3c2..a506c64 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -188,6 +188,8 @@ describe("renderer localization", () => { ["Zwischenablage: 3 Link(s) erkannt", "Clipboard: 3 link(s) detected"], ["2/4 API-Keys deaktiviert.", "2/4 API keys disabled."], ["Account-Check: 3/4 Login gültig, 2 mit Premium.", "Account check: 3/4 logins valid, 2 with premium."], + ["Aktive Accounts: 1/1 Login gültig, 1 mit Premium.", "Active accounts: 1/1 logins valid, 1 with premium."], + ["Alle Accounts: 2/5 Login gültig, 2 mit Premium.", "All accounts: 2/5 logins valid, 2 with premium."], ["Soll RapidGator wirklich aus der Accountliste entfernt werden?", "Remove RapidGator from the account list?"], ["Konflikte gelöst: 2 überschrieben, 3 übersprungen", "Conflicts resolved: 2 overwritten, 3 skipped"], ["DLC importiert: 2 Paket(e), 5 Link(s)", "DLC imported: 2 package(s), 5 link(s)"], diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 8ababed..4575d01 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -8,7 +8,9 @@ import { buildAccountAddFields, createAccountDialogState } from "../src/renderer import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit"; import { buildBulkAccountEnabledState, - buildConfiguredProviderOrder + buildConfiguredProviderOrder, + resolveAccountStatusState, + runOptimisticAccountUpdate } from "../src/renderer/account-ui"; import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; @@ -304,6 +306,7 @@ function workspaceActions(overrides: Partial = {}): Acc onCopyIdentity: () => {}, onAdd: () => {}, onRemoveSelected: () => {}, + onCheckActive: () => {}, onCheckAll: () => {}, ...overrides }; @@ -727,6 +730,15 @@ describe("account workspace", () => { expect(html).not.toContain("role=\"toolbar\""); }); + it("offers separate checks for active accounts and every configured account", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Aktive aktualisieren"); + expect(html).toContain("Alle aktualisieren"); + expect(html).toContain('title="Prüft nur aktivierte Accounts."'); + expect(html).toContain('title="Prüft alle angelegten Accounts, auch deaktivierte."'); + }); + it("keeps row selection, enable toggles, edit and context actions separate", () => { const calls: string[] = []; const tree = AccountWorkspace({ @@ -946,6 +958,34 @@ describe("account workspace", () => { }); describe("settings App integration", () => { + it("shows a failed all-account check even when the account is disabled", () => { + expect(resolveAccountStatusState(true, { valid: false, isPremium: false })).toBe("invalid"); + expect(resolveAccountStatusState(true, { valid: true, isPremium: true })).toBe("disabled"); + expect(resolveAccountStatusState(false, undefined)).toBe("unchecked"); + }); + + it("applies account switches before persistence settles and rolls back failed saves", async () => { + const events: string[] = []; + let resolvePersist: (value: string) => void = () => { throw new Error("persist resolver missing"); }; + const pending = runOptimisticAccountUpdate( + () => events.push("apply"), + () => new Promise((resolve) => { resolvePersist = resolve; }), + () => events.push("rollback") + ); + + expect(events).toEqual(["apply"]); + resolvePersist("saved"); + await expect(pending).resolves.toBe("saved"); + expect(events).toEqual(["apply"]); + + await expect(runOptimisticAccountUpdate( + () => events.push("apply-failed"), + async () => { throw new Error("save failed"); }, + () => events.push("rollback") + )).rejects.toThrow("save failed"); + expect(events.slice(-2)).toEqual(["apply-failed", "rollback"]); + }); + it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => { const settings = { ...defaultSettings(), @@ -974,7 +1014,7 @@ describe("settings App integration", () => { it("keeps unchecked single accounts honest without a positive status", () => { const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId"); - expect(block).toMatch(/:\s*!checkedStatus\s*\?\s*"unchecked"/s); + expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus)"); }); it("stores only the stable account row id in context-menu state", () => {