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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest)
|
[](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest)
|
||||||
[](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest)
|
[](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](LICENSE)
|
[](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**.
|
||||||
|
|||||||
Generated
+4
-4
@@ -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
@@ -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/**/*",
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+77
-29
@@ -5,7 +5,8 @@ import { extractHosterFromUrl } from "../shared/hoster";
|
|||||||
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
||||||
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";
|
||||||
@@ -60,12 +61,13 @@ const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000;
|
|||||||
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
|
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
|
||||||
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
|
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
export function resetDebridLinkRuntimeStateForTests(): void {
|
export function resetDebridLinkRuntimeStateForTests(): void {
|
||||||
debridLinkKeyCooldowns.clear();
|
debridLinkKeyCooldowns.clear();
|
||||||
debridLinkKeyCooldownDetails.clear();
|
debridLinkKeyCooldownDetails.clear();
|
||||||
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 {
|
||||||
@@ -333,11 +335,13 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
|||||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||||
megaDebridAccountCooldowns.clear();
|
megaDebridAccountCooldowns.clear();
|
||||||
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;
|
||||||
@@ -480,7 +489,26 @@ 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(
|
||||||
@@ -2268,11 +2299,13 @@ class MegaDebridClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
|
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
|
||||||
link: linkShort
|
link: linkShort
|
||||||
});
|
});
|
||||||
const testStartedAt = Date.now();
|
const runtimeProvider: DebridProvider = mode === "api" ? "megadebrid-api" : "megadebrid-web";
|
||||||
|
recordAccountRuntimeAttempt(runtimeProvider, account.id);
|
||||||
|
const testStartedAt = Date.now();
|
||||||
|
|
||||||
usableAccountSeen = true;
|
usableAccountSeen = true;
|
||||||
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
|
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
|
||||||
@@ -2291,11 +2324,12 @@ class MegaDebridClient {
|
|||||||
megaDebridRotationCursor = idx;
|
megaDebridRotationCursor = idx;
|
||||||
}
|
}
|
||||||
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
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}`,
|
||||||
@@ -2354,7 +2388,8 @@ 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",
|
||||||
@@ -3029,9 +3064,10 @@ class DebridLinkClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
||||||
const testStartedAt = Date.now();
|
recordAccountRuntimeAttempt("debridlink", apiKey.id);
|
||||||
|
const testStartedAt = Date.now();
|
||||||
|
|
||||||
usableKeySeen = true;
|
usableKeySeen = true;
|
||||||
try {
|
try {
|
||||||
@@ -3040,11 +3076,12 @@ class DebridLinkClient {
|
|||||||
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich");
|
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich");
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
fileName: result.fileName || "",
|
fileName: result.fileName || "",
|
||||||
link: linkShort
|
link: linkShort
|
||||||
});
|
});
|
||||||
|
recordAccountRuntimeSuccess("debridlink", apiKey.id);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
sourceLabel: apiKey.label,
|
sourceLabel: apiKey.label,
|
||||||
@@ -3055,7 +3092,7 @@ class DebridLinkClient {
|
|||||||
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||||
if (ranLongEnough) {
|
if (ranLongEnough) {
|
||||||
setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
|
setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
|
||||||
@@ -3069,8 +3106,9 @@ class DebridLinkClient {
|
|||||||
cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0,
|
cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0,
|
||||||
next: "naechster Key beim Retry"
|
next: "naechster Key beim Retry"
|
||||||
});
|
});
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ import { classifyDiskError } from "./fs-error";
|
|||||||
import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor";
|
import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor";
|
||||||
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";
|
||||||
@@ -2576,8 +2577,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? { ...this.summary }
|
? { ...this.summary }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -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") {
|
||||||
@@ -40,7 +41,7 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
|
|||||||
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
|
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
|
||||||
const RENAME_PACKAGE_MAX_CHARS = 240;
|
const RENAME_PACKAGE_MAX_CHARS = 240;
|
||||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||||
"realdebrid",
|
"realdebrid",
|
||||||
"megadebrid-api",
|
"megadebrid-api",
|
||||||
"megadebrid-web",
|
"megadebrid-web",
|
||||||
@@ -50,8 +51,13 @@ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
|||||||
"onefichier",
|
"onefichier",
|
||||||
"debridlink",
|
"debridlink",
|
||||||
"linksnappy"
|
"linksnappy"
|
||||||
]);
|
]);
|
||||||
function validateStringArray(value: unknown, name: string): string[] {
|
|
||||||
|
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[] {
|
||||||
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,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;
|
||||||
|
|||||||
@@ -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)."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -11,8 +11,22 @@ import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDeb
|
|||||||
import { defaultSettings } from "./constants";
|
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";
|
||||||
|
|
||||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
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_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"]);
|
||||||
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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";
|
||||||
@@ -79,7 +80,7 @@ function getAccountPanelNavigationIndex(currentIndex: number, key: string): numb
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountRulesViewModel {
|
export interface AccountRulesViewModel {
|
||||||
providerOrder: readonly string[];
|
providerOrder: readonly string[];
|
||||||
routing: readonly string[];
|
routing: readonly string[];
|
||||||
autoFallback: boolean;
|
autoFallback: boolean;
|
||||||
@@ -91,8 +92,36 @@ export interface AccountRulesViewModel {
|
|||||||
provider: string;
|
provider: string;
|
||||||
providers: readonly { value: string; label: string }[];
|
providers: readonly { value: string; label: string }[];
|
||||||
}[];
|
}[];
|
||||||
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;
|
||||||
@@ -101,8 +130,9 @@ export interface AccountWorkspaceViewModel {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
statusSort?: "none" | "desc" | "asc";
|
statusSort?: "none" | "desc" | "asc";
|
||||||
rules: AccountRulesViewModel;
|
rules: AccountRulesViewModel;
|
||||||
}
|
runtime: AccountRuntimeViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountWorkspaceActions {
|
export interface AccountWorkspaceActions {
|
||||||
onPanelChange: (panel: AccountWorkspacePanel) => void;
|
onPanelChange: (panel: AccountWorkspacePanel) => void;
|
||||||
@@ -464,7 +494,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="settings-account-rules">
|
<div className="settings-account-rules">
|
||||||
<section className="settings-rule-section">
|
<section className="settings-rule-section">
|
||||||
@@ -578,9 +608,61 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -628,18 +710,27 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
|
|||||||
>
|
>
|
||||||
{AccountOverview({ actions, model })}
|
{AccountOverview({ actions, model })}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
aria-labelledby="settings-account-rules-tab"
|
aria-labelledby="settings-account-rules-tab"
|
||||||
className="settings-account-panel"
|
className="settings-account-panel"
|
||||||
hidden={model.activePanel !== "rules"}
|
hidden={model.activePanel !== "rules"}
|
||||||
id="settings-account-rules"
|
id="settings-account-rules"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
>
|
>
|
||||||
{AccountRules({ actions, model })}
|
{AccountRules({ actions, model })}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountAddDialog({
|
export function AccountAddDialog({
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+25
-7
@@ -493,7 +493,7 @@ export interface ContainerImportResult {
|
|||||||
source: "dlc";
|
source: "dlc";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RotationEvent {
|
export interface RotationEvent {
|
||||||
id: string;
|
id: string;
|
||||||
at: number;
|
at: number;
|
||||||
level: "INFO" | "WARN" | "ERROR";
|
level: "INFO" | "WARN" | "ERROR";
|
||||||
@@ -503,9 +503,26 @@ export interface RotationEvent {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
cooldownSec?: number;
|
cooldownSec?: number;
|
||||||
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[];
|
||||||
@@ -533,9 +550,10 @@ export interface UiSnapshot {
|
|||||||
}>;
|
}>;
|
||||||
payloadKind?: "full" | "delta";
|
payloadKind?: "full" | "delta";
|
||||||
removedItemIds?: string[];
|
removedItemIds?: string[];
|
||||||
removedPackageIds?: string[];
|
removedPackageIds?: string[];
|
||||||
rotationEvents?: RotationEvent[];
|
rotationEvents?: RotationEvent[];
|
||||||
}
|
accountRuntime?: AccountRuntimeEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddLinksPayload {
|
export interface AddLinksPayload {
|
||||||
rawText: string;
|
rawText: string;
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -379,9 +379,10 @@ describe("settings storage", () => {
|
|||||||
|
|
||||||
it("migrates the previous private update repository to the public release repository", () => {
|
it("migrates the previous private update repository to the public release repository", () => {
|
||||||
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
@@ -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", () => {
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user