Fix stale-state when account credentials change at runtime

Reported user bug: "When I add an account, remove it, add a new one, the
new account is only really used after restart." Multiple cache layers were
not invalidated when settings changed, causing the system to keep using
stale state until the app was restarted.

Three layers of caching needed invalidation:

1. DebridService cached client instances (debrid.ts ~3067)
   The cached DebridLinkClient / LinkSnappyClient / DdownloadClient hold
   internal state (session cookies, auth tokens, parsed key lists). Without
   explicit invalidation when credentials change, the OLD client instance
   keeps serving requests until apiKeysRaw / login+password no longer match
   the cache key - which doesn't always trigger because the cache key may
   incidentally match (e.g. user removes and re-adds the same key).
   Fix: setSettings() now compares previous vs next credentials per provider
   and explicitly clears the cache when they differ.

2. MegaDebridClient.cachedApiTokens (debrid.ts ~1533)
   Static module-level token cache keyed by login (lowercase). When a user
   changes the password for an existing login (same login, new password),
   the cached token was kept for up to 20 minutes and would only get cleared
   after the API returned 401/403.
   Fix: Two new static methods - pruneCachedTokensNotIn() removes entries
   whose login is no longer in the active list, and clearCachedApiToken()
   force-clears a specific login. Both called from setSettings() based on
   diff between previous and next account list.

3. Module-level Debrid-Link cooldown maps (debrid.ts ~41-51)
   debridLinkKeyCooldowns / debridLinkKeyCooldownDetails / runtime statuses
   are keyed by API key ID (FNV-1a hash of the key). When a key was put
   into cooldown then removed from settings then re-added later, the old
   cooldown entry would still block it.
   Fix: New pruneDebridLinkRuntimeStateForKeys() function called from
   setSettings() removes cooldown entries for keys no longer in the active
   set.

4. providerFailures circuit-breaker map (download-manager.ts ~1990)
   Per-provider failure tracking with cooldownUntil. When a user removes a
   failing account and adds a new one of the same provider, the cooldown
   would carry over. Now setSettings() compares per-provider credentials
   and clears matching providerFailures entries when they change.

Reproduction (now fixed):
  1. Add Debrid-Link key A
  2. Trigger a failure (e.g. invalid link) so A goes into cooldown
  3. Remove A, add B in settings
  4. Try a download immediately - previously the cooldown for A or the
     cached client with A's state could still prevent B from being used.
     After this fix B is used immediately.

Tests: 201/201 (debrid + download-manager) green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-04-19 21:59:28 +02:00
parent 04413599d8
commit c3590f08fc
2 changed files with 121 additions and 0 deletions

View File

