Add Mega-Debrid multi-account support with automatic fallback

Multiple Mega-Debrid accounts can now be configured as login:password
pairs (one per line). When an account hits Fair-Use limits or errors,
the next account is tried automatically.

- New parser module mega-debrid-accounts.ts (parse, ID generation,
  masking, serialization)
- Per-account daily limits, usage tracking, enable/disable
- Account rotation with per-mode cooldowns (API failures don't
  block Web attempts)
- Backward compatible: existing single megaLogin/megaPassword
  is auto-migrated to the new format
- UI: textarea for credentials, account list with masked logins

Follows the existing Debrid-Link multi-key pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-23 20:12:51 +01:00
parent 672c74f98f
commit 6df0834b67
10 changed files with 3614 additions and 3001 deletions

View File

@ -0,0 +1,91 @@
# Mega-Debrid Multi-Account Support
> **For agentic workers:** Use superpowers:subagent-driven-development to implement this plan.
**Goal:** Multiple Mega-Debrid accounts with automatic fallback when an account hits Fair-Use limits or errors.
**Architecture:** Follow the existing Debrid-Link multi-key pattern. Store credentials as newline-separated `login:password` pairs. Account rotation uses linear iteration with cooldown/disable/daily-limit checks.
**Tech Stack:** TypeScript, Electron, React
---
### Task 1: Create mega-debrid-accounts.ts parser module
**Files:**
- Create: `src/shared/mega-debrid-accounts.ts`
- [ ] Create `MegaDebridAccountEntry` interface (id, login, password, index, label, maskedLogin)
- [ ] Create `parseMegaDebridAccounts(raw: string): MegaDebridAccountEntry[]` - split by newlines, parse `login:password` pairs, deduplicate by login, generate stable IDs via FNV-1a hash (`mda_` prefix)
- [ ] Create `getMegaDebridAccountId(login: string): string`
- [ ] Create `maskMegaDebridLogin(login: string): string`
- [ ] Create `getMegaDebridAccountLabel(index: number): string` - "Account 1", "Account 2"
- [ ] Create `serializeMegaDebridAccounts(accounts: {login: string, password: string}[]): string` - back to newline-separated format
- [ ] Backward compat: if raw string has no `:` separator, treat as legacy single-login (use megaPassword from settings)
### Task 2: Extend AppSettings with multi-account fields
**Files:**
- Modify: `src/shared/types.ts`
- [ ] Replace `megaLogin: string``megaCredentials: string` (newline-separated `login:password` pairs)
- [ ] Keep `megaPassword: string` for backward compat (migration reads it once)
- [ ] Add `megaDebridDisabledAccountIds: string[]`
- [ ] Add `megaDebridAccountDailyLimitBytes: Record<string, number>`
- [ ] Add `megaDebridAccountDailyUsageBytes: Record<string, number>`
- [ ] Add `megaDebridAccountTotalUsageBytes: Record<string, number>`
### Task 3: Add per-account daily limit functions
**Files:**
- Modify: `src/shared/provider-daily-limits.ts`
- [ ] Add `getMegaDebridAccountDailyLimitBytes(settings, accountId)`
- [ ] Add `getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs)`
- [ ] Add `isMegaDebridAccountDailyLimitReached(settings, accountId, epochMs)`
- [ ] Add `addMegaDebridAccountDailyUsageBytes(settings, accountId, bytes, epochMs)`
- [ ] Add `addMegaDebridAccountTotalUsageBytes(settings, accountId, bytes)`
- [ ] Add `isMegaDebridAccountDisabled(settings, accountId)`
### Task 4: Migrate storage from single to multi-account
**Files:**
- Modify: `src/main/storage.ts`
- [ ] In `normalizeSettings`: migrate old `megaLogin`+`megaPassword` → `megaCredentials` format (`login:password`)
- [ ] Normalize new fields with defaults
### Task 5: Implement account rotation in debrid.ts
**Files:**
- Modify: `src/main/debrid.ts`
- [ ] Add in-memory cooldown cache for Mega accounts (like `debridLinkKeyCooldowns`)
- [ ] Update `hasMegaDebridCredentials()` to check `parseMegaDebridAccounts().length > 0`
- [ ] Update Mega-Debrid API unrestrict to iterate accounts (skip disabled/limited/cooldown)
- [ ] Update Mega-Debrid Web unrestrict to iterate accounts
- [ ] Return `sourceAccountId` and `sourceAccountLabel` on success
- [ ] On failure: classify error, apply cooldown, try next account
### Task 6: Update download-manager usage tracking
**Files:**
- Modify: `src/main/download-manager.ts`
- [ ] Track per-account bytes for Mega-Debrid (like Debrid-Link key tracking)
- [ ] Update `isProviderDailyLimited` to check if ANY Mega account is available
### Task 7: Update UI for multi-account management
**Files:**
- Modify: `src/renderer/App.tsx`
- [ ] Update Mega-Debrid account dialog: textarea for credentials (`login:password` per line)
- [ ] Display account list with masked logins, enable/disable toggle, per-account daily limits
- [ ] Update account summary display to show individual accounts
### Task 8: Tests
- [ ] Unit tests for `parseMegaDebridAccounts` (parse, deduplicate, legacy compat)
- [ ] Unit tests for per-account daily limits
- [ ] Run full test suite: `npx vitest run`

