From dd4a39e47dc44a8ca76cc87ea4ac8646b699db36 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 6 Sep 2026 12:39:11 +0200 Subject: [PATCH] feat: add per-provider account priority rules --- PROJECT_MEMORY.md | 7 +- src/main/account-commands.ts | 17 +- src/main/account-usage-rules.ts | 14 ++ src/main/constants.ts | 3 +- src/main/debrid.ts | 46 ++++-- src/main/renderer-settings.ts | 10 ++ src/main/renderer-state.ts | 1 + src/main/storage.ts | 9 +- src/renderer/App.tsx | 42 ++++- src/renderer/i18n.ts | 4 + .../settings/AccountPriorityControls.tsx | 49 ++++++ .../views/settings/AccountWorkspace.tsx | 13 +- src/renderer/views/settings/settings.css | 51 ++++++ src/shared/account-usage-rules.ts | 36 ++++ src/shared/types.ts | 12 +- tests/account-priority-routing.test.ts | 156 ++++++++++++++++++ tests/account-usage-rules.test.ts | 80 +++++++++ tests/backup-payload.test.ts | 7 + tests/i18n.test.ts | 6 + tests/visual/account-priority.html | 5 + tests/visual/account-priority.tsx | 67 ++++++++ tests/visual/fixtures.ts | 3 +- 22 files changed, 607 insertions(+), 31 deletions(-) create mode 100644 src/main/account-usage-rules.ts create mode 100644 src/renderer/views/settings/AccountPriorityControls.tsx create mode 100644 src/shared/account-usage-rules.ts create mode 100644 tests/account-priority-routing.test.ts create mode 100644 tests/account-usage-rules.test.ts create mode 100644 tests/visual/account-priority.html create mode 100644 tests/visual/account-priority.tsx diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index b8da079..6481314 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -8,10 +8,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält ## Zuletzt verifizierter Stand +- 6. September 2026, Account-Reihenfolge implementiert, noch nicht veröffentlicht: Unter Einstellungen → Accounts → Verwendungsregeln lassen sich Real-Debrid, Mega-Debrid API/Web und Debrid-Link aufklappen. Pro Provider Wahl zwischen unveränderter automatischer Verteilung (Standard) und fester Priorität. Accounts mit Identität, Modus und Status werden per Pfeilen oder Drag-and-drop innerhalb desselben Providers geordnet; Änderungen werden mit „Einstellungen speichern“ übernommen. Provider ohne Mehrfachaccount-Unterstützung behalten ihre bestehende einzelne Zugangskonfiguration. +- `accountUsageRules` speichert Modus und Account-IDs getrennt von Zugangsdaten. Normalisierung entfernt fremde/gelöschte IDs, dedupliziert und hängt neue Accounts an. Account-Ersetzen/Passwortwechsel erhält die Prioritätsposition auch bei wechselnder ID; Löschen entfernt Referenzen. Renderer-Payloads werden verschachtelt validiert. Einstellungen und verschlüsselte Backups übernehmen die Regeln; bestehende Installationen bleiben automatisch. +- Feste Priorität wirkt in der tatsächlichen Link-Auflösung der drei Provider-Engines, nicht nur in der Ansicht. Disabled-/Tageslimit-/Cooldown-/Hosterprüfungen und Provider-Fallback bleiben erhalten. Nach Cooldown-Ende hat der bevorzugte Account für neue Auflösungen wieder Vorrang. Bei Mega-Debrid belegt jede Prioritätsauflösung höchstens einen Umwandlungsplatz pro Account: belegte Accounts werden zugunsten des nächsten freien übersprungen, Vollbelegung wartet abbrechbar und prüft Sperren erneut. Real-Debrid und Debrid-Link behalten ihre vorhandenen Parallelitätsgrenzen; Account-Kapazitätsfehler werden weiterhin als Sperr-/Fallbackgrund behandelt. Laufende Downloads mit bereits aufgelösten URLs werden nicht umgeschaltet. +- Prüfung des Account-Modus: neue Tests für alle vier Routen einschließlich gemischter Real-Debrid API/Web-Priorität, Cooldown-Rückkehr, Disabled/Tageslimit, Provider-Fallback an/aus, Mega-Parallelplätze samt Warten/Abbrechen, Account-Ersetzen/Anlegen/Löschen, Neustart und verschlüsselten Backup-Roundtrip. Alle 124 bisherigen Debrid-Tests unverändert erfolgreich. `tests/visual/account-priority.html` prüft die echte App mit ausschließlich Testdaten: Aufklappen, Standardmodus, Moduswechsel, Pfeile, Drag-and-drop, Ablehnung fremder Provider-Drops, Speichern und horizontale Grenzen. Dark-/Light-Screenshots geprüft. TypeScript, Self-Check und beide Builds erfolgreich. Finaler Clientlauf: 2.774 Tests bestanden, vier optionale Tests übersprungen; separat 22 Release-Metadatentests bestanden, zwei bekannte Windows-Symlink-Fixtures ausgenommen (insgesamt 2.796 erfolgreiche Client-Tests). Zusätzlich alle 16 Backup-API-Tests erfolgreich. Kein Release, keine produktive Installation und kein Serverneustart. - 6. September 2026, noch nicht veröffentlicht: `switchToCollectorOnClipboard` macht den automatischen Tabwechsel bei erkannten Zwischenablage-Links optional. Standard und Migration vorhandener Konfigurationen ohne Feld sind `false`; nur explizites `true` aktiviert ihn. Schalter unter Einstellungen → Allgemein → Download-Verhalten, deutsch/englisch übersetzt. Zwischenablage-Import sammelt und speichert weiterhin im Hintergrund und verändert bei ausgeschalteter Option weder Tab noch Linksammlerfilter. Manuelles Einfügen, Drag-and-drop und DLC-Import behalten ihre bisherige Navigation. Die Entscheidung liest nach der asynchronen Vorbereitung die aktuell bestätigte Einstellung, damit Ausschalten während eines Imports berücksichtigt wird. - Verifiziert: TypeScript, Main-/Renderer-Build und 522 gezielte Tests für Einstellungen, Persistenz/Migration, Renderer-Validierung, Übersetzung, Collector und Backups. Isolierte Electron-Browserprobe `tests/visual/collector-navigation.html` verwendet ausschließlich Testdaten und bestätigt Hintergrundimporte aus Downloads/Einstellungen, Opt-in sowie Opt-out während eines laufenden Imports; alle vier Pakete bleiben gespeichert. Keine produktive Konfiguration geändert, kein Serverneustart und kein neues Release. -- Planungsauftrag Account-Reihenfolge (noch nicht implementieren): Unter Verwendungsregeln die Provider aufklappbar machen, darin nur deren Accounts mit Name, API/Web-Modus, Prioritätsnummer und aktuellem Status anzeigen; Reihenfolge per Drag-and-drop und Pfeiltasten ändern. Pro Provider Wahl zwischen bisheriger automatischer Verteilung (Migrationsstandard) und fester Priorität. Feste Priorität nutzt für neue Auflösungen den ersten tatsächlich nutzbaren Account; deaktivierte Accounts, Tageslimits, passende Cooldowns und ausgeschöpfte Account-Kapazität werden übersprungen. Nach Erholung hat Account 1 wieder Vorrang; laufende Downloads werden nicht umgeschaltet. Erst nach Ausschöpfen der Accounts greift die bestehende Provider-Fallbackregel. Link-/providerweite Fehler dürfen keine sinnlose Rotation durch alle Accounts auslösen. -- Technische Basis des Account-Plans: `AccountWorkspace.tsx` rendert aktuell nur Provider-Zeilen. Debrid-Link iteriert in `debrid.ts` bereits in Key-Reihenfolge mit Disabled-/Limit-/Cooldown-Prüfungen; Real-Debrid verwendet geringste In-flight-Auslastung plus Sticky-/Rotationszustand, Mega-Debrid Cursor-Reihenfolge plus In-flight-Sortierung. Deshalb ist eine reine UI-Umsortierung unzureichend. Umsetzung in drei überprüfbaren Schritten: (1) separates Prioritätsmodell pro Provider mit stabilen Account-IDs, Normalisierung, Speichern/Backup sowie Referenzpflege beim Ersetzen/Löschen und Anhängen neuer Accounts; (2) aufklappbare Bedienung und Moduswahl ohne Geheimnisse; (3) priorisierte Auswahl in den drei Engines unter Erhalt von Hoster-/Account-Limits und Reservierungen. Tests müssen 1→2→3 bei Sperren, Rückkehr zu 1 nach Cooldown, Parallelität, Provider-Fallback an/aus, Neustart/Import, Accountänderungen und unveränderten Automatikmodus abdecken. Mittlerer Funktionsumfang, kein bloßer Listenumbau; Freigabe der konkreten Semantik vor Implementierung. +- Der ursprünglich nur geplante Account-Prioritätsmodus wurde anschließend mit „Mach das Chef“ zur Umsetzung freigegeben; aktueller Implementierungs- und Prüfstand siehe oben. Die unveränderte Automatik bleibt der Migrationsstandard. - Release `v2.0.89` nach ausdrücklichem „release!“ auf GitHub und Forgejo veröffentlicht: Drei-Klick-Verfügbarkeitssortierung mit Part-Anzahl bei Gleichstand und korrigierte Hot-Dev-Startskripte. TypeScript, Node-Self-Check, Main-/Renderer-Build und 16 Backup-API-Tests erfolgreich. Finaler Clientlauf mit vier Workern: 2.748 Tests erfolgreich, vier optionale JVM-Tests übersprungen; separat 22 Release-Metadatentests erfolgreich, zwei bekannte Windows-Symlink-Fixtures ausgenommen (insgesamt 2.770 erfolgreiche Client-Tests). Ein bestehender Updater-Timeout im ersten stark parallelen Lauf bestand isoliert und im vollständigen Wiederholungslauf. - Veröffentlichungsprüfung `v2.0.89`: Tag auf beiden Remotes exakt `e18dfa12044e775ff30739029f07f30f9931fced`; beide Plattformen führen denselben Titel und Tag als neuesten stabilen Release, GitHub-Text Englisch und Forgejo-Text Deutsch. Alle zwölf öffentlichen Assets erneut heruntergeladen und Größe sowie SHA-256 mit den lokalen Originalen verglichen. Beide `latest.yml` bestätigen Version und Installer-SHA-512. Setup-SHA-256 `830856d02b41a37a938552b1d828062617fb7feb131791f1eaed1bc9e34c644c`, Portable-SHA-256 `97f04e83397866da50b7750f55f517d5872d52280432ac348251b0088677af43`. Installer-/Portable-Inhalte mit `verify_public_release.mjs --verify-archives` geprüft; Quellarchiv mit 7-Zip geprüft, entspricht dem Tag-Commit. Lokale Originale unter `release/staging-v2.0.89`. Keine produktive Installation und kein Serverneustart. - Availability-Sortierung nach `v2.0.88`: Klickfolge online/viele Parts zuerst → offline/wenige Parts zuerst → ursprüngliche Reihenfolge ohne aktiven Sortierpfeil. Online-/Offline-Anteile bleiben vorrangig; Part-Anzahl entscheidet nur bei identischer Verfügbarkeit. Neue Pakete bleiben beim Ausschalten hinten erhalten, gelöschte werden nicht wieder eingefügt. Wechsel auf eine andere Sortierspalte setzt den Availability-Zyklus zurück. Bestehender Pending-Snapshot-Schutz und sofortiger Sortierwechsel bleiben erhalten. TypeScript und gezielte Sortier-/Ansichtstests erfolgreich; isolierte Electron-Browserprobe mit Testdaten prüft zwei vollständige Zyklen samt verzögerten Snapshots, Sortierindikatoren und DOM-Reihenfolge ohne falsche Zwischenstände. Kein neues Release. diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts index ad9bfaa..d7940dc 100644 --- a/src/main/account-commands.ts +++ b/src/main/account-commands.ts @@ -1,5 +1,7 @@ import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { randomUUID } from "node:crypto"; +import { normalizeConfiguredAccountRules } from "./account-usage-rules"; +import { isAccountRuleProvider } from "../shared/account-usage-rules"; import { getMegaDebridAccountId, getMegaDebridAccountsForMode, @@ -617,7 +619,7 @@ function createSingle(settings: AppSettings, command: Extract = { action: "replace", @@ -625,7 +627,7 @@ export function applyAccountCommand(settings: AppSettings, command: AccountComma accountId: command.accountId, secret: command.secret }; - return applyAccountCommand(settings, replace); + return applyAccountCommandToCredentials(settings, replace); } if (command.kind === "realdebrid-api" || command.kind === "realdebrid-web") { const normalizedSettings = normalizeRealDebridCommandSettings(settings); @@ -647,3 +649,14 @@ export function applyAccountCommand(settings: AppSettings, command: AccountComma if (command.action === "replace") return replaceSingle(settings, command); return deleteSingle(settings, command); } + +export function applyAccountCommand(settings: AppSettings, command: AccountCommand): AppliedAccountCommand { + const result = applyAccountCommandToCredentials(settings, command); + const rules = structuredClone(settings.accountUsageRules ?? {}); + const provider = command.kind.startsWith("realdebrid") ? "realdebrid" : command.kind === "debridlink-api" ? "debridlink" : command.kind; + if (isAccountRuleProvider(provider) && rules[provider] && (command.action === "replace" || command.action === "update-secret") && result.response.accountId) { + rules[provider]!.accountIds = rules[provider]!.accountIds.map((id) => id === command.accountId ? result.response.accountId! : id); + } + result.settings.accountUsageRules = normalizeConfiguredAccountRules({ ...result.settings, accountUsageRules: rules }); + return result; +} diff --git a/src/main/account-usage-rules.ts b/src/main/account-usage-rules.ts new file mode 100644 index 0000000..9b89b5b --- /dev/null +++ b/src/main/account-usage-rules.ts @@ -0,0 +1,14 @@ +import { normalizeAccountUsageRules } from "../shared/account-usage-rules"; +import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; +import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import type { AppSettings } from "../shared/types"; + +export function normalizeConfiguredAccountRules(settings: AppSettings) { + return normalizeAccountUsageRules(settings.accountUsageRules, { + realdebrid: getRealDebridAccounts(settings).map((account) => account.id), + "megadebrid-api": getMegaDebridAccountsForMode(settings, "api").map((account) => account.id), + "megadebrid-web": getMegaDebridAccountsForMode(settings, "web").map((account) => account.id), + debridlink: parseDebridLinkApiKeys(settings.debridLinkApiKeys).map((account) => account.id) + }); +} diff --git a/src/main/constants.ts b/src/main/constants.ts index 992b073..0367d49 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -75,7 +75,8 @@ export function defaultSettings(): AppSettings { linkSnappyPassword: "", archivePasswordList: "", rememberToken: true, - providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"], + providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"], + accountUsageRules: {}, providerPrimary: "realdebrid", providerSecondary: "megadebrid-api", providerTertiary: "bestdebrid", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index a77c0ec..f18340a 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,4 +1,5 @@ -import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { orderAccountsByRule } from "../shared/account-usage-rules"; import { getMegaDebridAccountsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, type MegaDebridAccountEntry, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts"; import { extractHosterFromUrl } from "../shared/hoster"; @@ -2380,13 +2381,31 @@ class MegaDebridClient { // Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account. const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number => megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0; - const orderedEntries = cursorOrder + const automaticEntries = cursorOrder .map((entry, position) => ({ entry, position })) .sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position)) - .map((wrapped) => wrapped.entry); - - for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) { - const entry = orderedEntries[orderPos]; + .map((wrapped) => wrapped.entry); + const accountRule = settings.accountUsageRules?.[mode === "api" ? "megadebrid-api" : "megadebrid-web"]; + const orderedEntries = accountRule?.mode === "priority" + ? orderAccountsByRule(accounts, accountRule).map((account) => ({ account, idx: accounts.findIndex((candidate) => candidate.id === account.id) })) + : automaticEntries; + for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) { + if (accountRule?.mode === "priority") { + const usableEntries = orderedEntries.slice(orderPos).filter(({ account }) => !isMegaDebridAccountDisabled(settings, account.id, mode) + && !isMegaDebridAccountDailyLimitReached(settings, account.id) + && !getMegaDebridAccountCooldownState(`${account.id}:${mode}`)); + const freeEntry = usableEntries.find(({ account }) => (megaDebridInFlight.get(`${account.id}:${mode}`) ?? 0) === 0); + if (freeEntry) { + const freeIndex = orderedEntries.indexOf(freeEntry); + orderedEntries.splice(freeIndex, 1); + orderedEntries.splice(orderPos, 0, freeEntry); + } else if (usableEntries.length > 0) { + await sleepWithSignal(50, signal); + orderPos -= 1; + continue; + } + } + const entry = orderedEntries[orderPos]; const account = entry.account; const idx = entry.idx; const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; @@ -3140,13 +3159,14 @@ class DebridLinkClient { let earliestCooldownUntil = 0; const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = []; let consecutiveTransportFailures = 0; - const totalKeys = this.apiKeys.length; + const apiKeys = orderAccountsByRule(this.apiKeys, settings.accountUsageRules?.debridlink); + const totalKeys = apiKeys.length; const providerName = "Debrid-Link"; const linkShort = String(link || "").slice(0, 80); const linkHoster = extractHosterFromUrl(link); - for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { - const apiKey = this.apiKeys[keyIdx]; + for (let keyIdx = 0; keyIdx < apiKeys.length; keyIdx += 1) { + const apiKey = apiKeys[keyIdx]; const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`; const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`; if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { @@ -3297,8 +3317,8 @@ class DebridLinkClient { throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`); } let nextLabel = "ENDE"; - for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) { - const nextKey = this.apiKeys[nextIdx]; + for (let nextIdx = keyIdx + 1; nextIdx < apiKeys.length; nextIdx += 1) { + const nextKey = apiKeys[nextIdx]; if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) { nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`; break; @@ -4584,7 +4604,9 @@ export class DebridService { if (available.length === 0) { break; } - const account = this.selectRealDebridAccount(available); + const account = settings.accountUsageRules?.realdebrid?.mode === "priority" + ? orderAccountsByRule(available, settings.accountUsageRules.realdebrid)[0] + : this.selectRealDebridAccount(available); attempted.add(account.id); realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1); recordAccountRuntimeAttempt("realdebrid", account.id); diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts index 02bb87f..85bc25e 100644 --- a/src/main/renderer-settings.ts +++ b/src/main/renderer-settings.ts @@ -1,5 +1,6 @@ import type { AppSettings, RendererSettingsUpdate } from "../shared/types"; import { createRendererSettings } from "./renderer-state"; +import { isAccountRuleProvider } from "../shared/account-usage-rules"; import { isValidLocalDate } from "./daily-start-scheduler"; const DERIVED_KEYS = new Set([ @@ -91,6 +92,15 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti if (!(key in safe)) { invalid(); } + if (key === "accountUsageRules") { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) invalid(); + for (const [provider, rule] of Object.entries(entry)) { + if (!isAccountRuleProvider(provider) || !rule || typeof rule !== "object" || Array.isArray(rule)) invalid(); + if (Object.keys(rule).some((field) => field !== "mode" && field !== "accountIds")) invalid(); + if (rule.mode !== "automatic" && rule.mode !== "priority") invalid(); + if (!Array.isArray(rule.accountIds) || rule.accountIds.length > 10000 || rule.accountIds.some((id: unknown) => typeof id !== "string" || id.length === 0 || id.length > 256)) invalid(); + } + } if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") { invalid(); } diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 79bb436..2d592a0 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -174,6 +174,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings rememberToken: settings.rememberToken, configuredProviders, providerOrder: [...settings.providerOrder], + accountUsageRules: structuredClone(settings.accountUsageRules ?? {}), providerPrimary: settings.providerPrimary, providerSecondary: settings.providerSecondary, providerTertiary: settings.providerTertiary, diff --git a/src/main/storage.ts b/src/main/storage.ts index 0b3de89..be2b259 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,4 +1,5 @@ -import fs from "node:fs"; +import fs from "node:fs"; +import { normalizeConfiguredAccountRules } from "./account-usage-rules"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -577,7 +578,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { linkSnappyPassword: asText(settings.linkSnappyPassword), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"), rememberToken: Boolean(settings.rememberToken), - providerOrder: normalizeProviderOrder( + accountUsageRules: {}, + providerOrder: normalizeProviderOrder( settings.providerOrder, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, settings.providerPrimary, settings.providerSecondary, settings.providerTertiary @@ -704,7 +706,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) }; - if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { + normalized.accountUsageRules = normalizeConfiguredAccountRules({ ...normalized, accountUsageRules: settings.accountUsageRules }); + if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { normalized.providerPrimary = defaults.providerPrimary; } if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerSecondary)) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 794c113..911eb1a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -42,6 +42,7 @@ import { import { createAvailabilitySortCycle, preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order"; import { createPackageOrderState } from "./package-order-state"; import { shouldFocusCollectorImport, type CollectorImportSource } from "./collector-navigation"; +import { isAccountRuleProvider, reconcileAccountOrder, type AccountUsageRule } from "../shared/account-usage-rules"; import { getPackagesWithOfflineLinks } from "../shared/offline-packages"; import type { OfflineSkipScope } from "../shared/types"; import { OfflineRemovalScopeChoice } from "./views/downloads/OfflineRemovalScopeChoice"; @@ -951,7 +952,7 @@ const emptySnapshot = (): UiSnapshot => ({ language: "en", realDebridUseWebLogin: false, realDebridDisabledAccountIds: [], realDebridAccountDailyLimitBytes: {}, realDebridAccountDailyUsageBytes: {}, realDebridAccountTotalUsageBytes: {}, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false, debridLinkDisabledKeyIds: [], archivePasswordListConfigured: false, notifyUrlConfigured: false, - rememberToken: true, configuredProviders: [], providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", + rememberToken: true, configuredProviders: [], accountUsageRules: {}, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", providerTertiary: "none", autoProviderFallback: true, outputDir: "", createWorkDirectoriesOnStartup: false, packageName: "", autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, collectMkvToLibrary: false, mkvLibraryDir: "", @@ -5909,7 +5910,21 @@ export function App(): ReactElement { statusSort: accountStatusSort, runtime: accountRuntimeModel, rules: { - providerOrder: activeProviderOrder.map((provider) => buildProviderOrderEntry(provider, settingsDraft)), + providerOrder: activeProviderOrder.map((provider) => { + const entry = buildProviderOrderEntry(provider, settingsDraft); + if (!isAccountRuleProvider(provider)) return entry; + const rows = accountRows.filter((row) => row.entry.service === provider && row.accountId); + const rule = settingsDraft.accountUsageRules?.[provider]; + const ids = reconcileAccountOrder(rule?.accountIds ?? [], rows.map((row) => row.accountId!)); + return { ...entry, accountSelection: { + mode: rule?.mode ?? "automatic", + accounts: ids.map((id) => { + const row = rows.find((candidate) => candidate.accountId === id)!; + const runtime = accountRuntimeModel.accounts.find((account) => account.id === accountRowViewId(row)); + return { id, label: row.username || row.dlKey?.label || row.entry.summary || id, mode: row.modeLabel, status: runtime?.stateLabel ?? (row.disabled ? "Deaktiviert" : "Bereit") }; + }) + } }; + }), routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId} → ${providerLabelWithMode(provider, settingsDraft)}`), autoFallback: settingsDraft.autoProviderFallback, rememberCredentials: settingsDraft.rememberToken, @@ -5938,7 +5953,30 @@ export function App(): ReactElement { setSettingsSaveState("dirty"); setSettingsDraft((current) => ({ ...current, hosterRouting })); }; + const updateAccountUsageRule = (provider: string, update: (rule: AccountUsageRule) => AccountUsageRule): void => { + if (!isAccountRuleProvider(provider)) return; + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); + setSettingsDraft((current) => { + const previous = current.accountUsageRules?.[provider]; + const rule = { mode: previous?.mode ?? "automatic", accountIds: reconcileAccountOrder(previous?.accountIds ?? [], accountRows.filter((row) => row.entry.service === provider && row.accountId).map((row) => row.accountId!)) }; + return { ...current, accountUsageRules: { ...current.accountUsageRules, [provider]: update(rule) } }; + }); + }; const accountWorkspaceActions: AccountWorkspaceActions = { + onAccountSelectionMode: (provider, mode) => updateAccountUsageRule(provider, (rule) => ({ ...rule, mode })), + onMovePriorityAccount: (provider, accountId, targetId) => updateAccountUsageRule(provider, (rule) => { + const accountIds = [...rule.accountIds]; + const from = accountIds.indexOf(accountId); + const to = accountIds.indexOf(targetId); + if (from < 0 || to < 0 || from === to) return rule; + accountIds.splice(from, 1); + accountIds.splice(to, 0, accountId); + return { ...rule, accountIds }; + }), onPanelChange: setAccountManagementTab, onSelect: (rowId, additive) => { const rowKey = accountRowBindings.get(rowId)?.rowKey; diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index dc3f937..bbf8809 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -14,6 +14,10 @@ const pairs = [ ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Animationen", "Animations"], ["Bei erkannten Zwischenablage-Links zum Linksammler wechseln", "Switch to Link Collector when clipboard links are detected"], + ["Account-Reihenfolge", "Account order"], ["Account-Auswahl", "Account selection"], ["Account-Prioritäten", "Account priorities"], + ["Automatisch verteilen", "Automatic distribution"], ["Feste Reihenfolge", "Fixed priority"], ["Keine Accounts vorhanden.", "No accounts available."], + ["Der erste nutzbare Account hat Vorrang. Gesperrte Accounts werden übersprungen; laufende Downloads bleiben unverändert.", "The first usable account takes priority. Blocked accounts are skipped; active downloads remain unchanged."], + ["Die bisherige automatische Account-Verteilung bleibt aktiv. Die Reihenfolge gilt erst im Modus Feste Reihenfolge.", "Existing automatic account distribution remains active. The order applies only in Fixed priority mode."], ["Standardmäßig aus: Links werden im Hintergrund gesammelt, ohne den aktuellen Tab zu wechseln. Gilt nur bei aktivierter Zwischenablage-Überwachung.", "Off by default: links are collected in the background without switching tabs. Applies only when clipboard monitoring is enabled."], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], diff --git a/src/renderer/views/settings/AccountPriorityControls.tsx b/src/renderer/views/settings/AccountPriorityControls.tsx new file mode 100644 index 0000000..a97abbb --- /dev/null +++ b/src/renderer/views/settings/AccountPriorityControls.tsx @@ -0,0 +1,49 @@ +import type { ReactElement } from "react"; +import type { AccountSelectionMode } from "../../../shared/account-usage-rules"; + +export interface AccountPriorityModel { + mode: AccountSelectionMode; + accounts: readonly { id: string; label: string; mode: string; status: string }[]; +} + +export function AccountPriorityControls({ provider, model, busy, onModeChange, onMove }: { + provider: string; + model: AccountPriorityModel; + busy: boolean; + onModeChange?: (provider: string, mode: AccountSelectionMode) => void; + onMove?: (provider: string, accountId: string, targetId: string) => void; +}): ReactElement { + const locale = typeof document !== "undefined" && document.documentElement.lang === "de" ? "de-DE" : "en-US"; + return
event.stopPropagation()} onDragOver={(event) => event.stopPropagation()} onDrop={(event) => event.stopPropagation()}> + Account-Reihenfolge +
+ Account-Auswahl + {(["automatic", "priority"] as const).map((mode) => )} +
+