@ -59,6 +59,23 @@ export function resetDebridLinkRuntimeStateForTests(): void {
debridLinkKeyRuntimeStatuses.clear(); debridLinkKeyRuntimeStatuses.clear();
} }
/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no
* longer in the active key set. Called when settings change so removed
* keys don't keep blocking the system if they're re-added later. */
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
for (const keyId of debridLinkKeyCooldowns.keys()) {
if (!activeKeyIds.has(keyId)) {
debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId);
}
}
for (const keyId of debridLinkKeyRuntimeStatuses.keys()) {
if (!activeKeyIds.has(keyId)) {
debridLinkKeyRuntimeStatuses.delete(keyId);
}
}
}
/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries. /** Periodic cleanup of expired Debrid-Link cooldown/runtime entries.
* Without this, module-level Maps grow unbounded over 24/7 operation. * Without this, module-level Maps grow unbounded over 24/7 operation.
* Removes entries whose cooldown expired more than 1 hour ago. */ * Removes entries whose cooldown expired more than 1 hour ago. */
@ -1535,6 +1552,33 @@ class MegaDebridClient {
/** Per-account pending connect deduplication: login (lowercase) → promise */ /** Per-account pending connect deduplication: login (lowercase) → promise */
private static pendingConnects = new Map<string, Promise<string | null>>(); private static pendingConnects = new Map<string, Promise<string | null>>();
/** Clear cached tokens for accounts whose login is no longer in the given set.
* Called when settings change so removed accounts don't keep stale tokens. */
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
const keep = new Set<string>();
for (const login of activeLogins) {
keep.add(String(login || "").toLowerCase());
}
for (const login of MegaDebridClient.cachedApiTokens.keys()) {
if (!keep.has(login)) {
MegaDebridClient.cachedApiTokens.delete(login);
}
}
for (const login of MegaDebridClient.pendingConnects.keys()) {
if (!keep.has(login)) {
MegaDebridClient.pendingConnects.delete(login);
}
}
}
/** Force-clear the API token for a specific login (e.g. when its password
* changes same login, but cached token is now invalid for new password). */
public static clearCachedApiToken(login: string): void {
const key = String(login || "").toLowerCase();
MegaDebridClient.cachedApiTokens.delete(key);
MegaDebridClient.pendingConnects.delete(key);
}
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
this.login = login; this.login = login;
this.password = password; this.password = password;
@ -3065,7 +3109,53 @@ export class DebridService {
} }
public setSettings(next: AppSettings): void { public setSettings(next: AppSettings): void {
const prev = this.settings;
this.settings = cloneSettings(next); this.settings = cloneSettings(next);
// Invalidate cached provider clients whose credentials/keys changed.
// Without this, switching API keys or session-cookie-bound accounts
// (LinkSnappy, Ddownload) would keep using the previous Client instance
// — which holds the OLD session cookies — until the app is restarted.
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
this.cachedDebridLinkClient = null;
this.cachedDebridLinkKey = "";
}
if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) {
this.cachedLinkSnappyClient = null;
this.cachedLinkSnappyKey = "";
}
if (prev.ddownloadLogin !== next.ddownloadLogin || prev.ddownloadPassword !== next.ddownloadPassword) {
this.cachedDdownloadClient = null;
this.cachedDdownloadKey = "";
}
// Mega-Debrid token cache (static, module-level): tokens are keyed by
// login (lowercase). When credentials change, drop tokens for logins
// that are no longer in the active account list, AND force-clear any
// login whose password changed. Otherwise stale tokens linger up to
// 20 minutes and the new credentials won't be tried until the cached
// token starts returning 401/403.
const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
const nextLogins = new Set<string>();
const nextPasswordByLogin = new Map<string, string>();
for (const acc of nextAccounts) {
nextLogins.add(acc.login.toLowerCase());
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
}
// Drop tokens for logins no longer present
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
// For logins still present but with a changed password, force-clear the token
for (const prevAcc of prevAccounts) {
const loginKey = prevAcc.login.toLowerCase();
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
MegaDebridClient.clearCachedApiToken(prevAcc.login);
}
}
// Also prune module-level Debrid-Link cooldowns for keys that no longer exist —
// otherwise a key removed and re-added later would still show its old cooldown.
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
} }
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient { private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {

View File

@ -1987,6 +1987,37 @@ export class DownloadManager extends EventEmitter {
this.hybridFailedArchives.clear(); this.hybridFailedArchives.clear();
} }
// When account credentials change, clear the provider-failure circuit-breaker
// for affected providers. Otherwise a freshly added account would inherit
// the cooldown that the previous (now removed) account had triggered, and
// the user would be confused why "their new account doesn't work right away".
const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
{ prev: previous.bestDebridApiKey || "", next: next.bestDebridApiKey || "", providers: ["bestdebrid"] },
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
{ prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
{ prev: previous.megaCredentials + "|" + previous.megaPassword, next: next.megaCredentials + "|" + next.megaPassword, providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] }
];
let clearedProviderFailures = 0;
for (const change of credChanges) {
if (change.prev === change.next) continue;
for (const provider of change.providers) {
// Provider failure keys are sometimes "provider" alone, sometimes "provider:hoster".
// Clear all entries that start with the provider name.
for (const key of [...this.providerFailures.keys()]) {
if (key === provider || key.startsWith(`${provider}:`)) {
this.providerFailures.delete(key);
clearedProviderFailures += 1;
}
}
}
}
if (clearedProviderFailures > 0) {
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
}
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (next.completedCleanupPolicy !== "never") { if (next.completedCleanupPolicy !== "never") {