feat: add per-provider account priority rules
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user