feat: add per-provider account priority rules

This commit is contained in:
Sucukdeluxe
2026-09-06 12:39:11 +02:00
parent 8957aa3c23
commit dd4a39e47d
22 changed files with 607 additions and 31 deletions
+5 -2
View File
@@ -8,10 +8,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Zuletzt verifizierter Stand ## 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. - 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. - 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. - 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.
- 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.
- 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. - 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. - 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. - 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.
+15 -2
View File
@@ -1,5 +1,7 @@
import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { normalizeConfiguredAccountRules } from "./account-usage-rules";
import { isAccountRuleProvider } from "../shared/account-usage-rules";
import { import {
getMegaDebridAccountId, getMegaDebridAccountId,
getMegaDebridAccountsForMode, getMegaDebridAccountsForMode,
@@ -617,7 +619,7 @@ function createSingle(settings: AppSettings, command: Extract<AccountCommand, {
return { settings: next, response: { accountId: `svc-${provider}` } }; return { settings: next, response: { accountId: `svc-${provider}` } };
} }
export function applyAccountCommand(settings: AppSettings, command: AccountCommand): AppliedAccountCommand { function applyAccountCommandToCredentials(settings: AppSettings, command: AccountCommand): AppliedAccountCommand {
if (command.action === "update-secret") { if (command.action === "update-secret") {
const replace: Extract<AccountCommand, { action: "replace" }> = { const replace: Extract<AccountCommand, { action: "replace" }> = {
action: "replace", action: "replace",
@@ -625,7 +627,7 @@ export function applyAccountCommand(settings: AppSettings, command: AccountComma
accountId: command.accountId, accountId: command.accountId,
secret: command.secret secret: command.secret
}; };
return applyAccountCommand(settings, replace); return applyAccountCommandToCredentials(settings, replace);
} }
if (command.kind === "realdebrid-api" || command.kind === "realdebrid-web") { if (command.kind === "realdebrid-api" || command.kind === "realdebrid-web") {
const normalizedSettings = normalizeRealDebridCommandSettings(settings); const normalizedSettings = normalizeRealDebridCommandSettings(settings);
@@ -647,3 +649,14 @@ export function applyAccountCommand(settings: AppSettings, command: AccountComma
if (command.action === "replace") return replaceSingle(settings, command); if (command.action === "replace") return replaceSingle(settings, command);
return deleteSingle(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;
}
+14
View File
@@ -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)
});
}
+1
View File
@@ -76,6 +76,7 @@ export function defaultSettings(): AppSettings {
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, rememberToken: true,
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"], providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
accountUsageRules: {},
providerPrimary: "realdebrid", providerPrimary: "realdebrid",
providerSecondary: "megadebrid-api", providerSecondary: "megadebrid-api",
providerTertiary: "bestdebrid", providerTertiary: "bestdebrid",
+30 -8
View File
@@ -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 { getMegaDebridAccountsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, type MegaDebridAccountEntry, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts";
import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts"; import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts";
import { extractHosterFromUrl } from "../shared/hoster"; import { extractHosterFromUrl } from "../shared/hoster";
@@ -2380,12 +2381,30 @@ class MegaDebridClient {
// Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account. // Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account.
const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number => const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number =>
megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0; megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0;
const orderedEntries = cursorOrder const automaticEntries = cursorOrder
.map((entry, position) => ({ entry, position })) .map((entry, position) => ({ entry, position }))
.sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position)) .sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position))
.map((wrapped) => wrapped.entry); .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) { 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 entry = orderedEntries[orderPos];
const account = entry.account; const account = entry.account;
const idx = entry.idx; const idx = entry.idx;
@@ -3140,13 +3159,14 @@ class DebridLinkClient {
let earliestCooldownUntil = 0; let earliestCooldownUntil = 0;
const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = []; const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
let consecutiveTransportFailures = 0; 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 providerName = "Debrid-Link";
const linkShort = String(link || "").slice(0, 80); const linkShort = String(link || "").slice(0, 80);
const linkHoster = extractHosterFromUrl(link); const linkHoster = extractHosterFromUrl(link);
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { for (let keyIdx = 0; keyIdx < apiKeys.length; keyIdx += 1) {
const apiKey = this.apiKeys[keyIdx]; const apiKey = apiKeys[keyIdx];
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`; const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`; const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
@@ -3297,8 +3317,8 @@ class DebridLinkClient {
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`); throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
} }
let nextLabel = "ENDE"; let nextLabel = "ENDE";
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) { for (let nextIdx = keyIdx + 1; nextIdx < apiKeys.length; nextIdx += 1) {
const nextKey = this.apiKeys[nextIdx]; const nextKey = apiKeys[nextIdx];
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) { if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`; nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
break; break;
@@ -4584,7 +4604,9 @@ export class DebridService {
if (available.length === 0) { if (available.length === 0) {
break; 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); attempted.add(account.id);
realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1); realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1);
recordAccountRuntimeAttempt("realdebrid", account.id); recordAccountRuntimeAttempt("realdebrid", account.id);
+10
View File
@@ -1,5 +1,6 @@
import type { AppSettings, RendererSettingsUpdate } from "../shared/types"; import type { AppSettings, RendererSettingsUpdate } from "../shared/types";
import { createRendererSettings } from "./renderer-state"; import { createRendererSettings } from "./renderer-state";
import { isAccountRuleProvider } from "../shared/account-usage-rules";
import { isValidLocalDate } from "./daily-start-scheduler"; import { isValidLocalDate } from "./daily-start-scheduler";
const DERIVED_KEYS = new Set([ const DERIVED_KEYS = new Set([
@@ -91,6 +92,15 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
if (!(key in safe)) { if (!(key in safe)) {
invalid(); 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") { if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") {
invalid(); invalid();
} }
+1
View File
@@ -174,6 +174,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
rememberToken: settings.rememberToken, rememberToken: settings.rememberToken,
configuredProviders, configuredProviders,
providerOrder: [...settings.providerOrder], providerOrder: [...settings.providerOrder],
accountUsageRules: structuredClone(settings.accountUsageRules ?? {}),
providerPrimary: settings.providerPrimary, providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary, providerSecondary: settings.providerSecondary,
providerTertiary: settings.providerTertiary, providerTertiary: settings.providerTertiary,
+3
View File
@@ -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 fsp from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -577,6 +578,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
linkSnappyPassword: asText(settings.linkSnappyPassword), linkSnappyPassword: asText(settings.linkSnappyPassword),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
rememberToken: Boolean(settings.rememberToken), rememberToken: Boolean(settings.rememberToken),
accountUsageRules: {},
providerOrder: normalizeProviderOrder( providerOrder: normalizeProviderOrder(
settings.providerOrder, settings.providerOrder,
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
@@ -704,6 +706,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
}; };
normalized.accountUsageRules = normalizeConfiguredAccountRules({ ...normalized, accountUsageRules: settings.accountUsageRules });
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
normalized.providerPrimary = defaults.providerPrimary; normalized.providerPrimary = defaults.providerPrimary;
} }
+40 -2
View File
@@ -42,6 +42,7 @@ import {
import { createAvailabilitySortCycle, preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order"; import { createAvailabilitySortCycle, preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
import { createPackageOrderState } from "./package-order-state"; import { createPackageOrderState } from "./package-order-state";
import { shouldFocusCollectorImport, type CollectorImportSource } from "./collector-navigation"; import { shouldFocusCollectorImport, type CollectorImportSource } from "./collector-navigation";
import { isAccountRuleProvider, reconcileAccountOrder, type AccountUsageRule } from "../shared/account-usage-rules";
import { getPackagesWithOfflineLinks } from "../shared/offline-packages"; import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
import type { OfflineSkipScope } from "../shared/types"; import type { OfflineSkipScope } from "../shared/types";
import { OfflineRemovalScopeChoice } from "./views/downloads/OfflineRemovalScopeChoice"; 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, language: "en", realDebridUseWebLogin: false, realDebridDisabledAccountIds: [], realDebridAccountDailyLimitBytes: {}, realDebridAccountDailyUsageBytes: {}, realDebridAccountTotalUsageBytes: {}, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false,
debridLinkDisabledKeyIds: [], debridLinkDisabledKeyIds: [],
archivePasswordListConfigured: false, notifyUrlConfigured: false, 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: "", providerTertiary: "none", autoProviderFallback: true, outputDir: "", createWorkDirectoriesOnStartup: false, packageName: "",
autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true,
collectMkvToLibrary: false, mkvLibraryDir: "", collectMkvToLibrary: false, mkvLibraryDir: "",
@@ -5909,7 +5910,21 @@ export function App(): ReactElement {
statusSort: accountStatusSort, statusSort: accountStatusSort,
runtime: accountRuntimeModel, runtime: accountRuntimeModel,
rules: { 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)}`), routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId}${providerLabelWithMode(provider, settingsDraft)}`),
autoFallback: settingsDraft.autoProviderFallback, autoFallback: settingsDraft.autoProviderFallback,
rememberCredentials: settingsDraft.rememberToken, rememberCredentials: settingsDraft.rememberToken,
@@ -5938,7 +5953,30 @@ export function App(): ReactElement {
setSettingsSaveState("dirty"); setSettingsSaveState("dirty");
setSettingsDraft((current) => ({ ...current, hosterRouting })); 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 = { 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, onPanelChange: setAccountManagementTab,
onSelect: (rowId, additive) => { onSelect: (rowId, additive) => {
const rowKey = accountRowBindings.get(rowId)?.rowKey; const rowKey = accountRowBindings.get(rowId)?.rowKey;
+4
View File
@@ -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"], ["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"], ["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"], ["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."], ["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"], ["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)"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
@@ -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 <details className="settings-account-priority" onDragStart={(event) => event.stopPropagation()} onDragOver={(event) => event.stopPropagation()} onDrop={(event) => event.stopPropagation()}>
<summary>Account-Reihenfolge</summary>
<fieldset disabled={busy} className="settings-account-priority-mode">
<legend>Account-Auswahl</legend>
{(["automatic", "priority"] as const).map((mode) => <label key={mode}>
<input type="radio" name={`account-selection-${provider}`} checked={model.mode === mode} disabled={!onModeChange} onChange={() => onModeChange?.(provider, mode)} />
{mode === "automatic" ? "Automatisch verteilen" : "Feste Reihenfolge"}
</label>)}
</fieldset>
<p>{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."}</p>
<ol aria-label="Account-Prioritäten">
{model.accounts.map((account, index) => <li key={account.id} data-priority-account={account.id} draggable={!busy && Boolean(onMove)}
onDragStart={(event) => { 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 {}
}}>
<span className="settings-account-priority-identity"><span aria-hidden="true">{(index + 1).toLocaleString(locale)}.</span> <span>{account.label}</span><small>{account.mode} · {account.status}</small></span>
<span className="settings-provider-order-actions">
<button type="button" disabled={busy || !onMove || index === 0} aria-label={`${account.label} nach oben`} onClick={() => onMove?.(provider, account.id, model.accounts[index - 1].id)}></button>
<button type="button" disabled={busy || !onMove || index === model.accounts.length - 1} aria-label={`${account.label} nach unten`} onClick={() => onMove?.(provider, account.id, model.accounts[index + 1].id)}></button>
</span>
</li>)}
</ol>
{model.accounts.length === 0 ? <p>Keine Accounts vorhanden.</p> : null}
</details>;
}
@@ -8,6 +8,8 @@ import {
type UIEvent type UIEvent
} from "react"; } from "react";
import { SlidingSelection } from "../../ui/SlidingSelection"; import { SlidingSelection } from "../../ui/SlidingSelection";
import { AccountPriorityControls, type AccountPriorityModel } from "./AccountPriorityControls";
import type { AccountSelectionMode } from "../../../shared/account-usage-rules";
import { import {
DataTable, DataTable,
DataTableBody, DataTableBody,
@@ -81,7 +83,7 @@ function getAccountPanelNavigationIndex(currentIndex: number, key: string): numb
} }
export interface AccountRulesViewModel { export interface AccountRulesViewModel {
providerOrder: readonly { id: string; label: string; icon: string }[]; providerOrder: readonly { id: string; label: string; icon: string; accountSelection?: AccountPriorityModel }[];
routing: readonly string[]; routing: readonly string[];
autoFallback: boolean; autoFallback: boolean;
rememberCredentials?: boolean; rememberCredentials?: boolean;
@@ -147,6 +149,8 @@ export interface AccountWorkspaceActions {
onCheckAll: () => void; onCheckAll: () => void;
onStatusSort?: () => 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<HTMLElement>, index: number) => void; onProviderDragStart?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDragOver?: (event: DragEvent<HTMLElement>, index: number) => void; onProviderDragOver?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDrop?: (event: DragEvent<HTMLElement>, index: number) => void; onProviderDrop?: (event: DragEvent<HTMLElement>, index: number) => void;
@@ -525,6 +529,7 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
<button aria-label={`${provider.label} nach unten`} disabled={index === model.rules.providerOrder.length - 1} onClick={() => actions.onMoveProvider?.(index, 1)} type="button"></button> <button aria-label={`${provider.label} nach unten`} disabled={index === model.rules.providerOrder.length - 1} onClick={() => actions.onMoveProvider?.(index, 1)} type="button"></button>
</span> </span>
) : null} ) : null}
{provider.accountSelection ? <AccountPriorityControls provider={provider.id} model={provider.accountSelection} busy={model.busy} onModeChange={actions.onAccountSelectionMode} onMove={actions.onMovePriorityAccount} /> : null}
</li> </li>
))} ))}
</ol> </ol>
+51
View File
@@ -1071,6 +1071,57 @@
gap: 4px; 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 { .settings-provider-order-provider {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+36
View File
@@ -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<Record<AccountRuleProvider, AccountUsageRule>>;
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<Record<AccountRuleProvider, readonly string[]>>): 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<string, unknown>)[provider];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
const rule = raw as Record<string, unknown>;
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<T extends { id: string }>(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)!);
}
+4
View File
@@ -10,6 +10,8 @@ export type DownloadStatus =
| "failed" | "failed"
| "cancelled"; | "cancelled";
export type { AccountUsageRules } from "./account-usage-rules";
export type CleanupMode = "none" | "trash" | "delete"; export type CleanupMode = "none" | "trash" | "delete";
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
export type SpeedMode = "global" | "per_download"; export type SpeedMode = "global" | "per_download";
@@ -179,6 +181,7 @@ export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings {
linkSnappyPassword: string; linkSnappyPassword: string;
archivePasswordList: string; archivePasswordList: string;
rememberToken: boolean; rememberToken: boolean;
accountUsageRules: import("./account-usage-rules").AccountUsageRules;
providerOrder: readonly DebridProvider[]; providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider; providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider; providerSecondary: DebridFallbackProvider;
@@ -317,6 +320,7 @@ export interface RendererSettings extends DailyStartSettings, ProxyDownloadSetti
debridLinkDisabledKeyIds: string[]; debridLinkDisabledKeyIds: string[];
rememberToken: boolean; rememberToken: boolean;
configuredProviders: DebridProvider[]; configuredProviders: DebridProvider[];
accountUsageRules: import("./account-usage-rules").AccountUsageRules;
providerOrder: readonly DebridProvider[]; providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider; providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider; providerSecondary: DebridFallbackProvider;
+156
View File
@@ -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<typeof result>((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);
});
});
+80
View File
@@ -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]);
});
});
+7
View File
@@ -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 }; const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
describe("buildBackupPayload — default is settings-only", () => { 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", () => { it("keeps the semantic theme preference in exported settings", () => {
const payload = buildBackupPayload({ const payload = buildBackupPayload({
...baseInput, ...baseInput,
+6
View File
@@ -2,6 +2,12 @@ import { describe, expect, it } from "vitest";
import { normalizeLanguage, translateUiText } from "../src/renderer/i18n"; import { normalizeLanguage, translateUiText } from "../src/renderer/i18n";
describe("renderer localization", () => { 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", () => { 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("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"); expect(translateUiText("Switch to Link Collector when clipboard links are detected", "de")).toBe("Bei erkannten Zwischenablage-Links zum Linksammler wechseln");
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="de">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Account-Prioritätsprüfung</title></head>
<body><div id="root"></div><script type="module" src="/account-priority.tsx"></script></body>
</html>
+67
View File
@@ -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(<App />);
const report = document.createElement("output");
document.body.append(report);
const waitFor = async (condition: () => boolean): Promise<void> => {
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<HTMLElement>(selector)!.click();
const assert = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); };
async function verify(): Promise<void> {
await waitFor(() => Boolean(document.querySelector('[data-main-view="settings"]')));
click('[data-main-view="settings"]');
await waitFor(() => Boolean(document.querySelector(".settings-sidebar-item")));
[...document.querySelectorAll<HTMLButtonElement>(".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<HTMLInputElement>('[name="account-selection-debridlink"]')!.closest<HTMLDetailsElement>("details")!;
control.open = true;
const ids = () => [...control.querySelectorAll<HTMLElement>("[data-priority-account]")].map((row) => row.dataset.priorityAccount!);
const original = ids();
assert(original.length === 2, "Fixture needs two keys");
const radios = control.querySelectorAll<HTMLInputElement>('input[type="radio"]');
assert(radios[0].checked, "Automatic mode must be the default");
radios[1].click();
await waitFor(() => radios[1].checked);
control.querySelectorAll<HTMLButtonElement>('[data-priority-account] button')[1].click();
await waitFor(() => ids()[0] === original[1]);
const transfer = new DataTransfer();
const rows = control.querySelectorAll<HTMLElement>("[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<HTMLButtonElement>('[data-priority-account] button')[1].click();
await waitFor(() => ids()[0] === original[1]);
[...document.querySelectorAll<HTMLButtonElement>("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); });
+1
View File
@@ -87,6 +87,7 @@ function createSettings(): AppSettings {
archivePasswordList: "visual-archive-password", archivePasswordList: "visual-archive-password",
rememberToken: true, rememberToken: true,
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid", "alldebrid", "debridlink"], providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid", "alldebrid", "debridlink"],
accountUsageRules: {},
providerPrimary: "realdebrid", providerPrimary: "realdebrid",
providerSecondary: "megadebrid-api", providerSecondary: "megadebrid-api",
providerTertiary: "bestdebrid", providerTertiary: "bestdebrid",