release: prepare v2.0.43

Add sanitized provider and account runtime diagnostics, complete the Multi-Debrid-Downloader product rename, preserve existing application data during migration, update public release verification, and publish the accompanying English documentation and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-16 21:50:01 +02:00
parent 7107a92ae2
commit a8a5ca4b40
36 changed files with 1117 additions and 146 deletions
+16
View File
@@ -4,6 +4,22 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
## [Unreleased] ## [Unreleased]
## [2.0.43] - 2026-08-16
### Product identity
- Renamed the packaged application, installer, portable executable, support bundles, update repository, and public download references to Multi-Debrid-Downloader.
- Migrated the legacy application data directory automatically so existing settings, accounts, queue state, history, statistics, and runtime logs remain available after the rename.
- Preserved the existing Windows application identity to support in-place updates while retaining the legacy data directory as a safe fallback when Windows temporarily blocks the migration.
- Updated diagnostics, exported link headers, host checks, user agents, release verification, and public documentation to use the current product name.
### Account runtime
- Added a Runtime tab to account management with provider summaries for available accounts, active downloads, and daily traffic.
- Added per-account runtime details for current state, active downloads, daily traffic, session success rate, last use, and cooldown or skip reason.
- Kept API and browser accounts with matching identifiers isolated by provider and access mode throughout runtime attribution.
- Removed runtime counters for deleted accounts and limited renderer data to sanitized states and aggregate counters without credentials, API responses, or raw provider errors.
## [2.0.42] - 2026-08-15 ## [2.0.42] - 2026-08-15
### Real-Debrid accounts ### Real-Debrid accounts
+6 -6
View File
@@ -2,8 +2,8 @@
Fast package-based downloading for Windows with multiple debrid services, automatic fallback, link availability checks, extraction, queue recovery, history, statistics, and built-in updates. Fast package-based downloading for Windows with multiple debrid services, automatic fallback, link availability checks, extraction, queue recovery, history, statistics, and built-in updates.
[![Latest release](https://img.shields.io/github/v/release/Sucukdeluxe/multi-debrid-downloader?display_name=tag)](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest) [![Latest release](https://img.shields.io/github/v/release/Sucukdeluxe/Multi-Debrid-Downloader?display_name=tag)](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest)
[![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D6)](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest) [![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D6)](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest)
[![Electron](https://img.shields.io/badge/Electron-31-47848F)](https://www.electronjs.org/) [![Electron](https://img.shields.io/badge/Electron-31-47848F)](https://www.electronjs.org/)
[![License](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE)
@@ -17,12 +17,12 @@ The interface is available in English and German. New installations start in Eng
## Download ## Download
The latest Windows release is available on the [GitHub Releases page](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest). The latest Windows release is available on the [GitHub Releases page](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest).
| Package | Recommended for | | Package | Recommended for |
| --- | --- | | --- | --- |
| `Real-Debrid-Downloader-Setup-<version>.exe` | Normal installation with Start menu and optional desktop shortcut | | `Multi-Debrid-Downloader-Setup-<version>.exe` | Normal installation with Start menu and optional desktop shortcut |
| `Real-Debrid-Downloader-<version>-portable.exe` | Portable use without installation | | `Multi-Debrid-Downloader-<version>-portable.exe` | Portable use without installation |
| `SHA256SUMS.txt` | Verifying downloaded files | | `SHA256SUMS.txt` | Verifying downloaded files |
Windows 10 or Windows 11 is required. Release executables are currently unsigned, so Windows may show a SmartScreen warning on first launch. Windows 10 or Windows 11 is required. Release executables are currently unsigned, so Windows may show a SmartScreen warning on first launch.
@@ -124,7 +124,7 @@ The Downloads workspace is optimized for large queues:
## Getting started ## Getting started
1. Download the installer or portable executable from [Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest). 1. Download the installer or portable executable from [Releases](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest).
2. Open **Settings → Accounts** and add at least one supported debrid account. 2. Open **Settings → Accounts** and add at least one supported debrid account.
3. Configure provider order and optional hoster routing under **Usage rules**. 3. Configure provider order and optional hoster routing under **Usage rules**.
4. Add links from **Downloads** or prepare collections in **Link collector**. 4. Add links from **Downloads** or prepare collections in **Link collector**.
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.42", "version": "2.0.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.42", "version": "2.0.43",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.42", "version": "2.0.43",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@@ -51,7 +51,7 @@
}, },
"build": { "build": {
"appId": "com.sucukdeluxe.realdebrid", "appId": "com.sucukdeluxe.realdebrid",
"productName": "Real-Debrid-Downloader", "productName": "Multi-Debrid-Downloader",
"directories": { "directories": {
"buildResources": "assets", "buildResources": "assets",
"output": "release" "output": "release"
@@ -59,7 +59,7 @@
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "Sucukdeluxe", "owner": "Sucukdeluxe",
"repo": "multi-debrid-downloader" "repo": "Multi-Debrid-Downloader"
}, },
"files": [ "files": [
"build/main/**/*", "build/main/**/*",
+2 -2
View File
@@ -9,9 +9,9 @@ import { parse } from "yaml";
const EXPECTED_PUBLISH = Object.freeze({ const EXPECTED_PUBLISH = Object.freeze({
provider: "github", provider: "github",
owner: "Sucukdeluxe", owner: "Sucukdeluxe",
repo: "multi-debrid-downloader" repo: "Multi-Debrid-Downloader"
}); });
const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader"; const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader";
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
const REQUIRED_BUILD_FILES = Object.freeze([ const REQUIRED_BUILD_FILES = Object.freeze([
+122
View File
@@ -0,0 +1,122 @@
import type {
AccountRuntimeEntry,
DebridProvider,
DownloadItem,
RendererAccount
} from "../shared/types";
import { getAccountRuntimeSessionStats } from "./account-runtime";
import { getProviderRuntimeSnapshot, type ProviderRuntimeCooldown } from "./debrid";
const ACTIVE_DOWNLOAD_STATUSES = new Set(["validating", "downloading", "paused", "reconnect_wait"]);
function providerFamily(provider: DebridProvider | null): string {
if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") return "megadebrid";
return provider || "";
}
function accountRuntimeKey(provider: DebridProvider, accountId: string): string {
return `${provider}:${accountId}`;
}
function sanitizedCooldownReason(category: string): string {
if (category === "invalid") return "Anmeldung ungültig";
if (category === "rate_limit") return "Rate-Limit aktiv";
if (category === "quota") return "Traffic- oder Kontolimit erreicht";
if (category === "provider_or_link") return "Provider oder Link vorübergehend nicht verfügbar";
return "Vorübergehender Cooldown";
}
function accountCooldown(
account: RendererAccount,
runtime: ReturnType<typeof getProviderRuntimeSnapshot>
): { cooldown: ProviderRuntimeCooldown | null; inFlight: number } {
if (account.provider === "realdebrid") {
const entry = runtime.realDebrid.accounts.find((candidate) => candidate.accountId === account.accountId);
return { cooldown: entry?.cooldown ?? null, inFlight: entry?.inFlight ?? 0 };
}
if (account.provider === "megadebrid-api" || account.provider === "megadebrid-web") {
const mode = account.provider === "megadebrid-api" ? "api" : "web";
const entry = runtime.megaDebrid.accounts.find((candidate) => candidate.key === `${account.accountId}:${mode}`);
return { cooldown: entry?.cooldown ?? null, inFlight: entry?.inFlight ?? 0 };
}
if (account.provider === "debridlink") {
const entry = runtime.debridLink.keys.find((candidate) => candidate.keyId === account.accountId);
return { cooldown: entry?.cooldown ?? null, inFlight: 0 };
}
return { cooldown: null, inFlight: 0 };
}
function attributedAccount(item: DownloadItem, accounts: readonly RendererAccount[]): RendererAccount | null {
if (item.providerAccountId) {
const exact = accounts.filter((account) => account.accountId === item.providerAccountId && account.provider === item.provider);
if (exact.length === 1) return exact[0];
const sameFamily = accounts.filter((account) => account.accountId === item.providerAccountId && providerFamily(account.provider) === providerFamily(item.provider));
if (sameFamily.length === 1) return sameFamily[0];
}
const family = providerFamily(item.provider);
const candidates = accounts.filter((account) => providerFamily(account.provider) === family);
return candidates.length === 1 ? candidates[0] : null;
}
export function createAccountRuntimeEntries(
accounts: readonly RendererAccount[],
items: readonly DownloadItem[],
now = Date.now()
): AccountRuntimeEntry[] {
const providerRuntime = getProviderRuntimeSnapshot(now);
const activeByAccount = new Map<string, { count: number; lastUsedAt: number }>();
for (const item of items) {
if (!ACTIVE_DOWNLOAD_STATUSES.has(item.status)) continue;
const account = attributedAccount(item, accounts);
if (!account) continue;
const key = accountRuntimeKey(account.provider, account.accountId);
const current = activeByAccount.get(key) ?? { count: 0, lastUsedAt: 0 };
activeByAccount.set(key, {
count: current.count + 1,
lastUsedAt: Math.max(current.lastUsedAt, item.updatedAt || item.createdAt || now)
});
}
return accounts.map((account) => {
const stats = getAccountRuntimeSessionStats(account.provider, account.accountId);
const active = activeByAccount.get(accountRuntimeKey(account.provider, account.accountId)) ?? { count: 0, lastUsedAt: 0 };
const { cooldown, inFlight } = accountCooldown(account, providerRuntime);
const dailyLimitReached = account.dailyLimitBytes > 0 && account.dailyUsageBytes >= account.dailyLimitBytes;
const invalid = account.status?.valid === false;
let state: AccountRuntimeEntry["state"] = "ready";
let reason = "Bereit";
if (!account.enabled) {
state = "disabled";
reason = "Account deaktiviert";
} else if (dailyLimitReached) {
state = "daily_limit";
reason = "Tageslimit erreicht";
} else if (cooldown && cooldown.remainingMs > 0) {
state = "cooldown";
reason = sanitizedCooldownReason(cooldown.category);
} else if (active.count > 0) {
state = "active";
reason = active.count === 1 ? "1 aktiver Download" : `${active.count} aktive Downloads`;
} else if (inFlight > 0) {
state = "checking";
reason = "Link wird geprüft";
} else if (invalid) {
state = "invalid";
reason = "Accountprüfung fehlgeschlagen";
}
return {
accountId: account.accountId,
provider: account.provider,
state,
reason,
activeDownloads: active.count,
inFlight,
attempts: stats.attempts,
successes: stats.successes,
failures: stats.failures,
lastUsedAt: Math.max(stats.lastUsedAt ?? 0, active.lastUsedAt) || null,
cooldownUntil: cooldown && cooldown.remainingMs > 0 ? cooldown.untilMs : null,
dailyUsageBytes: account.dailyUsageBytes
};
});
}
+67
View File
@@ -0,0 +1,67 @@
import type { DebridProvider } from "../shared/types";
export interface AccountRuntimeSessionStats {
attempts: number;
successes: number;
failures: number;
lastUsedAt: number | null;
}
const accountRuntimeSession = new Map<string, AccountRuntimeSessionStats>();
function runtimeKey(provider: DebridProvider, accountId: string): string {
return `${provider}:${accountId}`;
}
function updateAccountRuntimeSession(
provider: DebridProvider,
accountId: string,
update: (current: AccountRuntimeSessionStats) => AccountRuntimeSessionStats
): void {
if (!accountId) return;
const key = runtimeKey(provider, accountId);
const current = accountRuntimeSession.get(key) ?? { attempts: 0, successes: 0, failures: 0, lastUsedAt: null };
accountRuntimeSession.set(key, update(current));
}
export function recordAccountRuntimeAttempt(provider: DebridProvider, accountId: string, at = Date.now()): void {
updateAccountRuntimeSession(provider, accountId, (current) => ({
...current,
attempts: current.attempts + 1,
lastUsedAt: at
}));
}
export function recordAccountRuntimeSuccess(provider: DebridProvider, accountId: string, at = Date.now()): void {
updateAccountRuntimeSession(provider, accountId, (current) => ({
...current,
successes: current.successes + 1,
lastUsedAt: at
}));
}
export function recordAccountRuntimeFailure(provider: DebridProvider, accountId: string, at = Date.now()): void {
updateAccountRuntimeSession(provider, accountId, (current) => ({
...current,
failures: current.failures + 1,
lastUsedAt: at
}));
}
export function getAccountRuntimeSessionStats(provider: DebridProvider, accountId: string): AccountRuntimeSessionStats {
const current = accountRuntimeSession.get(runtimeKey(provider, accountId));
return current ? { ...current } : { attempts: 0, successes: 0, failures: 0, lastUsedAt: null };
}
export function pruneAccountRuntimeSession(validKeys: ReadonlySet<string>): void {
for (const key of accountRuntimeSession.keys()) {
if (!validKeys.has(key)) accountRuntimeSession.delete(key);
}
}
export function resetAccountRuntimeSessionForProvider(provider: DebridProvider): void {
const prefix = `${provider}:`;
for (const key of accountRuntimeSession.keys()) {
if (key.startsWith(prefix)) accountRuntimeSession.delete(key);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
export const SPEED_WINDOW_SECONDS = 1; export const SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000; export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader"; export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/Multi-Debrid-Downloader";
export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api"; export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api";
export function defaultSettings(): AppSettings { export function defaultSettings(): AppSettings {
+53 -5
View File
@@ -6,6 +6,7 @@ import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostL
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached, isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached, isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { pruneAccountRuntimeSession, recordAccountRuntimeAttempt, recordAccountRuntimeFailure, recordAccountRuntimeSuccess, resetAccountRuntimeSessionForProvider } from "./account-runtime";
import { logger } from "./logger"; import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log"; import { logAccountRotation } from "./account-rotation-log";
import { traceConversionPhase } from "./conversion-trace"; import { traceConversionPhase } from "./conversion-trace";
@@ -15,7 +16,7 @@ import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000; const API_TIMEOUT_MS = 30000;
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const DEBRID_USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`;
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
@@ -66,6 +67,7 @@ export function resetDebridLinkRuntimeStateForTests(): void {
debridLinkKeyRuntimeStatuses.clear(); debridLinkKeyRuntimeStatuses.clear();
debridLinkKeyHostCooldowns.clear(); debridLinkKeyHostCooldowns.clear();
debridLinkKeyHostCooldownDetails.clear(); debridLinkKeyHostCooldownDetails.clear();
resetAccountRuntimeSessionForProvider("debridlink");
} }
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void { export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
@@ -338,6 +340,8 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridEmptyResponseStreaks.clear(); megaDebridEmptyResponseStreaks.clear();
megaDebridRotationCursor = 0; megaDebridRotationCursor = 0;
megaDebridStickyCount = 0; megaDebridStickyCount = 0;
resetAccountRuntimeSessionForProvider("megadebrid-api");
resetAccountRuntimeSessionForProvider("megadebrid-web");
megaDebridInFlight.clear(); megaDebridInFlight.clear();
} }
@@ -459,6 +463,11 @@ export interface ProviderRuntimeSnapshot {
stickyCount: number; stickyCount: number;
cooldownCount: number; cooldownCount: number;
inFlightCount: number; inFlightCount: number;
accounts: Array<{
accountId: string;
cooldown: ProviderRuntimeCooldown | null;
inFlight: number;
}>;
}; };
megaDebrid: { megaDebrid: {
rotationCursor: number; rotationCursor: number;
@@ -481,6 +490,25 @@ export interface ProviderRuntimeSnapshot {
} }
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot { export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
const realDebridAccountIds = new Set<string>([
...realDebridAccountCooldowns.keys(),
...realDebridInFlight.keys()
]);
const realDebridAccounts = [...realDebridAccountIds].sort().map((accountId) => {
const detail = realDebridAccountCooldowns.get(accountId);
return {
accountId,
cooldown: detail
? {
untilMs: detail.until,
remainingMs: Math.max(0, detail.until - now),
message: detail.message,
category: detail.category
}
: null,
inFlight: realDebridInFlight.get(accountId) ?? 0
};
});
const megaKeys = new Set<string>([ const megaKeys = new Set<string>([
...megaDebridAccountCooldowns.keys(), ...megaDebridAccountCooldowns.keys(),
...megaDebridInFlight.keys(), ...megaDebridInFlight.keys(),
@@ -545,7 +573,8 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
rotationCursor: realDebridRotationCursor, rotationCursor: realDebridRotationCursor,
stickyCount: realDebridStickyCount, stickyCount: realDebridStickyCount,
cooldownCount: [...realDebridAccountCooldowns.values()].filter((entry) => entry.until > now).length, cooldownCount: [...realDebridAccountCooldowns.values()].filter((entry) => entry.until > now).length,
inFlightCount: [...realDebridInFlight.values()].reduce((total, count) => total + count, 0) inFlightCount: [...realDebridInFlight.values()].reduce((total, count) => total + count, 0),
accounts: realDebridAccounts
}, },
megaDebrid: { megaDebrid: {
rotationCursor: megaDebridRotationCursor, rotationCursor: megaDebridRotationCursor,
@@ -660,6 +689,7 @@ export function resetRealDebridRuntimeStateForTests(): void {
realDebridRotationCursor = 0; realDebridRotationCursor = 0;
realDebridStickyAccountId = ""; realDebridStickyAccountId = "";
realDebridStickyCount = 0; realDebridStickyCount = 0;
resetAccountRuntimeSessionForProvider("realdebrid");
} }
export function pruneRealDebridRuntimeStateForAccounts(activeAccountIds: Set<string>): void { export function pruneRealDebridRuntimeStateForAccounts(activeAccountIds: Set<string>): void {
@@ -698,9 +728,10 @@ export function pruneExpiredRealDebridRuntimeState(now = Date.now()): number {
export function primeRealDebridRuntimeCooldownForTests( export function primeRealDebridRuntimeCooldownForTests(
accountId: string, accountId: string,
cooldownMs: number, cooldownMs: number,
message = "Real-Debrid Account im Cooldown" message = "Real-Debrid Account im Cooldown",
category: RealDebridCooldownCategory = "temporary"
): void { ): void {
setRealDebridAccountCooldown(accountId, cooldownMs, message, "temporary"); setRealDebridAccountCooldown(accountId, cooldownMs, message, category);
} }
function setRealDebridAccountCooldown( function setRealDebridAccountCooldown(
@@ -2272,6 +2303,8 @@ class MegaDebridClient {
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
link: linkShort link: linkShort
}); });
const runtimeProvider: DebridProvider = mode === "api" ? "megadebrid-api" : "megadebrid-web";
recordAccountRuntimeAttempt(runtimeProvider, account.id);
const testStartedAt = Date.now(); const testStartedAt = Date.now();
usableAccountSeen = true; usableAccountSeen = true;
@@ -2296,6 +2329,7 @@ class MegaDebridClient {
fileName: result.fileName || "", fileName: result.fileName || "",
link: linkShort link: linkShort
}); });
recordAccountRuntimeSuccess(runtimeProvider, account.id);
return { return {
...result, ...result,
sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`, sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`,
@@ -2355,6 +2389,7 @@ class MegaDebridClient {
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
} }
const failure = MegaDebridClient.classifyAccountFailure(error); const failure = MegaDebridClient.classifyAccountFailure(error);
recordAccountRuntimeFailure(runtimeProvider, account.id);
traceConversionPhase({ traceConversionPhase({
phase: "mega-account", phase: "mega-account",
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
@@ -3031,6 +3066,7 @@ class DebridLinkClient {
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`); logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
recordAccountRuntimeAttempt("debridlink", apiKey.id);
const testStartedAt = Date.now(); const testStartedAt = Date.now();
usableKeySeen = true; usableKeySeen = true;
@@ -3045,6 +3081,7 @@ class DebridLinkClient {
fileName: result.fileName || "", fileName: result.fileName || "",
link: linkShort link: linkShort
}); });
recordAccountRuntimeSuccess("debridlink", apiKey.id);
return { return {
...result, ...result,
sourceLabel: apiKey.label, sourceLabel: apiKey.label,
@@ -3071,6 +3108,7 @@ class DebridLinkClient {
}); });
throw new Error(`Debrid-Link${keyLabel}: ${abortText}`); throw new Error(`Debrid-Link${keyLabel}: ${abortText}`);
} }
recordAccountRuntimeFailure("debridlink", apiKey.id);
attemptedKeyFailures.push({ attemptedKeyFailures.push({
message: `Debrid-Link${keyLabel}: ${failure.message}`, message: `Debrid-Link${keyLabel}: ${failure.message}`,
cooldownMs: failure.cooldownMs, cooldownMs: failure.cooldownMs,
@@ -3854,7 +3892,14 @@ export class DebridService {
} }
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds); pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
pruneRealDebridRuntimeStateForAccounts(new Set(this.getConfiguredRealDebridAccounts(next).map((account) => account.id))); const nextRealDebridAccountIds = new Set(this.getConfiguredRealDebridAccounts(next).map((account) => account.id));
pruneRealDebridRuntimeStateForAccounts(nextRealDebridAccountIds);
const validRuntimeKeys = new Set<string>();
for (const accountId of nextRealDebridAccountIds) validRuntimeKeys.add(`realdebrid:${accountId}`);
for (const account of getMegaDebridAccountsForMode(next, "api")) validRuntimeKeys.add(`megadebrid-api:${account.id}`);
for (const account of getMegaDebridAccountsForMode(next, "web")) validRuntimeKeys.add(`megadebrid-web:${account.id}`);
for (const keyId of nextDebridLinkKeyIds) validRuntimeKeys.add(`debridlink:${keyId}`);
pruneAccountRuntimeSession(validRuntimeKeys);
} }
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient { private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
@@ -4292,6 +4337,7 @@ export class DebridService {
const account = this.selectRealDebridAccount(available); const account = this.selectRealDebridAccount(available);
attempted.add(account.id); attempted.add(account.id);
realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1); realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1);
recordAccountRuntimeAttempt("realdebrid", account.id);
const timeoutMs = getRealDebridAccountAttemptTimeoutMs(); const timeoutMs = getRealDebridAccountAttemptTimeoutMs();
const timeoutSignal = AbortSignal.timeout(timeoutMs); const timeoutSignal = AbortSignal.timeout(timeoutMs);
const accountSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const accountSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -4313,6 +4359,7 @@ export class DebridService {
if (realDebridStickyCount >= REAL_DEBRID_STICKY_LINKS && accountIndex >= 0) { if (realDebridStickyCount >= REAL_DEBRID_STICKY_LINKS && accountIndex >= 0) {
realDebridRotationCursor = (accountIndex + 1) % available.length; realDebridRotationCursor = (accountIndex + 1) % available.length;
} }
recordAccountRuntimeSuccess("realdebrid", account.id);
return { return {
...result, ...result,
sourceLabel: account.kind === "api" ? "API" : "Web", sourceLabel: account.kind === "api" ? "API" : "Web",
@@ -4324,6 +4371,7 @@ export class DebridService {
throw error; throw error;
} }
const failure = this.classifyRealDebridFailure(error); const failure = this.classifyRealDebridFailure(error);
recordAccountRuntimeFailure("realdebrid", account.id);
if (!failure.rotateAccount) { if (!failure.rotateAccount) {
throw error; throw error;
} }
+2
View File
@@ -66,6 +66,7 @@ import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLang
import { sendNotification } from "./notify"; import { sendNotification } from "./notify";
import { logger } from "./logger"; import { logger } from "./logger";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import { createAccountRuntimeEntries } from "./account-runtime-snapshot";
import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace"; import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace";
import type { RotationEvent } from "../shared/types"; import type { RotationEvent } from "../shared/types";
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
@@ -2578,6 +2579,7 @@ export class DownloadManager extends EventEmitter {
return { return {
rotationEvents: getRecentRotationEvents(40), rotationEvents: getRecentRotationEvents(40),
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
settings: rendererState.settings, settings: rendererState.settings,
accounts: rendererState.accounts, accounts: rendererState.accounts,
session: snapshotSession, session: snapshotSession,
+1 -1
View File
@@ -214,7 +214,7 @@ export function cleanupStaleSubstDrives(): void {
if (!match) continue; if (!match) continue;
const drive = match[1].toUpperCase(); const drive = match[1].toUpperCase();
const target = match[2].trim(); const target = match[2].trim();
if (/\\rd-extract-|\\Real-Debrid-Downloader/i.test(target)) { if (/\\rd-extract-|\\(?:Real|Multi)-Debrid-Downloader/i.test(target)) {
spawnSync("subst", [`${drive}:`, "/d"], { stdio: "pipe", timeout: 5000 }); spawnSync("subst", [`${drive}:`, "/d"], { stdio: "pipe", timeout: 5000 });
logger.info(`Stale subst ${drive}: entfernt (${target})`); logger.info(`Stale subst ${drive}: entfernt (${target})`);
} }
+1 -1
View File
@@ -88,7 +88,7 @@ export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: strin
export function serializeLinkExportText(packages: ParsedPackageInput[]): string { export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
const lines: string[] = [ const lines: string[] = [
"# rd-link-export: 1", "# rd-link-export: 1",
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.", "# Re-import in Multi-Debrid-Downloader keeps package names and optional file names.",
"" ""
]; ];
+6
View File
@@ -22,6 +22,7 @@ import { validateRendererSettingsUpdate } from "./renderer-settings";
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { validateRealDebridLoginRequest } from "../shared/preload-api";
import { migrateProductUserDataDirectory } from "./storage";
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -51,6 +52,11 @@ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"debridlink", "debridlink",
"linksnappy" "linksnappy"
]); ]);
if (app.isPackaged && !process.argv.some((arg) => arg === "--user-data-dir" || arg.startsWith("--user-data-dir="))) {
app.setPath("userData", migrateProductUserDataDirectory(app.getPath("appData")));
}
function validateStringArray(value: unknown, name: string): string[] { function validateStringArray(value: unknown, name: string): string[] {
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
throw new Error(`${name} muss ein String-Array sein`); throw new Error(`${name} muss ein String-Array sein`);
+1 -1
View File
@@ -1,7 +1,7 @@
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants"; import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
import { compactErrorText, sleep } from "./utils"; import { compactErrorText, sleep } from "./utils";
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const DEBRID_USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`;
export interface UnrestrictedLink { export interface UnrestrictedLink {
fileName: string; fileName: string;
+1 -1
View File
@@ -188,7 +188,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
severity: "ERROR", severity: "ERROR",
code: "baseDir_not_writable", code: "baseDir_not_writable",
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`, message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)." hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Multi-Debrid-Downloader/runtime)."
}); });
} }
+15 -1
View File
@@ -12,6 +12,20 @@ import { defaultSettings } from "./constants";
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection"; import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
import { logger } from "./logger"; import { logger } from "./logger";
export function migrateProductUserDataDirectory(appDataPath: string): string {
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
const targetPath = path.join(appDataPath, "Multi-Debrid-Downloader");
if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) {
return targetPath;
}
try {
fs.renameSync(legacyPath, targetPath);
return targetPath;
} catch {
return legacyPath;
}
}
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]); const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]); const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
@@ -393,7 +407,7 @@ const DEPRECATED_UPDATE_REPO_NAMES = new Set([
function migrateUpdateRepo(raw: string, fallback: string): string { function migrateUpdateRepo(raw: string, fallback: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
const repoName = trimmed.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "").toLowerCase() || ""; const repoName = trimmed.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "").toLowerCase() || "";
if (!trimmed || (DEPRECATED_UPDATE_REPO_NAMES.has(repoName) && trimmed.toLowerCase() !== fallback.toLowerCase())) { if (!trimmed || (DEPRECATED_UPDATE_REPO_NAMES.has(repoName) && trimmed !== fallback)) {
return fallback; return fallback;
} }
return trimmed; return trimmed;
+1 -1
View File
@@ -94,7 +94,7 @@ function formatTimestampForFileName(date: Date): string {
} }
export function getSupportBundleDefaultFileName(): string { export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; return `mdd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
} }
type HostDiagnosticsMode = "full" | "cached" | "none"; type HostDiagnosticsMode = "full" | "cached" | "none";
+1 -1
View File
@@ -14,7 +14,7 @@ const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
const RETRIES_PER_CANDIDATE = 3; const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1_500; const RETRY_DELAY_MS = 1_500;
const MAX_DOWNLOAD_PASSES = 3; const MAX_DOWNLOAD_PASSES = 3;
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`;
type UpdateSource = { type UpdateSource = {
name: string; name: string;
+1 -1
View File
@@ -221,7 +221,7 @@ $appCrashes = @(
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 | Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
Where-Object { Where-Object {
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and ($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js") ($_.Message -match "(?:Real|Multi)-Debrid-Downloader|electron|node\.exe|main\.js")
} | } |
Select-Object -First 10 | Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ } ForEach-Object { Convert-EventRecord $_ }
+126 -1
View File
@@ -1593,7 +1593,7 @@ export function App(): ReactElement {
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein"); const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview"); const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview");
const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set()); const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set());
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null); const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
@@ -5011,6 +5011,130 @@ export function App(): ReactElement {
const visibleAccountRows = useMemo(() => accountStatusSort === "none" const visibleAccountRows = useMemo(() => accountStatusSort === "none"
? projectedAccountRows ? projectedAccountRows
: sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]); : sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]);
const accountRuntimeModel = useMemo<AccountWorkspaceViewModel["runtime"]>(() => {
const runtimeEntries = snapshot.accountRuntime || [];
const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry]));
const runtimeAccountIdCounts = new Map<string, number>();
for (const entry of runtimeEntries) {
runtimeAccountIdCounts.set(entry.accountId, (runtimeAccountIdCounts.get(entry.accountId) || 0) + 1);
}
const uniqueRuntimeByAccountId = new Map(runtimeEntries
.filter((entry) => runtimeAccountIdCounts.get(entry.accountId) === 1)
.map((entry) => [entry.accountId, entry]));
const projectedById = new Map(projectedAccountRows.map((row) => [row.id, row]));
const providerGroups = new Map<string, {
id: string;
label: string;
accountCount: number;
availableAccountCount: number;
activeDownloads: number;
dailyUsageBytes: number;
}>();
const stateLabels = {
ready: "Bereit",
active: "Aktiv",
checking: "Prüfung",
cooldown: "Cooldown",
disabled: "Deaktiviert",
daily_limit: "Tageslimit",
invalid: "Fehler"
} as const;
const stateTones = {
ready: "ok",
active: "active",
checking: "active",
cooldown: "warning",
disabled: "muted",
daily_limit: "warning",
invalid: "danger"
} as const;
const accounts = accountRows.map((row) => {
const viewId = accountRowViewId(row);
const projected = projectedById.get(viewId);
const runtimeId = row.accountId || `svc-${row.entry.provider}`;
const runtimeProvider = row.entry.provider === "megadebrid"
? (row.modeLabel.toLocaleLowerCase("de-DE").includes("web") ? "megadebrid-web" : "megadebrid-api")
: row.entry.provider;
const runtime = runtimeByAccountId.get(`${runtimeProvider}:${runtimeId}`)
?? uniqueRuntimeByAccountId.get(runtimeId);
const fallbackState = row.disabled
? "disabled"
: row.dailyLimitBytes > 0 && row.dailyUsedBytes >= row.dailyLimitBytes
? "daily_limit"
: projected?.problem
? "invalid"
: "ready";
const state = runtime?.state || fallbackState;
const outcomes = (runtime?.successes || 0) + (runtime?.failures || 0);
const successRateText = outcomes > 0
? `${Math.round(((runtime?.successes || 0) / outcomes) * 100)} % (${runtime?.successes || 0}/${outcomes})`
: "—";
const lastUsedAt = runtime?.lastUsedAt || null;
let lastUsedText = "Noch nicht in dieser Sitzung";
if (lastUsedAt) {
const ageSeconds = Math.max(0, Math.floor((runtimeNow - lastUsedAt) / 1000));
lastUsedText = ageSeconds < 60
? "Gerade eben"
: ageSeconds < 3600
? `vor ${Math.floor(ageSeconds / 60)} Min.`
: ageSeconds < 86400
? `vor ${Math.floor(ageSeconds / 3600)} Std.`
: formatDateTime(lastUsedAt);
}
let cooldownText = "—";
if (runtime?.cooldownUntil && runtime.cooldownUntil > runtimeNow) {
const seconds = Math.max(1, Math.ceil((runtime.cooldownUntil - runtimeNow) / 1000));
const duration = seconds < 60
? `${seconds} Sek.`
: seconds < 3600
? `${Math.ceil(seconds / 60)} Min.`
: `${Math.ceil(seconds / 3600)} Std.`;
cooldownText = `${runtime.reason} · ${duration}`;
} else if (state === "disabled" || state === "daily_limit" || state === "invalid") {
cooldownText = runtime?.reason || stateLabels[state];
}
const providerKey = row.hosterLabel.toLocaleLowerCase("de-DE");
const providerGroup = providerGroups.get(providerKey) ?? {
id: providerKey,
label: row.hosterLabel,
accountCount: 0,
availableAccountCount: 0,
activeDownloads: 0,
dailyUsageBytes: 0
};
providerGroup.accountCount += 1;
providerGroup.availableAccountCount += state === "ready" || state === "active" || state === "checking" ? 1 : 0;
providerGroup.activeDownloads += runtime?.activeDownloads || 0;
providerGroup.dailyUsageBytes += runtime?.dailyUsageBytes ?? row.dailyUsedBytes;
providerGroups.set(providerKey, providerGroup);
return {
id: viewId,
providerLabel: row.hosterLabel,
modeLabel: row.modeLabel,
identity: projected?.username !== "—" ? projected?.username || "" : projected?.email || "",
stateLabel: stateLabels[state],
stateTone: stateTones[state],
activeDownloads: runtime?.activeDownloads || 0,
dailyUsageText: humanSize(runtime?.dailyUsageBytes ?? row.dailyUsedBytes),
successRateText,
lastUsedText,
cooldownText
};
});
return {
providers: [...providerGroups.values()]
.sort((left, right) => left.label.localeCompare(right.label, "de-DE", { sensitivity: "base" }))
.map((provider) => ({
id: provider.id,
label: provider.label,
accountCount: provider.accountCount,
availableAccountCount: provider.availableAccountCount,
activeDownloads: provider.activeDownloads,
dailyUsageText: humanSize(provider.dailyUsageBytes)
})),
accounts
};
}, [accountRows, projectedAccountRows, runtimeNow, snapshot.accountRuntime]);
const routingEntries = useMemo(() => Object.entries(settingsDraft.hosterRouting || {}).sort(([left], [right]) => left.localeCompare(right)), [settingsDraft.hosterRouting]); const routingEntries = useMemo(() => Object.entries(settingsDraft.hosterRouting || {}).sort(([left], [right]) => left.localeCompare(right)), [settingsDraft.hosterRouting]);
const usedRoutingHosters = useMemo(() => new Set(routingEntries.map(([hosterId]) => hosterId)), [routingEntries]); const usedRoutingHosters = useMemo(() => new Set(routingEntries.map(([hosterId]) => hosterId)), [routingEntries]);
const routingProviderOptions = useMemo(() => configuredProviders.map((provider) => ({ const routingProviderOptions = useMemo(() => configuredProviders.map((provider) => ({
@@ -5023,6 +5147,7 @@ export function App(): ReactElement {
selectedIds: selectedAccountViewIds, selectedIds: selectedAccountViewIds,
busy: actionBusy || accountCheckBusy, busy: actionBusy || accountCheckBusy,
statusSort: accountStatusSort, statusSort: accountStatusSort,
runtime: accountRuntimeModel,
rules: { rules: {
providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)), providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)),
routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId}${providerLabelWithMode(provider, settingsDraft)}`), routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId}${providerLabelWithMode(provider, settingsDraft)}`),
+24 -1
View File
@@ -28,7 +28,10 @@ const pairs = [
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"], ["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"], ["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"], ["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."], ["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Laufzeit", "Runtime"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
["Provider-Laufzeit", "Provider runtime"], ["Account-Laufzeit", "Account runtime"], ["Aktive Downloads", "Active downloads"], ["Erfolgsquote · Diese Sitzung", "Success rate · This session"], ["Zuletzt verwendet", "Last used"], ["Cooldown / Grund", "Cooldown / reason"],
["Noch keine Accounts konfiguriert.", "No accounts configured yet."], ["Noch keine Laufzeitdaten verfügbar.", "No runtime data available yet."], ["Noch nicht in dieser Sitzung", "Not yet in this session"], ["Gerade eben", "Just now"], ["Prüfung", "Checking"], ["Tageslimit", "Daily limit"], ["Cooldown", "Cooldown"],
["aktiver Download", "active download"], ["aktive Downloads", "active downloads"], ["heute", "today"], ["Account deaktiviert", "Account disabled"], ["Tageslimit erreicht", "Daily limit reached"], ["Anmeldung ungültig", "Invalid login"], ["Rate-Limit aktiv", "Rate limit active"], ["Traffic- oder Kontolimit erreicht", "Traffic or account limit reached"], ["Vorübergehender Cooldown", "Temporary cooldown"], ["Provider oder Link vorübergehend nicht verfügbar", "Provider or link temporarily unavailable"],
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"], ["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"], ["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"], ["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
@@ -236,6 +239,16 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (pageStatus) return `Page ${pageStatus[1]} of ${pageStatus[2]}`; if (pageStatus) return `Page ${pageStatus[1]} of ${pageStatus[2]}`;
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/); const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`; if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const runtimeAvailability = value.match(/^(\d+) von (\d+) verfügbar$/);
if (runtimeAvailability) return `${runtimeAvailability[1]} of ${runtimeAvailability[2]} available`;
const runtimeAgo = value.match(/^vor (\d+) (Min|Std)\.$/);
if (runtimeAgo) return `${runtimeAgo[1]} ${runtimeAgo[2] === "Min" ? "min" : "hr"} ago`;
const runtimeCooldown = value.match(/^(.+) · (\d+) (Sek|Min|Std)\.$/);
if (runtimeCooldown) {
const reason = deToEn.get(runtimeCooldown[1]) ?? runtimeCooldown[1];
const unit = runtimeCooldown[3] === "Sek" ? "sec" : runtimeCooldown[3] === "Min" ? "min" : "hr";
return `${reason} · ${runtimeCooldown[2]} ${unit}`;
}
const remaining = value.match(/^(.+) von (.+) übrig$/); const remaining = value.match(/^(.+) von (.+) übrig$/);
if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`; if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`;
const actionsFor = value.match(/^Aktionen für (.+)$/); const actionsFor = value.match(/^Aktionen für (.+)$/);
@@ -391,6 +404,16 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (perPage) return `${perPage[1]} pro Seite`; if (perPage) return `${perPage[1]} pro Seite`;
const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/); const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/);
if (filter) return `${enToDe.get(filter[1]) ?? filter[1]} ${filter[2]}`; if (filter) return `${enToDe.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const runtimeAvailability = value.match(/^(\d+) of (\d+) available$/);
if (runtimeAvailability) return `${runtimeAvailability[1]} von ${runtimeAvailability[2]} verfügbar`;
const runtimeAgo = value.match(/^(\d+) (min|hr) ago$/);
if (runtimeAgo) return `vor ${runtimeAgo[1]} ${runtimeAgo[2] === "min" ? "Min" : "Std"}`;
const runtimeCooldown = value.match(/^(.+) · (\d+) (sec|min|hr)$/);
if (runtimeCooldown) {
const reason = enToDe.get(runtimeCooldown[1]) ?? runtimeCooldown[1];
const unit = runtimeCooldown[3] === "sec" ? "Sek" : runtimeCooldown[3] === "min" ? "Min" : "Std";
return `${reason} · ${runtimeCooldown[2]} ${unit}.`;
}
const remaining = value.match(/^(.+) of (.+) remaining$/); const remaining = value.match(/^(.+) of (.+) remaining$/);
if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`; if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`;
const actionsFor = value.match(/^Actions for (.+)$/); const actionsFor = value.match(/^Actions for (.+)$/);
@@ -29,11 +29,12 @@ import {
type AccountTableColumnWidths type AccountTableColumnWidths
} from "./settings-model"; } from "./settings-model";
export type AccountWorkspacePanel = "overview" | "rules"; export type AccountWorkspacePanel = "overview" | "rules" | "runtime";
const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: string }[] = [ const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: string }[] = [
{ id: "overview", label: "Übersicht" }, { id: "overview", label: "Übersicht" },
{ id: "rules", label: "Verwendungsregeln" } { id: "rules", label: "Verwendungsregeln" },
{ id: "runtime", label: "Laufzeit" }
]; ];
const ACCOUNT_TABLE_COLUMN_STORAGE_KEY = "mdd.account-table-columns.v1"; const ACCOUNT_TABLE_COLUMN_STORAGE_KEY = "mdd.account-table-columns.v1";
@@ -94,6 +95,34 @@ export interface AccountRulesViewModel {
availableRoutingHosters?: readonly { value: string; label: string }[]; availableRoutingHosters?: readonly { value: string; label: string }[];
} }
export interface AccountRuntimeProviderViewModel {
id: string;
label: string;
accountCount: number;
availableAccountCount: number;
activeDownloads: number;
dailyUsageText: string;
}
export interface AccountRuntimeRowViewModel {
id: string;
providerLabel: string;
modeLabel: string;
identity: string;
stateLabel: string;
stateTone: "ok" | "active" | "warning" | "danger" | "muted";
activeDownloads: number;
dailyUsageText: string;
successRateText: string;
lastUsedText: string;
cooldownText: string;
}
export interface AccountRuntimeViewModel {
providers: readonly AccountRuntimeProviderViewModel[];
accounts: readonly AccountRuntimeRowViewModel[];
}
export interface AccountWorkspaceViewModel { export interface AccountWorkspaceViewModel {
activePanel: AccountWorkspacePanel; activePanel: AccountWorkspacePanel;
rows: readonly AccountRowViewModel[]; rows: readonly AccountRowViewModel[];
@@ -102,6 +131,7 @@ export interface AccountWorkspaceViewModel {
error?: string; error?: string;
statusSort?: "none" | "desc" | "asc"; statusSort?: "none" | "desc" | "asc";
rules: AccountRulesViewModel; rules: AccountRulesViewModel;
runtime: AccountRuntimeViewModel;
} }
export interface AccountWorkspaceActions { export interface AccountWorkspaceActions {
@@ -581,6 +611,58 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
); );
} }
function AccountRuntime({ model }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-runtime">
<section aria-label="Provider-Laufzeit" className="settings-runtime-provider-grid">
{model.runtime.providers.length === 0 ? (
<div className="settings-runtime-empty">Noch keine Accounts konfiguriert.</div>
) : model.runtime.providers.map((provider) => (
<article className="settings-runtime-provider-card" key={provider.id}>
<header>
<h3>{provider.label}</h3>
<span>{provider.availableAccountCount} von {provider.accountCount} verfügbar</span>
</header>
<div>
<span><strong>{provider.activeDownloads}</strong>{provider.activeDownloads === 1 ? " aktiver Download" : " aktive Downloads"}</span>
<span><strong>{provider.dailyUsageText}</strong> heute</span>
</div>
</article>
))}
</section>
<section aria-label="Account-Laufzeit" className="settings-runtime-table" role="table">
<div className="settings-runtime-table-scroll">
<div className="settings-runtime-row settings-runtime-header" role="row">
<span role="columnheader">Account</span>
<span role="columnheader">Zustand</span>
<span role="columnheader">Aktive Downloads</span>
<span role="columnheader">Heute</span>
<span role="columnheader">Erfolgsquote · Diese Sitzung</span>
<span role="columnheader">Zuletzt verwendet</span>
<span role="columnheader">Cooldown / Grund</span>
</div>
{model.runtime.accounts.length === 0 ? (
<div className="settings-runtime-empty">Noch keine Laufzeitdaten verfügbar.</div>
) : model.runtime.accounts.map((account) => (
<div className="settings-runtime-row" key={account.id} role="row">
<span className="settings-runtime-account" role="cell">
<strong>{account.providerLabel}</strong>
<small>{account.modeLabel}{account.identity && account.identity !== "—" ? ` · ${account.identity}` : ""}</small>
</span>
<span role="cell"><span className={`settings-runtime-state is-${account.stateTone}`}>{account.stateLabel}</span></span>
<span role="cell">{account.activeDownloads}</span>
<span role="cell">{account.dailyUsageText}</span>
<span role="cell">{account.successRateText}</span>
<span role="cell">{account.lastUsedText}</span>
<span role="cell">{account.cooldownText}</span>
</div>
))}
</div>
</section>
</div>
);
}
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement { export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
return ( return (
<div className="settings-account-workspace"> <div className="settings-account-workspace">
@@ -637,6 +719,15 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
> >
{AccountRules({ actions, model })} {AccountRules({ actions, model })}
</div> </div>
<div
aria-labelledby="settings-account-runtime-tab"
className="settings-account-panel"
hidden={model.activePanel !== "runtime"}
id="settings-account-runtime"
role="tabpanel"
>
{AccountRuntime({ actions, model })}
</div>
</div> </div>
); );
} }
+149
View File
@@ -850,6 +850,155 @@
overflow-y: auto; overflow-y: auto;
} }
.settings-account-runtime {
display: grid;
width: 100%;
min-width: 0;
gap: 16px;
overflow-y: auto;
}
.settings-runtime-provider-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 10px;
}
.settings-runtime-provider-card {
display: grid;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-panel);
}
.settings-runtime-provider-card header,
.settings-runtime-provider-card > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-runtime-provider-card h3 {
margin: 0;
color: var(--ui-text);
font-size: 14px;
}
.settings-runtime-provider-card header span,
.settings-runtime-provider-card > div span,
.settings-runtime-account small {
color: var(--ui-text-muted);
}
.settings-runtime-provider-card strong {
margin-right: 4px;
color: var(--ui-text);
}
.settings-runtime-table {
min-width: 0;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-panel);
}
.settings-runtime-table-scroll {
overflow-x: auto;
}
.settings-runtime-row {
display: grid;
grid-template-columns: minmax(210px, 1.35fr) 150px 125px 130px 175px 155px minmax(220px, 1fr);
min-width: 1180px;
min-height: 48px;
align-items: center;
border-bottom: 1px solid var(--ui-border);
}
.settings-runtime-row:last-child {
border-bottom: 0;
}
.settings-runtime-row > span {
min-width: 0;
padding: 9px 12px;
color: var(--ui-text-secondary);
}
.settings-runtime-header {
min-height: 40px;
background: var(--ui-table-header);
}
.settings-runtime-header > span {
color: var(--ui-text);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.settings-runtime-account {
display: grid;
gap: 2px;
}
.settings-runtime-account strong {
overflow: hidden;
color: var(--ui-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-runtime-account small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-runtime-state {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--ui-input);
color: var(--ui-text-secondary);
white-space: nowrap;
}
.settings-runtime-state.is-ok {
background: color-mix(in srgb, var(--ui-success) 18%, transparent);
color: var(--ui-success-text);
}
.settings-runtime-state.is-active {
background: color-mix(in srgb, var(--ui-accent) 18%, transparent);
color: var(--ui-text);
}
.settings-runtime-state.is-warning {
background: color-mix(in srgb, var(--ui-warning) 18%, transparent);
color: var(--ui-warning-text);
}
.settings-runtime-state.is-danger {
background: color-mix(in srgb, var(--ui-danger) 18%, transparent);
color: var(--ui-danger-text);
}
.settings-runtime-state.is-muted {
color: var(--ui-text-muted);
}
.settings-runtime-empty {
padding: 20px;
color: var(--ui-text-muted);
}
.settings-rule-section { .settings-rule-section {
display: grid; display: grid;
gap: 12px; gap: 12px;
+18
View File
@@ -506,6 +506,23 @@ export interface RotationEvent {
next?: string; next?: string;
} }
export type AccountRuntimeState = "ready" | "active" | "checking" | "cooldown" | "disabled" | "daily_limit" | "invalid";
export interface AccountRuntimeEntry {
accountId: string;
provider: DebridProvider;
state: AccountRuntimeState;
reason: string;
activeDownloads: number;
inFlight: number;
attempts: number;
successes: number;
failures: number;
lastUsedAt: number | null;
cooldownUntil: number | null;
dailyUsageBytes: number;
}
export interface UiSnapshot { export interface UiSnapshot {
settings: RendererSettings; settings: RendererSettings;
accounts: RendererAccount[]; accounts: RendererAccount[];
@@ -535,6 +552,7 @@ export interface UiSnapshot {
removedItemIds?: string[]; removedItemIds?: string[];
removedPackageIds?: string[]; removedPackageIds?: string[];
rotationEvents?: RotationEvent[]; rotationEvents?: RotationEvent[];
accountRuntime?: AccountRuntimeEntry[];
} }
export interface AddLinksPayload { export interface AddLinksPayload {
+3 -2
View File
@@ -11,11 +11,12 @@ const styles = readFileSync(
); );
describe("account management layout", () => { describe("account management layout", () => {
it("keeps both account tabs inside the same fixed content row", () => { it("keeps all account tabs inside the same fixed content row", () => {
expect(workspaceSource).toContain('<div className="settings-account-workspace">'); expect(workspaceSource).toContain('<div className="settings-account-workspace">');
expect(workspaceSource.match(/className="settings-account-panel"/g)).toHaveLength(2); expect(workspaceSource.match(/className="settings-account-panel"/g)).toHaveLength(3);
expect(workspaceSource).toContain('hidden={model.activePanel !== "overview"}'); expect(workspaceSource).toContain('hidden={model.activePanel !== "overview"}');
expect(workspaceSource).toContain('hidden={model.activePanel !== "rules"}'); expect(workspaceSource).toContain('hidden={model.activePanel !== "rules"}');
expect(workspaceSource).toContain('hidden={model.activePanel !== "runtime"}');
expect(styles).toMatch( expect(styles).toMatch(
/\.settings-account-workspace\s*{[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s /\.settings-account-workspace\s*{[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s
); );
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from "vitest";
import { createAccountRuntimeEntries } from "../src/main/account-runtime-snapshot";
import {
recordAccountRuntimeAttempt,
recordAccountRuntimeFailure,
recordAccountRuntimeSuccess,
resetAccountRuntimeSessionForProvider
} from "../src/main/account-runtime";
import { resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
import type { DownloadItem, RendererAccount } from "../src/shared/types";
afterEach(() => {
resetMegaDebridRuntimeStateForTests();
resetAccountRuntimeSessionForProvider("megadebrid-api");
resetAccountRuntimeSessionForProvider("megadebrid-web");
});
function account(provider: "megadebrid-api" | "megadebrid-web", enabled = true): RendererAccount {
return {
accountId: "shared-account",
kind: provider,
provider,
identity: "runtime-user",
maskedIdentity: "run***ser",
hasSecret: true,
enabled,
dailyLimitBytes: 0,
dailyUsageBytes: provider === "megadebrid-api" ? 1000 : 2000,
totalUsageBytes: 0,
status: null
};
}
function item(provider: "megadebrid-api" | "megadebrid-web"): DownloadItem {
return {
id: `item-${provider}`,
packageId: "package-runtime",
url: "https://hoster.example/runtime.bin",
provider,
providerAccountId: "shared-account",
status: "downloading",
retries: 0,
speedBps: 100,
downloadedBytes: 10,
totalBytes: 100,
progressPercent: 10,
fileName: "runtime.bin",
targetPath: "runtime.bin",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 1000,
updatedAt: 2000
};
}
describe("account runtime snapshot", () => {
it("keeps API and web telemetry separate when both modes share an account id", () => {
recordAccountRuntimeAttempt("megadebrid-api", "shared-account", 1500);
recordAccountRuntimeSuccess("megadebrid-api", "shared-account", 1600);
recordAccountRuntimeAttempt("megadebrid-web", "shared-account", 1700);
recordAccountRuntimeFailure("megadebrid-web", "shared-account", 1800);
const entries = createAccountRuntimeEntries(
[account("megadebrid-api"), account("megadebrid-web")],
[item("megadebrid-api")],
3000
);
expect(entries).toEqual([
expect.objectContaining({ provider: "megadebrid-api", activeDownloads: 1, attempts: 1, successes: 1, failures: 0 }),
expect.objectContaining({ provider: "megadebrid-web", activeDownloads: 0, attempts: 1, successes: 0, failures: 1 })
]);
});
it("keeps a disabled account disabled even when an old item still references it", () => {
const [entry] = createAccountRuntimeEntries([account("megadebrid-api", false)], [item("megadebrid-api")], 3000);
expect(entry).toEqual(expect.objectContaining({
state: "disabled",
reason: "Account deaktiviert",
activeDownloads: 1
}));
});
});
+4 -4
View File
@@ -147,13 +147,13 @@ describe("browser-security", () => {
externalHosts: githubOnly externalHosts: githubOnly
}); });
const allowed = harness.navigate("https://github.com/Sucukdeluxe/multi-debrid-downloader"); const allowed = harness.navigate("https://github.com/Sucukdeluxe/Multi-Debrid-Downloader");
const lookalike = harness.navigate("https://github.com.evil.example/Sucukdeluxe"); const lookalike = harness.navigate("https://github.com.evil.example/Sucukdeluxe");
expect(allowed.preventDefault).toHaveBeenCalledTimes(1); expect(allowed.preventDefault).toHaveBeenCalledTimes(1);
expect(lookalike.preventDefault).toHaveBeenCalledTimes(1); expect(lookalike.preventDefault).toHaveBeenCalledTimes(1);
expect(electron.openExternal).toHaveBeenCalledTimes(1); expect(electron.openExternal).toHaveBeenCalledTimes(1);
expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/multi-debrid-downloader"); expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/Multi-Debrid-Downloader");
}); });
it("applies the main-window navigation policy to redirects", () => { it("applies the main-window navigation policy to redirects", () => {
@@ -163,13 +163,13 @@ describe("browser-security", () => {
externalHosts: githubOnly externalHosts: githubOnly
}); });
const allowed = harness.redirect("https://github.com/Sucukdeluxe/multi-debrid-downloader"); const allowed = harness.redirect("https://github.com/Sucukdeluxe/Multi-Debrid-Downloader");
const lookalike = harness.redirect("https://github.com.evil.example/Sucukdeluxe"); const lookalike = harness.redirect("https://github.com.evil.example/Sucukdeluxe");
expect(allowed.preventDefault).toHaveBeenCalledTimes(1); expect(allowed.preventDefault).toHaveBeenCalledTimes(1);
expect(lookalike.preventDefault).toHaveBeenCalledTimes(1); expect(lookalike.preventDefault).toHaveBeenCalledTimes(1);
expect(electron.openExternal).toHaveBeenCalledTimes(1); expect(electron.openExternal).toHaveBeenCalledTimes(1);
expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/multi-debrid-downloader"); expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/Multi-Debrid-Downloader");
}); });
it("denies local-file navigation outside the packaged main renderer file", () => { it("denies local-file navigation outside the packaged main renderer file", () => {
+9
View File
@@ -6,6 +6,7 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
import { getAccountRuntimeSessionStats } from "../src/main/account-runtime";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -2857,6 +2858,12 @@ describe("Real-Debrid account rotation", () => {
expect(result.sourceAccountId).toBe("rda_one"); expect(result.sourceAccountId).toBe("rda_one");
expect(result.sourceAccountLabel).toBe("API-Token 1"); expect(result.sourceAccountLabel).toBe("API-Token 1");
expect(getAccountRuntimeSessionStats("realdebrid", "rda_one")).toEqual(expect.objectContaining({
attempts: 1,
successes: 1,
failures: 0,
lastUsedAt: expect.any(Number)
}));
}); });
it("fails over from a rejected API account to the next account in the same call", async () => { it("fails over from a rejected API account to the next account in the same call", async () => {
@@ -2878,6 +2885,8 @@ describe("Real-Debrid account rotation", () => {
expect(result.sourceAccountId).toBe("rda_two"); expect(result.sourceAccountId).toBe("rda_two");
expect(usedTokens).toEqual(["token-one", "token-two"]); expect(usedTokens).toEqual(["token-one", "token-two"]);
expect(getAccountRuntimeSessionStats("realdebrid", "rda_one")).toEqual(expect.objectContaining({ attempts: 1, successes: 0, failures: 1 }));
expect(getAccountRuntimeSessionStats("realdebrid", "rda_two")).toEqual(expect.objectContaining({ attempts: 1, successes: 1, failures: 0 }));
expect(getAvailableRealDebridAccounts(settings, Date.now() + 3 * 60 * 1000).map((account) => account.id)).toEqual(["rda_two"]); expect(getAvailableRealDebridAccounts(settings, Date.now() + 3 * 60 * 1000).map((account) => account.id)).toEqual(["rda_two"]);
}); });
+75
View File
@@ -13304,6 +13304,81 @@ describe("download manager", () => {
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0); expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0);
}); });
it("exposes sanitized per-account runtime state in the renderer snapshot", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]),
realDebridAccountDailyUsageBytes: { rda_one: 4_200_000_000 }
};
const session = emptySession();
session.items.runtime_item = {
id: "runtime_item",
packageId: "runtime_package",
url: "https://hoster.example/runtime.bin",
provider: "realdebrid",
providerAccountId: "rda_one",
status: "downloading",
retries: 0,
speedBps: 1024,
downloadedBytes: 100,
totalBytes: 1000,
progressPercent: 10,
fileName: "runtime.bin",
targetPath: path.join(root, "runtime.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: Date.now() - 1000,
updatedAt: Date.now()
};
session.packages.runtime_package = {
id: "runtime_package",
name: "runtime",
outputDir: root,
extractDir: root,
status: "downloading",
itemIds: ["runtime_item"],
cancelled: false,
enabled: true,
createdAt: Date.now() - 1000,
updatedAt: Date.now()
};
session.packageOrder = ["runtime_package"];
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
const activeItem = (manager as unknown as { session: typeof session }).session.items.runtime_item;
activeItem.status = "downloading";
activeItem.provider = "realdebrid";
activeItem.providerAccountId = "rda_one";
primeRealDebridRuntimeCooldownForTests("rda_one", 60_000, "HTTP 429 token-one", "rate_limit");
const runtime = (manager.getSnapshot() as unknown as {
accountRuntime: Array<{
accountId: string;
state: string;
reason: string;
cooldownUntil: number | null;
dailyUsageBytes: number;
}>;
}).accountRuntime;
expect(runtime).toEqual([
expect.objectContaining({
accountId: "rda_one",
state: "cooldown",
reason: "Rate-Limit aktiv",
cooldownUntil: expect.any(Number),
dailyUsageBytes: 4_200_000_000,
activeDownloads: 1,
lastUsedAt: expect.any(Number)
})
]);
expect(JSON.stringify(runtime)).not.toContain("token-one");
expect(JSON.stringify(runtime)).not.toContain("HTTP 429");
});
it("does not hang when rapid stop is followed by disabling the last provider", async () => { it("does not hang when rapid stop is followed by disabling the last provider", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
+4
View File
@@ -84,6 +84,10 @@ describe("renderer localization", () => {
expect(translateUiText("Seite 2 von 7", "en")).toBe("Page 2 of 7"); expect(translateUiText("Seite 2 von 7", "en")).toBe("Page 2 of 7");
expect(translateUiText("Page 2 of 7", "de")).toBe("Seite 2 von 7"); expect(translateUiText("Page 2 of 7", "de")).toBe("Seite 2 von 7");
expect(translateUiText("Seite 1.000 von 2.500", "en")).toBe("Page 1.000 of 2.500"); expect(translateUiText("Seite 1.000 von 2.500", "en")).toBe("Page 1.000 of 2.500");
expect(translateUiText("1 von 2 verfügbar", "en")).toBe("1 of 2 available");
expect(translateUiText("vor 2 Min.", "en")).toBe("2 min ago");
expect(translateUiText("Rate-Limit aktiv · 48 Sek.", "en")).toBe("Rate limit active · 48 sec");
expect(translateUiText("Rate limit active · 48 sec", "de")).toBe("Rate-Limit aktiv · 48 Sek.");
expect(translateUiText("Accountdaten werden aktualisiert.", "en")).toBe("Account data is being updated."); expect(translateUiText("Accountdaten werden aktualisiert.", "en")).toBe("Account data is being updated.");
expect(translateUiText("Keine passenden Dienste oder Zugangstypen gefunden.", "en")) expect(translateUiText("Keine passenden Dienste oder Zugangstypen gefunden.", "en"))
.toBe("No matching services or access types found."); .toBe("No matching services or access types found.");
+68
View File
@@ -0,0 +1,68 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import * as storage from "../src/main/storage";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("Multi-Debrid-Downloader product metadata", () => {
it("uses the canonical public product and repository names while preserving the installer identity", () => {
const packageJson = JSON.parse(fs.readFileSync(path.resolve("package.json"), "utf8"));
const verifier = fs.readFileSync(path.resolve("scripts", "verify_public_release.mjs"), "utf8");
const readme = fs.readFileSync(path.resolve("README.md"), "utf8");
const supportBundle = fs.readFileSync(path.resolve("src", "main", "support-bundle.ts"), "utf8");
expect(packageJson.name).toBe("multi-debrid-downloader");
expect(packageJson.build.productName).toBe("Multi-Debrid-Downloader");
expect(packageJson.build.appId).toBe("com.sucukdeluxe.realdebrid");
expect(packageJson.build.publish.repo).toBe("Multi-Debrid-Downloader");
expect(verifier).toContain('repo: "Multi-Debrid-Downloader"');
expect(verifier).toContain('EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader"');
expect(readme).toContain("Sucukdeluxe/Multi-Debrid-Downloader/releases/latest");
expect(readme).toContain("Multi-Debrid-Downloader-Setup-<version>.exe");
expect(readme).not.toContain("Real-Debrid-Downloader-Setup-");
expect(supportBundle).toContain("mdd-support-bundle-");
});
it("moves the legacy user data directory without losing runtime state", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-user-data-"));
tempDirs.push(root);
const legacy = path.join(root, "Real-Debrid-Downloader");
const target = path.join(root, "Multi-Debrid-Downloader");
fs.mkdirSync(path.join(legacy, "runtime"), { recursive: true });
fs.writeFileSync(path.join(legacy, "runtime", "settings.json"), "saved-state", "utf8");
const migrate = (storage as unknown as Record<string, (rootPath: string) => string>).migrateProductUserDataDirectory;
expect(typeof migrate).toBe("function");
expect(migrate(root)).toBe(target);
expect(fs.existsSync(legacy)).toBe(false);
expect(fs.readFileSync(path.join(target, "runtime", "settings.json"), "utf8")).toBe("saved-state");
});
it("keeps using legacy user data when the directory cannot be moved", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-user-data-fallback-"));
tempDirs.push(root);
const legacy = path.join(root, "Real-Debrid-Downloader");
fs.mkdirSync(legacy, { recursive: true });
const rename = fs.renameSync;
fs.renameSync = (() => {
throw Object.assign(new Error("locked"), { code: "EPERM" });
}) as typeof fs.renameSync;
try {
const migrate = (storage as unknown as Record<string, (rootPath: string) => string>).migrateProductUserDataDirectory;
expect(typeof migrate).toBe("function");
expect(migrate(root)).toBe(legacy);
} finally {
fs.renameSync = rename;
}
});
});
+19 -19
View File
@@ -111,14 +111,14 @@ function createReleaseFixture(): string {
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64"); const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
writeFile(rootDir, "package.json", `${JSON.stringify({ writeFile(rootDir, "package.json", `${JSON.stringify({
name: "real-debrid-downloader", name: "multi-debrid-downloader",
version: "1.7.233", version: "1.7.233",
build: { build: {
productName: "Real-Debrid-Downloader", productName: "Multi-Debrid-Downloader",
publish: { publish: {
provider: "github", provider: "github",
owner: "Sucukdeluxe", owner: "Sucukdeluxe",
repo: "multi-debrid-downloader" repo: "Multi-Debrid-Downloader"
}, },
files: [ files: [
"build/main/**/*", "build/main/**/*",
@@ -157,16 +157,16 @@ function createReleaseFixture(): string {
writeFile( writeFile(
rootDir, rootDir,
"latest.yml", "latest.yml",
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n` `version: 1.7.233\nfiles:\n - url: Multi-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Multi-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
); );
writeFile( writeFile(
rootDir, rootDir,
"win-unpacked/resources/app-update.yml", "win-unpacked/resources/app-update.yml",
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n" "provider: github\nowner: Sucukdeluxe\nrepo: Multi-Debrid-Downloader\n"
); );
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload); writeFile(rootDir, "Multi-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap"); writeFile(rootDir, "Multi-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable"); writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable");
writeRedistributionFiles(rootDir); writeRedistributionFiles(rootDir);
writeRedistributionFiles(rootDir, true); writeRedistributionFiles(rootDir, true);
writeFile(rootDir, "assets/app_icon.ico", "application-icon"); writeFile(rootDir, "assets/app_icon.ico", "application-icon");
@@ -190,9 +190,9 @@ describe("public release metadata", () => {
expect(result.publish).toEqual({ expect(result.publish).toEqual({
provider: "github", provider: "github",
owner: "Sucukdeluxe", owner: "Sucukdeluxe",
repo: "multi-debrid-downloader" repo: "Multi-Debrid-Downloader"
}); });
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe"); expect(result.latestArtifact).toBe("Multi-Debrid-Downloader-Setup-1.7.233.exe");
expect(result.missingArtifacts).toEqual([]); expect(result.missingArtifacts).toEqual([]);
}); });
@@ -208,16 +208,16 @@ describe("public release metadata", () => {
it("rejects a latest.yml path whose artifact does not exist", () => { it("rejects a latest.yml path whose artifact does not exist", () => {
const rootDir = createReleaseFixture(); const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe")); fs.rmSync(path.join(rootDir, "Multi-Debrid-Downloader-Setup-1.7.233.exe"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/); expect(() => verifyPublicRelease(rootDir)).toThrow(/Multi-Debrid-Downloader-Setup-1\.7\.233\.exe/);
}); });
it("rejects syntactically invalid latest.yml", () => { it("rejects syntactically invalid latest.yml", () => {
const rootDir = createReleaseFixture(); const rootDir = createReleaseFixture();
fs.writeFileSync( fs.writeFileSync(
path.join(rootDir, "latest.yml"), path.join(rootDir, "latest.yml"),
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n" "version: 1.7.233\nfiles: [\npath: Multi-Debrid-Downloader-Setup-1.7.233.exe\n"
); );
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i); expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
@@ -227,7 +227,7 @@ describe("public release metadata", () => {
const rootDir = createReleaseFixture(); const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml"); const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8").replace( const latest = fs.readFileSync(latestPath, "utf8").replace(
"url: Real-Debrid-Downloader-Setup-1.7.233.exe", "url: Multi-Debrid-Downloader-Setup-1.7.233.exe",
"url: Different-Setup-1.7.233.exe" "url: Different-Setup-1.7.233.exe"
); );
fs.writeFileSync(latestPath, latest); fs.writeFileSync(latestPath, latest);
@@ -247,7 +247,7 @@ describe("public release metadata", () => {
it("rejects a directory in place of an artifact file", () => { it("rejects a directory in place of an artifact file", () => {
const rootDir = createReleaseFixture(); const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"); const setupPath = path.join(rootDir, "Multi-Debrid-Downloader-Setup-1.7.233.exe");
fs.rmSync(setupPath); fs.rmSync(setupPath);
fs.mkdirSync(setupPath); fs.mkdirSync(setupPath);
@@ -256,7 +256,7 @@ describe("public release metadata", () => {
it("rejects an empty artifact file", () => { it("rejects an empty artifact file", () => {
const rootDir = createReleaseFixture(); const rootDir = createReleaseFixture();
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), ""); fs.writeFileSync(path.join(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe"), "");
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i); expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
}); });
@@ -307,7 +307,7 @@ describe("public release metadata", () => {
it("rejects a symlink in place of a release artifact", () => { it("rejects a symlink in place of a release artifact", () => {
const rootDir = createReleaseFixture(); const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"); const setupPath = path.join(rootDir, "Multi-Debrid-Downloader-Setup-1.7.233.exe");
const targetPath = path.join(rootDir, "setup-target.exe"); const targetPath = path.join(rootDir, "setup-target.exe");
fs.renameSync(setupPath, targetPath); fs.renameSync(setupPath, targetPath);
fs.symlinkSync(targetPath, setupPath, "file"); fs.symlinkSync(targetPath, setupPath, "file");
@@ -385,8 +385,8 @@ describe("public release metadata", () => {
}); });
expect(result.verifiedArchives).toEqual([ expect(result.verifiedArchives).toEqual([
"Real-Debrid-Downloader-Setup-1.7.233.exe", "Multi-Debrid-Downloader-Setup-1.7.233.exe",
"Real-Debrid-Downloader-1.7.233-portable.exe" "Multi-Debrid-Downloader-1.7.233-portable.exe"
]); ]);
}); });
+54 -8
View File
@@ -293,6 +293,33 @@ function workspaceModel(): AccountWorkspaceViewModel {
providerOrder: ["Debrid-Link", "Real-Debrid"], providerOrder: ["Debrid-Link", "Real-Debrid"],
routing: ["rapidgator.net → Debrid-Link"], routing: ["rapidgator.net → Debrid-Link"],
autoFallback: true autoFallback: true
},
runtime: {
providers: [
{
id: "realdebrid",
label: "Real-Debrid",
accountCount: 2,
availableAccountCount: 1,
activeDownloads: 3,
dailyUsageText: "12,5 GB"
}
],
accounts: [
{
id: "rd-runtime",
providerLabel: "Real-Debrid",
modeLabel: "API",
identity: "stored-user",
stateLabel: "Cooldown",
stateTone: "warning",
activeDownloads: 0,
dailyUsageText: "4,2 GB",
successRateText: "75 % (3/4)",
lastUsedText: "vor 2 Min.",
cooldownText: "Rate-Limit aktiv · 48 Sek."
}
]
} }
}; };
} }
@@ -644,10 +671,28 @@ describe("account workspace", () => {
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />); const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-horizontal"); expect(html).toContain("ui-sliding-selection ui-sliding-selection-horizontal");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2); expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(3);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1); expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
}); });
it("shows a live provider and account overview without exposing raw failure details", () => {
const html = renderToStaticMarkup(
<AccountWorkspace
actions={workspaceActions()}
model={{ ...workspaceModel(), activePanel: "runtime" as never }}
/>
);
expect(html).toContain("Laufzeit");
expect(html).toContain("Real-Debrid");
expect(html).toContain("1 von 2 verfügbar");
expect(html).toContain("<strong>3</strong> aktive Downloads");
expect(html).toContain("75 % (3/4)");
expect(html).toContain("Rate-Limit aktiv · 48 Sek.");
expect(html).not.toContain("HTTP 429");
expect(html).not.toContain("token-");
});
it("uses roving focus and horizontal keyboard navigation for account tabs", () => { it("uses roving focus and horizontal keyboard navigation for account tabs", () => {
const panels: string[] = []; const panels: string[] = [];
const tree = AccountWorkspace({ const tree = AccountWorkspace({
@@ -656,13 +701,13 @@ describe("account workspace", () => {
}); });
const tabs = findElements(tree, (element) => element.props.role === "tab"); const tabs = findElements(tree, (element) => element.props.role === "tab");
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1]); expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1, -1]);
for (const [key, sourceIndex, targetIndex, panel] of [ for (const [key, sourceIndex, targetIndex, panel] of [
["ArrowRight", 0, 1, "rules"], ["ArrowRight", 0, 1, "rules"],
["ArrowLeft", 0, 1, "rules"], ["ArrowLeft", 0, 2, "runtime"],
["Home", 1, 0, "overview"], ["Home", 1, 0, "overview"],
["End", 0, 1, "rules"] ["End", 0, 2, "runtime"]
] as const) { ] as const) {
const target = compositeKeyboardTarget(tabs.length); const target = compositeKeyboardTarget(tabs.length);
let prevented = false; let prevented = false;
@@ -681,7 +726,7 @@ describe("account workspace", () => {
actions: workspaceActions() actions: workspaceActions()
}); });
const rulesTabs = findElements(rulesTree, (element) => element.props.role === "tab"); const rulesTabs = findElements(rulesTree, (element) => element.props.role === "tab");
expect(rulesTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0]); expect(rulesTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0, -1]);
}); });
it("announces account loading and errors without hiding the existing table state", () => { it("announces account loading and errors without hiding the existing table state", () => {
@@ -870,13 +915,14 @@ describe("account workspace", () => {
expect(stopped).toBe(3); expect(stopped).toBe(3);
}); });
it("keeps overview and rules in the same workspace while only one panel is active", () => { it("keeps all account panels in the same workspace while only one panel is active", () => {
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />); const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
expect(count(html, "class=\"settings-account-panel\"")).toBe(2); expect(count(html, "class=\"settings-account-panel\"")).toBe(3);
expect(count(html, "hidden=\"\"")).toBe(1); expect(count(html, "hidden=\"\"")).toBe(2);
expect(html).toContain("Provider-Reihenfolge"); expect(html).toContain("Provider-Reihenfolge");
expect(html).toContain("Hoster-Routing"); expect(html).toContain("Hoster-Routing");
expect(html).toContain("Automatischer Fallback"); expect(html).toContain("Automatischer Fallback");
expect(html).toContain("Provider-Laufzeit");
}); });
it("keeps add and edit dialogs separate and every secret field protected", () => { it("keeps add and edit dialogs separate and every secret field protected", () => {
+2 -1
View File
@@ -381,7 +381,8 @@ describe("settings storage", () => {
for (const updateRepo of [ for (const updateRepo of [
"legacy-owner/real-debrid-downloader", "legacy-owner/real-debrid-downloader",
"legacy-owner/multi-debrid-downloader", "legacy-owner/multi-debrid-downloader",
"legacy-owner/real-debrid-downloader.git" "legacy-owner/real-debrid-downloader.git",
"Sucukdeluxe/multi-debrid-downloader"
]) { ]) {
const normalized = normalizeSettings({ ...defaultSettings(), updateRepo }); const normalized = normalizeSettings({ ...defaultSettings(), updateRepo });
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo); expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
+16 -16
View File
@@ -46,7 +46,7 @@ afterEach(() => {
describe("update", () => { describe("update", () => {
it("normalizes update repo input", () => { it("normalizes update repo input", () => {
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader"); expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
@@ -86,11 +86,11 @@ describe("update", () => {
html_url: "https://github.com/owner/repo/releases/tag/v9.9.9", html_url: "https://github.com/owner/repo/releases/tag/v9.9.9",
assets: [ assets: [
{ {
name: "Real-Debrid-Downloader-9.9.9-portable.exe", name: "Multi-Debrid-Downloader-9.9.9-portable.exe",
browser_download_url: "https://example.invalid/portable.exe" browser_download_url: "https://example.invalid/portable.exe"
}, },
{ {
name: "Real-Debrid-Downloader-Setup-9.9.9.exe", name: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup.exe", browser_download_url: "https://example.invalid/setup.exe",
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
} }
@@ -105,7 +105,7 @@ describe("update", () => {
const result = await checkGitHubUpdate("owner/repo"); const result = await checkGitHubUpdate("owner/repo");
expect(result.updateAvailable).toBe(true); expect(result.updateAvailable).toBe(true);
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe"); expect(result.setupAssetName).toBe("Multi-Debrid-Downloader-Setup-9.9.9.exe");
}); });
it("combines every stable release note newer than the installed version", async () => { it("combines every stable release note newer than the installed version", async () => {
@@ -185,7 +185,7 @@ describe("update", () => {
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/stale-setup.exe", setupAssetUrl: "https://example.invalid/stale-setup.exe",
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe", setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: `sha256:${executableDigest}` setupAssetDigest: `sha256:${executableDigest}`
}; };
@@ -411,7 +411,7 @@ describe("update", () => {
prerelease: false, prerelease: false,
assets: [ assets: [
{ {
name: "Real-Debrid-Downloader-Setup-9.9.9.exe", name: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe" browser_download_url: "https://example.invalid/setup-no-digest.exe"
}, },
{ {
@@ -427,7 +427,7 @@ describe("update", () => {
if (url.includes("latest.yml")) { if (url.includes("latest.yml")) {
return new Response( return new Response(
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${digestSha512Base64}\n`, `version: 9.9.9\npath: Multi-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${digestSha512Base64}\n`,
{ {
status: 200, status: 200,
headers: { "Content-Type": "text/yaml" } headers: { "Content-Type": "text/yaml" }
@@ -455,7 +455,7 @@ describe("update", () => {
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe", setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe", setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: "" setupAssetDigest: ""
}; };
@@ -479,7 +479,7 @@ describe("update", () => {
prerelease: false, prerelease: false,
assets: [ assets: [
{ {
name: "Real-Debrid-Downloader-Setup-9.9.9.exe", name: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe" browser_download_url: "https://example.invalid/setup-no-digest.exe"
}, },
{ {
@@ -495,7 +495,7 @@ describe("update", () => {
if (url.includes("latest.yml")) { if (url.includes("latest.yml")) {
return new Response( return new Response(
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`, `version: 9.9.9\npath: Multi-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
{ {
status: 200, status: 200,
headers: { "Content-Type": "text/yaml" } headers: { "Content-Type": "text/yaml" }
@@ -523,7 +523,7 @@ describe("update", () => {
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe", setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe", setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: "" setupAssetDigest: ""
}; };
@@ -593,14 +593,14 @@ describe("normalizeUpdateRepo extended", () => {
}); });
it("returns default for malformed inputs", () => { it("returns default for malformed inputs", () => {
expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader"); expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader"); expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
}); });
it("rejects traversal-like owner or repo segments", () => { it("rejects traversal-like owner or repo segments", () => {
expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/multi-debrid-downloader"); expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/multi-debrid-downloader"); expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader"); expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
}); });
it("handles www prefix", () => { it("handles www prefix", () => {
+3 -3
View File
@@ -116,7 +116,7 @@ function createSettings(): AppSettings {
speedLimitEnabled: false, speedLimitEnabled: false,
speedLimitKbps: 0, speedLimitKbps: 0,
speedLimitMode: "global", speedLimitMode: "global",
updateRepo: "Sucukdeluxe/multi-debrid-downloader", updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
autoUpdateCheck: true, autoUpdateCheck: true,
clipboardWatch: true, clipboardWatch: true,
minimizeToTray: false, minimizeToTray: false,
@@ -565,7 +565,7 @@ function createUpdate(updateAvailable: boolean): UpdateCheckResult {
currentVersion: "2.0.12", currentVersion: "2.0.12",
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/tag/v9.9.9", releaseUrl: "https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/tag/v9.9.9",
setupAssetUrl: "https://example.test/Multi-Debrid-Downloader-Setup-9.9.9.exe", setupAssetUrl: "https://example.test/Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe", setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", setupAssetDigest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
@@ -576,7 +576,7 @@ function createUpdate(updateAvailable: boolean): UpdateCheckResult {
currentVersion: "2.0.12", currentVersion: "2.0.12",
latestVersion: "2.0.12", latestVersion: "2.0.12",
latestTag: "v2.0.12", latestTag: "v2.0.12",
releaseUrl: "https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/tag/v2.0.12", releaseUrl: "https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/tag/v2.0.12",
releaseNotes: "Aktuelle Version" releaseNotes: "Aktuelle Version"
}; };
} }