View File

@ -46,6 +46,7 @@ export function defaultSettings(): AppSettings {
realDebridUseWebLogin: false, realDebridUseWebLogin: false,
megaLogin: "", megaLogin: "",
megaPassword: "", megaPassword: "",
megaCredentials: "",
megaDebridApiEnabled: false, megaDebridApiEnabled: false,
megaDebridWebEnabled: false, megaDebridWebEnabled: false,
megaDebridPreferApi: true, megaDebridPreferApi: true,
@ -118,6 +119,10 @@ export function defaultSettings(): AppSettings {
debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {}, debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {}, debridLinkApiKeyTotalUsageBytes: {},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0 scheduledStartEpochMs: 0
}; };

View File

@ -1,6 +1,7 @@
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
import { isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
@ -94,6 +95,62 @@ function getDebridLinkKeyCooldownState(
}; };
} }
/** Per-account cooldown cache for Mega-Debrid: accountId → expiry timestamp. */
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear();
}
export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldownMs: number, message = "Mega-Debrid Account im Cooldown"): void {
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
}
function clearMegaDebridAccountCooldownState(accountId: string): void {
megaDebridAccountCooldowns.delete(accountId);
}
function setMegaDebridAccountCooldownState(
accountId: string,
cooldownMs: number,
message: string,
category: MegaDebridCooldownCategory
): void {
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearMegaDebridAccountCooldownState(accountId);
return;
}
megaDebridAccountCooldowns.set(accountId, {
until: Date.now() + Math.max(1000, Math.floor(cooldownMs)),
message,
category
});
}
export function getMegaDebridAccountCooldownState(
accountId: string,
now = Date.now()
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory } | null {
const detail = megaDebridAccountCooldowns.get(accountId);
if (!detail) {
return null;
}
if (detail.until <= now) {
clearMegaDebridAccountCooldownState(accountId);
return null;
}
return {
until: detail.until,
remainingMs: detail.until - now,
message: detail.message,
category: detail.category
};
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
const PROVIDER_LABELS: Record<DebridProvider, string> = { const PROVIDER_LABELS: Record<DebridProvider, string> = {
@ -146,7 +203,11 @@ function cloneSettings(settings: AppSettings): AppSettings {
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) } debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) },
megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])],
megaDebridAccountDailyLimitBytes: { ...(settings.megaDebridAccountDailyLimitBytes || {}) },
megaDebridAccountDailyUsageBytes: { ...(settings.megaDebridAccountDailyUsageBytes || {}) },
megaDebridAccountTotalUsageBytes: { ...(settings.megaDebridAccountTotalUsageBytes || {}) }
}; };
} }
@ -160,8 +221,29 @@ export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = D
); );
} }
/** Returns Mega-Debrid accounts that are not disabled and not daily-limited. */
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
return getMegaDebridAccountList(settings).filter(
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
);
}
/** Resolves the full list of Mega-Debrid accounts from settings (multi-account or legacy single). */
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
// Multi-account format: newline-separated "login:password" pairs in megaCredentials
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
if (multiAccounts.length > 0) {
return multiAccounts;
}
// Backward compat: single legacy megaLogin/megaPassword
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
}
return [];
}
function hasMegaDebridCredentials(settings: AppSettings): boolean { function hasMegaDebridCredentials(settings: AppSettings): boolean {
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); return getMegaDebridAccountList(settings).length > 0;
} }
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean { function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
@ -1004,11 +1086,11 @@ class MegaDebridClient {
private allowApiFallback: boolean; private allowApiFallback: boolean;
private static cachedApiToken = ""; /** Per-account API token cache: login (lowercase) → { token, timestamp } */
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
private static cachedApiTokenAt = 0; /** Per-account pending connect deduplication: login (lowercase) → promise */
private static pendingConnects = new Map<string, Promise<string | null>>();
private static pendingConnect: Promise<string | null> | null = null;
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
this.login = login; this.login = login;
@ -1018,21 +1100,33 @@ class MegaDebridClient {
this.megaWebUnrestrict = megaWebUnrestrict; this.megaWebUnrestrict = megaWebUnrestrict;
} }
private get cacheKey(): string {
return this.login.trim().toLowerCase();
}
private async connectApi(signal?: AbortSignal): Promise<string | null> { private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey;
// Return cached token if fresh (max 20 min) // Return cached token if fresh (max 20 min)
if (MegaDebridClient.cachedApiToken && Date.now() - MegaDebridClient.cachedApiTokenAt < 20 * 60 * 1000) { const cached = MegaDebridClient.cachedApiTokens.get(key);
return MegaDebridClient.cachedApiToken; if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
return cached.token;
} }
// Deduplicate parallel connectUser calls — only one in-flight request at a time // Deduplicate parallel connectUser calls — only one in-flight request per account
if (MegaDebridClient.pendingConnect) { const pending = MegaDebridClient.pendingConnects.get(key);
return MegaDebridClient.pendingConnect; if (pending) {
return pending;
} }
MegaDebridClient.pendingConnect = this.doConnectApi(signal).finally(() => { const promise = this.doConnectApi(signal).finally(() => {
MegaDebridClient.pendingConnect = null; MegaDebridClient.pendingConnects.delete(key);
}); });
return MegaDebridClient.pendingConnect; MegaDebridClient.pendingConnects.set(key, promise);
return promise;
}
private clearTokenCache(): void {
MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
} }
private async doConnectApi(signal?: AbortSignal): Promise<string | null> { private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
@ -1053,8 +1147,7 @@ class MegaDebridClient {
if (!token) { if (!token) {
return null; return null;
} }
MegaDebridClient.cachedApiToken = token; MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
MegaDebridClient.cachedApiTokenAt = Date.now();
return token; return token;
} }
@ -1078,8 +1171,7 @@ class MegaDebridClient {
if (!response.ok) { if (!response.ok) {
// Token might be invalid, clear cache // Token might be invalid, clear cache
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
MegaDebridClient.cachedApiToken = ""; this.clearTokenCache();
MegaDebridClient.cachedApiTokenAt = 0;
} }
return null; return null;
} }
@ -1087,8 +1179,7 @@ class MegaDebridClient {
if (!payload || payload.response_code !== "ok") { if (!payload || payload.response_code !== "ok") {
// Token expired — clear cache for next attempt // Token expired — clear cache for next attempt
if (payload && String(payload.response_code || "").includes("token")) { if (payload && String(payload.response_code || "").includes("token")) {
MegaDebridClient.cachedApiToken = ""; this.clearTokenCache();
MegaDebridClient.cachedApiTokenAt = 0;
} }
const errorText = String(payload?.response_text || "").trim(); const errorText = String(payload?.response_text || "").trim();
if (errorText) { if (errorText) {
@ -1173,6 +1264,168 @@ class MegaDebridClient {
return this.unrestrictViaWeb(link, signal); return this.unrestrictViaWeb(link, signal);
} }
/**
* Multi-account rotation for Mega-Debrid, following the same pattern as Debrid-Link multi-key rotation.
* Iterates through all configured accounts, skipping disabled/daily-limited/cooldown accounts.
* On success: clears cooldown, returns result with sourceAccountId/sourceAccountLabel.
* On failure: classifies error, sets cooldown, tries next account.
*/
public static async unrestrictWithAccounts(
settings: AppSettings,
mode: "api" | "web",
allowApiFallback: boolean,
link: string,
megaWebUnrestrict: MegaWebUnrestrictor | undefined,
signal?: AbortSignal
): Promise<UnrestrictedLink> {
const accounts = getMegaDebridAccountList(settings);
if (accounts.length === 0) {
throw new Error("Mega-Debrid: Kein Account konfiguriert");
}
if (getAvailableMegaDebridAccounts(settings).length === 0) {
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)");
}
const failures: string[] = [];
let usableAccountSeen = false;
const cooldownFailures: string[] = [];
let earliestCooldownUntil = 0;
const hasMultiple = accounts.length > 1;
// Always start from first account — use first available, skip disabled/limited/cooldown.
for (let idx = 0; idx < accounts.length; idx += 1) {
const account = accounts[idx];
const accountLabel = hasMultiple ? ` (${account.label})` : "";
if (isMegaDebridAccountDisabled(settings, account.id)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
continue;
}
if (isMegaDebridAccountDailyLimitReached(settings, account.id)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`);
continue;
}
// Cooldown key includes mode so API failures don't block Web attempts
const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (Cooldown bis ${new Date(accountCooldownState.until).toLocaleTimeString()}), pruefe naechsten Account`);
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = accountCooldownState.until;
}
continue;
}
usableAccountSeen = true;
try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal);
clearMegaDebridAccountCooldownState(cooldownKey);
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK -> ${result.fileName || "?"}`);
return {
...result,
sourceLabel: account.label,
sourceAccountId: account.id,
sourceAccountLabel: account.label
};
} catch (error) {
const failure = MegaDebridClient.classifyAccountFailure(error);
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) {
setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category);
} else {
clearMegaDebridAccountCooldownState(cooldownKey);
}
if (failure.fatal) {
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
}
const cooldownInfo = failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
logger.warn(`Mega-Debrid${accountLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Account`);
}
}
if (!usableAccountSeen) {
if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) {
const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000);
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
}
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
}
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
}
/**
* Classify error from a single Mega-Debrid account attempt.
* Returns whether the error is fatal (stop all accounts) and how long to cool down.
*/
private static classifyAccountFailure(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Abort — don't retry other accounts
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
}
// Auth/login failures — long cooldown, try next account
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS,
message: `ungueltiger Account (${errorText})`,
category: "invalid"
};
}
// Permanent hoster errors — fatal, don't try other accounts
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
}
// Quota/limit errors — cooldown, try next account
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Quota/Limit erreicht (${errorText})`,
category: "quota"
};
}
// Rate limit
if (/rate.?limit|too.?many|429/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Rate-Limit (${errorText})`,
category: "rate_limit"
};
}
// Temporary/transport errors — short cooldown, try next account
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: errorText || "temporaerer Fehler",
category: "temporary"
};
}
// Unknown errors — short cooldown, try next account (non-fatal)
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: errorText || "unbekannter Fehler",
category: "temporary"
};
}
} }
class BestDebridClient { class BestDebridClient {
@ -2466,6 +2719,12 @@ export class DebridService {
return true; return true;
} }
} }
if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
const configuredAccounts = getMegaDebridAccountList(settings);
if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
return true;
}
}
return isProviderDailyLimitReached(settings, effectiveProvider); return isProviderDailyLimitReached(settings, effectiveProvider);
} }
@ -2478,6 +2737,9 @@ export class DebridService {
if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)"; return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)";
} }
if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && getMegaDebridAccountList(settings).length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)";
}
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
} }
@ -2704,10 +2966,10 @@ export class DebridService {
return result; return result;
} }
if (effectiveProvider === "megadebrid-api") { if (effectiveProvider === "megadebrid-api") {
return new MegaDebridClient(settings.megaLogin, settings.megaPassword, "api", provider === "megadebrid" && settings.megaDebridPreferApi, this.options.megaWebUnrestrict).unrestrictLink(link, signal); return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal);
} }
if (effectiveProvider === "megadebrid-web") { if (effectiveProvider === "megadebrid-web") {
return new MegaDebridClient(settings.megaLogin, settings.megaPassword, "web", false, this.options.megaWebUnrestrict).unrestrictLink(link, signal); return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal);
} }
if (effectiveProvider === "alldebrid") { if (effectiveProvider === "alldebrid") {
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {

View File

@ -2,6 +2,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { defaultSettings } from "./constants"; import { defaultSettings } from "./constants";
@ -281,6 +282,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
const currentUsageDay = getProviderUsageDayKey(); const currentUsageDay = getProviderUsageDayKey();
const megaLogin = asText(settings.megaLogin); const megaLogin = asText(settings.megaLogin);
const megaPassword = asText(settings.megaPassword); const megaPassword = asText(settings.megaPassword);
// Migrate legacy single-account to multi-account format
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
if (!megaCredentials && megaLogin && megaPassword) {
megaCredentials = `${megaLogin}:${megaPassword}`;
}
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true; const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
const hasMegaCreds = Boolean(megaLogin && megaPassword); const hasMegaCreds = Boolean(megaLogin && megaPassword);
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
@ -322,6 +329,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
megaLogin, megaLogin,
megaPassword, megaPassword,
megaCredentials,
megaDebridApiEnabled, megaDebridApiEnabled,
megaDebridWebEnabled, megaDebridWebEnabled,
megaDebridPreferApi, megaDebridPreferApi,
@ -406,6 +414,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {}, debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
debridLinkApiKeyTotalUsageBytes, debridLinkApiKeyTotalUsageBytes,
megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds),
megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds),
megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
: {},
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
}; };
@ -454,6 +468,7 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
realDebridUseWebLogin: settings.realDebridUseWebLogin, realDebridUseWebLogin: settings.realDebridUseWebLogin,
megaLogin: "", megaLogin: "",
megaPassword: "", megaPassword: "",
megaCredentials: "",
bestToken: "", bestToken: "",
bestDebridUseWebLogin: settings.bestDebridUseWebLogin, bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
allDebridToken: "", allDebridToken: "",

View File

@ -1,5 +1,6 @@
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import type { import type {
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
@ -232,8 +233,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "Mega-Debrid", serviceLabel: "Mega-Debrid",
title: "Mega-Debrid API", title: "Mega-Debrid API",
modeLabel: "API", modeLabel: "API",
pickerDescription: "Login nur über die API, ohne Web-Fallback.", pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.",
needsCredentials: true needsToken: true
}, },
{ {
kind: "megadebrid-web", kind: "megadebrid-web",
@ -241,8 +242,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "Mega-Debrid", serviceLabel: "Mega-Debrid",
title: "Mega-Debrid Web", title: "Mega-Debrid Web",
modeLabel: "Web", modeLabel: "Web",
pickerDescription: "Login nur über Web, ohne API-Fallback.", pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.",
needsCredentials: true needsToken: true
}, },
{ {
kind: "bestdebrid-api", kind: "bestdebrid-api",
@ -404,9 +405,9 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
case "alldebrid-web": case "alldebrid-web":
return "Browser-Login"; return "Browser-Login";
case "megadebrid-api": case "megadebrid-api":
return "Login + Passwort (API)"; return "Login:Passwort (API)";
case "megadebrid-web": case "megadebrid-web":
return "Login + Passwort (Web)"; return "Login:Passwort (Web)";
case "bestdebrid-web": case "bestdebrid-web":
return "Cookies.txt-Import"; return "Cookies.txt-Import";
case "alldebrid-api": case "alldebrid-api":
@ -420,6 +421,7 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
} }
function hasMegaDebridCredentials(settings: AppSettings): boolean { function hasMegaDebridCredentials(settings: AppSettings): boolean {
if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true;
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
} }
@ -527,8 +529,12 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
case "realdebrid-web": case "realdebrid-web":
return "Browser-Login"; return "Browser-Login";
case "megadebrid-api": case "megadebrid-api":
case "megadebrid-web": case "megadebrid-web": {
return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Login + Passwort"; const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword);
if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`;
if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin;
return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt";
}
case "bestdebrid-api": case "bestdebrid-api":
return maskValue(settings.bestToken, 3, 3); return maskValue(settings.bestToken, 3, 3);
case "bestdebrid-web": case "bestdebrid-web":
@ -560,6 +566,12 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string
return keys.map((entry) => `${entry.label}: ${entry.masked}`); return keys.map((entry) => `${entry.label}: ${entry.masked}`);
} }
} }
if (kind === "megadebrid-api" || kind === "megadebrid-web") {
const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword);
if (accounts.length > 1) {
return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`);
}
}
return [summarizeAccount(kind, settings)]; return [summarizeAccount(kind, settings)];
} }
@ -583,8 +595,14 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
case "realdebrid-web": case "realdebrid-web":
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "megadebrid-api": case "megadebrid-api":
case "megadebrid-web": case "megadebrid-web": {
return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword, dailyLimitGb, keyDailyLimitGbById: {} }; // Populate token field with megaCredentials, or build from legacy megaLogin/megaPassword
let megaToken = (settings.megaCredentials || "").trim();
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
}
return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
}
case "bestdebrid-api": case "bestdebrid-api":
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "bestdebrid-web": case "bestdebrid-web":
@ -642,10 +660,18 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial
return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "realdebrid-web": case "realdebrid-web":
return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "megadebrid-api": case "megadebrid-api": {
return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; const megaAccounts = parseMegaDebridAccounts(token);
case "megadebrid-web": const firstLogin = megaAccounts.length > 0 ? megaAccounts[0].login : "";
return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; const firstPassword = megaAccounts.length > 0 ? megaAccounts[0].password : "";
return { ...settings, megaCredentials: token, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
}
case "megadebrid-web": {
const megaAccounts = parseMegaDebridAccounts(token);
const firstLogin = megaAccounts.length > 0 ? megaAccounts[0].login : "";
const firstPassword = megaAccounts.length > 0 ? megaAccounts[0].password : "";
return { ...settings, megaCredentials: token, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
}
case "bestdebrid-api": case "bestdebrid-api":
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "bestdebrid-web": case "bestdebrid-web":
@ -692,11 +718,11 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account
case "megadebrid-api": case "megadebrid-api":
return settings.megaDebridWebEnabled return settings.megaDebridWebEnabled
? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } ? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }
: { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "megadebrid-web": case "megadebrid-web":
return settings.megaDebridApiEnabled return settings.megaDebridApiEnabled
? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } ? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }
: { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "bestdebrid": case "bestdebrid":
return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "alldebrid": case "alldebrid":
@ -729,6 +755,11 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
if (option.needsToken && !dialog.token.trim()) { if (option.needsToken && !dialog.token.trim()) {
return `${option.title}: Bitte Zugangstoken eintragen.`; return `${option.title}: Bitte Zugangstoken eintragen.`;
} }
if ((dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") && dialog.token.trim()) {
if (parseMegaDebridAccounts(dialog.token).length === 0) {
return `${option.title}: Mindestens ein gültiges Login:Passwort-Paar eintragen (Format: login:passwort, pro Zeile).`;
}
}
if (option.needsCredentials) { if (option.needsCredentials) {
if (!dialog.login.trim()) { if (!dialog.login.trim()) {
return `${option.title}: Bitte Login oder E-Mail eintragen.`; return `${option.title}: Bitte Login oder E-Mail eintragen.`;
@ -792,7 +823,7 @@ type StatsSection = {
const emptySnapshot = (): UiSnapshot => ({ const emptySnapshot = (): UiSnapshot => ({
settings: { settings: {
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
debridLinkDisabledKeyIds: [], debridLinkDisabledKeyIds: [],
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
@ -817,6 +848,10 @@ const emptySnapshot = (): UiSnapshot => ({
debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {}, debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {}, debridLinkApiKeyTotalUsageBytes: {},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0 scheduledStartEpochMs: 0
}, },
@ -2082,8 +2117,12 @@ export function App(): ReactElement {
let statusLabel = "Aktiviert"; let statusLabel = "Aktiviert";
let note = ""; let note = "";
if (kind === "megadebrid-api") { if (kind === "megadebrid-api") {
const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length;
statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert";
note = "Nur API aktiv. Kein Web-Fallback."; note = "Nur API aktiv. Kein Web-Fallback.";
} else if (kind === "megadebrid-web") { } else if (kind === "megadebrid-web") {
const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length;
statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert";
note = "Nur Web aktiv. Kein API-Fallback."; note = "Nur Web aktiv. Kein API-Fallback.";
} else if (kind === "realdebrid-web") { } else if (kind === "realdebrid-web") {
note = "Login kann bei Bedarf direkt aus der Liste geöffnet werden."; note = "Login kann bei Bedarf direkt aus der Liste geöffnet werden.";
@ -5006,10 +5045,8 @@ export function App(): ReactElement {
<button className="btn" disabled={actionBusy} onClick={() => { void onOpenRealDebridLogin(); }}>Real-Debrid Web-Login öffnen</button> <button className="btn" disabled={actionBusy} onClick={() => { void onOpenRealDebridLogin(); }}>Real-Debrid Web-Login öffnen</button>
</> </>
)} )}
<label>Mega-Debrid Login</label> <label>Mega-Debrid Accounts (Login:Passwort pro Zeile)</label>
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} /> <textarea rows={3} value={settingsDraft.megaCredentials || ""} onChange={(e) => setText("megaCredentials", e.target.value)} style={{ fontFamily: "monospace", resize: "vertical" }} placeholder={"user@example.com:passwort"} />
<label>Mega-Debrid Passwort</label>
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.megaDebridPreferApi} onChange={(e) => setBool("megaDebridPreferApi", e.target.checked)} /> Mega-Debrid bevorzugt über API (schneller, Fallback auf Web)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.megaDebridPreferApi} onChange={(e) => setBool("megaDebridPreferApi", e.target.checked)} /> Mega-Debrid bevorzugt über API (schneller, Fallback auf Web)</label>
<label>BestDebrid API Token</label> <label>BestDebrid API Token</label>
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} /> <input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
@ -5375,7 +5412,7 @@ export function App(): ReactElement {
<div className="account-modal-fields"> <div className="account-modal-fields">
{accountDialogOption.needsToken && ( {accountDialogOption.needsToken && (
<div> <div>
<label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label> <label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web" ? "Login:Passwort (pro Zeile)" : "Token"}</label>
{accountDialogOption.service === "debridlink" ? ( {accountDialogOption.service === "debridlink" ? (
<textarea <textarea
rows={4} rows={4}
@ -5388,6 +5425,14 @@ export function App(): ReactElement {
} : prev)} } : prev)}
style={{ fontFamily: "monospace", resize: "vertical" }} style={{ fontFamily: "monospace", resize: "vertical" }}
/> />
) : accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web" ? (
<textarea
rows={4}
placeholder={"user1@example.com:passwort1\nuser2@example.com:passwort2"}
value={accountDialog.token}
onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)}
style={{ fontFamily: "monospace", resize: "vertical" }}
/>
) : ( ) : (
<input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} /> <input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
)} )}
@ -5457,10 +5502,10 @@ export function App(): ReactElement {
<div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber läuft.</div> <div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber läuft.</div>
)} )}
{accountDialog.kind === "megadebrid-api" && ( {accountDialog.kind === "megadebrid-api" && (
<div className="account-modal-note">Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div> <div className="account-modal-note">Ein Login:Passwort-Paar pro Zeile. Mehrere Accounts werden rotierend genutzt. Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div>
)} )}
{accountDialog.kind === "megadebrid-web" && ( {accountDialog.kind === "megadebrid-web" && (
<div className="account-modal-note">Dieser Account nutzt nur Mega-Debrid Web. Kein API-Fallback.</div> <div className="account-modal-note">Ein Login:Passwort-Paar pro Zeile. Mehrere Accounts werden rotierend genutzt. Dieser Account nutzt nur Mega-Debrid Web. Kein API-Fallback.</div>
)} )}
{accountDialogOption.service === "alldebrid" && allDebridHostInfo && ( {accountDialogOption.service === "alldebrid" && allDebridHostInfo && (

View File

@ -0,0 +1,96 @@
export interface MegaDebridAccountEntry {
id: string;
login: string;
password: string;
index: number;
label: string;
maskedLogin: string;
}
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
const FNV64_PRIME = 0x100000001b3n;
const FNV64_MASK = 0xffffffffffffffffn;
function fnv1a64(text: string): string {
let hash = FNV64_OFFSET_BASIS;
for (const char of text) {
hash ^= BigInt(char.codePointAt(0) || 0);
hash = (hash * FNV64_PRIME) & FNV64_MASK;
}
return hash.toString(36);
}
export function getMegaDebridAccountId(login: string): string {
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
}
export function maskMegaDebridLogin(login: string): string {
const trimmed = login.trim();
if (!trimmed) {
return "Nicht hinterlegt";
}
if (trimmed.length <= 4) {
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
}
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
}
export function getMegaDebridAccountLabel(index: number): string {
return `Account ${index + 1}`;
}
/**
* Parse newline-separated "login:password" pairs.
* Falls back to treating the entire string as a single login if no colon
* is found (backward compat with old megaLogin field).
*/
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
const seen = new Set<string>();
const lines = String(raw || "")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
const entries: MegaDebridAccountEntry[] = [];
for (const line of lines) {
const colonIdx = line.indexOf(":");
let login: string;
let password: string;
if (colonIdx >= 0) {
login = line.slice(0, colonIdx).trim();
password = line.slice(colonIdx + 1).trim();
} else {
// Legacy format: just a login, use the provided fallback password
login = line;
password = legacyPassword;
}
if (!login || !password) {
continue;
}
const key = login.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
entries.push({
id: getMegaDebridAccountId(login),
login,
password,
index: entries.length,
label: getMegaDebridAccountLabel(entries.length),
maskedLogin: maskMegaDebridLogin(login)
});
}
return entries;
}
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
return accounts
.filter((a) => a.login.trim() && a.password.trim())
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
.join("\n");
}
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
}

View File

@ -5,11 +5,13 @@ export type DebridLinkKeyByteMap = Record<string, number>;
type ProviderDailySettings = type ProviderDailySettings =
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay"> Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>; & Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
type ProviderUsageSettings = type ProviderUsageSettings =
ProviderDailySettings ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>; & Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
function normalizePositiveBytes(value: unknown): number { function normalizePositiveBytes(value: unknown): number {
const numeric = Number(value); const numeric = Number(value);
@ -247,3 +249,83 @@ export function addDebridLinkApiKeyTotalUsageBytes(
debridLinkApiKeyTotalUsageBytes: currentUsageBytes debridLinkApiKeyTotalUsageBytes: currentUsageBytes
}; };
} }
// ── Mega-Debrid per-account limits ──
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
}
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
}
export function getMegaDebridAccountDailyUsageBytes(
settings: ProviderDailySettings,
accountId: string,
epochMs = Date.now()
): number {
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
return 0;
}
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
}
export function isMegaDebridAccountDailyLimitReached(
settings: ProviderDailySettings,
accountId: string,
epochMs = Date.now()
): boolean {
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
}
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
}
export function addMegaDebridAccountDailyUsageBytes(
settings: ProviderDailySettings,
accountId: string,
byteDelta: number,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const dayKey = getProviderUsageDayKey(epochMs);
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
: {};
if (increment <= 0) {
return {
providerDailyUsageDay: dayKey,
megaDebridAccountDailyUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
providerDailyUsageDay: dayKey,
megaDebridAccountDailyUsageBytes: currentUsageBytes
};
}
export function addMegaDebridAccountTotalUsageBytes(
settings: ProviderUsageSettings,
accountId: string,
byteDelta: number
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
if (increment <= 0) {
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}

View File

@ -58,6 +58,7 @@ export interface AppSettings {
realDebridUseWebLogin: boolean; realDebridUseWebLogin: boolean;
megaLogin: string; megaLogin: string;
megaPassword: string; megaPassword: string;
megaCredentials: string;
megaDebridApiEnabled: boolean; megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean; megaDebridWebEnabled: boolean;
megaDebridPreferApi: boolean; megaDebridPreferApi: boolean;
@ -130,6 +131,10 @@ export interface AppSettings {
debridLinkApiKeyDailyLimitBytes: Record<string, number>; debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>; debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>; debridLinkApiKeyTotalUsageBytes: Record<string, number>;
megaDebridDisabledAccountIds: string[];
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>;
providerDailyUsageDay: string; providerDailyUsageDay: string;
scheduledStartEpochMs: number; scheduledStartEpochMs: number;
} }

View File

@ -2,13 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid"; import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
afterEach(() => { afterEach(() => {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
resetDebridLinkRuntimeStateForTests(); resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -19,6 +20,7 @@ describe("debrid service", () => {
token: "rd-token", token: "rd-token",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
bestToken: "", bestToken: "",
providerOrder: [] as const, providerOrder: [] as const,
providerPrimary: "realdebrid" as const, providerPrimary: "realdebrid" as const,
@ -58,6 +60,7 @@ describe("debrid service", () => {
token: "rd-token", token: "rd-token",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "realdebrid" as const, providerPrimary: "realdebrid" as const,
providerSecondary: "megadebrid" as const, providerSecondary: "megadebrid" as const,
providerTertiary: "bestdebrid" as const, providerTertiary: "bestdebrid" as const,
@ -966,6 +969,7 @@ describe("debrid service", () => {
allDebridToken: "", allDebridToken: "",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
providerOrder: [] as const, providerOrder: [] as const,
providerPrimary: "megadebrid" as const, providerPrimary: "megadebrid" as const,
providerSecondary: "megadebrid" as const, providerSecondary: "megadebrid" as const,
@ -999,6 +1003,7 @@ describe("debrid service", () => {
allDebridToken: "", allDebridToken: "",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true, megaDebridApiEnabled: true,
megaDebridWebEnabled: true, megaDebridWebEnabled: true,
providerPrimary: "megadebrid-api" as const, providerPrimary: "megadebrid-api" as const,
@ -1029,6 +1034,7 @@ describe("debrid service", () => {
allDebridToken: "", allDebridToken: "",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true, megaDebridApiEnabled: true,
megaDebridWebEnabled: true, megaDebridWebEnabled: true,
providerOrder: [] as const, providerOrder: [] as const,
@ -1062,6 +1068,7 @@ describe("debrid service", () => {
allDebridToken: "", allDebridToken: "",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
providerOrder: [] as const, providerOrder: [] as const,
providerPrimary: "megadebrid" as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerSecondary: "none" as const,
@ -1104,6 +1111,7 @@ describe("debrid service", () => {
allDebridToken: "ad-token", allDebridToken: "ad-token",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "megadebrid" as const, providerPrimary: "megadebrid" as const,
providerSecondary: "megadebrid" as const, providerSecondary: "megadebrid" as const,
providerTertiary: "megadebrid" as const, providerTertiary: "megadebrid" as const,
@ -1135,6 +1143,7 @@ describe("debrid service", () => {
token: "", token: "",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "realdebrid" as const, providerPrimary: "realdebrid" as const,
providerSecondary: "megadebrid" as const, providerSecondary: "megadebrid" as const,
providerTertiary: "none" as const, providerTertiary: "none" as const,
@ -1159,6 +1168,7 @@ describe("debrid service", () => {
token: "rd-token", token: "rd-token",
megaLogin: "user", megaLogin: "user",
megaPassword: "pass", megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "realdebrid" as const, providerPrimary: "realdebrid" as const,
providerSecondary: "none" as const, providerSecondary: "none" as const,
providerTertiary: "none" as const, providerTertiary: "none" as const,

View File

@ -6167,6 +6167,7 @@ describe("download manager", () => {
...defaultSettings(), ...defaultSettings(),
megaLogin: "mega-user", megaLogin: "mega-user",
megaPassword: "mega-pass", megaPassword: "mega-pass",
megaCredentials: "mega-user:mega-pass",
megaDebridWebEnabled: true, megaDebridWebEnabled: true,
megaDebridApiEnabled: false, megaDebridApiEnabled: false,
megaDebridPreferApi: false, megaDebridPreferApi: false,
@ -9391,6 +9392,7 @@ describe("download manager", () => {
...defaultSettings(), ...defaultSettings(),
megaLogin: "mega-user", megaLogin: "mega-user",
megaPassword: "mega-pass", megaPassword: "mega-pass",
megaCredentials: "mega-user:mega-pass",
megaDebridApiEnabled: true, megaDebridApiEnabled: true,
providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 512 }, providerDailyUsageBytes: { realdebrid: 512 },