feat(accounts): manage Real-Debrid account pool
This commit is contained in:
+55
-13
@@ -1,6 +1,7 @@
|
||||
import type { AccountCheckScope, AppSettings, DebridAccountStatus, DebridProvider } from "../shared/types";
|
||||
import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts";
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
@@ -23,6 +24,15 @@ export interface RealDebridSessionProbeResult {
|
||||
}
|
||||
|
||||
export type RealDebridSessionProbe = (signal?: AbortSignal) => Promise<RealDebridSessionProbeResult>;
|
||||
export type RealDebridSessionProbeResolver = (accountId: string, signal?: AbortSignal) => Promise<RealDebridSessionProbeResult>;
|
||||
|
||||
export function retainConfiguredRealDebridStatuses(settings: AppSettings, statuses: readonly DebridAccountStatus[]): DebridAccountStatus[] {
|
||||
const accountIds = new Set(getRealDebridAccounts(settings).map((account) => account.id));
|
||||
if (accountIds.size === 0 && (settings.realDebridUseWebLogin || settings.token.trim())) {
|
||||
accountIds.add(REAL_DEBRID_STATUS_ID);
|
||||
}
|
||||
return statuses.filter((status) => status.provider !== "realdebrid" || accountIds.has(status.accountId));
|
||||
}
|
||||
|
||||
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(ms);
|
||||
@@ -69,18 +79,30 @@ function maskSecret(value: string): string {
|
||||
}
|
||||
|
||||
export async function checkRealDebridAccount(
|
||||
settings: AppSettings,
|
||||
accountOrSettings: RealDebridAccountEntry | AppSettings,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now(),
|
||||
probeWebSession?: RealDebridSessionProbe
|
||||
): Promise<DebridAccountStatus> {
|
||||
const token = String(settings.token || "").trim();
|
||||
const useWebLogin = Boolean(settings.realDebridUseWebLogin);
|
||||
const isAccount = typeof (accountOrSettings as RealDebridAccountEntry).kind === "string";
|
||||
const account = isAccount
|
||||
? accountOrSettings as RealDebridAccountEntry
|
||||
: {
|
||||
id: REAL_DEBRID_STATUS_ID,
|
||||
kind: (accountOrSettings as AppSettings).realDebridUseWebLogin ? "web" as const : "api" as const,
|
||||
index: 0,
|
||||
label: "Real-Debrid",
|
||||
maskedLogin: (accountOrSettings as AppSettings).realDebridUseWebLogin ? "Browser-Login" : maskSecret(String((accountOrSettings as AppSettings).token || "")),
|
||||
enabled: true,
|
||||
...((accountOrSettings as AppSettings).realDebridUseWebLogin ? {} : { token: String((accountOrSettings as AppSettings).token || "").trim() })
|
||||
} as RealDebridAccountEntry;
|
||||
const token = account.kind === "api" ? account.token.trim() : "";
|
||||
const useWebLogin = account.kind === "web";
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: REAL_DEBRID_STATUS_ID,
|
||||
accountId: account.id,
|
||||
provider: "realdebrid",
|
||||
label: "Real-Debrid",
|
||||
maskedLogin: useWebLogin ? "Browser-Login" : maskSecret(token),
|
||||
label: account.label,
|
||||
maskedLogin: account.maskedLogin,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
@@ -274,7 +296,7 @@ export async function checkDebridLinkKey(
|
||||
export async function checkAllDebridAccounts(
|
||||
settings: AppSettings,
|
||||
signal?: AbortSignal,
|
||||
probeRealDebridWebSession?: RealDebridSessionProbe,
|
||||
probeRealDebridWebSession?: RealDebridSessionProbeResolver,
|
||||
scope: AccountCheckScope = "all"
|
||||
): Promise<DebridAccountStatus[]> {
|
||||
const now = Date.now();
|
||||
@@ -298,13 +320,33 @@ export async function checkAllDebridAccounts(
|
||||
: providerEnabled("debridlink")
|
||||
? configuredDebridLinkKeys.filter((key) => !(settings.debridLinkDisabledKeyIds || []).includes(key.id))
|
||||
: [];
|
||||
const checkRealDebrid = Boolean(settings.realDebridUseWebLogin || String(settings.token || "").trim())
|
||||
&& (scope === "all" || providerEnabled("realdebrid"));
|
||||
const configuredRealDebridAccounts = getRealDebridAccounts(settings);
|
||||
const legacyRealDebridAccounts: RealDebridAccountEntry[] = configuredRealDebridAccounts.length === 0
|
||||
&& (settings.realDebridUseWebLogin || String(settings.token || "").trim())
|
||||
? [{
|
||||
id: REAL_DEBRID_STATUS_ID,
|
||||
kind: settings.realDebridUseWebLogin ? "web" : "api",
|
||||
index: 0,
|
||||
label: "Real-Debrid",
|
||||
maskedLogin: settings.realDebridUseWebLogin ? "Browser-Login" : maskSecret(settings.token),
|
||||
enabled: true,
|
||||
...(settings.realDebridUseWebLogin ? {} : { token: settings.token.trim() })
|
||||
} as RealDebridAccountEntry]
|
||||
: [];
|
||||
const allRealDebridAccounts = configuredRealDebridAccounts.length > 0 ? configuredRealDebridAccounts : legacyRealDebridAccounts;
|
||||
const realDebridAccounts = scope === "all"
|
||||
? allRealDebridAccounts
|
||||
: providerEnabled("realdebrid") ? allRealDebridAccounts.filter((account) => account.enabled) : [];
|
||||
|
||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
...(checkRealDebrid
|
||||
? [() => checkRealDebridAccount(settings, signal, now, probeRealDebridWebSession)]
|
||||
: []),
|
||||
...realDebridAccounts.map((account) => () => checkRealDebridAccount(
|
||||
account,
|
||||
signal,
|
||||
now,
|
||||
account.kind === "web" && probeRealDebridWebSession
|
||||
? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal)
|
||||
: undefined
|
||||
)),
|
||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
getMegaDebridAccountId,
|
||||
getMegaDebridAccountsForMode,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
serializeMegaDebridAccounts,
|
||||
type MegaDebridAccountMode
|
||||
} from "../shared/mega-debrid-accounts";
|
||||
import { getRealDebridAccounts, isRealDebridWebAccountId, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
||||
import type { AccountCommand, AccountCredentialCheckInput, AccountSecretRequest, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types";
|
||||
|
||||
export interface AppliedAccountCommand {
|
||||
@@ -150,6 +152,13 @@ function storedSecretMissing(): never {
|
||||
}
|
||||
|
||||
export function resolveStoredAccountSecret(settings: AppSettings, request: AccountSecretRequest): string {
|
||||
if (request.kind === "realdebrid-api") {
|
||||
const accounts = parseRealDebridApiAccounts(settings.realDebridApiTokens);
|
||||
const account = accounts.find((entry) => entry.id === request.accountId);
|
||||
if (account?.token) return account.token;
|
||||
if (accounts.length === 0 && request.accountId === REAL_DEBRID_LEGACY_ID && settings.token) return settings.token;
|
||||
return storedSecretMissing();
|
||||
}
|
||||
if (request.kind === "megadebrid-api" || request.kind === "megadebrid-web") {
|
||||
const mode = request.kind === "megadebrid-web" ? "web" : "api";
|
||||
const account = getMegaDebridAccountsForMode(settings, mode).find((entry) => entry.id === request.accountId);
|
||||
@@ -163,7 +172,6 @@ export function resolveStoredAccountSecret(settings: AppSettings, request: Accou
|
||||
}
|
||||
const provider = singleProvider(request.kind);
|
||||
if (request.accountId !== `svc-${provider}` || !singleConfigured(settings, request.kind)) storedSecretMissing();
|
||||
if (request.kind === "realdebrid-api" && settings.token) return settings.token;
|
||||
if (request.kind === "bestdebrid-api" && settings.bestToken) return settings.bestToken;
|
||||
if (request.kind === "alldebrid-api" && settings.allDebridToken) return settings.allDebridToken;
|
||||
if (request.kind === "ddownload-login" && settings.ddownloadPassword) return settings.ddownloadPassword;
|
||||
@@ -172,6 +180,122 @@ export function resolveStoredAccountSecret(settings: AppSettings, request: Accou
|
||||
return storedSecretMissing();
|
||||
}
|
||||
|
||||
const REAL_DEBRID_LEGACY_ID = "svc-realdebrid";
|
||||
|
||||
function syncRealDebridLegacyFields(settings: AppSettings): AppSettings {
|
||||
const firstApi = parseRealDebridApiAccounts(settings.realDebridApiTokens)[0];
|
||||
return {
|
||||
...settings,
|
||||
token: firstApi?.token || "",
|
||||
realDebridUseWebLogin: settings.realDebridWebAccountIds.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRealDebridCommandSettings(settings: AppSettings): AppSettings {
|
||||
const rawPool = settings.realDebridApiTokens.trim() || (settings.realDebridUseWebLogin ? "" : settings.token.trim());
|
||||
const apiAccounts = parseRealDebridApiAccounts(rawPool).map((account) => ({
|
||||
id: account.id.startsWith("rda_legacy_") ? `rda_${randomUUID().replace(/-/g, "")}` : account.id,
|
||||
token: account.token
|
||||
}));
|
||||
return {
|
||||
...settings,
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts(apiAccounts),
|
||||
realDebridWebAccountIds: settings.realDebridWebAccountIds.length > 0
|
||||
? [...settings.realDebridWebAccountIds]
|
||||
: settings.realDebridUseWebLogin ? ["rdw_legacy"] : []
|
||||
};
|
||||
}
|
||||
|
||||
export function setRealDebridAccountEnabled(settings: AppSettings, accountId: string, enabled: boolean): AppSettings {
|
||||
const normalized = normalizeRealDebridCommandSettings(settings);
|
||||
if (!getRealDebridAccounts(normalized).some((account) => account.id === accountId)) invalid();
|
||||
return {
|
||||
...normalized,
|
||||
realDebridDisabledAccountIds: enabled
|
||||
? normalized.realDebridDisabledAccountIds.filter((id) => id !== accountId)
|
||||
: [...new Set([...normalized.realDebridDisabledAccountIds, accountId])]
|
||||
};
|
||||
}
|
||||
|
||||
function migrateRealDebridMetadata(settings: AppSettings, oldId: string, newId: string, limit: number | undefined): AppSettings {
|
||||
const idChanged = oldId !== newId;
|
||||
return {
|
||||
...settings,
|
||||
realDebridDisabledAccountIds: idChanged
|
||||
? migrateIdList(settings.realDebridDisabledAccountIds, oldId, newId)
|
||||
: [...settings.realDebridDisabledAccountIds],
|
||||
realDebridAccountDailyLimitBytes: migrateLimit(settings.realDebridAccountDailyLimitBytes, oldId, newId, limit),
|
||||
realDebridAccountDailyUsageBytes: idChanged ? withoutKeys(settings.realDebridAccountDailyUsageBytes, oldId, newId) : { ...settings.realDebridAccountDailyUsageBytes },
|
||||
realDebridAccountTotalUsageBytes: idChanged ? withoutKeys(settings.realDebridAccountTotalUsageBytes, oldId, newId) : { ...settings.realDebridAccountTotalUsageBytes },
|
||||
debridAccountStatuses: idChanged ? withoutKeys(settings.debridAccountStatuses, oldId, newId) : { ...settings.debridAccountStatuses }
|
||||
};
|
||||
}
|
||||
|
||||
function createRealDebrid(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
|
||||
if (command.kind === "realdebrid-api") {
|
||||
const token = validateSecret(command.secret || "");
|
||||
const accounts = parseRealDebridApiAccounts(settings.realDebridApiTokens);
|
||||
if (accounts.some((account) => account.token === token)) invalid();
|
||||
const accountId = `rda_${randomUUID().replace(/-/g, "")}`;
|
||||
const next = syncRealDebridLegacyFields({
|
||||
...settings,
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([...accounts, { id: accountId, token }]),
|
||||
realDebridDisabledAccountIds: settings.realDebridDisabledAccountIds.filter((id) => id !== accountId),
|
||||
realDebridAccountDailyLimitBytes: setLimit(settings.realDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes)
|
||||
});
|
||||
return { settings: next, response: { accountId } };
|
||||
}
|
||||
const requestedId = String(command.identity || "").trim();
|
||||
const accountId = requestedId && isRealDebridWebAccountId(requestedId) ? requestedId : `rdw_${randomUUID().replace(/-/g, "")}`;
|
||||
if (settings.realDebridWebAccountIds.includes(accountId)) invalid();
|
||||
const next = syncRealDebridLegacyFields({
|
||||
...settings,
|
||||
realDebridWebAccountIds: [...settings.realDebridWebAccountIds, accountId],
|
||||
realDebridDisabledAccountIds: settings.realDebridDisabledAccountIds.filter((id) => id !== accountId),
|
||||
realDebridAccountDailyLimitBytes: setLimit(settings.realDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes)
|
||||
});
|
||||
return { settings: next, response: { accountId } };
|
||||
}
|
||||
|
||||
function replaceRealDebrid(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
|
||||
const accounts = getRealDebridAccounts(settings);
|
||||
const current = accounts.find((account) => account.id === command.accountId);
|
||||
if (!current || (current.kind === "api") !== (command.kind === "realdebrid-api")) invalid();
|
||||
if (current.kind === "web") {
|
||||
const next = syncRealDebridLegacyFields(migrateRealDebridMetadata(settings, current.id, current.id, command.dailyLimitBytes));
|
||||
return { settings: next, response: { accountId: current.id } };
|
||||
}
|
||||
const token = command.secret?.trim() ? validateSecret(command.secret) : current.token;
|
||||
if (accounts.some((account) => account.id !== current.id && account.kind === "api" && account.token === token)) invalid();
|
||||
const credentials = parseRealDebridApiAccounts(settings.realDebridApiTokens).map((account) => ({
|
||||
id: account.id,
|
||||
token: account.id === current.id ? token : account.token
|
||||
}));
|
||||
const next = syncRealDebridLegacyFields(migrateRealDebridMetadata({
|
||||
...settings,
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts(credentials)
|
||||
}, current.id, current.id, command.dailyLimitBytes));
|
||||
return { settings: next, response: { accountId: current.id } };
|
||||
}
|
||||
|
||||
function deleteRealDebrid(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
|
||||
const account = getRealDebridAccounts(settings).find((entry) => entry.id === command.accountId);
|
||||
if (!account || (account.kind === "api") !== (command.kind === "realdebrid-api")) invalid();
|
||||
const next = syncRealDebridLegacyFields({
|
||||
...settings,
|
||||
realDebridApiTokens: account.kind === "api"
|
||||
? serializeRealDebridApiAccounts(parseRealDebridApiAccounts(settings.realDebridApiTokens).filter((entry) => entry.id !== account.id))
|
||||
: settings.realDebridApiTokens,
|
||||
realDebridWebAccountIds: account.kind === "web" ? settings.realDebridWebAccountIds.filter((id) => id !== account.id) : [...settings.realDebridWebAccountIds],
|
||||
realDebridDisabledAccountIds: settings.realDebridDisabledAccountIds.filter((id) => id !== account.id),
|
||||
realDebridAccountDailyLimitBytes: withoutKeys(settings.realDebridAccountDailyLimitBytes, account.id),
|
||||
realDebridAccountDailyUsageBytes: withoutKeys(settings.realDebridAccountDailyUsageBytes, account.id),
|
||||
realDebridAccountTotalUsageBytes: withoutKeys(settings.realDebridAccountTotalUsageBytes, account.id),
|
||||
debridAccountStatuses: withoutKeys(settings.debridAccountStatuses, account.id)
|
||||
});
|
||||
return { settings: next, response: { accountId: getRealDebridAccounts(next)[0]?.id || null } };
|
||||
}
|
||||
|
||||
function withoutKeys<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
|
||||
const removed = new Set(keys);
|
||||
return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key)));
|
||||
@@ -492,6 +616,12 @@ export function applyAccountCommand(settings: AppSettings, command: AccountComma
|
||||
};
|
||||
return applyAccountCommand(settings, replace);
|
||||
}
|
||||
if (command.kind === "realdebrid-api" || command.kind === "realdebrid-web") {
|
||||
const normalizedSettings = normalizeRealDebridCommandSettings(settings);
|
||||
if (command.action === "create") return createRealDebrid(normalizedSettings, command);
|
||||
if (command.action === "replace") return replaceRealDebrid(normalizedSettings, command);
|
||||
return deleteRealDebrid(normalizedSettings, command);
|
||||
}
|
||||
if (command.kind === "megadebrid-api" || command.kind === "megadebrid-web") {
|
||||
if (command.action === "create") return createMega(settings, command);
|
||||
if (command.action === "replace") return replaceMega(settings, command);
|
||||
|
||||
+29
-11
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import v8 from "node:v8";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
@@ -33,10 +34,11 @@ import { importDlcContainers } from "./container";
|
||||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount } from "./account-check";
|
||||
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||
import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands";
|
||||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer";
|
||||
import { createRendererState } from "./renderer-state";
|
||||
@@ -524,6 +526,11 @@ export class AppController {
|
||||
if (!key) throw new Error("Account-Payload ist ungültig");
|
||||
checkedStatus = await checkDebridLinkKey(key);
|
||||
}
|
||||
if (command.action !== "delete" && applied.response.accountId && command.kind === "realdebrid-api") {
|
||||
const account = getRealDebridAccounts(applied.settings).find((entry) => entry.id === applied.response.accountId && entry.kind === "api");
|
||||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||||
checkedStatus = await checkRealDebridAccount(account);
|
||||
}
|
||||
if (checkedStatus) {
|
||||
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
||||
}
|
||||
@@ -547,19 +554,28 @@ export class AppController {
|
||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||||
const useWebLogin = input.kind === "realdebrid-web";
|
||||
const settings = input.secret?.trim()
|
||||
? { ...this.settings, token: input.secret.trim(), realDebridUseWebLogin: useWebLogin }
|
||||
: { ...this.settings, realDebridUseWebLogin: useWebLogin };
|
||||
const account = input.secret?.trim() && !useWebLogin
|
||||
? {
|
||||
id: `rda_${randomUUID().replace(/-/g, "")}`,
|
||||
kind: "api" as const,
|
||||
index: 0,
|
||||
label: "Real-Debrid",
|
||||
maskedLogin: "Geschützter API-Token",
|
||||
enabled: true,
|
||||
token: input.secret.trim()
|
||||
}
|
||||
: getRealDebridAccounts(this.settings).find((entry) => entry.id === input.accountId && entry.kind === (useWebLogin ? "web" : "api"));
|
||||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||||
const status = sanitizeDebridAccountStatus(
|
||||
await checkRealDebridAccount(
|
||||
settings,
|
||||
account,
|
||||
undefined,
|
||||
Date.now(),
|
||||
useWebLogin ? (signal) => this.realDebridWebFallback.probeLoginState(signal) : undefined
|
||||
),
|
||||
redactions
|
||||
);
|
||||
if (!input.secret && useWebLogin === this.settings.realDebridUseWebLogin) {
|
||||
if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
return status;
|
||||
@@ -613,12 +629,13 @@ export class AppController {
|
||||
}
|
||||
|
||||
private async refreshRealDebridWebStatus(): Promise<void> {
|
||||
if (!this.settings.realDebridUseWebLogin) {
|
||||
const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web");
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
const status = sanitizeDebridAccountStatus(
|
||||
await checkRealDebridAccount(
|
||||
this.settings,
|
||||
account,
|
||||
undefined,
|
||||
Date.now(),
|
||||
(signal) => this.realDebridWebFallback.probeLoginState(signal)
|
||||
@@ -658,15 +675,16 @@ export class AppController {
|
||||
}
|
||||
|
||||
public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> {
|
||||
const statuses = sanitizeDebridAccountStatuses(
|
||||
const checkedStatuses = sanitizeDebridAccountStatuses(
|
||||
await checkAllDebridAccounts(
|
||||
this.settings,
|
||||
undefined,
|
||||
(signal) => this.realDebridWebFallback.probeLoginState(signal),
|
||||
(_accountId, signal) => this.realDebridWebFallback.probeLoginState(signal),
|
||||
scope
|
||||
),
|
||||
collectAccountStatusRedactionValues(this.settings)
|
||||
);
|
||||
const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses);
|
||||
this.manager.applyDebridAccountStatuses(statuses);
|
||||
this.audit("INFO", "Debrid-Accounts geprueft", {
|
||||
total: statuses.length,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts";
|
||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
|
||||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
||||
|
||||
@@ -51,7 +52,24 @@ function singleAccount(
|
||||
export function createRendererAccounts(settings: AppSettings): RendererAccount[] {
|
||||
const accounts: RendererAccount[] = [];
|
||||
const redactions = collectAccountStatusRedactionValues(settings);
|
||||
if (settings.realDebridUseWebLogin || settings.token.trim()) {
|
||||
const realDebridAccounts = getRealDebridAccounts(settings);
|
||||
for (const account of realDebridAccounts) {
|
||||
const status = safeStatus(settings.debridAccountStatuses[account.id], redactions);
|
||||
accounts.push({
|
||||
accountId: account.id,
|
||||
kind: account.kind === "web" ? "realdebrid-web" : "realdebrid-api",
|
||||
provider: "realdebrid",
|
||||
identity: status?.username || "",
|
||||
maskedIdentity: account.maskedLogin,
|
||||
hasSecret: true,
|
||||
enabled: providerEnabled(settings, "realdebrid") && account.enabled,
|
||||
dailyLimitBytes: settings.realDebridAccountDailyLimitBytes[account.id] || 0,
|
||||
dailyUsageBytes: settings.realDebridAccountDailyUsageBytes[account.id] || 0,
|
||||
totalUsageBytes: settings.realDebridAccountTotalUsageBytes[account.id] || 0,
|
||||
status
|
||||
});
|
||||
}
|
||||
if (realDebridAccounts.length === 0 && (settings.realDebridUseWebLogin || settings.token.trim())) {
|
||||
accounts.push(singleAccount(
|
||||
settings,
|
||||
settings.realDebridUseWebLogin ? "realdebrid-web" : "realdebrid-api",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts";
|
||||
import { getRealDebridAccountIds } from "../shared/real-debrid-accounts";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
|
||||
const realDebridAccountIds = new Set(getRealDebridAccountIds(target));
|
||||
const validAccountIds = new Set([
|
||||
...debridLinkKeyIds,
|
||||
...megaAccountIds,
|
||||
...(target.realDebridUseWebLogin || target.token.trim() ? ["svc-realdebrid"] : [])
|
||||
...realDebridAccountIds,
|
||||
...(realDebridAccountIds.size === 0 && (target.realDebridUseWebLogin || target.token.trim()) ? ["svc-realdebrid"] : [])
|
||||
]);
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
@@ -25,9 +28,15 @@ export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppS
|
||||
target.megaDebridAccountDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.megaDebridAccountDailyUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
|
||||
);
|
||||
target.megaDebridAccountTotalUsageBytes = Object.fromEntries(
|
||||
target.megaDebridAccountTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.megaDebridAccountTotalUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
|
||||
);
|
||||
);
|
||||
target.realDebridAccountDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.realDebridAccountDailyUsageBytes || {}).filter(([accountId]) => realDebridAccountIds.has(accountId))
|
||||
);
|
||||
target.realDebridAccountTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.realDebridAccountTotalUsageBytes || {}).filter(([accountId]) => realDebridAccountIds.has(accountId))
|
||||
);
|
||||
target.debridAccountStatuses = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridAccountStatuses || {}).filter(([accountId]) => validAccountIds.has(accountId))
|
||||
);
|
||||
|
||||
+9
-7
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||
@@ -447,12 +448,13 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
const megaDebridDisabledAccountIds = [...new Set([...megaDebridApiDisabledAccountIds, ...megaDebridWebDisabledAccountIds])];
|
||||
const legacyRealDebridToken = asText(settings.token);
|
||||
const hasRealDebridApiPool = Object.prototype.hasOwnProperty.call(settings, "realDebridApiTokens");
|
||||
const storedRealDebridApiTokens = serializeRealDebridApiAccounts(
|
||||
parseRealDebridApiAccounts(String(settings.realDebridApiTokens ?? "")).map((entry) => entry.token)
|
||||
);
|
||||
const realDebridApiTokens = hasRealDebridApiPool
|
||||
? storedRealDebridApiTokens
|
||||
: serializeRealDebridApiAccounts([legacyRealDebridToken]);
|
||||
const rawRealDebridApiAccounts = hasRealDebridApiPool
|
||||
? parseRealDebridApiAccounts(String(settings.realDebridApiTokens ?? ""))
|
||||
: legacyRealDebridToken ? parseRealDebridApiAccounts(legacyRealDebridToken) : [];
|
||||
const realDebridApiTokens = serializeRealDebridApiAccounts(rawRealDebridApiAccounts.map((account) => ({
|
||||
id: account.id.startsWith("rda_legacy_") ? `rda_${randomUUID().replace(/-/g, "")}` : account.id,
|
||||
token: account.token
|
||||
})));
|
||||
const hasRealDebridWebPool = Object.prototype.hasOwnProperty.call(settings, "realDebridWebAccountIds");
|
||||
let realDebridWebAccountIds = normalizeRealDebridWebAccountIds(settings.realDebridWebAccountIds);
|
||||
if (!hasRealDebridWebPool && Boolean(settings.realDebridUseWebLogin)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ export type AccountKind = RendererAccountKind;
|
||||
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
|
||||
|
||||
export type AccountEditTarget =
|
||||
| { type: "single"; rowKey: string; kind: SingleAccountKind; service: AccountService; provider: DebridProvider }
|
||||
| { type: "single"; rowKey: string; kind: SingleAccountKind; service: AccountService; provider: DebridProvider; accountId?: string }
|
||||
| { type: "mega"; rowKey: string; kind: "megadebrid-api" | "megadebrid-web"; service: "megadebrid-api" | "megadebrid-web"; accountId: string }
|
||||
| { type: "debridlink"; rowKey: string; kind: "debridlink-api"; service: "debridlink"; keyId: string };
|
||||
|
||||
@@ -37,7 +37,7 @@ function parseDailyLimit(value: string, originalBytes: number): number {
|
||||
function targetAccountId(target: AccountEditTarget): string {
|
||||
if (target.type === "mega") return target.accountId;
|
||||
if (target.type === "debridlink") return target.keyId;
|
||||
return `svc-${target.provider}`;
|
||||
return target.accountId || `svc-${target.provider}`;
|
||||
}
|
||||
|
||||
export function createAccountEditState(target: AccountEditTarget, accounts: readonly RendererAccount[]): AccountEditState {
|
||||
|
||||
@@ -18,113 +18,79 @@ export interface RealDebridWebAccountEntry extends RealDebridAccountBase {
|
||||
|
||||
export type RealDebridAccountEntry = RealDebridApiAccountEntry | RealDebridWebAccountEntry;
|
||||
|
||||
export type RealDebridApiCredential = {
|
||||
id: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type RealDebridAccountSettings = {
|
||||
realDebridApiTokens?: string;
|
||||
realDebridWebAccountIds?: string[];
|
||||
realDebridDisabledAccountIds?: string[];
|
||||
};
|
||||
|
||||
const API_ACCOUNT_ID_RE = /^rda_[A-Za-z0-9_-]{1,96}$/;
|
||||
const WEB_ACCOUNT_ID_RE = /^rdw_[A-Za-z0-9_-]{1,96}$/;
|
||||
const SHA256_INITIAL = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
|
||||
] as const;
|
||||
const SHA256_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||
] as const;
|
||||
|
||||
function rotateRight(value: number, count: number): number {
|
||||
return (value >>> count) | (value << (32 - count));
|
||||
}
|
||||
|
||||
function sha256(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const paddingLength = (64 - ((bytes.length + 1 + 8) % 64)) % 64;
|
||||
const data = new Uint8Array(bytes.length + 1 + paddingLength + 8);
|
||||
data.set(bytes);
|
||||
data[bytes.length] = 0x80;
|
||||
let bitLength = BigInt(bytes.length) * 8n;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
data[data.length - 1 - index] = Number(bitLength & 0xffn);
|
||||
bitLength >>= 8n;
|
||||
}
|
||||
const hash: number[] = [...SHA256_INITIAL];
|
||||
const words = new Uint32Array(64);
|
||||
for (let offset = 0; offset < data.length; offset += 64) {
|
||||
const view = new DataView(data.buffer, data.byteOffset + offset, 64);
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
words[index] = view.getUint32(index * 4, false);
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const previous15 = words[index - 15];
|
||||
const previous2 = words[index - 2];
|
||||
const sigma0 = rotateRight(previous15, 7) ^ rotateRight(previous15, 18) ^ (previous15 >>> 3);
|
||||
const sigma1 = rotateRight(previous2, 17) ^ rotateRight(previous2, 19) ^ (previous2 >>> 10);
|
||||
words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, h] = hash;
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const upperSigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
||||
const choice = (e & f) ^ (~e & g);
|
||||
const temporary1 = (h + upperSigma1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0;
|
||||
const upperSigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temporary2 = (upperSigma0 + majority) >>> 0;
|
||||
h = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temporary1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temporary1 + temporary2) >>> 0;
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0;
|
||||
hash[1] = (hash[1] + b) >>> 0;
|
||||
hash[2] = (hash[2] + c) >>> 0;
|
||||
hash[3] = (hash[3] + d) >>> 0;
|
||||
hash[4] = (hash[4] + e) >>> 0;
|
||||
hash[5] = (hash[5] + f) >>> 0;
|
||||
hash[6] = (hash[6] + g) >>> 0;
|
||||
hash[7] = (hash[7] + h) >>> 0;
|
||||
}
|
||||
return hash.map((value) => value.toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
function normalizeTokens(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const token = String(value || "").trim();
|
||||
if (!token || seen.has(token)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(token);
|
||||
result.push(token);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRealDebridApiAccountId(token: string): string {
|
||||
return `rda_${sha256(String(token || "").trim()).slice(0, 32)}`;
|
||||
export function isRealDebridApiAccountId(value: string): boolean {
|
||||
return API_ACCOUNT_ID_RE.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
export function isRealDebridWebAccountId(value: string): boolean {
|
||||
return WEB_ACCOUNT_ID_RE.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function normalizeCredentials(values: readonly RealDebridApiCredential[]): RealDebridApiCredential[] {
|
||||
const ids = new Set<string>();
|
||||
const tokens = new Set<string>();
|
||||
const result: RealDebridApiCredential[] = [];
|
||||
for (const value of values) {
|
||||
const id = String(value?.id || "").trim();
|
||||
const token = String(value?.token || "").trim();
|
||||
if (!isRealDebridApiAccountId(id) || !token || /[\r\n]/.test(token) || ids.has(id) || tokens.has(token)) {
|
||||
continue;
|
||||
}
|
||||
ids.add(id);
|
||||
tokens.add(token);
|
||||
result.push({ id, token });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parsePersistedCredentials(raw: string): RealDebridApiCredential[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { version?: unknown; accounts?: unknown };
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.accounts)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeCredentials(parsed.accounts as RealDebridApiCredential[]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLegacyCredentials(raw: string): RealDebridApiCredential[] {
|
||||
const tokens = new Set<string>();
|
||||
const result: RealDebridApiCredential[] = [];
|
||||
for (const value of raw.split(/[\n,]+/)) {
|
||||
const token = value.trim();
|
||||
if (!token || /\r/.test(token) || tokens.has(token)) {
|
||||
continue;
|
||||
}
|
||||
tokens.add(token);
|
||||
result.push({ id: `rda_legacy_${result.length + 1}`, token });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseRealDebridApiAccounts(raw: string): RealDebridApiAccountEntry[] {
|
||||
return normalizeTokens(String(raw || "").split(/[\n,]+/)).map((token, index) => ({
|
||||
id: getRealDebridApiAccountId(token),
|
||||
const normalizedRaw = String(raw || "").trim();
|
||||
const credentials = normalizedRaw
|
||||
? parsePersistedCredentials(normalizedRaw) ?? parseLegacyCredentials(normalizedRaw)
|
||||
: [];
|
||||
return credentials.map((entry, index) => ({
|
||||
...entry,
|
||||
kind: "api",
|
||||
token,
|
||||
index,
|
||||
label: `API-Token ${index + 1}`,
|
||||
maskedLogin: "Geschützter API-Token",
|
||||
@@ -132,8 +98,9 @@ export function parseRealDebridApiAccounts(raw: string): RealDebridApiAccountEnt
|
||||
}));
|
||||
}
|
||||
|
||||
export function serializeRealDebridApiAccounts(tokens: readonly string[]): string {
|
||||
return normalizeTokens(tokens).join("\n");
|
||||
export function serializeRealDebridApiAccounts(accounts: readonly RealDebridApiCredential[]): string {
|
||||
const normalized = normalizeCredentials(accounts);
|
||||
return normalized.length > 0 ? JSON.stringify({ version: 1, accounts: normalized }) : "";
|
||||
}
|
||||
|
||||
export function normalizeRealDebridWebAccountIds(raw: unknown): string[] {
|
||||
|
||||
Reference in New Issue
Block a user