fix(accounts): serialize rapid toggle intents
This commit is contained in:
+162
-269
@@ -41,7 +41,7 @@ import {
|
|||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||||
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
|
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
|
||||||
import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runAccountEnableRefresh, runOptimisticAccountUpdate, sortAccountServices, updateAccountRowSelection } from "./account-ui";
|
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
|
||||||
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||||
@@ -324,6 +324,15 @@ interface AccountContextMenuState {
|
|||||||
rowId: string;
|
rowId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAccountToggleTarget(account: AccountTableRow): AccountToggleTarget {
|
||||||
|
if (account.toggleKind === "rd" && account.accountId) return { type: "realdebrid", accountId: account.accountId };
|
||||||
|
if (account.toggleKind === "mega" && account.accountId) {
|
||||||
|
return { type: "megadebrid", provider: account.entry.kind as "megadebrid-api" | "megadebrid-web", accountId: account.accountId };
|
||||||
|
}
|
||||||
|
if (account.toggleKind === "dl" && account.dlKey) return { type: "debridlink", accountId: account.dlKey.id };
|
||||||
|
return { type: "provider", provider: account.entry.provider };
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsThemeChoice = AppTheme | "system";
|
type SettingsThemeChoice = AppTheme | "system";
|
||||||
|
|
||||||
interface RendererSettingsDraft extends RendererSettings {
|
interface RendererSettingsDraft extends RendererSettings {
|
||||||
@@ -366,25 +375,6 @@ export function resolveSettingsSaveCompletion(revisionAtStart: number, currentRe
|
|||||||
: { saveState: "dirty", applyPersistedTheme: false, toast: "Zwischenstand gespeichert – weitere Änderungen sind ungespeichert" };
|
: { saveState: "dirty", applyPersistedTheme: false, toast: "Zwischenstand gespeichert – weitere Änderungen sind ungespeichert" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsValueEqual(left: unknown, right: unknown): boolean {
|
|
||||||
return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeConcurrentSpecificSettings(
|
|
||||||
base: RendererSettingsDraft,
|
|
||||||
requested: RendererSettingsDraft,
|
|
||||||
persisted: RendererSettings,
|
|
||||||
current: RendererSettingsDraft
|
|
||||||
): RendererSettingsDraft {
|
|
||||||
const merged = { ...current } as Record<string, unknown>;
|
|
||||||
for (const key of Object.keys(requested) as Array<keyof RendererSettingsDraft>) {
|
|
||||||
if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key]) && key in persisted) {
|
|
||||||
merged[key] = persisted[key as keyof RendererSettings];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged as unknown as RendererSettingsDraft;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme {
|
export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme {
|
||||||
return choice === "system" ? (prefersLight ? "light" : "dark") : choice;
|
return choice === "system" ? (prefersLight ? "light" : "dark") : choice;
|
||||||
}
|
}
|
||||||
@@ -1812,6 +1802,10 @@ export function App(): ReactElement {
|
|||||||
const [accountEditSecretBusy, setAccountEditSecretBusy] = useState<string | null>(null);
|
const [accountEditSecretBusy, setAccountEditSecretBusy] = useState<string | null>(null);
|
||||||
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
||||||
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
|
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
|
||||||
|
const [pendingAccountToggles, setPendingAccountToggles] = useState<Record<string, { enabled: boolean; sequence: number }>>({});
|
||||||
|
const accountToggleQueueRef = useRef(createAccountToggleQueue());
|
||||||
|
const accountToggleSequenceRef = useRef(0);
|
||||||
|
const settingsMutationSequenceRef = useRef(0);
|
||||||
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
|
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
|
||||||
const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({});
|
const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({});
|
||||||
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
|
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
|
||||||
@@ -2718,8 +2712,21 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows.map((row) => {
|
||||||
}, [configuredAccounts, settingsDraft, snapshot.accounts]);
|
const pending = pendingAccountToggles[row.rowKey];
|
||||||
|
if (!pending) return row;
|
||||||
|
const disabled = !pending.enabled;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
disabled,
|
||||||
|
entry: {
|
||||||
|
...row.entry,
|
||||||
|
disabled,
|
||||||
|
statusLabel: disabled ? "Deaktiviert" : "Aktiviert"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [configuredAccounts, pendingAccountToggles, settingsDraft, snapshot.accounts]);
|
||||||
|
|
||||||
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
||||||
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
||||||
@@ -2988,36 +2995,6 @@ export function App(): ReactElement {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistSpecificSettings = async (
|
|
||||||
nextDraft: RendererSettingsDraft,
|
|
||||||
persistenceContext = {
|
|
||||||
revisionAtStart: settingsDraftRevisionRef.current,
|
|
||||||
draftAtStart: settingsDraft,
|
|
||||||
themeChoiceAtStart: settingsThemeChoiceRef.current
|
|
||||||
}
|
|
||||||
): Promise<RendererSettings> => {
|
|
||||||
const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext;
|
|
||||||
const normalizedDraft = {
|
|
||||||
...nextDraft,
|
|
||||||
...normalizeProviderSelectionForSettings(nextDraft)
|
|
||||||
};
|
|
||||||
const update: RendererSettingsUpdate = { ...normalizedDraft };
|
|
||||||
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
|
||||||
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
|
||||||
const result = await window.rd.updateSettings(update);
|
|
||||||
persistedSettingsRef.current = result;
|
|
||||||
persistedThemeChoiceRef.current = themeChoiceAtStart;
|
|
||||||
if (settingsDraftRevisionRef.current === revisionAtStart) {
|
|
||||||
applyPersistedSettings(result, true, themeChoiceAtStart);
|
|
||||||
} else {
|
|
||||||
setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current));
|
|
||||||
settingsDirtyRef.current = true;
|
|
||||||
setSettingsDirty(true);
|
|
||||||
setSettingsSaveState("dirty");
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAccountQuickAction = async (action: AccountQuickAction, accountId?: string | null): Promise<void> => {
|
const runAccountQuickAction = async (action: AccountQuickAction, accountId?: string | null): Promise<void> => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "realdebrid-login":
|
case "realdebrid-login":
|
||||||
@@ -3122,16 +3099,18 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
await persistDraftSettings();
|
await runQueuedSettingsMutation(async () => {
|
||||||
const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot));
|
await persistDraftSettingsDirect();
|
||||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot));
|
||||||
applyPersistedSettings(result.settings);
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||||
closeAccountEditDialog();
|
applyPersistedSettings(result.settings);
|
||||||
if (quickAction) {
|
closeAccountEditDialog();
|
||||||
await runAccountQuickAction(quickAction, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null);
|
if (quickAction) {
|
||||||
} else {
|
await runAccountQuickAction(quickAction, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null);
|
||||||
showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200);
|
} else {
|
||||||
}
|
showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200);
|
||||||
|
}
|
||||||
|
});
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
||||||
});
|
});
|
||||||
@@ -3153,29 +3132,31 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null;
|
const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null;
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
await persistDraftSettings();
|
await runQueuedSettingsMutation(async () => {
|
||||||
if (dialogSnapshot.kind === "realdebrid-web") {
|
await persistDraftSettingsDirect();
|
||||||
const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`;
|
if (dialogSnapshot.kind === "realdebrid-web") {
|
||||||
const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId);
|
const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||||
if (!request) throw new Error("Account-Payload ist ungültig");
|
const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId);
|
||||||
await window.rd.openRealDebridLogin(request);
|
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);
|
||||||
|
const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings));
|
||||||
|
setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts }));
|
||||||
|
applyPersistedSettings(persistedSettings);
|
||||||
closeAccountDialog();
|
closeAccountDialog();
|
||||||
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
|
if (quickAction) {
|
||||||
return;
|
await runAccountQuickAction(quickAction, result.accountId);
|
||||||
}
|
} else if (selectedOption) {
|
||||||
const command = buildAccountCreateCommand(dialogSnapshot);
|
showToast(`${selectedOption.title} gespeichert`, 2200);
|
||||||
if (!command) throw new Error("Account-Payload ist ungültig");
|
}
|
||||||
const result = await window.rd.createAccount(command);
|
void checkAccounts("active");
|
||||||
const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings));
|
});
|
||||||
setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts }));
|
|
||||||
applyPersistedSettings(persistedSettings);
|
|
||||||
closeAccountDialog();
|
|
||||||
if (quickAction) {
|
|
||||||
await runAccountQuickAction(quickAction, result.accountId);
|
|
||||||
} else if (selectedOption) {
|
|
||||||
showToast(`${selectedOption.title} gespeichert`, 2200);
|
|
||||||
}
|
|
||||||
void checkAccounts("active");
|
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
||||||
});
|
});
|
||||||
@@ -3201,68 +3182,53 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistAccountToggle = async (
|
const requestAccountToggle = (row: AccountTableRow, enabled: boolean): void => {
|
||||||
nextDraft: RendererSettingsDraft,
|
const sequence = ++accountToggleSequenceRef.current;
|
||||||
refreshBeforePersist?: () => ReturnType<typeof window.rd.checkAccountCredentials>
|
const subject = row.toggleKind === "dl" && row.dlKey
|
||||||
): Promise<RendererSettings> => {
|
? `${row.entry.serviceLabel} ${row.dlKey.label}`
|
||||||
const previousDraft = settingsDraft;
|
: row.entry.serviceLabel;
|
||||||
const previousDirty = settingsDirtyRef.current;
|
const check = enabled
|
||||||
const previousSaveState = settingsSaveState;
|
? row.toggleKind === "rd" && row.accountId
|
||||||
const themeChoiceAtStart = settingsThemeChoiceRef.current;
|
? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "realdebrid-api" | "realdebrid-web", accountId: row.accountId || undefined })
|
||||||
const revision = ++settingsDraftRevisionRef.current;
|
: row.toggleKind === "mega" && row.accountId
|
||||||
const persistenceContext = {
|
? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "megadebrid-api" | "megadebrid-web", accountId: row.accountId || undefined })
|
||||||
revisionAtStart: revision,
|
: row.toggleKind === "dl" && row.dlKey
|
||||||
draftAtStart: previousDraft,
|
? () => window.rd.checkAccountCredentials({ kind: "debridlink-api", accountId: row.dlKey?.id })
|
||||||
themeChoiceAtStart
|
: row.entry.kind === "deepbrid-api"
|
||||||
};
|
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
|
||||||
return runOptimisticAccountUpdate(
|
: undefined
|
||||||
() => {
|
: undefined;
|
||||||
settingsDirtyRef.current = true;
|
setPendingAccountToggles((current) => ({
|
||||||
setSettingsDirty(true);
|
...current,
|
||||||
setSettingsSaveState("saving");
|
[row.rowKey]: { enabled, sequence }
|
||||||
setSettingsDraft(nextDraft);
|
}));
|
||||||
},
|
void enqueueAccountToggleIntent(accountToggleQueueRef.current, {
|
||||||
() => runAccountEnableRefresh(
|
key: row.rowKey,
|
||||||
refreshBeforePersist,
|
target: getAccountToggleTarget(row),
|
||||||
() => persistSpecificSettings(nextDraft, persistenceContext)
|
enabled,
|
||||||
),
|
check
|
||||||
() => {
|
}, {
|
||||||
if (settingsDraftRevisionRef.current !== revision) return;
|
getSettings: () => persistedSettingsRef.current,
|
||||||
settingsDraftRevisionRef.current += 1;
|
persist: async (patch) => {
|
||||||
settingsDirtyRef.current = previousDirty;
|
const result = await window.rd.updateSettings(patch);
|
||||||
setSettingsDirty(previousDirty);
|
persistedSettingsRef.current = result;
|
||||||
setSettingsSaveState(previousSaveState);
|
setSnapshot((current) => ({ ...current, settings: result }));
|
||||||
setSettingsDraft(previousDraft);
|
setSettingsDraft((current) => mergeAccountToggleSettings(current, result));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
);
|
}).then((result) => {
|
||||||
};
|
if (result.status === "superseded") return;
|
||||||
|
setPendingAccountToggles((current) => {
|
||||||
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry, enabled: boolean): Promise<void> => {
|
if (current[row.rowKey]?.sequence !== sequence) return current;
|
||||||
await performQuickAction(async () => {
|
const next = { ...current };
|
||||||
const nextState = buildScopedAccountEnabledState(
|
delete next[row.rowKey];
|
||||||
settingsDraft.disabledProviders || [],
|
return next;
|
||||||
["debridlink"],
|
});
|
||||||
settingsDraft.debridLinkDisabledKeyIds || [],
|
if (result.status === "failed") {
|
||||||
key.id,
|
showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200);
|
||||||
enabled
|
return;
|
||||||
);
|
}
|
||||||
const nextDraft: RendererSettingsDraft = {
|
showToast(`${subject} ${enabled ? "aktiviert" : "deaktiviert"}`, 2200);
|
||||||
...settingsDraft,
|
|
||||||
disabledProviders: nextState.disabledProviders,
|
|
||||||
debridLinkDisabledKeyIds: nextState.disabledAccountIds
|
|
||||||
};
|
|
||||||
await persistAccountToggle(
|
|
||||||
nextDraft,
|
|
||||||
enabled ? () => window.rd.checkAccountCredentials({ kind: "debridlink-api", accountId: key.id }) : undefined
|
|
||||||
);
|
|
||||||
showToast(
|
|
||||||
enabled
|
|
||||||
? `${entry.serviceLabel} ${key.label} aktiviert`
|
|
||||||
: `${entry.serviceLabel} ${key.label} deaktiviert`,
|
|
||||||
2200
|
|
||||||
);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3278,113 +3244,23 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, enabled: boolean): Promise<void> => {
|
|
||||||
await performQuickAction(async () => {
|
|
||||||
const mode = kind === "megadebrid-web" ? "web" : "api";
|
|
||||||
const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds;
|
|
||||||
const nextState = buildScopedAccountEnabledState(
|
|
||||||
settingsDraft.disabledProviders || [],
|
|
||||||
["megadebrid", kind],
|
|
||||||
current,
|
|
||||||
accountId,
|
|
||||||
enabled
|
|
||||||
);
|
|
||||||
const next = nextState.disabledAccountIds;
|
|
||||||
const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds;
|
|
||||||
const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds;
|
|
||||||
await persistAccountToggle({
|
|
||||||
...settingsDraft,
|
|
||||||
disabledProviders: nextState.disabledProviders,
|
|
||||||
megaDebridApiEnabled: mode === "api" && enabled ? true : settingsDraft.megaDebridApiEnabled,
|
|
||||||
megaDebridWebEnabled: mode === "web" && enabled ? true : settingsDraft.megaDebridWebEnabled,
|
|
||||||
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
|
|
||||||
megaDebridApiDisabledAccountIds: apiDisabledIds,
|
|
||||||
megaDebridWebDisabledAccountIds: webDisabledIds
|
|
||||||
}, enabled
|
|
||||||
? () => window.rd.checkAccountCredentials({ kind, accountId })
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
|
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
|
||||||
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
|
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
await persistDraftSettings();
|
await runQueuedSettingsMutation(async () => {
|
||||||
const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id });
|
await persistDraftSettingsDirect();
|
||||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id });
|
||||||
applyPersistedSettings(result.settings);
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||||
showToast("Key entfernt", 2000);
|
applyPersistedSettings(result.settings);
|
||||||
|
showToast("Key entfernt", 2000);
|
||||||
|
});
|
||||||
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
|
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
const toggleAccountTableRow = (row: AccountTableRow, enabled = row.disabled): void => {
|
||||||
await performQuickAction(async () => {
|
|
||||||
const provider = entry.service as DebridProvider;
|
|
||||||
const current = settingsDraft.disabledProviders || [];
|
|
||||||
const nextDisabledProviders = current.includes(provider)
|
|
||||||
? current.filter((existing) => existing !== provider)
|
|
||||||
: [...current, provider];
|
|
||||||
const nextDraft: RendererSettingsDraft = {
|
|
||||||
...settingsDraft,
|
|
||||||
disabledProviders: nextDisabledProviders
|
|
||||||
};
|
|
||||||
const enabled = current.includes(provider);
|
|
||||||
await persistAccountToggle(
|
|
||||||
nextDraft,
|
|
||||||
enabled && entry.kind === "deepbrid-api"
|
|
||||||
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
showToast(
|
|
||||||
nextDisabledProviders.includes(provider)
|
|
||||||
? `${entry.serviceLabel} deaktiviert`
|
|
||||||
: `${entry.serviceLabel} aktiviert`,
|
|
||||||
2200
|
|
||||||
);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onToggleRealDebridAccountEnabled = async (kind: "realdebrid-api" | "realdebrid-web", accountId: string, enabled: boolean): Promise<void> => {
|
|
||||||
await performQuickAction(async () => {
|
|
||||||
const nextState = buildScopedAccountEnabledState(
|
|
||||||
settingsDraft.disabledProviders || [],
|
|
||||||
["realdebrid"],
|
|
||||||
settingsDraft.realDebridDisabledAccountIds || [],
|
|
||||||
accountId,
|
|
||||||
enabled
|
|
||||||
);
|
|
||||||
await persistAccountToggle({
|
|
||||||
...settingsDraft,
|
|
||||||
disabledProviders: nextState.disabledProviders,
|
|
||||||
realDebridDisabledAccountIds: nextState.disabledAccountIds
|
|
||||||
}, enabled
|
|
||||||
? () => window.rd.checkAccountCredentials({ kind, accountId })
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAccountTableRow = (row: AccountTableRow): void => {
|
|
||||||
setAccountContextMenu(null);
|
setAccountContextMenu(null);
|
||||||
if (row.toggleKind === "rd" && row.accountId) {
|
requestAccountToggle(row, enabled);
|
||||||
void onToggleRealDebridAccountEnabled(row.entry.kind as "realdebrid-api" | "realdebrid-web", 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);
|
|
||||||
} else {
|
|
||||||
void onToggleAccountEnabled(row.entry);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAccountTableRows = (rows: readonly AccountTableRow[]): void => {
|
const removeAccountTableRows = (rows: readonly AccountTableRow[]): void => {
|
||||||
@@ -3410,18 +3286,20 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
await persistDraftSettings();
|
await runQueuedSettingsMutation(async () => {
|
||||||
for (const selectedRow of rows) {
|
await persistDraftSettingsDirect();
|
||||||
const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget));
|
for (const selectedRow of rows) {
|
||||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget));
|
||||||
applyPersistedSettings(result.settings);
|
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||||
if (selectedRow.entry.service === "alldebrid") {
|
applyPersistedSettings(result.settings);
|
||||||
setAllDebridHostInfo(null);
|
if (selectedRow.entry.service === "alldebrid") {
|
||||||
|
setAllDebridHostInfo(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const removedRowKeys = new Set(rows.map((selectedRow) => selectedRow.rowKey));
|
||||||
const removedRowKeys = new Set(rows.map((selectedRow) => selectedRow.rowKey));
|
setSelectedAccountRowKeys((current) => new Set([...current].filter((rowKey) => !removedRowKeys.has(rowKey))));
|
||||||
setSelectedAccountRowKeys((current) => new Set([...current].filter((rowKey) => !removedRowKeys.has(rowKey))));
|
showToast(rows.length === 1 ? `${row.hosterLabel} entfernt` : `${rows.length} Accounts entfernt`, 2200);
|
||||||
showToast(rows.length === 1 ? `${row.hosterLabel} entfernt` : `${rows.length} Accounts entfernt`, 2200);
|
});
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`${rows.length === 1 ? "Account" : "Accounts"} konnte${rows.length === 1 ? "" : "n"} nicht entfernt werden: ${String(error)}`, 3200);
|
showToast(`${rows.length === 1 ? "Account" : "Accounts"} konnte${rows.length === 1 ? "" : "n"} nicht entfernt werden: ${String(error)}`, 3200);
|
||||||
});
|
});
|
||||||
@@ -3462,9 +3340,17 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise<RendererSettings> => {
|
const runQueuedSettingsMutation = async <T,>(task: () => Promise<T>): Promise<T> => {
|
||||||
|
const result = await accountToggleQueueRef.current.enqueue(`settings-mutation-${++settingsMutationSequenceRef.current}`, async () => task());
|
||||||
|
if (result.status === "failed") throw result.error;
|
||||||
|
if (result.status === "superseded") throw new Error("Einstellungsänderung wurde ersetzt");
|
||||||
|
return result.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistDraftSettingsDirect = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise<RendererSettings> => {
|
||||||
const revisionAtStart = settingsDraftRevisionRef.current;
|
const revisionAtStart = settingsDraftRevisionRef.current;
|
||||||
const update: RendererSettingsUpdate = { ...normalizedSettingsDraft };
|
const rebasedDraft = mergeAccountToggleSettings(normalizedSettingsDraft, persistedSettingsRef.current);
|
||||||
|
const update: RendererSettingsUpdate = { ...rebasedDraft };
|
||||||
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
||||||
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
||||||
const result = await window.rd.updateSettings(update);
|
const result = await window.rd.updateSettings(update);
|
||||||
@@ -3476,6 +3362,10 @@ export function App(): ReactElement {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise<RendererSettings> => (
|
||||||
|
runQueuedSettingsMutation(() => persistDraftSettingsDirect(themeChoiceAtStart))
|
||||||
|
);
|
||||||
|
|
||||||
const closeStartConflictPrompt = (result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null): void => {
|
const closeStartConflictPrompt = (result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null): void => {
|
||||||
const resolver = startConflictResolverRef.current;
|
const resolver = startConflictResolverRef.current;
|
||||||
startConflictResolverRef.current = null;
|
startConflictResolverRef.current = null;
|
||||||
@@ -5635,9 +5525,9 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
setSelectedAccountRowKeys((current) => new Set(updateAccountRowSelection([...current], rowKey, additive)));
|
setSelectedAccountRowKeys((current) => new Set(updateAccountRowSelection([...current], rowKey, additive)));
|
||||||
},
|
},
|
||||||
onToggleEnabled: (rowId) => {
|
onToggleEnabled: (rowId, enabled) => {
|
||||||
const row = accountRowBindings.get(rowId);
|
const row = accountRowBindings.get(rowId);
|
||||||
if (row) toggleAccountTableRow(row);
|
if (row) toggleAccountTableRow(row, enabled);
|
||||||
},
|
},
|
||||||
onEdit: (rowId) => {
|
onEdit: (rowId) => {
|
||||||
const row = accountRowBindings.get(rowId);
|
const row = accountRowBindings.get(rowId);
|
||||||
@@ -6007,7 +5897,7 @@ export function App(): ReactElement {
|
|||||||
if (row) removeAccountTableRow(row);
|
if (row) removeAccountTableRow(row);
|
||||||
},
|
},
|
||||||
onToggleEnabled: () => {
|
onToggleEnabled: () => {
|
||||||
if (accountEditRow) toggleAccountTableRow(accountEditRow);
|
if (accountEditRow) toggleAccountTableRow(accountEditRow, accountEditRow.disabled);
|
||||||
},
|
},
|
||||||
onToggleSecret: (fieldId) => { void toggleAccountEditSecret(fieldId); },
|
onToggleSecret: (fieldId) => { void toggleAccountEditSecret(fieldId); },
|
||||||
onCopySecret: (fieldId) => { void copyAccountEditSecret(fieldId); }
|
onCopySecret: (fieldId) => { void copyAccountEditSecret(fieldId); }
|
||||||
@@ -6949,8 +6839,10 @@ export function App(): ReactElement {
|
|||||||
<span className="col-links">RG Links</span>
|
<span className="col-links">RG Links</span>
|
||||||
<span className="col-action"></span>
|
<span className="col-action"></span>
|
||||||
</div>
|
</div>
|
||||||
{entry.debridLinkKeys.map((key, ki) => (
|
{entry.debridLinkKeys.map((key, ki) => {
|
||||||
<div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached || (debridLinkHostLimits[key.id] && debridLinkHostLimits[key.id].state !== "ready") ? " warning" : ""}${entry.disabled || key.disabled ? " disabled" : ""}`}>
|
const toggleRow = accountRows.find((candidate) => candidate.toggleKind === "dl" && candidate.dlKey?.id === key.id);
|
||||||
|
const disabled = toggleRow ? toggleRow.disabled : entry.disabled || key.disabled;
|
||||||
|
return <div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached || (debridLinkHostLimits[key.id] && debridLinkHostLimits[key.id].state !== "ready") ? " warning" : ""}${disabled ? " disabled" : ""}`}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const hostInfo = debridLinkHostLimits[key.id];
|
const hostInfo = debridLinkHostLimits[key.id];
|
||||||
const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo);
|
const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo);
|
||||||
@@ -6971,17 +6863,18 @@ export function App(): ReactElement {
|
|||||||
{key.masked}
|
{key.masked}
|
||||||
</button>
|
</button>
|
||||||
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
||||||
<span className="col-limit">{entry.disabled || key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
<span className="col-limit">{disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
||||||
<span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</span>
|
<span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</span>
|
||||||
<span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span>
|
<span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span>
|
||||||
<span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span>
|
<span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span>
|
||||||
<span className="col-action">
|
<span className="col-action">
|
||||||
<button
|
<button
|
||||||
className={`btn btn-sm ${entry.disabled || key.disabled ? "success" : "danger"}`}
|
className={`btn btn-sm ${disabled ? "success" : "danger"}`}
|
||||||
disabled={actionBusy}
|
onClick={() => {
|
||||||
onClick={() => { void onToggleDebridLinkApiKeyEnabled(entry, key, entry.disabled || key.disabled); }}
|
if (toggleRow) requestAccountToggle(toggleRow, toggleRow.disabled);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{entry.disabled || key.disabled ? "Aktivieren" : "Deaktivieren"}
|
{disabled ? "Aktivieren" : "Deaktivieren"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
@@ -6994,8 +6887,8 @@ export function App(): ReactElement {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>;
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="btn" onClick={() => setKeyStatsPopup(null)}>Schließen</button>
|
<button className="btn" onClick={() => setKeyStatsPopup(null)}>Schließen</button>
|
||||||
|
|||||||
+142
-28
@@ -1,4 +1,43 @@
|
|||||||
import type { DebridProvider } from "../shared/types";
|
import type { DebridProvider, RendererSettings, RendererSettingsUpdate } from "../shared/types";
|
||||||
|
|
||||||
|
export type AccountToggleTarget =
|
||||||
|
| { type: "provider"; provider: DebridProvider }
|
||||||
|
| { type: "realdebrid"; accountId: string }
|
||||||
|
| { type: "megadebrid"; provider: "megadebrid-api" | "megadebrid-web"; accountId: string }
|
||||||
|
| { type: "debridlink"; accountId: string };
|
||||||
|
|
||||||
|
export type AccountToggleQueueResult<T> =
|
||||||
|
| { status: "applied"; value: T }
|
||||||
|
| { status: "failed"; error: unknown }
|
||||||
|
| { status: "superseded" };
|
||||||
|
|
||||||
|
export interface AccountToggleQueue {
|
||||||
|
enqueue<T>(key: string, task: (isCurrent: () => boolean) => Promise<T>): Promise<AccountToggleQueueResult<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccountToggleQueue(): AccountToggleQueue {
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
const versions = new Map<string, number>();
|
||||||
|
return {
|
||||||
|
enqueue<T>(key: string, task: (isCurrent: () => boolean) => Promise<T>): Promise<AccountToggleQueueResult<T>> {
|
||||||
|
const version = (versions.get(key) || 0) + 1;
|
||||||
|
versions.set(key, version);
|
||||||
|
const isCurrent = (): boolean => versions.get(key) === version;
|
||||||
|
const execute = async (): Promise<AccountToggleQueueResult<T>> => {
|
||||||
|
if (!isCurrent()) return { status: "superseded" };
|
||||||
|
try {
|
||||||
|
const value = await task(isCurrent);
|
||||||
|
return isCurrent() ? { status: "applied", value } : { status: "superseded" };
|
||||||
|
} catch (error) {
|
||||||
|
return isCurrent() ? { status: "failed", error } : { status: "superseded" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = tail.then(execute);
|
||||||
|
tail = result.then(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type AccountModeFilter = "all" | "api" | "web";
|
export type AccountModeFilter = "all" | "api" | "web";
|
||||||
|
|
||||||
@@ -139,33 +178,6 @@ export function resolveAccountStatusState(
|
|||||||
return checkedStatus.isPremium ? "premium" : "free";
|
return checkedStatus.isPremium ? "premium" : "free";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runOptimisticAccountUpdate<T>(
|
|
||||||
apply: () => void,
|
|
||||||
persist: () => Promise<T>,
|
|
||||||
rollback: () => void
|
|
||||||
): Promise<T> {
|
|
||||||
apply();
|
|
||||||
try {
|
|
||||||
return await persist();
|
|
||||||
} catch (error) {
|
|
||||||
rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runAccountEnableRefresh<T>(
|
|
||||||
refresh: (() => Promise<{ valid: boolean; message?: string }>) | undefined,
|
|
||||||
persist: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
if (refresh) {
|
|
||||||
const status = await refresh();
|
|
||||||
if (!status.valid) {
|
|
||||||
throw new Error(status.message || "Accountprüfung fehlgeschlagen");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildScopedAccountEnabledState(
|
export function buildScopedAccountEnabledState(
|
||||||
currentDisabledProviders: DebridProvider[],
|
currentDisabledProviders: DebridProvider[],
|
||||||
providerIds: DebridProvider[],
|
providerIds: DebridProvider[],
|
||||||
@@ -183,3 +195,105 @@ export function buildScopedAccountEnabledState(
|
|||||||
: [...new Set([...currentDisabledAccountIds, accountId])]
|
: [...new Set([...currentDisabledAccountIds, accountId])]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAccountTogglePatch(
|
||||||
|
settings: RendererSettings,
|
||||||
|
target: AccountToggleTarget,
|
||||||
|
enabled: boolean
|
||||||
|
): RendererSettingsUpdate {
|
||||||
|
if (target.type === "provider") {
|
||||||
|
return {
|
||||||
|
disabledProviders: enabled
|
||||||
|
? settings.disabledProviders.filter((provider) => provider !== target.provider)
|
||||||
|
: [...new Set([...settings.disabledProviders, target.provider])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target.type === "realdebrid") {
|
||||||
|
const next = buildScopedAccountEnabledState(
|
||||||
|
settings.disabledProviders,
|
||||||
|
["realdebrid"],
|
||||||
|
settings.realDebridDisabledAccountIds,
|
||||||
|
target.accountId,
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
disabledProviders: next.disabledProviders,
|
||||||
|
realDebridDisabledAccountIds: next.disabledAccountIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target.type === "debridlink") {
|
||||||
|
const next = buildScopedAccountEnabledState(
|
||||||
|
settings.disabledProviders,
|
||||||
|
["debridlink"],
|
||||||
|
settings.debridLinkDisabledKeyIds,
|
||||||
|
target.accountId,
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
disabledProviders: next.disabledProviders,
|
||||||
|
debridLinkDisabledKeyIds: next.disabledAccountIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const web = target.provider === "megadebrid-web";
|
||||||
|
const currentDisabledIds = web ? settings.megaDebridWebDisabledAccountIds : settings.megaDebridApiDisabledAccountIds;
|
||||||
|
const next = buildScopedAccountEnabledState(
|
||||||
|
settings.disabledProviders,
|
||||||
|
["megadebrid", target.provider],
|
||||||
|
currentDisabledIds,
|
||||||
|
target.accountId,
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
const apiDisabledIds = web ? settings.megaDebridApiDisabledAccountIds : next.disabledAccountIds;
|
||||||
|
const webDisabledIds = web ? next.disabledAccountIds : settings.megaDebridWebDisabledAccountIds;
|
||||||
|
return {
|
||||||
|
disabledProviders: next.disabledProviders,
|
||||||
|
megaDebridApiEnabled: !web && enabled ? true : settings.megaDebridApiEnabled,
|
||||||
|
megaDebridWebEnabled: web && enabled ? true : settings.megaDebridWebEnabled,
|
||||||
|
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
|
||||||
|
megaDebridApiDisabledAccountIds: apiDisabledIds,
|
||||||
|
megaDebridWebDisabledAccountIds: webDisabledIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountToggleIntent {
|
||||||
|
key: string;
|
||||||
|
target: AccountToggleTarget;
|
||||||
|
enabled: boolean;
|
||||||
|
check?: () => Promise<{ valid: boolean; message?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountToggleIntentDependencies {
|
||||||
|
getSettings: () => RendererSettings;
|
||||||
|
persist: (patch: RendererSettingsUpdate) => Promise<RendererSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueAccountToggleIntent(
|
||||||
|
queue: AccountToggleQueue,
|
||||||
|
intent: AccountToggleIntent,
|
||||||
|
dependencies: AccountToggleIntentDependencies
|
||||||
|
): Promise<AccountToggleQueueResult<RendererSettings>> {
|
||||||
|
return queue.enqueue(intent.key, async (isCurrent) => {
|
||||||
|
if (intent.check) {
|
||||||
|
const status = await intent.check();
|
||||||
|
if (!isCurrent()) return dependencies.getSettings();
|
||||||
|
if (!status.valid) throw new Error(status.message || "Accountprüfung fehlgeschlagen");
|
||||||
|
}
|
||||||
|
if (!isCurrent()) return dependencies.getSettings();
|
||||||
|
const patch = buildAccountTogglePatch(dependencies.getSettings(), intent.target, intent.enabled);
|
||||||
|
return dependencies.persist(patch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAccountToggleSettings<T extends RendererSettings>(current: T, persisted: RendererSettings): T {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
disabledProviders: [...persisted.disabledProviders],
|
||||||
|
realDebridDisabledAccountIds: [...persisted.realDebridDisabledAccountIds],
|
||||||
|
debridLinkDisabledKeyIds: [...persisted.debridLinkDisabledKeyIds],
|
||||||
|
megaDebridApiEnabled: persisted.megaDebridApiEnabled,
|
||||||
|
megaDebridWebEnabled: persisted.megaDebridWebEnabled,
|
||||||
|
megaDebridDisabledAccountIds: [...persisted.megaDebridDisabledAccountIds],
|
||||||
|
megaDebridApiDisabledAccountIds: [...persisted.megaDebridApiDisabledAccountIds],
|
||||||
|
megaDebridWebDisabledAccountIds: [...persisted.megaDebridWebDisabledAccountIds]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export interface AccountWorkspaceViewModel {
|
|||||||
export interface AccountWorkspaceActions {
|
export interface AccountWorkspaceActions {
|
||||||
onPanelChange: (panel: AccountWorkspacePanel) => void;
|
onPanelChange: (panel: AccountWorkspacePanel) => void;
|
||||||
onSelect: (rowId: string, additive: boolean) => void;
|
onSelect: (rowId: string, additive: boolean) => void;
|
||||||
onToggleEnabled: (rowId: string) => void;
|
onToggleEnabled: (rowId: string, enabled: boolean) => void;
|
||||||
onEdit: (rowId: string) => void;
|
onEdit: (rowId: string) => void;
|
||||||
onContextMenu: (rowId: string, x: number, y: number) => void;
|
onContextMenu: (rowId: string, x: number, y: number) => void;
|
||||||
onCopyIdentity: (label: "Benutzername" | "E-Mail", value: string) => void;
|
onCopyIdentity: (label: "Benutzername" | "E-Mail", value: string) => void;
|
||||||
@@ -361,7 +361,7 @@ function AccountRow({
|
|||||||
aria-label={`${row.hoster} ${row.enabled ? "deaktivieren" : "aktivieren"}`}
|
aria-label={`${row.hoster} ${row.enabled ? "deaktivieren" : "aktivieren"}`}
|
||||||
checked={row.enabled}
|
checked={row.enabled}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={() => actions.onToggleEnabled(row.id)}
|
onChange={(event) => actions.onToggleEnabled(row.id, event.target.checked)}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+143
-24
@@ -1,17 +1,23 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { createRendererSettings } from "../src/main/renderer-state";
|
||||||
import {
|
import {
|
||||||
|
buildAccountTogglePatch,
|
||||||
buildConfiguredProviderOrder,
|
buildConfiguredProviderOrder,
|
||||||
|
createAccountToggleQueue,
|
||||||
|
enqueueAccountToggleIntent,
|
||||||
filterAccountDialogOptions,
|
filterAccountDialogOptions,
|
||||||
getAvailableAccountOptions,
|
getAvailableAccountOptions,
|
||||||
getAccountDialogSelectableOptions,
|
getAccountDialogSelectableOptions,
|
||||||
isAccountRowSelectionKey,
|
isAccountRowSelectionKey,
|
||||||
matchesAccountModeFilter,
|
matchesAccountModeFilter,
|
||||||
|
mergeAccountToggleSettings,
|
||||||
pruneAccountRowSelection,
|
pruneAccountRowSelection,
|
||||||
resolveAccountUsername,
|
resolveAccountUsername,
|
||||||
resolveVisibleAccountKind,
|
resolveVisibleAccountKind,
|
||||||
runAccountEnableRefresh,
|
|
||||||
sortAccountServices
|
sortAccountServices
|
||||||
} from "../src/renderer/account-ui";
|
} from "../src/renderer/account-ui";
|
||||||
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||||
import * as accountUi from "../src/renderer/account-ui";
|
import * as accountUi from "../src/renderer/account-ui";
|
||||||
|
|
||||||
describe("account mode filter", () => {
|
describe("account mode filter", () => {
|
||||||
@@ -136,37 +142,150 @@ describe("account usernames", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("account activation refresh", () => {
|
describe("account toggle bursts", () => {
|
||||||
it("checks an account before enabling it", async () => {
|
it("serializes different account intents without dropping the second task", async () => {
|
||||||
|
const queue = createAccountToggleQueue();
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
let releaseFirst: () => void = () => {};
|
||||||
|
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||||
|
const first = queue.enqueue("account-a", async () => {
|
||||||
|
events.push("a:start");
|
||||||
|
await firstGate;
|
||||||
|
events.push("a:end");
|
||||||
|
return "a";
|
||||||
|
});
|
||||||
|
const second = queue.enqueue("account-b", async () => {
|
||||||
|
events.push("b:start");
|
||||||
|
events.push("b:end");
|
||||||
|
return "b";
|
||||||
|
});
|
||||||
|
|
||||||
await runAccountEnableRefresh(
|
await Promise.resolve();
|
||||||
async () => { events.push("check"); return { valid: true, message: "Premium aktiv" }; },
|
expect(events).toEqual(["a:start"]);
|
||||||
async () => { events.push("persist"); }
|
releaseFirst();
|
||||||
);
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||||
|
{ status: "applied", value: "a" },
|
||||||
expect(events).toEqual(["check", "persist"]);
|
{ status: "applied", value: "b" }
|
||||||
|
]);
|
||||||
|
expect(events).toEqual(["a:start", "a:end", "b:start", "b:end"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not check an account while disabling it", async () => {
|
it("lets only the latest queued intent for the same account persist", async () => {
|
||||||
const events: string[] = [];
|
const queue = createAccountToggleQueue();
|
||||||
|
const persisted: string[] = [];
|
||||||
|
let releaseBlocker: () => void = () => {};
|
||||||
|
const blocker = queue.enqueue("blocker", async () => new Promise<string>((resolve) => {
|
||||||
|
releaseBlocker = () => resolve("released");
|
||||||
|
}));
|
||||||
|
const stale = queue.enqueue("account-a", async () => {
|
||||||
|
persisted.push("stale");
|
||||||
|
return "stale";
|
||||||
|
});
|
||||||
|
const latest = queue.enqueue("account-a", async () => {
|
||||||
|
persisted.push("latest");
|
||||||
|
return "latest";
|
||||||
|
});
|
||||||
|
|
||||||
await runAccountEnableRefresh(
|
await Promise.resolve();
|
||||||
undefined,
|
releaseBlocker();
|
||||||
async () => { events.push("persist"); }
|
await blocker;
|
||||||
);
|
await expect(stale).resolves.toEqual({ status: "superseded" });
|
||||||
|
await expect(latest).resolves.toEqual({ status: "applied", value: "latest" });
|
||||||
expect(events).toEqual(["persist"]);
|
expect(persisted).toEqual(["latest"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not enable an account whose silent refresh is invalid", async () => {
|
it("rebases consecutive account patches on the latest persisted settings", () => {
|
||||||
const events: string[] = [];
|
const source = defaultSettings();
|
||||||
|
source.debridLinkApiKeys = "first-key\nsecond-key";
|
||||||
|
const settings = createRendererSettings(source);
|
||||||
|
const [first, second] = parseDebridLinkApiKeys(source.debridLinkApiKeys);
|
||||||
|
const afterFirst = { ...settings, ...buildAccountTogglePatch(settings, { type: "debridlink", accountId: first.id }, false) };
|
||||||
|
const afterSecond = { ...afterFirst, ...buildAccountTogglePatch(afterFirst, { type: "debridlink", accountId: second.id }, false) };
|
||||||
|
|
||||||
await expect(runAccountEnableRefresh(
|
expect(afterSecond.debridLinkDisabledKeyIds).toEqual([first.id, second.id]);
|
||||||
async () => { events.push("check"); return { valid: false, message: "Sitzung abgelaufen" }; },
|
});
|
||||||
async () => { events.push("persist"); }
|
|
||||||
)).rejects.toThrow("Sitzung abgelaufen");
|
|
||||||
|
|
||||||
expect(events).toEqual(["check"]);
|
it("does not let a stale enable check overwrite a newer disable intent", async () => {
|
||||||
|
const queue = createAccountToggleQueue();
|
||||||
|
let settings = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" });
|
||||||
|
let releaseCheck: () => void = () => {};
|
||||||
|
const checkGate = new Promise<void>((resolve) => { releaseCheck = resolve; });
|
||||||
|
const persistedPatches: unknown[] = [];
|
||||||
|
const dependencies = {
|
||||||
|
getSettings: () => settings,
|
||||||
|
persist: async (patch: ReturnType<typeof buildAccountTogglePatch>) => {
|
||||||
|
persistedPatches.push(patch);
|
||||||
|
settings = { ...settings, ...patch };
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const target = { type: "provider", provider: "deepbrid" } as const;
|
||||||
|
const enable = enqueueAccountToggleIntent(queue, {
|
||||||
|
key: "svc-deepbrid",
|
||||||
|
target,
|
||||||
|
enabled: true,
|
||||||
|
check: async () => {
|
||||||
|
await checkGate;
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}, dependencies);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
const disable = enqueueAccountToggleIntent(queue, { key: "svc-deepbrid", target, enabled: false }, dependencies);
|
||||||
|
releaseCheck();
|
||||||
|
|
||||||
|
await expect(enable).resolves.toEqual({ status: "superseded" });
|
||||||
|
await expect(disable).resolves.toEqual(expect.objectContaining({ status: "applied" }));
|
||||||
|
expect(persistedPatches).toHaveLength(1);
|
||||||
|
expect(settings.disabledProviders).toContain("deepbrid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues with the next account after an earlier toggle fails", async () => {
|
||||||
|
const queue = createAccountToggleQueue();
|
||||||
|
const first = queue.enqueue("account-a", async () => { throw new Error("invalid account"); });
|
||||||
|
const second = queue.enqueue("account-b", async () => "saved-b");
|
||||||
|
|
||||||
|
await expect(first).resolves.toEqual(expect.objectContaining({ status: "failed" }));
|
||||||
|
await expect(second).resolves.toEqual({ status: "applied", value: "saved-b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebases a full settings save onto the latest persisted account state", () => {
|
||||||
|
const base = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" });
|
||||||
|
const draft = { ...base, theme: "light" as const, disabledProviders: [] };
|
||||||
|
const persisted = { ...base, theme: "dark" as const, disabledProviders: ["deepbrid" as const] };
|
||||||
|
|
||||||
|
expect(mergeAccountToggleSettings(draft, persisted)).toEqual(expect.objectContaining({
|
||||||
|
theme: "light",
|
||||||
|
disabledProviders: ["deepbrid"]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a queued account toggle when a full settings save follows immediately", async () => {
|
||||||
|
const queue = createAccountToggleQueue();
|
||||||
|
let settings = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" });
|
||||||
|
const draft = { ...settings, theme: "light" as const };
|
||||||
|
const dependencies = {
|
||||||
|
getSettings: () => settings,
|
||||||
|
persist: async (patch: ReturnType<typeof buildAccountTogglePatch>) => {
|
||||||
|
settings = { ...settings, ...patch };
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toggle = enqueueAccountToggleIntent(queue, {
|
||||||
|
key: "svc-deepbrid",
|
||||||
|
target: { type: "provider", provider: "deepbrid" },
|
||||||
|
enabled: false
|
||||||
|
}, dependencies);
|
||||||
|
const fullSave = queue.enqueue("settings-save-1", async () => {
|
||||||
|
settings = mergeAccountToggleSettings(draft, settings);
|
||||||
|
return settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(toggle).resolves.toEqual(expect.objectContaining({ status: "applied" }));
|
||||||
|
await expect(fullSave).resolves.toEqual(expect.objectContaining({ status: "applied" }));
|
||||||
|
expect(settings).toEqual(expect.objectContaining({
|
||||||
|
theme: "light",
|
||||||
|
disabledProviders: ["deepbrid"]
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { createRendererSettings, createRendererState } from "../src/main/rendere
|
|||||||
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
|
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
|
||||||
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
||||||
import {
|
import {
|
||||||
|
buildAccountTogglePatch,
|
||||||
buildScopedAccountEnabledState,
|
buildScopedAccountEnabledState,
|
||||||
buildConfiguredProviderOrder,
|
buildConfiguredProviderOrder,
|
||||||
resolveAccountStatusState,
|
resolveAccountStatusState
|
||||||
runOptimisticAccountUpdate
|
|
||||||
} from "../src/renderer/account-ui";
|
} from "../src/renderer/account-ui";
|
||||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
@@ -1048,7 +1048,7 @@ describe("account workspace", () => {
|
|||||||
model: workspaceModel(),
|
model: workspaceModel(),
|
||||||
actions: workspaceActions({
|
actions: workspaceActions({
|
||||||
onSelect: (id, additive) => calls.push(`select:${id}:${additive}`),
|
onSelect: (id, additive) => calls.push(`select:${id}:${additive}`),
|
||||||
onToggleEnabled: (id) => calls.push(`toggle:${id}`),
|
onToggleEnabled: (id, enabled) => calls.push(`toggle:${id}:${enabled}`),
|
||||||
onEdit: (id) => calls.push(`edit:${id}`),
|
onEdit: (id) => calls.push(`edit:${id}`),
|
||||||
onContextMenu: (id) => calls.push(`context:${id}`)
|
onContextMenu: (id) => calls.push(`context:${id}`)
|
||||||
})
|
})
|
||||||
@@ -1062,7 +1062,7 @@ describe("account workspace", () => {
|
|||||||
row.props.onClick({ target: { role: "cell" }, currentTarget: row, ctrlKey: true, metaKey: false });
|
row.props.onClick({ target: { role: "cell" }, currentTarget: row, ctrlKey: true, metaKey: false });
|
||||||
row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} });
|
row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} });
|
||||||
row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} });
|
row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} });
|
||||||
checkbox.props.onChange();
|
checkbox.props.onChange({ target: { checked: false } });
|
||||||
row.props.onDoubleClick();
|
row.props.onDoubleClick();
|
||||||
actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } });
|
actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } });
|
||||||
actionButton.props.onDoubleClick({ stopPropagation: () => calls.push("action-double-click-stopped") });
|
actionButton.props.onDoubleClick({ stopPropagation: () => calls.push("action-double-click-stopped") });
|
||||||
@@ -1071,7 +1071,7 @@ describe("account workspace", () => {
|
|||||||
`select:${rowId}:false`,
|
`select:${rowId}:false`,
|
||||||
`select:${rowId}:true`,
|
`select:${rowId}:true`,
|
||||||
`select:${rowId}:false`,
|
`select:${rowId}:false`,
|
||||||
`toggle:${rowId}`,
|
`toggle:${rowId}:false`,
|
||||||
`edit:${rowId}`,
|
`edit:${rowId}`,
|
||||||
`context:${rowId}`,
|
`context:${rowId}`,
|
||||||
"action-double-click-stopped"
|
"action-double-click-stopped"
|
||||||
@@ -1432,10 +1432,10 @@ describe("settings App integration", () => {
|
|||||||
const deleteKeyBlock = sourceBlock(appSource, "const onRemoveDebridLinkKey", "const onToggleAccountEnabled");
|
const deleteKeyBlock = sourceBlock(appSource, "const onRemoveDebridLinkKey", "const onToggleAccountEnabled");
|
||||||
const deleteRowsBlock = sourceBlock(appSource, "const removeAccountTableRows", "const checkAccountsActive");
|
const deleteRowsBlock = sourceBlock(appSource, "const removeAccountTableRows", "const checkAccountsActive");
|
||||||
|
|
||||||
expect(editBlock.indexOf("await persistDraftSettings()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount"));
|
expect(editBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount"));
|
||||||
expect(createBlock.indexOf("await persistDraftSettings()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount"));
|
expect(createBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount"));
|
||||||
expect(deleteKeyBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount"));
|
expect(deleteKeyBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount"));
|
||||||
expect(deleteRowsBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount"));
|
expect(deleteRowsBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalidates an archive password reveal before applying imported settings", () => {
|
it("invalidates an archive password reveal before applying imported settings", () => {
|
||||||
@@ -1462,12 +1462,18 @@ describe("settings App integration", () => {
|
|||||||
disabledAccountIds: ["key-active"]
|
disabledAccountIds: ["key-active"]
|
||||||
});
|
});
|
||||||
|
|
||||||
const debridLinkBlock = sourceBlock(appSource, "const onToggleDebridLinkApiKeyEnabled", "const onAccountRowQuickAction");
|
const rendererSettings = createRendererSettings(defaultSettings());
|
||||||
const megaBlock = sourceBlock(appSource, "const onToggleMegaAccountEnabled", "const onRemoveDebridLinkKey");
|
const megaPatch = buildAccountTogglePatch({
|
||||||
expect(debridLinkBlock).toContain("buildScopedAccountEnabledState");
|
...rendererSettings,
|
||||||
expect(megaBlock).toContain("buildScopedAccountEnabledState");
|
disabledProviders: ["megadebrid-web"],
|
||||||
expect(megaBlock).toContain('megaDebridApiEnabled: mode === "api" && enabled ? true');
|
megaDebridWebEnabled: false,
|
||||||
expect(megaBlock).toContain('megaDebridWebEnabled: mode === "web" && enabled ? true');
|
megaDebridWebDisabledAccountIds: ["mega-web-account"]
|
||||||
|
}, { type: "megadebrid", provider: "megadebrid-web", accountId: "mega-web-account" }, true);
|
||||||
|
expect(megaPatch).toEqual(expect.objectContaining({
|
||||||
|
disabledProviders: [],
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
megaDebridWebDisabledAccountIds: []
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a failed all-account check even when the account is disabled", () => {
|
it("shows a failed all-account check even when the account is disabled", () => {
|
||||||
@@ -1476,28 +1482,6 @@ describe("settings App integration", () => {
|
|||||||
expect(resolveAccountStatusState(false, undefined)).toBe("unchecked");
|
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<string>((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", () => {
|
it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -1517,24 +1501,6 @@ describe("settings App integration", () => {
|
|||||||
expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" }));
|
expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps specific persistence revision-safe when the draft changes in flight", () => {
|
|
||||||
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
|
|
||||||
const toggleBlock = sourceBlock(appSource, "const persistAccountToggle", "const onToggleDebridLinkApiKeyEnabled");
|
|
||||||
expect(block).toContain("revisionAtStart");
|
|
||||||
expect(block).toContain("mergeConcurrentSpecificSettings");
|
|
||||||
expect(block).toContain('setSettingsSaveState("dirty")');
|
|
||||||
expect(block).toContain("persistedSettingsRef.current = result");
|
|
||||||
expect(block.indexOf("persistedSettingsRef.current = result")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)"));
|
|
||||||
expect(block).toContain("themeChoiceAtStart: settingsThemeChoiceRef.current");
|
|
||||||
expect(block).toContain("const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext");
|
|
||||||
expect(block).toContain("persistedThemeChoiceRef.current = themeChoiceAtStart");
|
|
||||||
expect(block.indexOf("persistedThemeChoiceRef.current = themeChoiceAtStart")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)"));
|
|
||||||
expect(toggleBlock).toContain("const themeChoiceAtStart = settingsThemeChoiceRef.current");
|
|
||||||
expect(toggleBlock).toContain("const persistenceContext = {");
|
|
||||||
expect(toggleBlock.indexOf("const persistenceContext = {")).toBeLessThan(toggleBlock.indexOf("runAccountEnableRefresh("));
|
|
||||||
expect(toggleBlock).toContain("persistSpecificSettings(nextDraft, persistenceContext)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps unchecked single accounts honest without a positive status", () => {
|
it("keeps unchecked single accounts honest without a positive status", () => {
|
||||||
const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId");
|
const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId");
|
||||||
expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow)");
|
expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow)");
|
||||||
|
|||||||
Reference in New Issue
Block a user