diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7e189..722f8f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ## [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 ### Real-Debrid accounts diff --git a/README.md b/README.md index f588914..bf3f343 100644 --- a/README.md +++ b/README.md @@ -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. -[![Latest release](https://img.shields.io/github/v/release/Sucukdeluxe/multi-debrid-downloader?display_name=tag)](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest) -[![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D6)](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest) +[![Latest release](https://img.shields.io/github/v/release/Sucukdeluxe/Multi-Debrid-Downloader?display_name=tag)](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest) +[![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D6)](https://github.com/Sucukdeluxe/Multi-Debrid-Downloader/releases/latest) [![Electron](https://img.shields.io/badge/Electron-31-47848F)](https://www.electronjs.org/) [![License](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE) @@ -17,12 +17,12 @@ The interface is available in English and German. New installations start in Eng ## Download -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 | | --- | --- | -| `Real-Debrid-Downloader-Setup-.exe` | Normal installation with Start menu and optional desktop shortcut | -| `Real-Debrid-Downloader--portable.exe` | Portable use without installation | +| `Multi-Debrid-Downloader-Setup-.exe` | Normal installation with Start menu and optional desktop shortcut | +| `Multi-Debrid-Downloader--portable.exe` | Portable use without installation | | `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. @@ -124,7 +124,7 @@ The Downloads workspace is optimized for large queues: ## 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. 3. Configure provider order and optional hoster routing under **Usage rules**. 4. Add links from **Downloads** or prepare collections in **Link collector**. diff --git a/package-lock.json b/package-lock.json index 7b9ca0a..0b59f8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "real-debrid-downloader", - "version": "2.0.42", + "name": "multi-debrid-downloader", + "version": "2.0.43", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "real-debrid-downloader", - "version": "2.0.42", + "name": "multi-debrid-downloader", + "version": "2.0.43", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 2d2197c..ef81037 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "real-debrid-downloader", - "version": "2.0.42", + "name": "multi-debrid-downloader", + "version": "2.0.43", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", @@ -51,7 +51,7 @@ }, "build": { "appId": "com.sucukdeluxe.realdebrid", - "productName": "Real-Debrid-Downloader", + "productName": "Multi-Debrid-Downloader", "directories": { "buildResources": "assets", "output": "release" @@ -59,7 +59,7 @@ "publish": { "provider": "github", "owner": "Sucukdeluxe", - "repo": "multi-debrid-downloader" + "repo": "Multi-Debrid-Downloader" }, "files": [ "build/main/**/*", diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs index e73c639..9706830 100644 --- a/scripts/verify_public_release.mjs +++ b/scripts/verify_public_release.mjs @@ -9,9 +9,9 @@ import { parse } from "yaml"; const EXPECTED_PUBLISH = Object.freeze({ provider: "github", owner: "Sucukdeluxe", - repo: "multi-debrid-downloader" -}); -const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader"; + repo: "Multi-Debrid-Downloader" +}); +const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader"; const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; const REQUIRED_BUILD_FILES = Object.freeze([ diff --git a/src/main/account-runtime-snapshot.ts b/src/main/account-runtime-snapshot.ts new file mode 100644 index 0000000..0b98160 --- /dev/null +++ b/src/main/account-runtime-snapshot.ts @@ -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 +): { 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(); + 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 + }; + }); +} diff --git a/src/main/account-runtime.ts b/src/main/account-runtime.ts new file mode 100644 index 0000000..9dbbde9 --- /dev/null +++ b/src/main/account-runtime.ts @@ -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(); + +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): 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); + } +} diff --git a/src/main/constants.ts b/src/main/constants.ts index 6c1c55d..911a1d3 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -37,7 +37,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; export const SPEED_WINDOW_SECONDS = 1; 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 function defaultSettings(): AppSettings { diff --git a/src/main/debrid.ts b/src/main/debrid.ts index bf0e240..e28ab08 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -5,7 +5,8 @@ import { extractHosterFromUrl } from "../shared/hoster"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached, isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits"; 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 { logAccountRotation } from "./account-rotation-log"; import { traceConversionPhase } from "./conversion-trace"; @@ -15,7 +16,7 @@ import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; 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 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_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000; -export function resetDebridLinkRuntimeStateForTests(): void { +export function resetDebridLinkRuntimeStateForTests(): void { debridLinkKeyCooldowns.clear(); debridLinkKeyCooldownDetails.clear(); debridLinkKeyRuntimeStatuses.clear(); debridLinkKeyHostCooldowns.clear(); - debridLinkKeyHostCooldownDetails.clear(); + debridLinkKeyHostCooldownDetails.clear(); + resetAccountRuntimeSessionForProvider("debridlink"); } export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set): void { @@ -333,11 +335,13 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void { megaDebridEmptyResponseStreaks.delete(accountId); } -export function resetMegaDebridRuntimeStateForTests(): void { +export function resetMegaDebridRuntimeStateForTests(): void { megaDebridAccountCooldowns.clear(); megaDebridEmptyResponseStreaks.clear(); megaDebridRotationCursor = 0; - megaDebridStickyCount = 0; + megaDebridStickyCount = 0; + resetAccountRuntimeSessionForProvider("megadebrid-api"); + resetAccountRuntimeSessionForProvider("megadebrid-web"); megaDebridInFlight.clear(); } @@ -459,6 +463,11 @@ export interface ProviderRuntimeSnapshot { stickyCount: number; cooldownCount: number; inFlightCount: number; + accounts: Array<{ + accountId: string; + cooldown: ProviderRuntimeCooldown | null; + inFlight: number; + }>; }; megaDebrid: { 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([ + ...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([ ...megaDebridAccountCooldowns.keys(), ...megaDebridInFlight.keys(), @@ -545,7 +573,8 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna rotationCursor: realDebridRotationCursor, stickyCount: realDebridStickyCount, 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: { rotationCursor: megaDebridRotationCursor, @@ -660,6 +689,7 @@ export function resetRealDebridRuntimeStateForTests(): void { realDebridRotationCursor = 0; realDebridStickyAccountId = ""; realDebridStickyCount = 0; + resetAccountRuntimeSessionForProvider("realdebrid"); } export function pruneRealDebridRuntimeStateForAccounts(activeAccountIds: Set): void { @@ -698,9 +728,10 @@ export function pruneExpiredRealDebridRuntimeState(now = Date.now()): number { export function primeRealDebridRuntimeCooldownForTests( accountId: string, cooldownMs: number, - message = "Real-Debrid Account im Cooldown" + message = "Real-Debrid Account im Cooldown", + category: RealDebridCooldownCategory = "temporary" ): void { - setRealDebridAccountCooldown(accountId, cooldownMs, message, "temporary"); + setRealDebridAccountCooldown(accountId, cooldownMs, message, category); } function setRealDebridAccountCooldown( @@ -2268,11 +2299,13 @@ class MegaDebridClient { continue; } - logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`); - logAccountRotation("INFO", providerName, rotationLabel, "TEST", { - link: linkShort - }); - const testStartedAt = Date.now(); + logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`); + logAccountRotation("INFO", providerName, rotationLabel, "TEST", { + link: linkShort + }); + const runtimeProvider: DebridProvider = mode === "api" ? "megadebrid-api" : "megadebrid-web"; + recordAccountRuntimeAttempt(runtimeProvider, account.id); + const testStartedAt = Date.now(); usableAccountSeen = true; megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1); @@ -2291,11 +2324,12 @@ class MegaDebridClient { megaDebridRotationCursor = idx; } logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); - logAccountRotation("INFO", providerName, rotationLabel, "OK", { + logAccountRotation("INFO", providerName, rotationLabel, "OK", { elapsedMs, fileName: result.fileName || "", - link: linkShort - }); + link: linkShort + }); + recordAccountRuntimeSuccess(runtimeProvider, account.id); return { ...result, sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`, @@ -2354,7 +2388,8 @@ class MegaDebridClient { } throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); } - const failure = MegaDebridClient.classifyAccountFailure(error); + const failure = MegaDebridClient.classifyAccountFailure(error); + recordAccountRuntimeFailure(runtimeProvider, account.id); traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", @@ -3029,9 +3064,10 @@ class DebridLinkClient { continue; } - logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`); - logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); - const testStartedAt = Date.now(); + logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`); + logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); + recordAccountRuntimeAttempt("debridlink", apiKey.id); + const testStartedAt = Date.now(); usableKeySeen = true; try { @@ -3040,11 +3076,12 @@ class DebridLinkClient { setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich"); const elapsedMs = Date.now() - testStartedAt; logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); - logAccountRotation("INFO", providerName, rotationLabel, "OK", { + logAccountRotation("INFO", providerName, rotationLabel, "OK", { elapsedMs, fileName: result.fileName || "", - link: linkShort - }); + link: linkShort + }); + recordAccountRuntimeSuccess("debridlink", apiKey.id); return { ...result, sourceLabel: apiKey.label, @@ -3055,7 +3092,7 @@ class DebridLinkClient { const failure = await this.classifyKeyFailure(error, apiKey, link, signal); const elapsedMs = Date.now() - testStartedAt; 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(); if (ranLongEnough) { 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, 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({ message: `Debrid-Link${keyLabel}: ${failure.message}`, cooldownMs: failure.cooldownMs, @@ -3854,7 +3892,14 @@ export class DebridService { } const nextDebridLinkKeyIds = new Set(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); 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(); + 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 { @@ -4292,6 +4337,7 @@ export class DebridService { const account = this.selectRealDebridAccount(available); attempted.add(account.id); realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1); + recordAccountRuntimeAttempt("realdebrid", account.id); const timeoutMs = getRealDebridAccountAttemptTimeoutMs(); const timeoutSignal = AbortSignal.timeout(timeoutMs); const accountSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; @@ -4313,6 +4359,7 @@ export class DebridService { if (realDebridStickyCount >= REAL_DEBRID_STICKY_LINKS && accountIndex >= 0) { realDebridRotationCursor = (accountIndex + 1) % available.length; } + recordAccountRuntimeSuccess("realdebrid", account.id); return { ...result, sourceLabel: account.kind === "api" ? "API" : "Web", @@ -4324,6 +4371,7 @@ export class DebridService { throw error; } const failure = this.classifyRealDebridFailure(error); + recordAccountRuntimeFailure("realdebrid", account.id); if (!failure.rotateAccount) { throw error; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index c662d35..bb76e58 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -65,7 +65,8 @@ import { classifyDiskError } from "./fs-error"; import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; import { sendNotification } from "./notify"; 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 type { RotationEvent } from "../shared/types"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; @@ -2576,8 +2577,9 @@ export class DownloadManager extends EventEmitter { ? { ...this.summary } : null; - return { - rotationEvents: getRecentRotationEvents(40), + return { + rotationEvents: getRecentRotationEvents(40), + accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now), settings: rendererState.settings, accounts: rendererState.accounts, session: snapshotSession, diff --git a/src/main/extractor.ts b/src/main/extractor.ts index f1923d9..da640b1 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -214,7 +214,7 @@ export function cleanupStaleSubstDrives(): void { if (!match) continue; const drive = match[1].toUpperCase(); 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 }); logger.info(`Stale subst ${drive}: entfernt (${target})`); } diff --git a/src/main/link-export.ts b/src/main/link-export.ts index 8d20427..3354c1e 100644 --- a/src/main/link-export.ts +++ b/src/main/link-export.ts @@ -88,7 +88,7 @@ export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: strin export function serializeLinkExportText(packages: ParsedPackageInput[]): string { const lines: string[] = [ "# 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.", "" ]; diff --git a/src/main/main.ts b/src/main/main.ts index 75f3d21..e17c921 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -22,6 +22,7 @@ import { validateRendererSettingsUpdate } from "./renderer-settings"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { validateRealDebridLoginRequest } from "../shared/preload-api"; +import { migrateProductUserDataDirectory } from "./storage"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -40,7 +41,7 @@ function validatePlainObject(value: unknown, name: string): Record([ +const RESETTABLE_PROVIDER_KEYS = new Set([ "realdebrid", "megadebrid-api", "megadebrid-web", @@ -50,8 +51,13 @@ const RESETTABLE_PROVIDER_KEYS = new Set([ "onefichier", "debridlink", "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")) { throw new Error(`${name} muss ein String-Array sein`); } diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 0d693c6..8a1e17a 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -1,7 +1,7 @@ import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants"; 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 { fileName: string; diff --git a/src/main/startup-health-check.ts b/src/main/startup-health-check.ts index 5ffe1fd..b0e12de 100644 --- a/src/main/startup-health-check.ts +++ b/src/main/startup-health-check.ts @@ -188,7 +188,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora severity: "ERROR", code: "baseDir_not_writable", 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)." }); } diff --git a/src/main/storage.ts b/src/main/storage.ts index 86f8595..35a4bd8 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -11,8 +11,22 @@ import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDeb import { defaultSettings } from "./constants"; import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection"; 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_CLEANUP_MODES = new Set(["none", "trash", "delete"]); 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 { const trimmed = raw.trim(); 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 trimmed; diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index e1ae524..4180792 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -94,7 +94,7 @@ function formatTimestampForFileName(date: Date): 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"; diff --git a/src/main/update.ts b/src/main/update.ts index 9ef4948..c18870c 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -14,7 +14,7 @@ const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000; const RETRIES_PER_CANDIDATE = 3; const RETRY_DELAY_MS = 1_500; const MAX_DOWNLOAD_PASSES = 3; -const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; +const USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`; type UpdateSource = { name: string; diff --git a/src/main/windows-host-diagnostics.ts b/src/main/windows-host-diagnostics.ts index b96f119..d9c6b54 100644 --- a/src/main/windows-host-diagnostics.ts +++ b/src/main/windows-host-diagnostics.ts @@ -221,7 +221,7 @@ $appCrashes = @( Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 | Where-Object { ($_.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 | ForEach-Object { Convert-EventRecord $_ } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 934d635..854e743 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1593,7 +1593,7 @@ export function App(): ReactElement { const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); - const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview"); + const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview"); const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState>(() => new Set()); const [openSubmenu, setOpenSubmenu] = useState(null); const [startConflictPrompt, setStartConflictPrompt] = useState(null); @@ -5011,6 +5011,130 @@ export function App(): ReactElement { const visibleAccountRows = useMemo(() => accountStatusSort === "none" ? projectedAccountRows : sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]); + const accountRuntimeModel = useMemo(() => { + const runtimeEntries = snapshot.accountRuntime || []; + const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry])); + const runtimeAccountIdCounts = new Map(); + 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(); + 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 usedRoutingHosters = useMemo(() => new Set(routingEntries.map(([hosterId]) => hosterId)), [routingEntries]); const routingProviderOptions = useMemo(() => configuredProviders.map((provider) => ({ @@ -5023,6 +5147,7 @@ export function App(): ReactElement { selectedIds: selectedAccountViewIds, busy: actionBusy || accountCheckBusy, statusSort: accountStatusSort, + runtime: accountRuntimeModel, rules: { providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)), routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId} → ${providerLabelWithMode(provider, settingsDraft)}`), diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 44f54a7..48a9fa2 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -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"], ["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"], - ["Ü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"], ["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"], @@ -236,6 +239,16 @@ function translateDynamic(value: string, language: AppLanguage): string { if (pageStatus) return `Page ${pageStatus[1]} of ${pageStatus[2]}`; const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/); 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$/); if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`; 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`; const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/); 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$/); if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`; const actionsFor = value.match(/^Actions for (.+)$/); diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index 97f2930..3a34031 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -29,11 +29,12 @@ import { type AccountTableColumnWidths } from "./settings-model"; -export type AccountWorkspacePanel = "overview" | "rules"; +export type AccountWorkspacePanel = "overview" | "rules" | "runtime"; const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: string }[] = [ { 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"; @@ -79,7 +80,7 @@ function getAccountPanelNavigationIndex(currentIndex: number, key: string): numb return null; } -export interface AccountRulesViewModel { +export interface AccountRulesViewModel { providerOrder: readonly string[]; routing: readonly string[]; autoFallback: boolean; @@ -91,8 +92,36 @@ export interface AccountRulesViewModel { provider: 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 { activePanel: AccountWorkspacePanel; @@ -101,8 +130,9 @@ export interface AccountWorkspaceViewModel { busy: boolean; error?: string; statusSort?: "none" | "desc" | "asc"; - rules: AccountRulesViewModel; -} + rules: AccountRulesViewModel; + runtime: AccountRuntimeViewModel; +} export interface AccountWorkspaceActions { 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 (
@@ -578,9 +608,61 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
) : null}
- ); -} - + ); +} + +function AccountRuntime({ model }: AccountWorkspaceProps): ReactElement { + return ( +
+
+ {model.runtime.providers.length === 0 ? ( +
Noch keine Accounts konfiguriert.
+ ) : model.runtime.providers.map((provider) => ( +
+
+

{provider.label}

+ {provider.availableAccountCount} von {provider.accountCount} verfügbar +
+
+ {provider.activeDownloads}{provider.activeDownloads === 1 ? " aktiver Download" : " aktive Downloads"} + {provider.dailyUsageText} heute +
+
+ ))} +
+
+
+
+ Account + Zustand + Aktive Downloads + Heute + Erfolgsquote · Diese Sitzung + Zuletzt verwendet + Cooldown / Grund +
+ {model.runtime.accounts.length === 0 ? ( +
Noch keine Laufzeitdaten verfügbar.
+ ) : model.runtime.accounts.map((account) => ( +
+ + {account.providerLabel} + {account.modeLabel}{account.identity && account.identity !== "—" ? ` · ${account.identity}` : ""} + + {account.stateLabel} + {account.activeDownloads} + {account.dailyUsageText} + {account.successRateText} + {account.lastUsedText} + {account.cooldownText} +
+ ))} +
+
+
+ ); +} + export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement { return (
@@ -628,18 +710,27 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea > {AccountOverview({ actions, model })}
- - - ); -} + > + {AccountRules({ actions, model })} + + + + ); +} export function AccountAddDialog({ model, diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index a31f73b..e0c7a43 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -850,6 +850,155 @@ 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 { display: grid; gap: 12px; diff --git a/src/shared/types.ts b/src/shared/types.ts index 6c7ff25..f0ce4aa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -493,7 +493,7 @@ export interface ContainerImportResult { source: "dlc"; } -export interface RotationEvent { +export interface RotationEvent { id: string; at: number; level: "INFO" | "WARN" | "ERROR"; @@ -503,9 +503,26 @@ export interface RotationEvent { reason?: string; category?: string; 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 { settings: RendererSettings; accounts: RendererAccount[]; @@ -533,9 +550,10 @@ export interface UiSnapshot { }>; payloadKind?: "full" | "delta"; removedItemIds?: string[]; - removedPackageIds?: string[]; - rotationEvents?: RotationEvent[]; -} + removedPackageIds?: string[]; + rotationEvents?: RotationEvent[]; + accountRuntime?: AccountRuntimeEntry[]; +} export interface AddLinksPayload { rawText: string; diff --git a/tests/account-layout.test.ts b/tests/account-layout.test.ts index 4bd55c7..e76ec9d 100644 --- a/tests/account-layout.test.ts +++ b/tests/account-layout.test.ts @@ -11,11 +11,12 @@ const styles = readFileSync( ); 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('
'); - 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 !== "rules"}'); + expect(workspaceSource).toContain('hidden={model.activePanel !== "runtime"}'); 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 ); diff --git a/tests/account-runtime.test.ts b/tests/account-runtime.test.ts new file mode 100644 index 0000000..31e7234 --- /dev/null +++ b/tests/account-runtime.test.ts @@ -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 + })); + }); +}); diff --git a/tests/browser-security.test.ts b/tests/browser-security.test.ts index c14cfa5..c7f7472 100644 --- a/tests/browser-security.test.ts +++ b/tests/browser-security.test.ts @@ -147,13 +147,13 @@ describe("browser-security", () => { 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"); expect(allowed.preventDefault).toHaveBeenCalledTimes(1); expect(lookalike.preventDefault).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", () => { @@ -163,13 +163,13 @@ describe("browser-security", () => { 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"); expect(allowed.preventDefault).toHaveBeenCalledTimes(1); expect(lookalike.preventDefault).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", () => { diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 2928992..63c24d5 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -6,6 +6,7 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; 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 { getAccountRuntimeSessionStats } from "../src/main/account-runtime"; const originalFetch = globalThis.fetch; @@ -2857,6 +2858,12 @@ describe("Real-Debrid account rotation", () => { expect(result.sourceAccountId).toBe("rda_one"); 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 () => { @@ -2878,6 +2885,8 @@ describe("Real-Debrid account rotation", () => { expect(result.sourceAccountId).toBe("rda_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"]); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 5039d49..9557c71 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -13304,6 +13304,81 @@ describe("download manager", () => { 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 () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 2554562..53515b2 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -84,6 +84,10 @@ describe("renderer localization", () => { 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("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("Keine passenden Dienste oder Zugangstypen gefunden.", "en")) .toBe("No matching services or access types found."); diff --git a/tests/product-metadata.test.ts b/tests/product-metadata.test.ts new file mode 100644 index 0000000..2bede4c --- /dev/null +++ b/tests/product-metadata.test.ts @@ -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-.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>).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>).migrateProductUserDataDirectory; + expect(typeof migrate).toBe("function"); + expect(migrate(root)).toBe(legacy); + } finally { + fs.renameSync = rename; + } + }); +}); diff --git a/tests/public-release-metadata.test.ts b/tests/public-release-metadata.test.ts index a19d563..3d83ccd 100644 --- a/tests/public-release-metadata.test.ts +++ b/tests/public-release-metadata.test.ts @@ -111,14 +111,14 @@ function createReleaseFixture(): string { const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64"); writeFile(rootDir, "package.json", `${JSON.stringify({ - name: "real-debrid-downloader", + name: "multi-debrid-downloader", version: "1.7.233", build: { - productName: "Real-Debrid-Downloader", + productName: "Multi-Debrid-Downloader", publish: { provider: "github", owner: "Sucukdeluxe", - repo: "multi-debrid-downloader" + repo: "Multi-Debrid-Downloader" }, files: [ "build/main/**/*", @@ -157,16 +157,16 @@ function createReleaseFixture(): string { writeFile( rootDir, "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( rootDir, "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, "Real-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-Setup-1.7.233.exe", setupPayload); + writeFile(rootDir, "Multi-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap"); + writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable"); writeRedistributionFiles(rootDir); writeRedistributionFiles(rootDir, true); writeFile(rootDir, "assets/app_icon.ico", "application-icon"); @@ -190,9 +190,9 @@ describe("public release metadata", () => { expect(result.publish).toEqual({ provider: "github", 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([]); }); @@ -208,16 +208,16 @@ describe("public release metadata", () => { it("rejects a latest.yml path whose artifact does not exist", () => { 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", () => { const rootDir = createReleaseFixture(); fs.writeFileSync( 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); @@ -227,7 +227,7 @@ describe("public release metadata", () => { const rootDir = createReleaseFixture(); const latestPath = path.join(rootDir, "latest.yml"); 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" ); fs.writeFileSync(latestPath, latest); @@ -247,7 +247,7 @@ describe("public release metadata", () => { it("rejects a directory in place of an artifact file", () => { 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.mkdirSync(setupPath); @@ -256,7 +256,7 @@ describe("public release metadata", () => { it("rejects an empty artifact file", () => { 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); }); @@ -307,7 +307,7 @@ describe("public release metadata", () => { it("rejects a symlink in place of a release artifact", () => { 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"); fs.renameSync(setupPath, targetPath); fs.symlinkSync(targetPath, setupPath, "file"); @@ -385,8 +385,8 @@ describe("public release metadata", () => { }); expect(result.verifiedArchives).toEqual([ - "Real-Debrid-Downloader-Setup-1.7.233.exe", - "Real-Debrid-Downloader-1.7.233-portable.exe" + "Multi-Debrid-Downloader-Setup-1.7.233.exe", + "Multi-Debrid-Downloader-1.7.233-portable.exe" ]); }); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index c325c68..3b2f6c2 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -293,6 +293,33 @@ function workspaceModel(): AccountWorkspaceViewModel { providerOrder: ["Debrid-Link", "Real-Debrid"], routing: ["rapidgator.net → Debrid-Link"], 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(); 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); }); + it("shows a live provider and account overview without exposing raw failure details", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Laufzeit"); + expect(html).toContain("Real-Debrid"); + expect(html).toContain("1 von 2 verfügbar"); + expect(html).toContain("3 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", () => { const panels: string[] = []; const tree = AccountWorkspace({ @@ -656,13 +701,13 @@ describe("account workspace", () => { }); 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 [ ["ArrowRight", 0, 1, "rules"], - ["ArrowLeft", 0, 1, "rules"], + ["ArrowLeft", 0, 2, "runtime"], ["Home", 1, 0, "overview"], - ["End", 0, 1, "rules"] + ["End", 0, 2, "runtime"] ] as const) { const target = compositeKeyboardTarget(tabs.length); let prevented = false; @@ -681,7 +726,7 @@ describe("account workspace", () => { actions: workspaceActions() }); 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", () => { @@ -870,13 +915,14 @@ describe("account workspace", () => { 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(); - expect(count(html, "class=\"settings-account-panel\"")).toBe(2); - expect(count(html, "hidden=\"\"")).toBe(1); + expect(count(html, "class=\"settings-account-panel\"")).toBe(3); + expect(count(html, "hidden=\"\"")).toBe(2); expect(html).toContain("Provider-Reihenfolge"); expect(html).toContain("Hoster-Routing"); expect(html).toContain("Automatischer Fallback"); + expect(html).toContain("Provider-Laufzeit"); }); it("keeps add and edit dialogs separate and every secret field protected", () => { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 2749e3c..116a40d 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -379,9 +379,10 @@ describe("settings storage", () => { it("migrates the previous private update repository to the public release repository", () => { for (const updateRepo of [ - "legacy-owner/real-debrid-downloader", - "legacy-owner/multi-debrid-downloader", - "legacy-owner/real-debrid-downloader.git" + "legacy-owner/real-debrid-downloader", + "legacy-owner/multi-debrid-downloader", + "legacy-owner/real-debrid-downloader.git", + "Sucukdeluxe/multi-debrid-downloader" ]) { const normalized = normalizeSettings({ ...defaultSettings(), updateRepo }); expect(normalized.updateRepo).toBe(defaultSettings().updateRepo); diff --git a/tests/update.test.ts b/tests/update.test.ts index aa662b3..72cd19d 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -46,7 +46,7 @@ afterEach(() => { describe("update", () => { 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("https://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", 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" }, { - 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", digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } @@ -105,7 +105,7 @@ describe("update", () => { const result = await checkGitHubUpdate("owner/repo"); expect(result.updateAvailable).toBe(true); 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 () => { @@ -185,7 +185,7 @@ describe("update", () => { latestTag: "v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", 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}` }; @@ -411,7 +411,7 @@ describe("update", () => { prerelease: false, 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" }, { @@ -427,7 +427,7 @@ describe("update", () => { if (url.includes("latest.yml")) { 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, headers: { "Content-Type": "text/yaml" } @@ -455,7 +455,7 @@ describe("update", () => { latestTag: "v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", 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: "" }; @@ -479,7 +479,7 @@ describe("update", () => { prerelease: false, 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" }, { @@ -495,7 +495,7 @@ describe("update", () => { if (url.includes("latest.yml")) { 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, headers: { "Content-Type": "text/yaml" } @@ -523,7 +523,7 @@ describe("update", () => { latestTag: "v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", 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: "" }; @@ -593,14 +593,14 @@ describe("normalizeUpdateRepo extended", () => { }); it("returns default for malformed inputs", () => { - expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader"); - expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader"); + expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/Multi-Debrid-Downloader"); + expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/Multi-Debrid-Downloader"); }); 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("https://github.com/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"); }); it("handles www prefix", () => { diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index f19d910..1fdd3da 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -116,7 +116,7 @@ function createSettings(): AppSettings { speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", - updateRepo: "Sucukdeluxe/multi-debrid-downloader", + updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader", autoUpdateCheck: true, clipboardWatch: true, minimizeToTray: false, @@ -565,7 +565,7 @@ function createUpdate(updateAvailable: boolean): UpdateCheckResult { currentVersion: "2.0.12", latestVersion: "9.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", setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe", setupAssetDigest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", @@ -576,7 +576,7 @@ function createUpdate(updateAvailable: boolean): UpdateCheckResult { currentVersion: "2.0.12", latestVersion: "2.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" }; }