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", () => {