feat: add per-provider account priority rules
This commit is contained in:
+5
-2
@@ -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.
|
||||
|
||||
@@ -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<AccountCommand, {
|
||||
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") {
|
||||
const replace: Extract<AccountCommand, { action: "replace" }> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+34
-12
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-3
@@ -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)) {
|
||||
|
||||
+40
-2
@@ -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;
|
||||
|
||||
@@ -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)"],
|
||||
|
||||
@@ -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
|
||||
} 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<HTMLElement>, index: number) => void;
|
||||
onProviderDragOver?: (event: DragEvent<HTMLElement>, index: number) => void;
|
||||
onProviderDrop?: (event: DragEvent<HTMLElement>, index: number) => void;
|
||||
@@ -524,8 +528,9 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
<button aria-label={`${provider.label} nach oben`} disabled={index === 0} 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>
|
||||
) : null}
|
||||
</li>
|
||||
) : null}
|
||||
{provider.accountSelection ? <AccountPriorityControls provider={provider.id} model={provider.accountSelection} busy={model.busy} onModeChange={actions.onAccountSelectionMode} onMove={actions.onMovePriorityAccount} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
+8
-4
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
@@ -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); });
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user