{model.mode === "priority" ? "Der erste nutzbare Account hat Vorrang. Gesperrte Accounts werden übersprungen; laufende Downloads bleiben unverändert." : "Die bisherige automatische Account-Verteilung bleibt aktiv. Die Reihenfolge gilt erst im Modus Feste Reihenfolge."}

+
    + {model.accounts.map((account, index) =>
  1. { event.stopPropagation(); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-mdd-account-priority", JSON.stringify({ provider, id: account.id })); }} + onDragOver={(event) => { event.stopPropagation(); if (!busy && event.dataTransfer.types.includes("application/x-mdd-account-priority")) event.preventDefault(); }} + onDrop={(event) => { + event.stopPropagation(); + if (busy) return; + event.preventDefault(); + try { + const source = JSON.parse(event.dataTransfer.getData("application/x-mdd-account-priority")); + if (source.provider === provider && model.accounts.some((candidate) => candidate.id === source.id)) onMove?.(provider, source.id, account.id); + } catch {} + }}> + {account.label}{account.mode} · {account.status} + + + + +
  2. )} +
+ {model.accounts.length === 0 ?

Keine Accounts vorhanden.

: null} +
; +} diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index 2f74473..d7872ba 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -8,6 +8,8 @@ import { type UIEvent } from "react"; import { SlidingSelection } from "../../ui/SlidingSelection"; +import { AccountPriorityControls, type AccountPriorityModel } from "./AccountPriorityControls"; +import type { AccountSelectionMode } from "../../../shared/account-usage-rules"; import { DataTable, DataTableBody, @@ -81,7 +83,7 @@ function getAccountPanelNavigationIndex(currentIndex: number, key: string): numb } export interface AccountRulesViewModel { - providerOrder: readonly { id: string; label: string; icon: string }[]; + providerOrder: readonly { id: string; label: string; icon: string; accountSelection?: AccountPriorityModel }[]; routing: readonly string[]; autoFallback: boolean; rememberCredentials?: boolean; @@ -146,7 +148,9 @@ export interface AccountWorkspaceActions { onCheckActive: () => void; onCheckAll: () => void; onStatusSort?: () => void; - onMoveProvider?: (index: number, direction: -1 | 1) => void; + onMoveProvider?: (index: number, direction: -1 | 1) => void; + onAccountSelectionMode?: (provider: string, mode: AccountSelectionMode) => void; + onMovePriorityAccount?: (provider: string, accountId: string, targetId: string) => void; onProviderDragStart?: (event: DragEvent, index: number) => void; onProviderDragOver?: (event: DragEvent, index: number) => void; onProviderDrop?: (event: DragEvent, index: number) => void; @@ -524,8 +528,9 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement { - ) : null} - + ) : null} + {provider.accountSelection ? : null} + ))} )} diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index ddae1c5..c986da3 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -1071,6 +1071,57 @@ gap: 4px; } +.settings-provider-order > li { + flex-wrap: wrap; +} + +.settings-account-priority { + flex-basis: 100%; + min-width: 0; + padding: 8px 12px; + border-top: 1px solid var(--ui-border); + font-size: 12px; +} + +.settings-account-priority summary { + cursor: pointer; + padding: 4px 0; + color: var(--ui-text); +} + +.settings-account-priority-mode { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 12px 0; + border: 0; + padding: 0; +} + +.settings-account-priority-mode label { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-account-priority ol { + display: grid; + gap: 6px; + list-style: none; + padding: 0; +} + +.settings-account-priority-identity { + min-width: 0; + overflow-wrap: anywhere; +} + +.settings-account-priority-identity small { + display: block; + margin-top: 3px; + font-size: 11px; +} + .settings-provider-order-provider { display: inline-flex; align-items: center; diff --git a/src/shared/account-usage-rules.ts b/src/shared/account-usage-rules.ts new file mode 100644 index 0000000..9c6e39e --- /dev/null +++ b/src/shared/account-usage-rules.ts @@ -0,0 +1,36 @@ +export const ACCOUNT_RULE_PROVIDERS = ["realdebrid", "megadebrid-api", "megadebrid-web", "debridlink"] as const; +export type AccountRuleProvider = typeof ACCOUNT_RULE_PROVIDERS[number]; +export type AccountSelectionMode = "automatic" | "priority"; +export interface AccountUsageRule { + mode: AccountSelectionMode; + accountIds: string[]; +} +export type AccountUsageRules = Partial>; + +export function isAccountRuleProvider(provider: string): provider is AccountRuleProvider { + return (ACCOUNT_RULE_PROVIDERS as readonly string[]).includes(provider); +} + +export function reconcileAccountOrder(order: readonly string[], available: readonly string[]): string[] { + const remaining = new Set(available); + return [...order.filter((id) => remaining.delete(id)), ...remaining]; +} + +export function normalizeAccountUsageRules(value: unknown, available?: Partial>): AccountUsageRules { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: AccountUsageRules = {}; + for (const provider of ACCOUNT_RULE_PROVIDERS) { + const raw = (value as Record)[provider]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const rule = raw as Record; + const ids = Array.isArray(rule.accountIds) ? [...new Set(rule.accountIds.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 256))].slice(0, 10000) : []; + result[provider] = { mode: rule.mode === "priority" ? "priority" : "automatic", accountIds: available ? reconcileAccountOrder(ids, available[provider] ?? []) : ids }; + } + return result; +} + +export function orderAccountsByRule(accounts: readonly T[], rule?: AccountUsageRule): T[] { + if (rule?.mode !== "priority") return [...accounts]; + const byId = new Map(accounts.map((account) => [account.id, account])); + return reconcileAccountOrder(rule.accountIds, accounts.map((account) => account.id)).map((id) => byId.get(id)!); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index d05e79a..62d1b60 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,4 @@ -export type DownloadStatus = +export type DownloadStatus = | "queued" | "validating" | "downloading" @@ -8,7 +8,9 @@ export type DownloadStatus = | "integrity_check" | "completed" | "failed" - | "cancelled"; + | "cancelled"; + +export type { AccountUsageRules } from "./account-usage-rules"; export type CleanupMode = "none" | "trash" | "delete"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; @@ -177,8 +179,9 @@ export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings { debridLinkDisabledKeyIds: string[]; linkSnappyLogin: string; linkSnappyPassword: string; - archivePasswordList: string; - rememberToken: boolean; + archivePasswordList: string; + rememberToken: boolean; + accountUsageRules: import("./account-usage-rules").AccountUsageRules; providerOrder: readonly DebridProvider[]; providerPrimary: DebridProvider; providerSecondary: DebridFallbackProvider; @@ -317,6 +320,7 @@ export interface RendererSettings extends DailyStartSettings, ProxyDownloadSetti debridLinkDisabledKeyIds: string[]; rememberToken: boolean; configuredProviders: DebridProvider[]; + accountUsageRules: import("./account-usage-rules").AccountUsageRules; providerOrder: readonly DebridProvider[]; providerPrimary: DebridProvider; providerSecondary: DebridFallbackProvider; diff --git a/tests/account-priority-routing.test.ts b/tests/account-priority-routing.test.ts new file mode 100644 index 0000000..da82269 --- /dev/null +++ b/tests/account-priority-routing.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { DebridService, MEGA_DEBRID_STICKY_LINKS, primeMegaDebridRuntimeCooldownForTests, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; +import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import type { AppSettings } from "../src/shared/types"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); resetDebridLinkRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests(); resetRealDebridRuntimeStateForTests(); }); +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" } }); +const result = { fileName: "fixture.rar", directUrl: "https://download.example.test/fixture.rar", fileSize: 1234, retriesUsed: 0 }; +const rdSettings = (): AppSettings => ({ ...defaultSettings(), token: "", realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_first", token: "first" }, { id: "rda_second", token: "second" }, { id: "rda_third", token: "third" }]), providerOrder: ["realdebrid"], providerPrimary: "realdebrid", autoProviderFallback: false, accountUsageRules: { realdebrid: { mode: "priority", accountIds: ["rda_second", "rda_first", "rda_third"] } } }); + +describe("fixed account priority routing", () => { + it("reserves one Mega-Debrid conversion per account, waits when full and cancels waiting work", async () => { + const first = getMegaDebridAccountId("first-slot"); + const second = getMegaDebridAccountId("second-slot"); + const settings: AppSettings = { ...defaultSettings(), megaDebridWebCredentials: "first-slot:pass-one\nsecond-slot:pass-two", megaDebridApiCredentials: "", megaDebridWebEnabled: true, megaDebridApiEnabled: false, providerOrder: ["megadebrid-web"], providerPrimary: "megadebrid-web", autoProviderFallback: false, accountUsageRules: { "megadebrid-web": { mode: "priority", accountIds: [second, first] } } }; + const completions: Array<() => void> = []; + const web = vi.fn(() => new Promise((resolve) => completions.push(() => resolve(result)))); + const service = new DebridService(settings, { megaWebUnrestrict: web }); + const one = service.unrestrictLink("https://hoster.example/one.rar"); + const two = service.unrestrictLink("https://hoster.example/two.rar"); + const controller = new AbortController(); + const waiting = service.unrestrictLink("https://hoster.example/wait.rar", controller.signal); + const cancelled = expect(waiting).rejects.toThrow(/aborted/i); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(web).toHaveBeenCalledTimes(2); + controller.abort(); + await cancelled; + const queued = service.unrestrictLink("https://hoster.example/queued.rar"); + completions[0](); + expect((await one).sourceAccountId).toBe(second); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(web).toHaveBeenCalledTimes(3); + completions[2](); + expect((await queued).sourceAccountId).toBe(second); + completions[1](); + expect((await two).sourceAccountId).toBe(first); + const next = service.unrestrictLink("https://hoster.example/next.rar"); + completions.at(-1)!(); + expect((await next).sourceAccountId).toBe(second); + }); + it("applies an independent Mega-Debrid API order", async () => { + const first = getMegaDebridAccountId("first-api"); + const second = getMegaDebridAccountId("second-api"); + const settings: AppSettings = { ...defaultSettings(), megaDebridApiCredentials: "first-api:pass-one\nsecond-api:pass-two", megaDebridWebCredentials: "", megaDebridApiEnabled: true, megaDebridWebEnabled: false, providerOrder: ["megadebrid-api"], providerPrimary: "megadebrid-api", autoProviderFallback: false, accountUsageRules: { "megadebrid-api": { mode: "priority", accountIds: [second, first] }, "megadebrid-web": { mode: "priority", accountIds: [first, second] } } }; + globalThis.fetch = vi.fn(async (input) => String(input).includes("action=connectUser") + ? json({ response_code: "ok", token: "fixture-api-session", vip_end: Math.floor(Date.now() / 1000) + 999999 }) + : json({ response_code: "ok", debridLink: result.directUrl, filename: result.fileName })); + expect((await new DebridService(settings).unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(second); + }); + + it("can prioritize a Real-Debrid web account ahead of API accounts", async () => { + const settings = rdSettings(); + settings.realDebridWebAccountIds = ["rdw_priority"]; + settings.realDebridUseWebLogin = true; + settings.accountUsageRules.realdebrid!.accountIds = ["rdw_priority", "rda_second", "rda_first", "rda_third"]; + globalThis.fetch = vi.fn(async () => { throw new Error("API must not be used"); }); + const web = vi.fn(async () => result); + expect((await new DebridService(settings, { realDebridWebUnrestrict: web }).unrestrictLink("https://hoster.example/web.rar")).sourceAccountId).toBe("rdw_priority"); + expect(web).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it.each([false, true])("exhausts the preferred provider's accounts before provider fallback=%s", async (autoProviderFallback) => { + const settings = rdSettings(); + settings.providerOrder = ["realdebrid", "debridlink"]; + settings.debridLinkApiKeys = "fixture-fallback-key"; + settings.autoProviderFallback = autoProviderFallback; + const used: string[] = []; + globalThis.fetch = vi.fn(async (input, init) => { + used.push(new Headers(init?.headers).get("Authorization")!); + return String(input).includes("real-debrid") ? json({ error: "bad_token", error_code: 8 }, 401) : json({ success: true, value: { downloadUrl: result.directUrl, name: result.fileName, size: 1234 } }); + }); + const request = new DebridService(settings).unrestrictLink("https://hoster.example/file.rar"); + if (autoProviderFallback) expect((await request).provider).toBe("debridlink"); + else await expect(request).rejects.toThrow(); + expect(used).toEqual(["Bearer second", "Bearer first", "Bearer third", ...(autoProviderFallback ? ["Bearer fixture-fallback-key"] : [])]); + }); + it("keeps Real-Debrid priority beyond sticky rotation and through parallel requests", async () => { + const tokens: string[] = []; + globalThis.fetch = vi.fn(async (_input, init) => { + tokens.push(new Headers(init?.headers).get("Authorization")!); + return json({ download: result.directUrl, filename: result.fileName, filesize: 1234 }); + }); + const service = new DebridService(rdSettings()); + for (let i = 0; i < 8; i++) expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_second"); + await Promise.all([1, 2, 3].map(() => service.unrestrictLink("https://hoster.example/file.rar"))); + expect(tokens).toEqual(Array(11).fill("Bearer second")); + }); + + it("skips Real-Debrid cooldown, returns to the preferred account after expiry, and stops on link-wide errors", async () => { + let fail = true; + let offline = false; + const used: string[] = []; + globalThis.fetch = vi.fn(async (_input, init) => { + const token = new Headers(init?.headers).get("Authorization")!; + used.push(token); + if (offline) return json({ error: "hoster_unavailable", error_code: 9 }, 503); + if (fail && token === "Bearer second") return json({ error: "too_many_active_downloads", error_code: 20 }, 403); + return json({ download: result.directUrl, filename: result.fileName, filesize: 1234 }); + }); + const service = new DebridService(rdSettings()); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_first"); + fail = false; + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_first"); + const now = Date.now(); + vi.spyOn(Date, "now").mockReturnValue(now + 2 * 60 * 60 * 1000); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_second"); + offline = true; + used.length = 0; + await expect(service.unrestrictLink("https://hoster.example/offline.rar")).rejects.toThrow(); + expect(new Set(used)).toEqual(new Set(["Bearer second"])); + }); + + it("skips disabled and daily-limited Real-Debrid accounts before calling the provider", async () => { + const settings = rdSettings(); + settings.realDebridDisabledAccountIds = ["rda_second"]; + settings.realDebridAccountDailyLimitBytes = { rda_first: 100 }; + settings.realDebridAccountDailyUsageBytes = { rda_first: 100 }; + settings.providerDailyUsageDay = getProviderUsageDayKey(); + globalThis.fetch = vi.fn(async () => json({ download: result.directUrl, filename: result.fileName, filesize: 1234 })); + expect((await new DebridService(settings).unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_third"); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("keeps Mega-Debrid Web priority beyond the automatic rotation threshold and restores it after cooldown", async () => { + const first = getMegaDebridAccountId("first"); + const second = getMegaDebridAccountId("second"); + const settings: AppSettings = { ...defaultSettings(), megaDebridWebCredentials: "first:pass-one\nsecond:pass-two", megaDebridApiCredentials: "", megaDebridWebEnabled: true, megaDebridApiEnabled: false, providerOrder: ["megadebrid-web"], providerPrimary: "megadebrid-web", autoProviderFallback: false, accountUsageRules: { "megadebrid-web": { mode: "priority", accountIds: [second, first] } } }; + const service = new DebridService(settings, { megaWebUnrestrict: vi.fn(async () => result) }); + for (let i = 0; i <= MEGA_DEBRID_STICKY_LINKS; i++) expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(second); + primeMegaDebridRuntimeCooldownForTests(`${second}:web`, 60000); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(first); + const now = Date.now(); + vi.spyOn(Date, "now").mockReturnValue(now + 120000); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(second); + }); + + it("uses Debrid-Link key priority, skips cooldown, and returns to the first usable key", async () => { + const debridLinkApiKeys = "fixture-key-one\nfixture-key-two\nfixture-key-three"; + const keys = parseDebridLinkApiKeys(debridLinkApiKeys); + const settings: AppSettings = { ...defaultSettings(), debridLinkApiKeys, providerOrder: ["debridlink"], providerPrimary: "debridlink", autoProviderFallback: false, accountUsageRules: { debridlink: { mode: "priority", accountIds: [keys[2].id, keys[1].id, keys[0].id] } } }; + globalThis.fetch = vi.fn(async () => json({ success: true, value: { downloadUrl: result.directUrl, name: result.fileName, size: 1234 } })); + const service = new DebridService(settings); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(keys[2].id); + primeDebridLinkRuntimeCooldownForTests(keys[2].id, 60000); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(keys[1].id); + const now = Date.now(); + vi.spyOn(Date, "now").mockReturnValue(now + 120000); + expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(keys[2].id); + }); +}); diff --git a/tests/account-usage-rules.test.ts b/tests/account-usage-rules.test.ts new file mode 100644 index 0000000..210c622 --- /dev/null +++ b/tests/account-usage-rules.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { configureCredentialProtector } from "../src/main/credential-protection"; +import { defaultSettings } from "../src/main/constants"; +import { applyAccountCommand } from "../src/main/account-commands"; +import { createRendererSettings } from "../src/main/renderer-state"; +import { validateRendererSettingsUpdate } from "../src/main/renderer-settings"; +import { createStoragePaths, loadSettings, normalizeSettings, saveSettings } from "../src/main/storage"; +import { normalizeAccountUsageRules, orderAccountsByRule, reconcileAccountOrder } from "../src/shared/account-usage-rules"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import type { AccountRuleProvider } from "../src/shared/account-usage-rules"; +import type { RendererAccountKind } from "../src/shared/types"; + +const directories: string[] = []; +beforeEach(() => configureCredentialProtector({ isEncryptionAvailable: () => true, encryptString: (value) => Buffer.from(value).reverse(), decryptString: (value) => Buffer.from(value).reverse().toString() })); +afterEach(() => { for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }); }); + +describe("account usage priorities", () => { + it("preserves automatic defaults and filters corrupt configuration", () => { + expect(defaultSettings().accountUsageRules).toEqual({}); + expect(normalizeSettings({ ...defaultSettings(), accountUsageRules: undefined as never }).accountUsageRules).toEqual({}); + expect(normalizeAccountUsageRules({ wrong: {}, realdebrid: { mode: "bad", accountIds: ["b", "b", 4, ""] } })).toEqual({ realdebrid: { mode: "automatic", accountIds: ["b"] } }); + }); + + it("restores known IDs, removes stale and duplicate IDs, and appends new accounts", () => { + expect(reconcileAccountOrder(["b", "gone", "b", "a"], ["a", "b", "new"])).toEqual(["b", "a", "new"]); + const accounts = [{ id: "a" }, { id: "b" }, { id: "new" }]; + expect(orderAccountsByRule(accounts, { mode: "priority", accountIds: ["b", "a"] }).map((a) => a.id)).toEqual(["b", "a", "new"]); + expect(orderAccountsByRule(accounts, { mode: "automatic", accountIds: ["b", "a"] })).toEqual(accounts); + }); + + it("validates nested rules without allowing arbitrary providers or malformed IDs", () => { + const current = defaultSettings(); + for (const rule of [{ mode: "random", accountIds: [] }, { mode: "priority", accountIds: [3] }, { mode: "priority", accountIds: [], secret: "no" }]) { + expect(() => validateRendererSettingsUpdate({ accountUsageRules: { realdebrid: rule } }, current)).toThrow(); + } + expect(() => validateRendererSettingsUpdate({ accountUsageRules: { unknown: { mode: "priority", accountIds: [] } } }, current)).toThrow(); + const accountUsageRules = { realdebrid: { mode: "priority" as const, accountIds: ["a"] } }; + expect(validateRendererSettingsUpdate({ accountUsageRules }, current)).toEqual({ accountUsageRules }); + const projected = createRendererSettings({ ...current, accountUsageRules }); + projected.accountUsageRules.realdebrid!.accountIds.push("b"); + expect(accountUsageRules.realdebrid.accountIds).toEqual(["a"]); + }); + + it.each([ + ["debridlink-api", "debridlink"], ["megadebrid-api", "megadebrid-api"], ["megadebrid-web", "megadebrid-web"], ["realdebrid-api", "realdebrid"] + ] as [RendererAccountKind, AccountRuleProvider][])("preserves position across replace, append, delete and reload for %s", (kind, provider) => { + let settings = defaultSettings(); + const add = (identity: string, secret: string) => { + const result = applyAccountCommand(settings, { action: "create", kind, identity, secret }); + settings = result.settings; + return result.response.accountId!; + }; + const first = add("first@example.test", "fixture-first-key"); + const second = add("second@example.test", "fixture-second-key"); + settings.accountUsageRules = { [provider]: { mode: "priority", accountIds: [second, first] } }; + const replacement = applyAccountCommand(settings, { action: "replace", kind, accountId: second, identity: "renamed@example.test", secret: "fixture-renamed-key" }); + settings = replacement.settings; + const replacementId = replacement.response.accountId!; + expect(settings.accountUsageRules[provider]?.accountIds).toEqual([replacementId, first]); + const third = add("third@example.test", "fixture-third-key"); + expect(settings.accountUsageRules[provider]?.accountIds).toEqual([replacementId, first, third]); + settings = applyAccountCommand(settings, { action: "delete", kind, accountId: first }).settings; + expect(settings.accountUsageRules[provider]?.accountIds).toEqual([replacementId, third]); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-account-priority-")); + directories.push(directory); + const paths = createStoragePaths(directory); + saveSettings(paths, settings); + expect(loadSettings(paths).accountUsageRules).toEqual(settings.accountUsageRules); + }); + + it("prunes references to accounts missing from imported configuration", () => { + const debridLinkApiKeys = "fixture-key-one\nfixture-key-two"; + const [first, second] = parseDebridLinkApiKeys(debridLinkApiKeys); + const settings = normalizeSettings({ ...defaultSettings(), debridLinkApiKeys, accountUsageRules: { debridlink: { mode: "priority", accountIds: ["deleted", second.id] } } }); + expect(settings.accountUsageRules.debridlink?.accountIds).toEqual([second.id, first.id]); + }); +}); diff --git a/tests/backup-payload.test.ts b/tests/backup-payload.test.ts index c5e92dd..2de2e29 100644 --- a/tests/backup-payload.test.ts +++ b/tests/backup-payload.test.ts @@ -19,6 +19,13 @@ const statistics: StatisticsLedger = { version: 2, startedAt: 1, days: [], minut const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics }; describe("buildBackupPayload — default is settings-only", () => { + it("keeps account selection mode and priority IDs through encrypted export and import", () => { + const accountUsageRules = { debridlink: { mode: "priority" as const, accountIds: ["second", "first"] } }; + const payload = buildBackupPayload({ ...baseInput, settings: settings({ accountUsageRules }) }); + const restored = JSON.parse(decryptBackup(encryptBackup(JSON.stringify(payload), "fixture-priority-passphrase"), "fixture-priority-passphrase")); + expect(planBackupImport(restored).valid).toBe(true); + expect(restored.settings.accountUsageRules).toEqual(accountUsageRules); + }); it("keeps the semantic theme preference in exported settings", () => { const payload = buildBackupPayload({ ...baseInput, diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index eab235c..8e2824b 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it } from "vitest"; import { normalizeLanguage, translateUiText } from "../src/renderer/i18n"; describe("renderer localization", () => { + it("translates account priority controls", () => { + expect(translateUiText("Account-Reihenfolge", "en")).toBe("Account order"); + expect(translateUiText("Automatisch verteilen", "en")).toBe("Automatic distribution"); + expect(translateUiText("Feste Reihenfolge", "en")).toBe("Fixed priority"); + expect(translateUiText("Fixed priority", "de")).toBe("Feste Reihenfolge"); + }); it("translates the optional clipboard navigation setting", () => { expect(translateUiText("Bei erkannten Zwischenablage-Links zum Linksammler wechseln", "en")).toBe("Switch to Link Collector when clipboard links are detected"); expect(translateUiText("Switch to Link Collector when clipboard links are detected", "de")).toBe("Bei erkannten Zwischenablage-Links zum Linksammler wechseln"); diff --git a/tests/visual/account-priority.html b/tests/visual/account-priority.html new file mode 100644 index 0000000..acb416e --- /dev/null +++ b/tests/visual/account-priority.html @@ -0,0 +1,5 @@ + + + Account-Prioritätsprüfung +
+ diff --git a/tests/visual/account-priority.tsx b/tests/visual/account-priority.tsx new file mode 100644 index 0000000..0789b68 --- /dev/null +++ b/tests/visual/account-priority.tsx @@ -0,0 +1,67 @@ +import { createRoot } from "react-dom/client"; +import { App } from "../../src/renderer/App"; +import { createVisualFixture } from "./fixtures"; +import { createVisualElectronApi } from "./mock-electron-api"; +import type { RendererSettingsUpdate } from "../../src/shared/types"; +import "../../src/renderer/theme.css"; +import "../../src/renderer/styles.css"; + +const fixture = createVisualFixture("dense"); +fixture.snapshot.settings.language = "de"; +fixture.snapshot.settings.theme = new URLSearchParams(location.search).get("theme") === "light" ? "light" : "dark"; +fixture.snapshot.settings.themePreference = fixture.snapshot.settings.theme; +const baseApi = createVisualElectronApi(fixture); +let saved: RendererSettingsUpdate | undefined; +window.rd = { ...baseApi, updateSettings: async (settings) => { saved = structuredClone(settings); return baseApi.updateSettings(settings); } }; +createRoot(document.getElementById("root")!).render(); +const report = document.createElement("output"); +document.body.append(report); +const waitFor = async (condition: () => boolean): Promise => { + const deadline = Date.now() + 10000; + while (!condition()) { + if (Date.now() > deadline) throw new Error("Account priority check timed out"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; +const click = (selector: string): void => document.querySelector(selector)!.click(); +const assert = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); }; + +async function verify(): Promise { + await waitFor(() => Boolean(document.querySelector('[data-main-view="settings"]'))); + click('[data-main-view="settings"]'); + await waitFor(() => Boolean(document.querySelector(".settings-sidebar-item"))); + [...document.querySelectorAll(".settings-sidebar-item")].find((button) => button.textContent === "Accounts")!.click(); + await waitFor(() => Boolean(document.querySelector("#settings-account-rules-tab"))); + click("#settings-account-rules-tab"); + await waitFor(() => Boolean(document.querySelector('[name="account-selection-debridlink"]'))); + const control = document.querySelector('[name="account-selection-debridlink"]')!.closest("details")!; + control.open = true; + const ids = () => [...control.querySelectorAll("[data-priority-account]")].map((row) => row.dataset.priorityAccount!); + const original = ids(); + assert(original.length === 2, "Fixture needs two keys"); + const radios = control.querySelectorAll('input[type="radio"]'); + assert(radios[0].checked, "Automatic mode must be the default"); + radios[1].click(); + await waitFor(() => radios[1].checked); + control.querySelectorAll('[data-priority-account] button')[1].click(); + await waitFor(() => ids()[0] === original[1]); + const transfer = new DataTransfer(); + const rows = control.querySelectorAll("[data-priority-account]"); + rows[0].dispatchEvent(new DragEvent("dragstart", { bubbles: true, dataTransfer: transfer })); + rows[1].dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })); + await waitFor(() => ids()[0] === original[0]); + const crossProvider = new DataTransfer(); + crossProvider.setData("application/x-mdd-account-priority", JSON.stringify({ provider: "realdebrid", id: original[1] })); + rows[0].dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: crossProvider })); + assert(ids()[0] === original[0], "Cross-provider drag changed the account order"); + control.querySelectorAll('[data-priority-account] button')[1].click(); + await waitFor(() => ids()[0] === original[1]); + [...document.querySelectorAll("button")].find((button) => button.textContent === "Einstellungen speichern")!.click(); + await waitFor(() => Boolean(saved?.accountUsageRules?.debridlink)); + assert(saved!.accountUsageRules!.debridlink!.mode === "priority", "Mode was not saved"); + assert(saved!.accountUsageRules!.debridlink!.accountIds.join() === [...original].reverse().join(), "Order was not saved"); + assert(control.scrollWidth <= control.clientWidth + 1, "Priority controls overflow horizontally"); + control.scrollIntoView({ block: "center" }); + report.textContent = JSON.stringify({ passed: true, theme: fixture.snapshot.settings.theme, checks: ["expand", "automatic default", "mode change", "arrow reorder", "drag reorder", "reject cross-provider drag", "save mode and order", "no horizontal overflow"] }); +} +void verify().catch((error) => { report.textContent = String(error); }); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 4ff94cc..339aaa5 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -86,7 +86,8 @@ function createSettings(): AppSettings { linkSnappyPassword: "visual-password", archivePasswordList: "visual-archive-password", rememberToken: true, - providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid", "alldebrid", "debridlink"], + providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid", "alldebrid", "debridlink"], + accountUsageRules: {}, providerPrimary: "realdebrid", providerSecondary: "megadebrid-api", providerTertiary: "bestdebrid",