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
co-authored by Claude Opus 4.6
parent 672c74f98f
commit 6df0834b67
10 changed files with 3614 additions and 3001 deletions
+32 -27
View File
@@ -21,8 +21,8 @@ export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDown
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow
export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
@@ -46,6 +46,7 @@ export function defaultSettings(): AppSettings {
realDebridUseWebLogin: false,
megaLogin: "",
megaPassword: "",
megaCredentials: "",
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridPreferApi: true,
@@ -93,32 +94,36 @@ export function defaultSettings(): AppSettings {
speedLimitMode: "global",
updateRepo: DEFAULT_UPDATE_REPO,
autoUpdateCheck: true,
clipboardWatch: false,
minimizeToTray: false,
theme: "dark" as const,
collapseNewPackages: true,
historyRetentionMode: "permanent",
accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true,
autoSkipExtracted: false,
hideExtractedItems: true,
confirmDeleteSelection: true,
totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
bandwidthSchedules: [],
clipboardWatch: false,
minimizeToTray: false,
theme: "dark" as const,
collapseNewPackages: true,
historyRetentionMode: "permanent",
accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true,
autoSkipExtracted: false,
hideExtractedItems: true,
confirmDeleteSelection: true,
totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
bandwidthSchedules: [],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "high",
autoExtractWhenStopped: true,
disabledProviders: [],
hosterRouting: {},
providerDailyLimitBytes: {},
providerDailyUsageBytes: {},
providerTotalUsageBytes: {},
debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
};
}
hosterRouting: {},
providerDailyLimitBytes: {},
providerDailyUsageBytes: {},
providerTotalUsageBytes: {},
debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
};
}
+2502 -2240
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
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 { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { defaultSettings } from "./constants";
@@ -281,6 +282,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
const currentUsageDay = getProviderUsageDayKey();
const megaLogin = asText(settings.megaLogin);
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 hasMegaCreds = Boolean(megaLogin && megaPassword);
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
@@ -322,6 +329,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
megaLogin,
megaPassword,
megaCredentials,
megaDebridApiEnabled,
megaDebridWebEnabled,
megaDebridPreferApi,
@@ -406,6 +414,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
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,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
};
@@ -454,6 +468,7 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
realDebridUseWebLogin: settings.realDebridUseWebLogin,
megaLogin: "",
megaPassword: "",
megaCredentials: "",
bestToken: "",
bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
allDebridToken: "",
+69 -24
View File
@@ -1,5 +1,6 @@
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 { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import type {
AllDebridHostInfo,
AppSettings,
@@ -232,8 +233,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "Mega-Debrid",
title: "Mega-Debrid API",
modeLabel: "API",
pickerDescription: "Login nur über die API, ohne Web-Fallback.",
needsCredentials: true
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.",
needsToken: true
},
{
kind: "megadebrid-web",
@@ -241,8 +242,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "Mega-Debrid",
title: "Mega-Debrid Web",
modeLabel: "Web",
pickerDescription: "Login nur über Web, ohne API-Fallback.",
needsCredentials: true
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.",
needsToken: true
},
{
kind: "bestdebrid-api",
@@ -404,9 +405,9 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
case "alldebrid-web":
return "Browser-Login";
case "megadebrid-api":
return "Login + Passwort (API)";
return "Login:Passwort (API)";
case "megadebrid-web":
return "Login + Passwort (Web)";
return "Login:Passwort (Web)";
case "bestdebrid-web":
return "Cookies.txt-Import";
case "alldebrid-api":
@@ -420,6 +421,7 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
}
function hasMegaDebridCredentials(settings: AppSettings): boolean {
if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true;
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
}
@@ -527,8 +529,12 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
case "realdebrid-web":
return "Browser-Login";
case "megadebrid-api":
case "megadebrid-web":
return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Login + Passwort";
case "megadebrid-web": {
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":
return maskValue(settings.bestToken, 3, 3);
case "bestdebrid-web":
@@ -560,6 +566,12 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string
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)];
}
@@ -583,8 +595,14 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
case "realdebrid-web":
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "megadebrid-api":
case "megadebrid-web":
return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword, dailyLimitGb, keyDailyLimitGbById: {} };
case "megadebrid-web": {
// 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":
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
case "bestdebrid-web":
@@ -642,10 +660,18 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial
return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "realdebrid-web":
return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "megadebrid-api":
return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "megadebrid-web":
return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "megadebrid-api": {
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, 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":
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
case "bestdebrid-web":
@@ -692,11 +718,11 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account
case "megadebrid-api":
return settings.megaDebridWebEnabled
? { ...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":
return settings.megaDebridApiEnabled
? { ...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":
return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
case "alldebrid":
@@ -729,6 +755,11 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
if (option.needsToken && !dialog.token.trim()) {
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 (!dialog.login.trim()) {
return `${option.title}: Bitte Login oder E-Mail eintragen.`;
@@ -792,7 +823,7 @@ type StatsSection = {
const emptySnapshot = (): UiSnapshot => ({
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: [],
archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
@@ -817,6 +848,10 @@ const emptySnapshot = (): UiSnapshot => ({
debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
},
@@ -2082,8 +2117,12 @@ export function App(): ReactElement {
let statusLabel = "Aktiviert";
let note = "";
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.";
} 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.";
} else if (kind === "realdebrid-web") {
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>
</>
)}
<label>Mega-Debrid Login</label>
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
<label>Mega-Debrid Passwort</label>
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
<label>Mega-Debrid Accounts (Login:Passwort pro Zeile)</label>
<textarea rows={3} value={settingsDraft.megaCredentials || ""} onChange={(e) => setText("megaCredentials", e.target.value)} style={{ fontFamily: "monospace", resize: "vertical" }} placeholder={"user@example.com:passwort"} />
<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>
<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">
{accountDialogOption.needsToken && (
<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" ? (
<textarea
rows={4}
@@ -5388,6 +5425,14 @@ export function App(): ReactElement {
} : prev)}
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)} />
)}
@@ -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>
)}
{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" && (
<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 && (
+96
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);
}
+84 -2
View File
@@ -5,11 +5,13 @@ export type DebridLinkKeyByteMap = Record<string, number>;
type ProviderDailySettings =
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>;
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
type ProviderUsageSettings =
ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>;
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
function normalizePositiveBytes(value: unknown): number {
const numeric = Number(value);
@@ -247,3 +249,83 @@ export function addDebridLinkApiKeyTotalUsageBytes(
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
};
}
+151 -146
View File
@@ -27,9 +27,9 @@ export type DebridProvider =
| "linksnappy";
export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent";
export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent";
export interface BandwidthScheduleEntry {
id: string;
@@ -39,25 +39,26 @@ export interface BandwidthScheduleEntry {
enabled: boolean;
}
export interface DownloadStats {
totalDownloaded: number;
totalDownloadedAllTime: number;
totalFiles?: number;
totalFilesSession: number;
totalFilesAllTime: number;
totalPackages: number;
sessionStartedAt: number;
appSessionStartedAt: number;
sessionRuntimeMs: number;
totalRuntimeMs: number;
runtimeMeasuredAt: number;
}
export interface DownloadStats {
totalDownloaded: number;
totalDownloadedAllTime: number;
totalFiles?: number;
totalFilesSession: number;
totalFilesAllTime: number;
totalPackages: number;
sessionStartedAt: number;
appSessionStartedAt: number;
sessionRuntimeMs: number;
totalRuntimeMs: number;
runtimeMeasuredAt: number;
}
export interface AppSettings {
token: string;
realDebridUseWebLogin: boolean;
megaLogin: string;
megaPassword: string;
megaCredentials: string;
megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean;
megaDebridPreferApi: boolean;
@@ -74,7 +75,7 @@ export interface AppSettings {
linkSnappyPassword: string;
archivePasswordList: string;
rememberToken: boolean;
providerOrder: readonly DebridProvider[];
providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider;
providerTertiary: DebridFallbackProvider;
@@ -107,32 +108,36 @@ export interface AppSettings {
autoUpdateCheck: boolean;
clipboardWatch: boolean;
minimizeToTray: boolean;
theme: AppTheme;
collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean;
theme: AppTheme;
collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean;
hideExtractedItems: boolean;
confirmDeleteSelection: boolean;
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
extractCpuPriority: ExtractCpuPriority;
hideExtractedItems: boolean;
confirmDeleteSelection: boolean;
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
extractCpuPriority: ExtractCpuPriority;
autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
}
hosterRouting: Record<string, DebridProvider>;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
megaDebridDisabledAccountIds: string[];
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
}
export interface DownloadItem {
id: string;
@@ -167,14 +172,14 @@ export interface PackageEntry {
status: DownloadStatus;
itemIds: string[];
cancelled: boolean;
enabled: boolean;
priority?: PackagePriority;
postProcessLabel?: string;
downloadStartedAt?: number;
downloadCompletedAt?: number;
createdAt: number;
updatedAt: number;
}
enabled: boolean;
priority?: PackagePriority;
postProcessLabel?: string;
downloadStartedAt?: number;
downloadCompletedAt?: number;
createdAt: number;
updatedAt: number;
}
export interface SessionState {
version: number;
@@ -338,104 +343,104 @@ export interface BandwidthStats {
sessionDurationSeconds: number;
}
export interface SessionStats {
bandwidth: BandwidthStats;
totalDownloads: number;
completedDownloads: number;
failedDownloads: number;
activeDownloads: number;
queuedDownloads: number;
}
export interface SupportTraceConfig {
enabled: boolean;
includeMainLog: boolean;
includeAudit: boolean;
logDebugRequests: boolean;
autoDisableAt: string | null;
updatedAt: string;
}
export interface SupportFileSizeInfo {
path: string | null;
exists: boolean;
bytes: number;
}
export interface SupportDirectorySizeInfo {
path: string;
exists: boolean;
fileCount: number;
bytes: number;
}
export interface SupportDiskSpaceInfo {
path: string;
totalBytes: number | null;
freeBytes: number | null;
freePercent: number | null;
}
export interface SupportBundleEstimate {
estimatedBytes: number;
estimatedEntries: number;
duplicatedLiveLogBytes: number;
note: string;
}
export interface DebugSetupCheckResult {
status: "ok" | "warn";
enabled: boolean;
runtimeBaseDir: string;
host: string;
port: number;
localOnly: boolean;
tokenConfigured: boolean;
tokenPath: string;
aiManifestPath: string;
aiManifestPresent: boolean;
traceConfigPath: string | null;
traceLogPath: string | null;
traceEnabled: boolean;
traceAutoDisableAt: string | null;
diskSpace: {
runtime: SupportDiskSpaceInfo;
output: SupportDiskSpaceInfo;
extract: SupportDiskSpaceInfo;
};
logSummary: {
totalBytes: number;
main: SupportFileSizeInfo;
mainBackup: SupportFileSizeInfo;
audit: SupportFileSizeInfo;
auditBackup: SupportFileSizeInfo;
rename: SupportFileSizeInfo;
renameBackup: SupportFileSizeInfo;
session: SupportFileSizeInfo;
trace: SupportFileSizeInfo;
traceBackup: SupportFileSizeInfo;
sessionLogs: SupportDirectorySizeInfo;
packageLogs: SupportDirectorySizeInfo;
itemLogs: SupportDirectorySizeInfo;
};
supportBundle: SupportBundleEstimate;
warnings: string[];
notes: string[];
localUrls: {
health: string;
meta: string;
diagnostics: string;
};
remoteUrlTemplates: {
health: string;
meta: string;
diagnostics: string;
};
}
export interface HistoryEntry {
id: string;
name: string;
export interface SessionStats {
bandwidth: BandwidthStats;
totalDownloads: number;
completedDownloads: number;
failedDownloads: number;
activeDownloads: number;
queuedDownloads: number;
}
export interface SupportTraceConfig {
enabled: boolean;
includeMainLog: boolean;
includeAudit: boolean;
logDebugRequests: boolean;
autoDisableAt: string | null;
updatedAt: string;
}
export interface SupportFileSizeInfo {
path: string | null;
exists: boolean;
bytes: number;
}
export interface SupportDirectorySizeInfo {
path: string;
exists: boolean;
fileCount: number;
bytes: number;
}
export interface SupportDiskSpaceInfo {
path: string;
totalBytes: number | null;
freeBytes: number | null;
freePercent: number | null;
}
export interface SupportBundleEstimate {
estimatedBytes: number;
estimatedEntries: number;
duplicatedLiveLogBytes: number;
note: string;
}
export interface DebugSetupCheckResult {
status: "ok" | "warn";
enabled: boolean;
runtimeBaseDir: string;
host: string;
port: number;
localOnly: boolean;
tokenConfigured: boolean;
tokenPath: string;
aiManifestPath: string;
aiManifestPresent: boolean;
traceConfigPath: string | null;
traceLogPath: string | null;
traceEnabled: boolean;
traceAutoDisableAt: string | null;
diskSpace: {
runtime: SupportDiskSpaceInfo;
output: SupportDiskSpaceInfo;
extract: SupportDiskSpaceInfo;
};
logSummary: {
totalBytes: number;
main: SupportFileSizeInfo;
mainBackup: SupportFileSizeInfo;
audit: SupportFileSizeInfo;
auditBackup: SupportFileSizeInfo;
rename: SupportFileSizeInfo;
renameBackup: SupportFileSizeInfo;
session: SupportFileSizeInfo;
trace: SupportFileSizeInfo;
traceBackup: SupportFileSizeInfo;
sessionLogs: SupportDirectorySizeInfo;
packageLogs: SupportDirectorySizeInfo;
itemLogs: SupportDirectorySizeInfo;
};
supportBundle: SupportBundleEstimate;
warnings: string[];
notes: string[];
localUrls: {
health: string;
meta: string;
diagnostics: string;
};
remoteUrlTemplates: {
health: string;
meta: string;
diagnostics: string;
};
}
export interface HistoryEntry {
id: string;
name: string;
totalBytes: number;
downloadedBytes: number;
fileCount: number;