feat: add Deepbrid, daily scheduling, and notification center
Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery.
This commit is contained in:
@@ -4,6 +4,31 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.71] - 2026-08-24
|
||||
|
||||
### Deepbrid accounts
|
||||
|
||||
- Add Deepbrid API-key accounts with encrypted credential storage, account validation, premium status, expiration details, usage counters, and explicit secret reveal controls.
|
||||
- Route downloads through Deepbrid in the normal provider order, hoster rules, daily limits, cooldown handling, and automatic fallback chain.
|
||||
- Validate API responses, retry only rate limits and temporary failures, reject unsafe download targets and filenames, and keep keys and provider responses out of logs and diagnostics.
|
||||
- Add the Deepbrid service icon and verified support for generating and downloading 1Fichier links.
|
||||
|
||||
### Recurring daily starts
|
||||
|
||||
- Add a persistent daily queue schedule using the local time zone with separate start-today and start-tomorrow choices.
|
||||
- Reconcile schedules after restarts, account changes, suspend, and resume while preventing duplicate starts for the same local calendar day.
|
||||
- Preserve existing one-time schedules during unrelated settings saves and show the next daily start as a live countdown.
|
||||
|
||||
### Discord notifications
|
||||
|
||||
- Add detailed package and run results, grouped or individual success messages, remaining-volume thresholds, confirmed download stalls, and recovery notifications.
|
||||
- Persist notifications across restarts with bounded retries, rate-limit handling, FIFO delivery, shutdown recovery, and protection against transient disk-write failures.
|
||||
- Store and export only fixed failure categories so local paths, URLs, provider accounts, and raw errors are excluded from Discord messages and support bundles.
|
||||
|
||||
### Hoster identification
|
||||
|
||||
- Show local 1Fichier and DDownload hoster icons with full names in tooltips throughout the download queue.
|
||||
|
||||
## [2.0.70] - 2026-08-24
|
||||
|
||||
### Real-Debrid browser accounts
|
||||
|
||||
@@ -46,6 +46,7 @@ Windows 10 or Windows 11 is required. Release executables are currently unsigned
|
||||
| Real-Debrid | API token, browser login |
|
||||
| AllDebrid | API token, browser login |
|
||||
| BestDebrid | API token, cookie import |
|
||||
| Deepbrid | API key |
|
||||
| Debrid-Link | Multiple API keys |
|
||||
| Mega-Debrid | API, web login |
|
||||
| LinkSnappy | Account login |
|
||||
@@ -59,7 +60,7 @@ Provider availability and supported hosters can change independently of the appl
|
||||
The Downloads workspace is optimized for large queues:
|
||||
|
||||
- Compact package and file rows with progress, downloaded size, total size, hoster, service, priority, status, speed, and availability.
|
||||
- RapidGator and DDownload hoster icons with full names in tooltips.
|
||||
- RapidGator, 1Fichier, and DDownload hoster icons with full names in tooltips.
|
||||
- Online counters for packages and clear online, partial, checking, and offline states.
|
||||
- Drag-reorderable columns with a smooth full-column preview and persisted layout.
|
||||
- Green progress indicators, animated package expansion, and clear selected-row highlighting.
|
||||
@@ -93,7 +94,7 @@ The Downloads workspace is optimized for large queues:
|
||||
- Global or per-download speed limits and time-based bandwidth schedules.
|
||||
- Package speed, ETA, progress, and live bandwidth statistics.
|
||||
- Duplicate handling with keep, skip, or overwrite choices.
|
||||
- Optional scheduled queue start and automatic resume after restart.
|
||||
- Optional one-time or recurring daily queue starts with local-time scheduling and automatic resume after restart.
|
||||
|
||||
## Extraction and cleanup
|
||||
|
||||
@@ -120,6 +121,7 @@ The Downloads workspace is optimized for large queues:
|
||||
- Configurable download folder, queue behavior, history retention, notifications, extraction, cleanup, and bandwidth.
|
||||
- GitHub update checks with localized release notes and download actions.
|
||||
- Backup export/import for settings and optional queue data.
|
||||
- Discord webhook notifications for package and run results, remaining volume, confirmed stalls, and recovery.
|
||||
- Optional minimize-to-tray and desktop notifications.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -8,6 +8,7 @@ The provider names, logos, and favicons bundled with this application are tradem
|
||||
| Mega-Debrid | https://cdn.mega-debrid.eu/images/favicon.png | 2026-08-09 | `12D3DE939965D13F852853A2A5BB873B0D7A89D411BEAFA02518B48356803AE4` |
|
||||
| BestDebrid | https://bestdebrid.com/en/files/assets/images/favicon.ico | 2026-08-09 | `11E54B1B0DCBBB8F5BCAA21D0EDC0CF182276C513ED733ACEEEBC35120CD9F33` |
|
||||
| AllDebrid | https://cdn.alldebrid.com/lib/images/default/favicon.png | 2026-08-09 | `AE296FBE43A6CCEABC85033E5C063BFCA01932EB3677FB679AF63C23535CD9EF` |
|
||||
| Deepbrid | https://www.deepbrid.com/favicon.ico | 2026-08-24 | `8CA0D28FC74B69C5AF146650FF2E6664AD280660D6A2AA53054326C8F6389BBB` |
|
||||
| DDownload | https://ddownload.com/images/favicon.ico | 2026-08-09 | `E0413F73F8A2A5AD85BE8082B0897E82AE3B354FE2490884D7A992BB18F34DF3` |
|
||||
| RapidGator | https://rapidgator.net/favicon.ico | 2026-08-10 | `9CD57803D93FBD183DE40D1007606A3C30F90FC82A205C7ABF4C12D322B7C144` |
|
||||
| 1Fichier | https://img.1fichier.com/favicon.png | 2026-08-09 | `C3567E0299879FE67924C6119BF082BE128A5E8962FC0D61A0D8AAA0B1304CB5` |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-debrid-downloader",
|
||||
"version": "2.0.70",
|
||||
"version": "2.0.71",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-debrid-downloader",
|
||||
"version": "2.0.70",
|
||||
"version": "2.0.71",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-debrid-downloader",
|
||||
"version": "2.0.70",
|
||||
"version": "2.0.71",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@@ -38,7 +38,7 @@ const REDISTRIBUTION_FILES = Object.freeze([
|
||||
Object.freeze({
|
||||
sourcePath: "THIRD_PARTY_NOTICES.md",
|
||||
packagedPath: "resources/THIRD_PARTY_NOTICES.md",
|
||||
sha256: "28f9d1f8692811758c41584e59d0db6cbf26654b4b93e335626921bef86bdf76"
|
||||
sha256: "1fabeb71bbbf9a58c88b5014a5c12370fc2f9558c2f0b09f4b615bb4f89458a8"
|
||||
}),
|
||||
Object.freeze({
|
||||
sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AccountCheckScope, AppSettings, DebridAccountStatus, DebridProvide
|
||||
import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts";
|
||||
import { DEEPBRID_ACCOUNT_ID, DeepbridApiError, DeepbridClient } from "./deepbrid";
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
@@ -176,6 +177,57 @@ export async function checkRealDebridAccount(
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDeepbridAccount(
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const key = apiKey.trim();
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: DEEPBRID_ACCOUNT_ID,
|
||||
provider: "deepbrid",
|
||||
label: "Deepbrid",
|
||||
maskedLogin: maskSecret(key),
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
if (!key) {
|
||||
return { ...base, message: "Kein API-Key hinterlegt" };
|
||||
}
|
||||
try {
|
||||
const user = await new DeepbridClient(key).getUser(signal);
|
||||
const expiration = Date.parse(user.expiration);
|
||||
const premiumUntilMs = Number.isFinite(expiration) ? expiration : null;
|
||||
const isPremium = user.type.trim().toLowerCase() === "premium"
|
||||
&& premiumUntilMs !== null
|
||||
&& premiumUntilMs > now;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs,
|
||||
username: user.username.trim() || undefined,
|
||||
email: user.email.trim() || undefined,
|
||||
message: isPremium
|
||||
? formatRemaining(premiumUntilMs, now)
|
||||
: user.type.trim().toLowerCase() === "premium" && premiumUntilMs !== null
|
||||
? formatRemaining(premiumUntilMs, now)
|
||||
: "Kein Premium (Free)"
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
return { ...base, message: "Prüfung abgebrochen" };
|
||||
}
|
||||
if (error instanceof DeepbridApiError && error.classification === "auth") {
|
||||
return { ...base, message: error.status === 403 ? "Deepbrid API-Key gesperrt" : "Ungültiger Deepbrid API-Key" };
|
||||
}
|
||||
return { ...base, message: "Prüfung fehlgeschlagen" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMegaDebridAccount(
|
||||
account: MegaDebridAccountEntry,
|
||||
signal?: AbortSignal,
|
||||
@@ -337,6 +389,8 @@ export async function checkAllDebridAccounts(
|
||||
const realDebridAccounts = scope === "all"
|
||||
? allRealDebridAccounts
|
||||
: providerEnabled("realdebrid") ? allRealDebridAccounts.filter((account) => account.enabled) : [];
|
||||
const deepbridApiKey = String(settings.deepbridApiKey || "").trim();
|
||||
const checkDeepbrid = Boolean(deepbridApiKey) && (scope === "all" || providerEnabled("deepbrid"));
|
||||
|
||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
...realDebridAccounts.map((account) => () => checkRealDebridAccount(
|
||||
@@ -348,7 +402,8 @@ export async function checkAllDebridAccounts(
|
||||
: undefined
|
||||
)),
|
||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)),
|
||||
...(checkDeepbrid ? [() => checkDeepbridAccount(deepbridApiKey, signal, now)] : [])
|
||||
];
|
||||
|
||||
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
||||
|
||||
@@ -27,6 +27,7 @@ const ACCOUNT_KINDS = new Set<RendererAccountKind>([
|
||||
"bestdebrid-web",
|
||||
"alldebrid-api",
|
||||
"alldebrid-web",
|
||||
"deepbrid-api",
|
||||
"ddownload-login",
|
||||
"onefichier-api",
|
||||
"debridlink-api",
|
||||
@@ -127,10 +128,13 @@ export function validateAccountCredentialCheckInput(value: unknown): AccountCred
|
||||
&& raw.kind !== "realdebrid-web"
|
||||
&& raw.kind !== "megadebrid-api"
|
||||
&& raw.kind !== "megadebrid-web"
|
||||
&& raw.kind !== "debridlink-api") invalid();
|
||||
&& raw.kind !== "debridlink-api"
|
||||
&& raw.kind !== "deepbrid-api") invalid();
|
||||
const accountId = optionalString(raw.accountId, 256);
|
||||
if (raw.kind === "deepbrid-api" && accountId !== undefined && accountId !== "svc-deepbrid") invalid();
|
||||
return {
|
||||
kind: raw.kind,
|
||||
accountId: optionalString(raw.accountId, 256),
|
||||
accountId,
|
||||
identity: optionalString(raw.identity, 512),
|
||||
secret: optionalString(raw.secret, 100_000)
|
||||
};
|
||||
@@ -174,6 +178,7 @@ export function resolveStoredAccountSecret(settings: AppSettings, request: Accou
|
||||
if (request.accountId !== `svc-${provider}` || !singleConfigured(settings, request.kind)) storedSecretMissing();
|
||||
if (request.kind === "bestdebrid-api" && settings.bestToken) return settings.bestToken;
|
||||
if (request.kind === "alldebrid-api" && settings.allDebridToken) return settings.allDebridToken;
|
||||
if (request.kind === "deepbrid-api" && settings.deepbridApiKey) return settings.deepbridApiKey;
|
||||
if (request.kind === "ddownload-login" && settings.ddownloadPassword) return settings.ddownloadPassword;
|
||||
if (request.kind === "onefichier-api" && settings.oneFichierApiKey) return settings.oneFichierApiKey;
|
||||
if (request.kind === "linksnappy-login" && settings.linkSnappyPassword) return settings.linkSnappyPassword;
|
||||
@@ -522,6 +527,7 @@ function singleProvider(kind: RendererAccountKind): DebridProvider {
|
||||
if (kind.startsWith("realdebrid")) return "realdebrid";
|
||||
if (kind.startsWith("bestdebrid")) return "bestdebrid";
|
||||
if (kind.startsWith("alldebrid")) return "alldebrid";
|
||||
if (kind === "deepbrid-api") return "deepbrid";
|
||||
if (kind === "ddownload-login") return "ddownload";
|
||||
if (kind === "onefichier-api") return "onefichier";
|
||||
if (kind === "linksnappy-login") return "linksnappy";
|
||||
@@ -535,6 +541,7 @@ function singleConfigured(settings: AppSettings, kind: RendererAccountKind): boo
|
||||
if (kind === "bestdebrid-web") return settings.bestDebridUseWebLogin;
|
||||
if (kind === "alldebrid-api") return Boolean(settings.allDebridToken.trim());
|
||||
if (kind === "alldebrid-web") return settings.allDebridUseWebLogin;
|
||||
if (kind === "deepbrid-api") return Boolean(settings.deepbridApiKey.trim());
|
||||
if (kind === "ddownload-login") return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword);
|
||||
if (kind === "onefichier-api") return Boolean(settings.oneFichierApiKey.trim());
|
||||
if (kind === "linksnappy-login") return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword);
|
||||
@@ -548,6 +555,7 @@ function setSingle(settings: AppSettings, kind: RendererAccountKind, identity: s
|
||||
if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true };
|
||||
if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false };
|
||||
if (kind === "alldebrid-web") return { ...settings, allDebridToken: "", allDebridUseWebLogin: true };
|
||||
if (kind === "deepbrid-api") return { ...settings, deepbridApiKey: validateSecret(secret || "") };
|
||||
if (kind === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") };
|
||||
if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") };
|
||||
if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") };
|
||||
@@ -563,6 +571,7 @@ function replaceSingle(settings: AppSettings, command: Extract<AccountCommand, {
|
||||
if (command.kind === "realdebrid-api") secret ||= settings.token;
|
||||
if (command.kind === "bestdebrid-api") secret ||= settings.bestToken;
|
||||
if (command.kind === "alldebrid-api") secret ||= settings.allDebridToken;
|
||||
if (command.kind === "deepbrid-api") secret ||= settings.deepbridApiKey;
|
||||
if (command.kind === "ddownload-login") {
|
||||
identity = identity?.trim() ? identity : settings.ddownloadLogin;
|
||||
secret ||= settings.ddownloadPassword;
|
||||
@@ -587,12 +596,14 @@ function deleteSingle(settings: AppSettings, command: Extract<AccountCommand, {
|
||||
if (provider === "realdebrid") next = { ...next, token: "", realDebridUseWebLogin: false };
|
||||
if (provider === "bestdebrid") next = { ...next, bestToken: "", bestDebridUseWebLogin: false };
|
||||
if (provider === "alldebrid") next = { ...next, allDebridToken: "", allDebridUseWebLogin: false };
|
||||
if (provider === "deepbrid") next = { ...next, deepbridApiKey: "" };
|
||||
if (provider === "ddownload") next = { ...next, ddownloadLogin: "", ddownloadPassword: "" };
|
||||
if (provider === "onefichier") next = { ...next, oneFichierApiKey: "" };
|
||||
if (provider === "linksnappy") next = { ...next, linkSnappyLogin: "", linkSnappyPassword: "" };
|
||||
next.providerDailyLimitBytes = withoutKeys(settings.providerDailyLimitBytes as Record<string, number>, provider);
|
||||
next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record<string, number>, provider);
|
||||
next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record<string, number>, provider);
|
||||
next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId);
|
||||
return { settings: next, response: { accountId: null } };
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export function collectAccountStatusRedactionValues(settings?: AppSettings, inpu
|
||||
addRedaction(values, settings.megaDebridWebCredentials);
|
||||
addRedaction(values, settings.bestToken);
|
||||
addRedaction(values, settings.allDebridToken);
|
||||
addRedaction(values, settings.deepbridApiKey);
|
||||
addRedaction(values, settings.ddownloadPassword);
|
||||
addRedaction(values, settings.oneFichierApiKey);
|
||||
addRedaction(values, settings.debridLinkApiKeys);
|
||||
|
||||
+130
-7
@@ -35,7 +35,7 @@ import { importDlcContainers } from "./container";
|
||||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check";
|
||||
import { checkAllDebridAccounts, checkDebridLinkKey, checkDeepbridAccount, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
@@ -68,7 +68,7 @@ import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
@@ -76,6 +76,10 @@ import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./
|
||||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||||
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||||
import { NotificationOutbox } from "./notification-outbox";
|
||||
import { sendNotification } from "./notify";
|
||||
import { DownloadHealthMonitor } from "./download-health-monitor";
|
||||
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@@ -116,6 +120,14 @@ export class AppController {
|
||||
|
||||
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
|
||||
|
||||
private notificationOutbox: NotificationOutbox;
|
||||
|
||||
private downloadHealthMonitor: DownloadHealthMonitor;
|
||||
|
||||
private downloadHealthTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private downloadHealthEvaluation: Promise<void> | null = null;
|
||||
|
||||
private logDirectory = this.storagePaths.baseDir;
|
||||
|
||||
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
|
||||
@@ -150,6 +162,29 @@ export class AppController {
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||
const session = loadResult.session;
|
||||
this.notificationOutbox = new NotificationOutbox({
|
||||
filePath: this.storagePaths.notificationOutboxFile,
|
||||
autoDrain: true,
|
||||
send: (event) => sendNotification(this.settings.notifyUrl, {
|
||||
title: event.payload.title,
|
||||
message: event.payload.description || "",
|
||||
mention: this.settings.notifyMention,
|
||||
color: event.payload.color ?? (event.priority === "error" ? 0xe74c3c : 0x2ecc71),
|
||||
fields: event.payload.fields,
|
||||
timestamp: event.createdAt
|
||||
}),
|
||||
onDelivered: (event, deliveredAt) => {
|
||||
return this.downloadHealthMonitor?.acknowledgeDelivery(
|
||||
event,
|
||||
deliveredAt,
|
||||
this.settings.notifyStallCooldownMinutes * 60_000
|
||||
);
|
||||
}
|
||||
});
|
||||
this.downloadHealthMonitor = new DownloadHealthMonitor(this.storagePaths.notificationHealthFile);
|
||||
void this.notificationOutbox.drain().catch((error) => {
|
||||
logger.warn(`Notification-Outbox konnte nicht gestartet werden: ${String(error)}`);
|
||||
});
|
||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||
login: this.settings.megaLogin,
|
||||
password: this.settings.megaPassword
|
||||
@@ -163,6 +198,7 @@ export class AppController {
|
||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||
enqueueNotification: (event) => this.notificationOutbox.enqueue(event),
|
||||
onHistoryEntry: (entry: HistoryEntry) => {
|
||||
this.recordHistoryEntry(entry);
|
||||
}
|
||||
@@ -170,6 +206,11 @@ export class AppController {
|
||||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||
this.onStateHandler?.(snapshot);
|
||||
});
|
||||
void this.evaluateDownloadHealth();
|
||||
this.downloadHealthTimer = setInterval(() => {
|
||||
void this.evaluateDownloadHealth();
|
||||
}, 15_000);
|
||||
this.downloadHealthTimer.unref?.();
|
||||
logger.info(`App gestartet v${APP_VERSION}`);
|
||||
logger.info(`Log-Datei: ${getLogFilePath()}`);
|
||||
logAuditEvent("INFO", "App gestartet", {
|
||||
@@ -204,7 +245,7 @@ export class AppController {
|
||||
} catch (err) {
|
||||
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
|
||||
}
|
||||
startDebugServer(this.manager, this.storagePaths.baseDir);
|
||||
startDebugServer(this.manager, this.storagePaths.baseDir, () => this.getNotificationSupportPayload());
|
||||
this.runtimeStatsTimer = setInterval(() => {
|
||||
this.manager.persistRuntimeStats();
|
||||
this.settings = this.manager.getSettings();
|
||||
@@ -212,7 +253,7 @@ export class AppController {
|
||||
}, 60_000);
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
if (this.settings.autoResumeOnStart && !shouldDeferAutoResumeToDailyStart(this.settings, loadResult.wasRunning)) {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||
@@ -550,6 +591,9 @@ export class AppController {
|
||||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||||
checkedStatus = await checkRealDebridAccount(account);
|
||||
}
|
||||
if (command.action !== "delete" && applied.response.accountId && command.kind === "deepbrid-api") {
|
||||
checkedStatus = await checkDeepbridAccount(applied.settings.deepbridApiKey);
|
||||
}
|
||||
if (checkedStatus) {
|
||||
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
||||
}
|
||||
@@ -578,6 +622,15 @@ export class AppController {
|
||||
|
||||
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||
if (input.kind === "deepbrid-api") {
|
||||
const key = input.secret?.trim() || this.settings.deepbridApiKey.trim();
|
||||
if (!key) throw new Error("Account-Payload ist ungültig");
|
||||
const status = sanitizeDebridAccountStatus(await checkDeepbridAccount(key), redactions);
|
||||
if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) {
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||||
const useWebLogin = input.kind === "realdebrid-web";
|
||||
const account = input.secret?.trim() && !useWebLogin
|
||||
@@ -1137,7 +1190,10 @@ export class AppController {
|
||||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
||||
});
|
||||
return {
|
||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
|
||||
hostDiagnosticsMode: "cached",
|
||||
notificationStatus: this.getNotificationSupportPayload()
|
||||
}),
|
||||
defaultFileName: getSupportBundleDefaultFileName()
|
||||
};
|
||||
}
|
||||
@@ -1146,6 +1202,13 @@ export class AppController {
|
||||
return getSupportBundleDefaultFileName();
|
||||
}
|
||||
|
||||
public getNotificationSupportPayload(): NotificationSupportPayload {
|
||||
return buildNotificationSupportPayload(
|
||||
this.notificationOutbox.getStatus(),
|
||||
this.downloadHealthMonitor.getState()
|
||||
);
|
||||
}
|
||||
|
||||
public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
@@ -1170,7 +1233,7 @@ export class AppController {
|
||||
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
||||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||||
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||||
"deepbridApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||||
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword",
|
||||
"notifyUrl"
|
||||
];
|
||||
@@ -1265,7 +1328,39 @@ export class AppController {
|
||||
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
private evaluateDownloadHealth(): Promise<void> {
|
||||
if (this.downloadHealthEvaluation) {
|
||||
return this.downloadHealthEvaluation;
|
||||
}
|
||||
const now = Date.now();
|
||||
const task = this.downloadHealthMonitor.sample(
|
||||
this.manager.getDownloadHealthSnapshot(now),
|
||||
now,
|
||||
{
|
||||
stallAfterMs: this.settings.notifyStallAfterSeconds * 1000,
|
||||
cooldownMs: this.settings.notifyStallCooldownMinutes * 60_000,
|
||||
notifyOnStall: this.settings.notifyOnDownloadStall && Boolean(String(this.settings.notifyUrl || "").trim()),
|
||||
notifyOnRecovery: this.settings.notifyOnDownloadRecovery && Boolean(String(this.settings.notifyUrl || "").trim())
|
||||
},
|
||||
(event) => this.notificationOutbox.enqueue(event)
|
||||
).then(() => undefined).catch((error) => {
|
||||
logger.warn(`Download-Health-Monitor konnte nicht ausgewertet werden: ${String(error)}`);
|
||||
}).finally(() => {
|
||||
if (this.downloadHealthEvaluation === task) {
|
||||
this.downloadHealthEvaluation = null;
|
||||
}
|
||||
});
|
||||
this.downloadHealthEvaluation = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
const deadlineAt = Date.now() + 3000;
|
||||
if (this.downloadHealthTimer) {
|
||||
clearInterval(this.downloadHealthTimer);
|
||||
this.downloadHealthTimer = null;
|
||||
}
|
||||
this.manager.suspendDownloadHealthMonitoring?.();
|
||||
if (this.runtimeStatsTimer) {
|
||||
clearInterval(this.runtimeStatsTimer);
|
||||
this.runtimeStatsTimer = null;
|
||||
@@ -1274,6 +1369,19 @@ export class AppController {
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
this.manager.prepareForShutdown();
|
||||
if (this.downloadHealthEvaluation) {
|
||||
await this.waitForShutdownTask(this.downloadHealthEvaluation, deadlineAt);
|
||||
}
|
||||
if (this.downloadHealthMonitor && Date.now() < deadlineAt) {
|
||||
await this.waitForShutdownTask(this.evaluateDownloadHealth(), deadlineAt);
|
||||
}
|
||||
const notificationFlush = this.manager.flushNotificationsForShutdown?.();
|
||||
if (notificationFlush && Date.now() < deadlineAt) {
|
||||
await this.waitForShutdownTask(notificationFlush, deadlineAt);
|
||||
}
|
||||
await this.notificationOutbox.drainForShutdown(Math.max(0, deadlineAt - Date.now())).catch((error) => {
|
||||
logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`);
|
||||
});
|
||||
this.megaWebFallback.dispose();
|
||||
for (const fallback of this.realDebridWebFallbacks.values()) {
|
||||
fallback.dispose();
|
||||
@@ -1294,6 +1402,21 @@ export class AppController {
|
||||
logger.info("App beendet");
|
||||
}
|
||||
|
||||
private async waitForShutdownTask(task: Promise<unknown>, deadlineAt: number): Promise<void> {
|
||||
const remainingMs = Math.max(0, deadlineAt - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
return;
|
||||
}
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, remainingMs);
|
||||
});
|
||||
await Promise.race([task.then(() => undefined, () => undefined), timeout]);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private getDesktopDirectory(): string | null {
|
||||
try {
|
||||
return app.getPath("desktop");
|
||||
|
||||
@@ -65,6 +65,7 @@ export function defaultSettings(): AppSettings {
|
||||
bestDebridUseWebLogin: false,
|
||||
allDebridToken: "",
|
||||
allDebridUseWebLogin: false,
|
||||
deepbridApiKey: "",
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
@@ -128,6 +129,13 @@ export function defaultSettings(): AppSettings {
|
||||
notifyOnPackageCompleted: false,
|
||||
notifyOnPackageFailed: false,
|
||||
notifyOnRunFinished: false,
|
||||
notifyPackageSuccessMode: "digest",
|
||||
notifyOnRemainingBelow: false,
|
||||
notifyRemainingThresholdGb: 50,
|
||||
notifyOnDownloadStall: false,
|
||||
notifyStallAfterSeconds: 90,
|
||||
notifyStallCooldownMinutes: 10,
|
||||
notifyOnDownloadRecovery: true,
|
||||
totalDownloadedAllTime: 0,
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
@@ -152,6 +160,12 @@ export function defaultSettings(): AppSettings {
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 0,
|
||||
dailyStartFirstLocalDate: "",
|
||||
dailyStartLastHandledLocalDate: "",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "",
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const CREDENTIAL_KEYS = [
|
||||
"megaDebridWebCredentials",
|
||||
"bestToken",
|
||||
"allDebridToken",
|
||||
"deepbridApiKey",
|
||||
"ddownloadLogin",
|
||||
"ddownloadPassword",
|
||||
"oneFichierApiKey",
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { DailyStartOutcome, DailyStartSettings } from "../shared/types";
|
||||
|
||||
interface DailyStartSnapshot {
|
||||
settings: DailyStartSettings;
|
||||
session: {
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
items: Record<string, { status: string; packageId: string }>;
|
||||
packages: Record<string, { enabled: boolean; cancelled: boolean }>;
|
||||
};
|
||||
canStart: boolean;
|
||||
}
|
||||
|
||||
interface DailyStartController {
|
||||
getSnapshot(): DailyStartSnapshot;
|
||||
updateSettings(partial: Partial<DailyStartSettings>): unknown;
|
||||
start(): Promise<void>;
|
||||
}
|
||||
|
||||
interface LocalDateParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
function parseLocalDate(value: string): LocalDateParts | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(year, month - 1, day, 12, 0, 0, 0);
|
||||
if (year < 1000 || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
return null;
|
||||
}
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function addLocalDays(value: string, days: number): string {
|
||||
const parts = parseLocalDate(value);
|
||||
if (!parts) {
|
||||
return "";
|
||||
}
|
||||
return formatLocalDate(new Date(parts.year, parts.month - 1, parts.day + days, 12, 0, 0, 0));
|
||||
}
|
||||
|
||||
function localTargetEpochMs(value: string, minuteOfDay: number): number {
|
||||
const parts = parseLocalDate(value);
|
||||
if (!parts) {
|
||||
return 0;
|
||||
}
|
||||
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
|
||||
return new Date(parts.year, parts.month - 1, parts.day, Math.floor(minute / 60), minute % 60, 0, 0).getTime();
|
||||
}
|
||||
|
||||
export function isValidLocalDate(value: string): boolean {
|
||||
return parseLocalDate(value) !== null;
|
||||
}
|
||||
|
||||
export function nextDailyStartEpochMs(settings: DailyStartSettings, nowEpochMs = Date.now()): number {
|
||||
if (!settings.dailyStartEnabled || !Number.isFinite(settings.dailyStartMinuteOfDay)) {
|
||||
return 0;
|
||||
}
|
||||
const firstDate = parseLocalDate(settings.dailyStartFirstLocalDate);
|
||||
if (!firstDate) {
|
||||
return 0;
|
||||
}
|
||||
const now = new Date(nowEpochMs);
|
||||
const today = formatLocalDate(now);
|
||||
let candidate = settings.dailyStartFirstLocalDate > today ? settings.dailyStartFirstLocalDate : today;
|
||||
const pending = isValidLocalDate(settings.dailyStartPendingLocalDate)
|
||||
? settings.dailyStartPendingLocalDate
|
||||
: "";
|
||||
if (pending && candidate < pending) {
|
||||
candidate = pending;
|
||||
}
|
||||
const handled = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
|
||||
? settings.dailyStartLastHandledLocalDate
|
||||
: "";
|
||||
if (handled && candidate <= handled) {
|
||||
candidate = addLocalDays(handled, 1);
|
||||
}
|
||||
return localTargetEpochMs(candidate, settings.dailyStartMinuteOfDay);
|
||||
}
|
||||
|
||||
export function hasDailyStartRulePatch(partial: object): boolean {
|
||||
const value = partial as Record<string, unknown>;
|
||||
return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"]
|
||||
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
||||
}
|
||||
|
||||
export function prepareDailyStartSettingsPatch<T extends object>(
|
||||
partial: T,
|
||||
current: DailyStartSettings
|
||||
): T & { scheduledStartEpochMs?: number } {
|
||||
const value = partial as Record<string, unknown>;
|
||||
const changed = (
|
||||
Object.prototype.hasOwnProperty.call(value, "dailyStartEnabled")
|
||||
&& value.dailyStartEnabled !== current.dailyStartEnabled
|
||||
) || (
|
||||
Object.prototype.hasOwnProperty.call(value, "dailyStartMinuteOfDay")
|
||||
&& value.dailyStartMinuteOfDay !== current.dailyStartMinuteOfDay
|
||||
) || (
|
||||
Object.prototype.hasOwnProperty.call(value, "dailyStartFirstLocalDate")
|
||||
&& value.dailyStartFirstLocalDate !== current.dailyStartFirstLocalDate
|
||||
);
|
||||
return changed
|
||||
? { ...partial, scheduledStartEpochMs: 0 }
|
||||
: { ...partial };
|
||||
}
|
||||
|
||||
export function shouldDeferAutoResumeToDailyStart(
|
||||
settings: DailyStartSettings,
|
||||
wasRunning: boolean,
|
||||
nowEpochMs = Date.now()
|
||||
): boolean {
|
||||
return !wasRunning && nextDailyStartEpochMs(settings, nowEpochMs) > nowEpochMs;
|
||||
}
|
||||
|
||||
export class DailyStartScheduler {
|
||||
private reconcileInFlight: Promise<DailyStartOutcome | null> | null = null;
|
||||
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lifecycleGeneration = 0;
|
||||
|
||||
public constructor(
|
||||
private readonly controller: DailyStartController,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
public reconcile(): Promise<DailyStartOutcome | null> {
|
||||
if (this.reconcileInFlight) {
|
||||
return this.reconcileInFlight;
|
||||
}
|
||||
const operation = this.reconcileOnce(this.lifecycleGeneration);
|
||||
this.reconcileInFlight = operation;
|
||||
void operation.finally(() => {
|
||||
if (this.reconcileInFlight === operation) {
|
||||
this.reconcileInFlight = null;
|
||||
}
|
||||
}).catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
public begin(onError: (error: unknown) => void = () => {}): void {
|
||||
this.end();
|
||||
void this.reconcile().catch(onError);
|
||||
this.reconcileTimer = setInterval(() => {
|
||||
void this.reconcile().catch(onError);
|
||||
}, 60_000);
|
||||
this.reconcileTimer.unref?.();
|
||||
}
|
||||
|
||||
public end(): void {
|
||||
this.lifecycleGeneration += 1;
|
||||
if (this.reconcileTimer !== null) {
|
||||
clearInterval(this.reconcileTimer);
|
||||
this.reconcileTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private finish(localDate: string, outcome: DailyStartOutcome): DailyStartOutcome {
|
||||
this.controller.updateSettings({
|
||||
dailyStartLastHandledLocalDate: localDate,
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: outcome
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async reconcileOnce(generation: number): Promise<DailyStartOutcome | null> {
|
||||
const nowEpochMs = this.now();
|
||||
let settings = this.controller.getSnapshot().settings;
|
||||
if (!settings.dailyStartEnabled || !isValidLocalDate(settings.dailyStartFirstLocalDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const today = formatLocalDate(new Date(nowEpochMs));
|
||||
const handledDate = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
|
||||
? settings.dailyStartLastHandledLocalDate
|
||||
: "";
|
||||
const pendingDate = isValidLocalDate(settings.dailyStartPendingLocalDate)
|
||||
? settings.dailyStartPendingLocalDate
|
||||
: "";
|
||||
if ((handledDate && today <= handledDate) || (pendingDate && today < pendingDate)) {
|
||||
return null;
|
||||
}
|
||||
let missed: DailyStartOutcome | null = null;
|
||||
if (isValidLocalDate(settings.dailyStartPendingLocalDate) && settings.dailyStartPendingLocalDate < today) {
|
||||
const missedDate = settings.dailyStartPendingLocalDate;
|
||||
const lastHandledLocalDate = handledDate > missedDate ? handledDate : missedDate;
|
||||
this.controller.updateSettings({
|
||||
dailyStartLastHandledLocalDate: lastHandledLocalDate,
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "missed"
|
||||
});
|
||||
settings = {
|
||||
...settings,
|
||||
dailyStartLastHandledLocalDate: lastHandledLocalDate,
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "missed"
|
||||
};
|
||||
missed = "missed";
|
||||
}
|
||||
|
||||
if (today < settings.dailyStartFirstLocalDate || settings.dailyStartLastHandledLocalDate === today) {
|
||||
return missed;
|
||||
}
|
||||
const targetEpochMs = localTargetEpochMs(today, settings.dailyStartMinuteOfDay);
|
||||
if (!targetEpochMs || targetEpochMs > nowEpochMs) {
|
||||
return missed;
|
||||
}
|
||||
|
||||
if (settings.dailyStartPendingLocalDate === today) {
|
||||
return this.finish(today, settings.dailyStartLastOutcome || "start_failed");
|
||||
}
|
||||
|
||||
const snapshot = this.controller.getSnapshot();
|
||||
if (snapshot.session.running || snapshot.session.paused) {
|
||||
return this.finish(today, "already_active");
|
||||
}
|
||||
const hasQueuedItems = Object.values(snapshot.session.items).some((item) => {
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
||||
return false;
|
||||
}
|
||||
const pkg = snapshot.session.packages[item.packageId];
|
||||
return Boolean(pkg && pkg.enabled && !pkg.cancelled);
|
||||
});
|
||||
if (!hasQueuedItems) {
|
||||
return this.finish(today, "empty_queue");
|
||||
}
|
||||
if (!snapshot.canStart) {
|
||||
this.controller.updateSettings({ dailyStartLastOutcome: "missing_account" });
|
||||
return "missing_account";
|
||||
}
|
||||
|
||||
this.controller.updateSettings({
|
||||
dailyStartPendingLocalDate: today,
|
||||
dailyStartLastOutcome: "start_failed"
|
||||
});
|
||||
try {
|
||||
await this.controller.start();
|
||||
} catch (error) {
|
||||
if (generation !== this.lifecycleGeneration) {
|
||||
return null;
|
||||
}
|
||||
this.controller.updateSettings({
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "start_failed"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (generation !== this.lifecycleGeneration) {
|
||||
return null;
|
||||
}
|
||||
return this.finish(today, "started");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostL
|
||||
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 { DEEPBRID_ACCOUNT_ID, DeepbridClient } from "./deepbrid";
|
||||
import { pruneAccountRuntimeSession, recordAccountRuntimeAttempt, recordAccountRuntimeFailure, recordAccountRuntimeSuccess, resetAccountRuntimeSessionForProvider } from "./account-runtime";
|
||||
import { logger } from "./logger";
|
||||
import { logAccountRotation } from "./account-rotation-log";
|
||||
@@ -597,6 +598,7 @@ const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
deepbrid: "Deepbrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
@@ -4503,6 +4505,9 @@ export class DebridService {
|
||||
if (effectiveProvider === "alldebrid") {
|
||||
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
|
||||
}
|
||||
if (effectiveProvider === "deepbrid") {
|
||||
return Boolean(settings.deepbridApiKey.trim());
|
||||
}
|
||||
if (effectiveProvider === "ddownload") {
|
||||
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
|
||||
}
|
||||
@@ -4665,6 +4670,15 @@ export class DebridService {
|
||||
adResult.sourceLabel = "API";
|
||||
return adResult;
|
||||
}
|
||||
if (effectiveProvider === "deepbrid") {
|
||||
const result = await new DeepbridClient(settings.deepbridApiKey).unrestrictLink(link, signal);
|
||||
return {
|
||||
...result,
|
||||
sourceLabel: "API",
|
||||
sourceAccountId: DEEPBRID_ACCOUNT_ID,
|
||||
sourceAccountLabel: "Deepbrid API"
|
||||
};
|
||||
}
|
||||
if (effectiveProvider === "ddownload") {
|
||||
return this.getDdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getSessionLogPath } from "./session-log";
|
||||
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
@@ -57,6 +57,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
||||
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
||||
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
|
||||
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
|
||||
{ method: "GET", path: "/notifications", description: "Returns safe notification delivery and incident aggregates." },
|
||||
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
|
||||
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
|
||||
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
|
||||
@@ -74,6 +75,7 @@ let bindPort = DEFAULT_PORT;
|
||||
let runtimeBaseDir = "";
|
||||
let allowlist: string[] = [];
|
||||
let requestLimits = new Map<string, { startedAt: number; count: number }>();
|
||||
let notificationStatusProvider: (() => NotificationSupportPayload) | null = null;
|
||||
|
||||
export interface DebugServerRuntimeStatus {
|
||||
running: boolean;
|
||||
@@ -96,6 +98,10 @@ function readSupportHistory() {
|
||||
return loadHistory(getStoragePaths());
|
||||
}
|
||||
|
||||
function readNotificationStatus(): NotificationSupportPayload {
|
||||
return normalizeNotificationSupportPayload(notificationStatusProvider?.());
|
||||
}
|
||||
|
||||
function extractDebugClientIp(req: http.IncomingMessage): string {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
@@ -926,6 +932,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/notifications") {
|
||||
jsonResponse(res, 200, readNotificationStatus());
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/history") {
|
||||
const entries = readSupportHistory();
|
||||
const limit = normalizeLinesParam(url.searchParams.get("limit"), 50);
|
||||
@@ -1046,7 +1057,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
const fileName = getSupportBundleDefaultFileName();
|
||||
buildSupportBundle(manager, runtimeBaseDir)
|
||||
buildSupportBundle(manager, runtimeBaseDir, { notificationStatus: readNotificationStatus() })
|
||||
.then((body) => {
|
||||
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
||||
fileName,
|
||||
@@ -1089,6 +1100,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
status: buildStatusPayload(snapshot),
|
||||
settings: buildRedactedSettingsPayload(readSupportSettings()),
|
||||
stats: buildStatsPayload(snapshot),
|
||||
notifications: readNotificationStatus(),
|
||||
accounts: buildAccountSummary(readSupportSettings()),
|
||||
providers: getProviderRuntimeSnapshot(),
|
||||
history: {
|
||||
@@ -1184,9 +1196,14 @@ function openServerSocket(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
||||
export function startDebugServer(
|
||||
mgr: DownloadManager,
|
||||
baseDir: string,
|
||||
readCurrentNotificationStatus?: () => NotificationSupportPayload
|
||||
): void {
|
||||
runtimeBaseDir = baseDir;
|
||||
manager = mgr;
|
||||
notificationStatusProvider = readCurrentNotificationStatus || null;
|
||||
void openServerSocket();
|
||||
}
|
||||
|
||||
@@ -1256,6 +1273,7 @@ export function clearDebugToken(): void {
|
||||
}
|
||||
|
||||
export function stopDebugServer(): void {
|
||||
notificationStatusProvider = null;
|
||||
if (server) {
|
||||
server.close();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { UnrestrictedLink } from "./realdebrid";
|
||||
|
||||
export const DEEPBRID_ACCOUNT_ID = "svc-deepbrid";
|
||||
|
||||
const API_BASE_URL = "https://www.deepbrid.com/api/v1";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
|
||||
export type DeepbridErrorClassification = "auth" | "rate_limit" | "temporary" | "link" | "malformed";
|
||||
|
||||
export class DeepbridApiError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly code: number;
|
||||
public readonly classification: DeepbridErrorClassification;
|
||||
|
||||
public constructor(status: number, code: number, classification: DeepbridErrorClassification) {
|
||||
super(`Deepbrid-Anfrage fehlgeschlagen (${classification}, HTTP ${status}, Code ${code})`);
|
||||
this.name = "DeepbridApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.classification = classification;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeepbridUserInfo {
|
||||
username: string;
|
||||
email: string;
|
||||
type: string;
|
||||
expiration: string;
|
||||
maxDownloads: number;
|
||||
maxConnections: number;
|
||||
}
|
||||
|
||||
export interface DeepbridHostInfo {
|
||||
domain: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function malformedError(status = 200): DeepbridApiError {
|
||||
return new DeepbridApiError(status, status || 0, "malformed");
|
||||
}
|
||||
|
||||
function parseErrorCode(payload: unknown, status: number): number {
|
||||
if (!isRecord(payload)) {
|
||||
return status;
|
||||
}
|
||||
const value = Number(payload.error ?? payload.code ?? status);
|
||||
return Number.isFinite(value) ? Math.trunc(value) : status;
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value: string | null): number | null {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const seconds = Number(text);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.min(MAX_RETRY_DELAY_MS, Math.floor(seconds * 1000));
|
||||
}
|
||||
const timestamp = Date.parse(text);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, timestamp - Date.now()));
|
||||
}
|
||||
|
||||
function defaultRetryDelayMs(attempt: number): number {
|
||||
return Math.min(MAX_RETRY_DELAY_MS, 250 * 2 ** (attempt - 1));
|
||||
}
|
||||
|
||||
function callerAbortReason(signal: AbortSignal): unknown {
|
||||
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw callerAbortReason(signal);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onAbort = (): void => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(signal ? callerAbortReason(signal) : new DOMException("The operation was aborted", "AbortError"));
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function requestSignal(signal?: AbortSignal): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
||||
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
}
|
||||
|
||||
async function parseJson(response: Response): Promise<unknown> {
|
||||
const mediaType = String(response.headers.get("content-type") || "")
|
||||
.split(";", 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const isJson = mediaType === "application/json"
|
||||
|| /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+\+json$/.test(mediaType);
|
||||
if (!isJson) {
|
||||
throw malformedError(response.status);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw malformedError(response.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!isRecord(payload) && !Array.isArray(payload)) {
|
||||
throw malformedError(response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function classifyHttpError(status: number, payload: unknown, linkRequest: boolean): DeepbridApiError {
|
||||
const code = parseErrorCode(payload, status);
|
||||
if (status === 401 || status === 403) {
|
||||
return new DeepbridApiError(status, code, "auth");
|
||||
}
|
||||
if (status === 429) {
|
||||
return new DeepbridApiError(status, code, "rate_limit");
|
||||
}
|
||||
if (status >= 500) {
|
||||
return new DeepbridApiError(status, code, "temporary");
|
||||
}
|
||||
if (linkRequest && isRecord(payload)) {
|
||||
const structured = Number.isFinite(Number(payload.error ?? payload.code)) || typeof payload.message === "string";
|
||||
if (structured) {
|
||||
return new DeepbridApiError(status, code, "link");
|
||||
}
|
||||
}
|
||||
return malformedError(status);
|
||||
}
|
||||
|
||||
function isRetryable(error: DeepbridApiError): boolean {
|
||||
return error.classification === "rate_limit" || error.classification === "temporary";
|
||||
}
|
||||
|
||||
function validateUser(payload: unknown): DeepbridUserInfo {
|
||||
if (!isRecord(payload)
|
||||
|| typeof payload.username !== "string"
|
||||
|| typeof payload.email !== "string"
|
||||
|| typeof payload.type !== "string"
|
||||
|| typeof payload.expiration !== "string"
|
||||
|| !Number.isFinite(payload.maxDownloads)
|
||||
|| !Number.isFinite(payload.maxConnections)) {
|
||||
throw malformedError();
|
||||
}
|
||||
return {
|
||||
username: payload.username,
|
||||
email: payload.email,
|
||||
type: payload.type,
|
||||
expiration: payload.expiration,
|
||||
maxDownloads: Number(payload.maxDownloads),
|
||||
maxConnections: Number(payload.maxConnections)
|
||||
};
|
||||
}
|
||||
|
||||
function validateHosts(payload: unknown): DeepbridHostInfo[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw malformedError();
|
||||
}
|
||||
const hosts: DeepbridHostInfo[] = [];
|
||||
for (const entry of payload) {
|
||||
if (typeof entry === "string") {
|
||||
const domain = entry.trim();
|
||||
if (!domain) {
|
||||
throw malformedError();
|
||||
}
|
||||
hosts.push({ domain, status: "unknown" });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(entry)) {
|
||||
throw malformedError();
|
||||
}
|
||||
if (typeof entry.domain === "string" && typeof entry.status === "string") {
|
||||
const domain = entry.domain.trim();
|
||||
const status = entry.status.trim();
|
||||
if (!domain || !status) {
|
||||
throw malformedError();
|
||||
}
|
||||
hosts.push({ domain, status });
|
||||
continue;
|
||||
}
|
||||
const pairs = Object.entries(entry);
|
||||
if (pairs.length === 0 || pairs.some(([domain, status]) => !domain.trim() || typeof status !== "string" || !status.trim())) {
|
||||
throw malformedError();
|
||||
}
|
||||
hosts.push(...pairs.map(([domain, status]) => ({ domain: domain.trim(), status: String(status).trim() })));
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
function safeBaseName(value: string): string | null {
|
||||
const baseName = value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.split(/[\\/]/)
|
||||
.filter((part) => part && part !== "." && part !== "..")
|
||||
.at(-1);
|
||||
if (!baseName) {
|
||||
return null;
|
||||
}
|
||||
const sanitized = baseName
|
||||
.replace(/[<>:"/\\|?*]/g, "_")
|
||||
.replace(/[. ]+$/g, "");
|
||||
if (!sanitized) {
|
||||
return null;
|
||||
}
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(sanitized)
|
||||
? `_${sanitized}`
|
||||
: sanitized;
|
||||
}
|
||||
|
||||
function fallbackFileName(url: URL): string {
|
||||
const segment = url.pathname.split("/").filter(Boolean).at(-1) || "";
|
||||
if (!segment) {
|
||||
return "download.bin";
|
||||
}
|
||||
try {
|
||||
return safeBaseName(decodeURIComponent(segment)) || "download.bin";
|
||||
} catch {
|
||||
return safeBaseName(segment) || "download.bin";
|
||||
}
|
||||
}
|
||||
|
||||
function validateUnrestrictedLink(payload: unknown, retriesUsed: number): UnrestrictedLink {
|
||||
if (!isRecord(payload)) {
|
||||
throw malformedError();
|
||||
}
|
||||
const apiCode = Number(payload.error ?? 0);
|
||||
if (Number.isFinite(apiCode) && apiCode !== 0) {
|
||||
throw new DeepbridApiError(200, Math.trunc(apiCode), "link");
|
||||
}
|
||||
if (typeof payload.link !== "string" || !payload.link.trim()) {
|
||||
throw malformedError();
|
||||
}
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(payload.link.trim());
|
||||
} catch {
|
||||
throw malformedError();
|
||||
}
|
||||
if ((parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || parsedUrl.username || parsedUrl.password) {
|
||||
throw malformedError();
|
||||
}
|
||||
const providedName = typeof payload.filename === "string" ? safeBaseName(payload.filename) : null;
|
||||
return {
|
||||
fileName: providedName || fallbackFileName(parsedUrl),
|
||||
directUrl: parsedUrl.toString(),
|
||||
fileSize: parseDeepbridSize(payload.size),
|
||||
retriesUsed
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDeepbridSize(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? Math.floor(value) : null;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/^(\d+(?:[.,]\d+)?)\s*(B|KB|MB|GB|TB|KIB|MIB|GIB|TIB)$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const amount = Number(match[1].replace(",", "."));
|
||||
const unit = match[2].toUpperCase();
|
||||
const exponents: Record<string, number> = { B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4 };
|
||||
const exponent = exponents[unit];
|
||||
if (exponent === undefined) {
|
||||
return null;
|
||||
}
|
||||
const bytes = amount * 1024 ** exponent;
|
||||
return Number.isFinite(bytes) && bytes >= 0 && bytes <= Number.MAX_SAFE_INTEGER ? Math.floor(bytes) : null;
|
||||
}
|
||||
|
||||
export class DeepbridClient {
|
||||
private readonly apiKey: string;
|
||||
|
||||
public constructor(apiKey: string) {
|
||||
this.apiKey = String(apiKey || "").trim();
|
||||
}
|
||||
|
||||
public async getUser(signal?: AbortSignal): Promise<DeepbridUserInfo> {
|
||||
return this.request("/user", { method: "GET" }, validateUser, signal, false, true);
|
||||
}
|
||||
|
||||
public async getHosts(signal?: AbortSignal): Promise<DeepbridHostInfo[]> {
|
||||
return this.request("/hosts", { method: "GET" }, validateHosts, signal, false, false);
|
||||
}
|
||||
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal, password?: string): Promise<UnrestrictedLink> {
|
||||
const body = new URLSearchParams({ link });
|
||||
if (password) {
|
||||
body.set("pass", password);
|
||||
}
|
||||
return this.request(
|
||||
"/generate/link",
|
||||
{ method: "POST", body },
|
||||
validateUnrestrictedLink,
|
||||
signal,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: { method: "GET" | "POST"; body?: URLSearchParams },
|
||||
validate: (payload: unknown, retriesUsed: number) => T,
|
||||
signal: AbortSignal | undefined,
|
||||
linkRequest: boolean,
|
||||
authenticated: boolean
|
||||
): Promise<T> {
|
||||
if (authenticated && !this.apiKey) {
|
||||
throw new DeepbridApiError(401, 401, "auth");
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw callerAbortReason(signal);
|
||||
}
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (authenticated) {
|
||||
headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
if (init.method === "POST") {
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: init.method,
|
||||
headers,
|
||||
body: init.body,
|
||||
signal: requestSignal(signal)
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
throw callerAbortReason(signal);
|
||||
}
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await parseJson(response);
|
||||
} catch (error) {
|
||||
if (response.ok || (response.status < 500 && response.status !== 429 && response.status !== 401 && response.status !== 403)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw callerAbortReason(signal);
|
||||
}
|
||||
if (response.ok) {
|
||||
return validate(payload, attempt - 1);
|
||||
}
|
||||
const error = classifyHttpError(response.status, payload, linkRequest);
|
||||
if (isRetryable(error) && attempt < MAX_ATTEMPTS) {
|
||||
const delay = response.status === 429
|
||||
? parseRetryAfterMs(response.headers.get("retry-after")) ?? defaultRetryDelayMs(attempt)
|
||||
: defaultRetryDelayMs(attempt);
|
||||
await sleepWithSignal(delay, signal);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw callerAbortReason(signal);
|
||||
}
|
||||
if (error instanceof DeepbridApiError) {
|
||||
throw error;
|
||||
}
|
||||
const transportError = new DeepbridApiError(0, 0, "temporary");
|
||||
if (attempt >= MAX_ATTEMPTS) {
|
||||
throw transportError;
|
||||
}
|
||||
await sleepWithSignal(defaultRetryDelayMs(attempt), signal);
|
||||
}
|
||||
}
|
||||
throw new DeepbridApiError(0, 0, "temporary");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { NotificationEvent } from "./notification-outbox";
|
||||
|
||||
export type DownloadHealthStatus =
|
||||
| "idle"
|
||||
| "suspended"
|
||||
| "expected_wait"
|
||||
| "healthy"
|
||||
| "suspect_scheduler"
|
||||
| "suspect_no_data"
|
||||
| "alerted"
|
||||
| "recovering";
|
||||
|
||||
export type DownloadHealthIncidentType = "scheduler" | "no_data";
|
||||
|
||||
export interface DownloadHealthSnapshot {
|
||||
runActive: boolean;
|
||||
runFingerprint: string;
|
||||
queueFingerprint: string;
|
||||
openItems: number;
|
||||
openPackages: number;
|
||||
knownDownloadedBytes: number;
|
||||
activeTasks: number;
|
||||
startableItems: number;
|
||||
lastSchedulerTickAt: number;
|
||||
downloadProgressSequence: number;
|
||||
itemCompletionSequence: number;
|
||||
lastPositiveByteAt: number;
|
||||
technicalRecoveryCount: number;
|
||||
paused: boolean;
|
||||
reconnectUntil: number;
|
||||
nextRetryAt: number;
|
||||
providerCooldownUntil: number;
|
||||
blockedOnDisk: boolean;
|
||||
blockedOnThrottleUntil: number;
|
||||
activePhaseDeadlineAt: number;
|
||||
terminalFailure: boolean;
|
||||
manualStop: boolean;
|
||||
shuttingDown: boolean;
|
||||
currentSpeedBps: number;
|
||||
}
|
||||
|
||||
export interface DownloadHealthState {
|
||||
version: 1;
|
||||
status: DownloadHealthStatus;
|
||||
runFingerprint: string | null;
|
||||
queueFingerprint: string | null;
|
||||
suspiciousDurationMs: number;
|
||||
suspiciousSamples: number;
|
||||
incidentStartedAt: number;
|
||||
incidentType: DownloadHealthIncidentType | null;
|
||||
alertedAt: number;
|
||||
lastAlertAt: number;
|
||||
cooldownUntil: number;
|
||||
lastDeliveredStallEventId: string | null;
|
||||
recoverySamples: number;
|
||||
lastSampleAt: number | null;
|
||||
downloadProgressSequence: number;
|
||||
itemCompletionSequence: number;
|
||||
lastPositiveByteAt: number;
|
||||
technicalRecoveryCount: number;
|
||||
restartPending: boolean;
|
||||
restartFreshSamples: number;
|
||||
}
|
||||
|
||||
export interface DownloadHealthOptions {
|
||||
stallAfterMs: number;
|
||||
cooldownMs: number;
|
||||
notifyOnStall: boolean;
|
||||
notifyOnRecovery: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadHealthEvaluation {
|
||||
state: DownloadHealthState;
|
||||
events: NotificationEvent[];
|
||||
}
|
||||
|
||||
const HEALTH_STATUSES = new Set<DownloadHealthStatus>([
|
||||
"idle",
|
||||
"suspended",
|
||||
"expected_wait",
|
||||
"healthy",
|
||||
"suspect_scheduler",
|
||||
"suspect_no_data",
|
||||
"alerted",
|
||||
"recovering"
|
||||
]);
|
||||
const INCIDENT_TYPES = new Set<DownloadHealthIncidentType>(["scheduler", "no_data"]);
|
||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const STALL_EVENT_ID_PATTERN = /^health:stall:[a-f0-9]{16}:\d+$/;
|
||||
const MIN_SUSPICIOUS_SAMPLES = 3;
|
||||
const SCHEDULER_STALE_AFTER_MS = 30_000;
|
||||
const ERROR_EVENT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const SUCCESS_EVENT_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function finiteInteger(value: unknown, fallback = 0): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
|
||||
}
|
||||
|
||||
function validFingerprint(value: unknown): string | null {
|
||||
return typeof value === "string" && FINGERPRINT_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function validStallEventId(value: unknown): string | null {
|
||||
return typeof value === "string" && STALL_EVENT_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function durationText(durationMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds} s`;
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds > 0 ? `${minutes} min ${seconds} s` : `${minutes} min`;
|
||||
}
|
||||
|
||||
function byteText(bytes: number): string {
|
||||
const value = Math.max(0, finiteInteger(bytes));
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let amount = value / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
|
||||
amount /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
function incidentEvent(
|
||||
state: DownloadHealthState,
|
||||
snapshot: DownloadHealthSnapshot,
|
||||
now: number
|
||||
): NotificationEvent {
|
||||
const durationMs = Math.max(state.suspiciousDurationMs, now - state.incidentStartedAt);
|
||||
return {
|
||||
id: `health:stall:${snapshot.runFingerprint.slice(0, 16)}:${state.incidentStartedAt}`,
|
||||
type: "download_stalled",
|
||||
priority: "error",
|
||||
createdAt: now,
|
||||
expiresAt: now + ERROR_EVENT_TTL_MS,
|
||||
attempts: 0,
|
||||
nextAttemptAt: now,
|
||||
payload: {
|
||||
title: "Downloadstillstand bestätigt",
|
||||
description: `Seit ${durationText(durationMs)} wurde kein bestätigter Downloadfortschritt erkannt.`,
|
||||
color: 0xe67e22,
|
||||
fields: [
|
||||
{ name: "Dauer", value: durationText(durationMs), inline: true },
|
||||
{ name: "Offene Pakete", value: String(snapshot.openPackages), inline: true },
|
||||
{ name: "Offene Dateien", value: String(snapshot.openItems), inline: true },
|
||||
{ name: "Bekannte Mindestmenge", value: byteText(snapshot.knownDownloadedBytes), inline: true },
|
||||
{ name: "Aktiv / startfähig", value: `${snapshot.activeTasks} / ${snapshot.startableItems}`, inline: true },
|
||||
{ name: "Technische Wiederherstellungen", value: String(snapshot.technicalRecoveryCount), inline: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function recoveryEvent(
|
||||
state: DownloadHealthState,
|
||||
snapshot: DownloadHealthSnapshot,
|
||||
now: number
|
||||
): NotificationEvent {
|
||||
return {
|
||||
id: `health:recovery:${snapshot.runFingerprint.slice(0, 16)}:${state.alertedAt}`,
|
||||
type: "download_recovered",
|
||||
priority: "success",
|
||||
createdAt: now,
|
||||
expiresAt: now + SUCCESS_EVENT_TTL_MS,
|
||||
attempts: 0,
|
||||
nextAttemptAt: now,
|
||||
payload: {
|
||||
title: "Download läuft wieder",
|
||||
description: "Nach dem bestätigten Stillstand wurde neuer Fortschritt erkannt.",
|
||||
color: 0x2ecc71,
|
||||
fields: [
|
||||
{ name: "Incident-Dauer", value: durationText(Math.max(0, now - state.incidentStartedAt)), inline: true },
|
||||
{ name: "Aktive Downloads", value: String(snapshot.activeTasks), inline: true },
|
||||
{ name: "Aktuelle Geschwindigkeit", value: `${byteText(snapshot.currentSpeedBps)}/s`, inline: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createDownloadHealthState(overrides: Partial<DownloadHealthState> = {}): DownloadHealthState {
|
||||
return {
|
||||
version: 1,
|
||||
status: "idle",
|
||||
runFingerprint: null,
|
||||
queueFingerprint: null,
|
||||
suspiciousDurationMs: 0,
|
||||
suspiciousSamples: 0,
|
||||
incidentStartedAt: 0,
|
||||
incidentType: null,
|
||||
alertedAt: 0,
|
||||
lastAlertAt: 0,
|
||||
cooldownUntil: 0,
|
||||
lastDeliveredStallEventId: null,
|
||||
recoverySamples: 0,
|
||||
lastSampleAt: null,
|
||||
downloadProgressSequence: 0,
|
||||
itemCompletionSequence: 0,
|
||||
lastPositiveByteAt: 0,
|
||||
technicalRecoveryCount: 0,
|
||||
restartPending: false,
|
||||
restartFreshSamples: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function resetForSnapshot(
|
||||
state: DownloadHealthState,
|
||||
snapshot: DownloadHealthSnapshot,
|
||||
now: number
|
||||
): DownloadHealthState {
|
||||
return createDownloadHealthState({
|
||||
runFingerprint: snapshot.runFingerprint,
|
||||
queueFingerprint: snapshot.queueFingerprint,
|
||||
lastAlertAt: state.lastAlertAt,
|
||||
cooldownUntil: state.cooldownUntil,
|
||||
lastDeliveredStallEventId: state.lastDeliveredStallEventId,
|
||||
lastSampleAt: now,
|
||||
downloadProgressSequence: finiteInteger(snapshot.downloadProgressSequence),
|
||||
itemCompletionSequence: finiteInteger(snapshot.itemCompletionSequence),
|
||||
lastPositiveByteAt: finiteInteger(snapshot.lastPositiveByteAt),
|
||||
technicalRecoveryCount: finiteInteger(snapshot.technicalRecoveryCount)
|
||||
});
|
||||
}
|
||||
|
||||
function endIncident(state: DownloadHealthState): DownloadHealthState {
|
||||
return createDownloadHealthState({
|
||||
lastAlertAt: state.lastAlertAt,
|
||||
cooldownUntil: state.cooldownUntil,
|
||||
lastDeliveredStallEventId: state.lastDeliveredStallEventId,
|
||||
downloadProgressSequence: state.downloadProgressSequence,
|
||||
itemCompletionSequence: state.itemCompletionSequence,
|
||||
lastPositiveByteAt: state.lastPositiveByteAt,
|
||||
technicalRecoveryCount: state.technicalRecoveryCount
|
||||
});
|
||||
}
|
||||
|
||||
function expectedWait(snapshot: DownloadHealthSnapshot, now: number): boolean {
|
||||
return snapshot.paused
|
||||
|| snapshot.reconnectUntil > now
|
||||
|| snapshot.nextRetryAt > now
|
||||
|| snapshot.providerCooldownUntil > now
|
||||
|| snapshot.blockedOnDisk
|
||||
|| snapshot.blockedOnThrottleUntil > now
|
||||
|| snapshot.activePhaseDeadlineAt > now;
|
||||
}
|
||||
|
||||
function suspicionType(snapshot: DownloadHealthSnapshot, now: number): DownloadHealthIncidentType | null {
|
||||
if (snapshot.activeTasks === 0 && snapshot.startableItems > 0) {
|
||||
if (snapshot.lastSchedulerTickAt <= 0 || now - snapshot.lastSchedulerTickAt >= SCHEDULER_STALE_AFTER_MS) {
|
||||
return "scheduler";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return "no_data";
|
||||
}
|
||||
|
||||
export function evaluateDownloadHealth(
|
||||
previous: DownloadHealthState,
|
||||
snapshot: DownloadHealthSnapshot,
|
||||
nowValue: number,
|
||||
options: DownloadHealthOptions
|
||||
): DownloadHealthEvaluation {
|
||||
const now = finiteInteger(nowValue);
|
||||
let state = createDownloadHealthState(previous);
|
||||
const events: NotificationEvent[] = [];
|
||||
const runFingerprint = validFingerprint(snapshot.runFingerprint);
|
||||
const queueFingerprint = validFingerprint(snapshot.queueFingerprint);
|
||||
|
||||
if (state.restartPending && !snapshot.runActive && !snapshot.terminalFailure && !snapshot.manualStop && !snapshot.shuttingDown) {
|
||||
state.status = "suspended";
|
||||
state.lastSampleAt = null;
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
if (!snapshot.runActive || snapshot.openItems <= 0 || snapshot.terminalFailure || snapshot.manualStop || snapshot.shuttingDown || !runFingerprint || !queueFingerprint) {
|
||||
state.downloadProgressSequence = Math.max(state.downloadProgressSequence, finiteInteger(snapshot.downloadProgressSequence));
|
||||
state.itemCompletionSequence = Math.max(state.itemCompletionSequence, finiteInteger(snapshot.itemCompletionSequence));
|
||||
state.lastPositiveByteAt = Math.max(state.lastPositiveByteAt, finiteInteger(snapshot.lastPositiveByteAt));
|
||||
state.technicalRecoveryCount = Math.max(state.technicalRecoveryCount, finiteInteger(snapshot.technicalRecoveryCount));
|
||||
return { state: endIncident(state), events };
|
||||
}
|
||||
|
||||
if (state.runFingerprint !== runFingerprint || state.queueFingerprint !== queueFingerprint) {
|
||||
state = resetForSnapshot(state, snapshot, now);
|
||||
}
|
||||
|
||||
state.runFingerprint = runFingerprint;
|
||||
state.queueFingerprint = queueFingerprint;
|
||||
|
||||
if (state.restartPending) {
|
||||
state.restartFreshSamples += 1;
|
||||
state.lastSampleAt = now;
|
||||
if (state.restartFreshSamples < 2) {
|
||||
state.downloadProgressSequence = finiteInteger(snapshot.downloadProgressSequence);
|
||||
state.itemCompletionSequence = finiteInteger(snapshot.itemCompletionSequence);
|
||||
state.lastPositiveByteAt = finiteInteger(snapshot.lastPositiveByteAt);
|
||||
state.technicalRecoveryCount = finiteInteger(snapshot.technicalRecoveryCount);
|
||||
return { state, events };
|
||||
}
|
||||
state.restartPending = false;
|
||||
}
|
||||
|
||||
const progressSequence = finiteInteger(snapshot.downloadProgressSequence);
|
||||
const completionSequence = finiteInteger(snapshot.itemCompletionSequence);
|
||||
const positiveByteProgress = progressSequence > state.downloadProgressSequence;
|
||||
const completionProgress = completionSequence > state.itemCompletionSequence;
|
||||
const progress = positiveByteProgress || completionProgress;
|
||||
state.downloadProgressSequence = Math.max(state.downloadProgressSequence, progressSequence);
|
||||
state.itemCompletionSequence = Math.max(state.itemCompletionSequence, completionSequence);
|
||||
state.lastPositiveByteAt = Math.max(state.lastPositiveByteAt, finiteInteger(snapshot.lastPositiveByteAt));
|
||||
state.technicalRecoveryCount = Math.max(state.technicalRecoveryCount, finiteInteger(snapshot.technicalRecoveryCount));
|
||||
|
||||
if (state.alertedAt > 0 && progress) {
|
||||
state.recoverySamples = completionProgress ? 2 : state.recoverySamples + 1;
|
||||
if (state.recoverySamples >= 2) {
|
||||
if (options.notifyOnRecovery) {
|
||||
events.push(recoveryEvent(state, snapshot, now));
|
||||
}
|
||||
const recovered = resetForSnapshot(state, snapshot, now);
|
||||
recovered.status = "healthy";
|
||||
recovered.lastAlertAt = state.lastAlertAt;
|
||||
recovered.cooldownUntil = state.cooldownUntil;
|
||||
return { state: recovered, events };
|
||||
}
|
||||
state.status = "recovering";
|
||||
state.lastSampleAt = now;
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
if (state.alertedAt > 0) {
|
||||
if (expectedWait(snapshot, now)) {
|
||||
state.status = "expected_wait";
|
||||
} else {
|
||||
state.status = "alerted";
|
||||
}
|
||||
state.lastSampleAt = now;
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
if (expectedWait(snapshot, now)) {
|
||||
state.status = "expected_wait";
|
||||
state.lastSampleAt = now;
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
state.status = "healthy";
|
||||
state.suspiciousDurationMs = 0;
|
||||
state.suspiciousSamples = 0;
|
||||
state.incidentStartedAt = 0;
|
||||
state.incidentType = null;
|
||||
state.recoverySamples = 0;
|
||||
state.lastSampleAt = now;
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
const incidentType = suspicionType(snapshot, now);
|
||||
if (!incidentType) {
|
||||
state.status = "healthy";
|
||||
state.suspiciousDurationMs = 0;
|
||||
state.suspiciousSamples = 0;
|
||||
state.incidentStartedAt = 0;
|
||||
state.incidentType = null;
|
||||
state.lastSampleAt = now;
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
const elapsed = state.lastSampleAt === null ? 0 : Math.max(0, now - state.lastSampleAt);
|
||||
if (state.incidentStartedAt <= 0) {
|
||||
state.incidentStartedAt = now;
|
||||
}
|
||||
state.suspiciousDurationMs += elapsed;
|
||||
state.suspiciousSamples += 1;
|
||||
state.incidentType = incidentType;
|
||||
state.status = incidentType === "scheduler" ? "suspect_scheduler" : "suspect_no_data";
|
||||
state.lastSampleAt = now;
|
||||
|
||||
const stallAfterMs = Math.max(0, finiteInteger(options.stallAfterMs, 90_000));
|
||||
const confirmed = state.suspiciousDurationMs >= stallAfterMs
|
||||
&& state.suspiciousSamples >= MIN_SUSPICIOUS_SAMPLES;
|
||||
if (confirmed && options.notifyOnStall && now >= state.cooldownUntil) {
|
||||
state.status = "alerted";
|
||||
state.alertedAt = now;
|
||||
state.recoverySamples = 0;
|
||||
events.push(incidentEvent(state, snapshot, now));
|
||||
}
|
||||
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
function persistedState(state: DownloadHealthState): Omit<DownloadHealthState, "restartPending" | "restartFreshSamples"> {
|
||||
return {
|
||||
version: 1,
|
||||
status: state.status,
|
||||
runFingerprint: state.runFingerprint,
|
||||
queueFingerprint: state.queueFingerprint,
|
||||
suspiciousDurationMs: finiteInteger(state.suspiciousDurationMs),
|
||||
suspiciousSamples: finiteInteger(state.suspiciousSamples),
|
||||
incidentStartedAt: finiteInteger(state.incidentStartedAt),
|
||||
incidentType: state.incidentType,
|
||||
alertedAt: finiteInteger(state.alertedAt),
|
||||
lastAlertAt: finiteInteger(state.lastAlertAt),
|
||||
cooldownUntil: finiteInteger(state.cooldownUntil),
|
||||
lastDeliveredStallEventId: validStallEventId(state.lastDeliveredStallEventId),
|
||||
recoverySamples: finiteInteger(state.recoverySamples),
|
||||
lastSampleAt: state.lastSampleAt === null ? null : finiteInteger(state.lastSampleAt),
|
||||
downloadProgressSequence: finiteInteger(state.downloadProgressSequence),
|
||||
itemCompletionSequence: finiteInteger(state.itemCompletionSequence),
|
||||
lastPositiveByteAt: finiteInteger(state.lastPositiveByteAt),
|
||||
technicalRecoveryCount: finiteInteger(state.technicalRecoveryCount)
|
||||
};
|
||||
}
|
||||
|
||||
export function saveDownloadHealthState(filePath: string, state: DownloadHealthState): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(persistedState(state)), "utf8");
|
||||
fs.renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDownloadHealthState(filePath: string): DownloadHealthState {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
const runFingerprint = raw.runFingerprint === null ? null : validFingerprint(raw.runFingerprint);
|
||||
const queueFingerprint = raw.queueFingerprint === null ? null : validFingerprint(raw.queueFingerprint);
|
||||
if (raw.version !== 1 || !HEALTH_STATUSES.has(raw.status as DownloadHealthStatus)) {
|
||||
return createDownloadHealthState();
|
||||
}
|
||||
if ((raw.runFingerprint !== null && !runFingerprint) || (raw.queueFingerprint !== null && !queueFingerprint)) {
|
||||
return createDownloadHealthState();
|
||||
}
|
||||
const incidentType = INCIDENT_TYPES.has(raw.incidentType as DownloadHealthIncidentType)
|
||||
? raw.incidentType as DownloadHealthIncidentType
|
||||
: null;
|
||||
return createDownloadHealthState({
|
||||
status: raw.status as DownloadHealthStatus,
|
||||
runFingerprint,
|
||||
queueFingerprint,
|
||||
suspiciousDurationMs: finiteInteger(raw.suspiciousDurationMs),
|
||||
suspiciousSamples: finiteInteger(raw.suspiciousSamples),
|
||||
incidentStartedAt: finiteInteger(raw.incidentStartedAt),
|
||||
incidentType,
|
||||
alertedAt: finiteInteger(raw.alertedAt),
|
||||
lastAlertAt: finiteInteger(raw.lastAlertAt),
|
||||
cooldownUntil: finiteInteger(raw.cooldownUntil),
|
||||
lastDeliveredStallEventId: validStallEventId(raw.lastDeliveredStallEventId),
|
||||
recoverySamples: finiteInteger(raw.recoverySamples),
|
||||
lastSampleAt: null,
|
||||
downloadProgressSequence: finiteInteger(raw.downloadProgressSequence),
|
||||
itemCompletionSequence: finiteInteger(raw.itemCompletionSequence),
|
||||
lastPositiveByteAt: finiteInteger(raw.lastPositiveByteAt),
|
||||
technicalRecoveryCount: finiteInteger(raw.technicalRecoveryCount),
|
||||
restartPending: Boolean(runFingerprint && queueFingerprint),
|
||||
restartFreshSamples: 0
|
||||
});
|
||||
} catch {
|
||||
return createDownloadHealthState();
|
||||
}
|
||||
}
|
||||
|
||||
export class DownloadHealthMonitor {
|
||||
private state: DownloadHealthState;
|
||||
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
|
||||
public constructor(private readonly filePath: string, initialState?: DownloadHealthState) {
|
||||
this.state = initialState ? createDownloadHealthState(initialState) : loadDownloadHealthState(filePath);
|
||||
}
|
||||
|
||||
public getState(): DownloadHealthState {
|
||||
return createDownloadHealthState(this.state);
|
||||
}
|
||||
|
||||
public acknowledgeDelivery(event: NotificationEvent, deliveredAt: number, cooldownMs: number): Promise<void> {
|
||||
return this.runExclusive(async () => {
|
||||
const eventId = validStallEventId(event.id);
|
||||
if (event.type !== "download_stalled" || !eventId || this.state.lastDeliveredStallEventId === eventId) {
|
||||
return;
|
||||
}
|
||||
const acknowledgedAt = finiteInteger(deliveredAt);
|
||||
this.state = createDownloadHealthState({
|
||||
...this.state,
|
||||
lastAlertAt: acknowledgedAt,
|
||||
cooldownUntil: acknowledgedAt + finiteInteger(cooldownMs),
|
||||
lastDeliveredStallEventId: eventId
|
||||
});
|
||||
saveDownloadHealthState(this.filePath, this.state);
|
||||
});
|
||||
}
|
||||
|
||||
public sample(
|
||||
snapshot: DownloadHealthSnapshot,
|
||||
now: number,
|
||||
options: DownloadHealthOptions,
|
||||
enqueue: (event: NotificationEvent) => Promise<void>
|
||||
): Promise<DownloadHealthEvaluation> {
|
||||
return this.runExclusive(async () => {
|
||||
const evaluation = evaluateDownloadHealth(this.state, snapshot, now, options);
|
||||
for (const event of evaluation.events) {
|
||||
await enqueue(event);
|
||||
}
|
||||
saveDownloadHealthState(this.filePath, evaluation.state);
|
||||
this.state = evaluation.state;
|
||||
return evaluation;
|
||||
});
|
||||
}
|
||||
|
||||
private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationChain.then(operation, operation);
|
||||
this.operationChain = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+996
-153
File diff suppressed because it is too large
Load Diff
+109
-19
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeTheme, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeTheme, powerMonitor, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
|
||||
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
|
||||
import { AppController } from "./app-controller";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
@@ -25,6 +25,7 @@ import { validateRealDebridLoginRequest } from "../shared/preload-api";
|
||||
import { migrateProductUserDataDirectory } from "./storage";
|
||||
import { forceDarkNativeTheme } from "./native-theme";
|
||||
import { validateClipboardWriteText } from "./clipboard-write";
|
||||
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
|
||||
|
||||
forceDarkNativeTheme(nativeTheme);
|
||||
|
||||
@@ -50,6 +51,7 @@ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||
"megadebrid-web",
|
||||
"bestdebrid",
|
||||
"alldebrid",
|
||||
"deepbrid",
|
||||
"ddownload",
|
||||
"onefichier",
|
||||
"debridlink",
|
||||
@@ -91,11 +93,73 @@ let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let dailyStartScheduler: DailyStartScheduler | null = null;
|
||||
let lastClipboardText = "";
|
||||
let controller: AppController;
|
||||
let pendingBackupImport: Buffer | null = null;
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
|
||||
function reconcileDailyStart(source: string): void {
|
||||
void dailyStartScheduler?.reconcile().catch((error) => {
|
||||
logger.warn(`Täglicher Start konnte nach ${source} nicht abgeglichen werden: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePowerSuspend(): void {
|
||||
reconcileDailyStart("Suspend");
|
||||
}
|
||||
|
||||
function handlePowerResume(): void {
|
||||
reconcileDailyStart("Resume");
|
||||
}
|
||||
|
||||
export function cleanupSchedulerLifecycle(
|
||||
scheduler: Pick<DailyStartScheduler, "end"> | null,
|
||||
legacyTimer: ReturnType<typeof setTimeout> | null
|
||||
): void {
|
||||
scheduler?.end();
|
||||
if (legacyTimer !== null) {
|
||||
clearTimeout(legacyTimer);
|
||||
}
|
||||
powerMonitor.removeListener("suspend", handlePowerSuspend);
|
||||
powerMonitor.removeListener("resume", handlePowerResume);
|
||||
}
|
||||
|
||||
export interface BeforeQuitHandlerOptions {
|
||||
cleanup: () => void;
|
||||
shutdown: () => Promise<void>;
|
||||
continueQuit: () => void;
|
||||
onError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function createBeforeQuitHandler(options: BeforeQuitHandlerOptions): (event: { preventDefault: () => void }) => void {
|
||||
let shutdownStarted = false;
|
||||
let quitAllowed = false;
|
||||
return (event) => {
|
||||
if (quitAllowed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (shutdownStarted) {
|
||||
return;
|
||||
}
|
||||
shutdownStarted = true;
|
||||
let shutdown: Promise<void>;
|
||||
try {
|
||||
options.cleanup();
|
||||
shutdown = options.shutdown();
|
||||
} catch (error) {
|
||||
shutdown = Promise.reject(error);
|
||||
}
|
||||
void shutdown.catch((error) => {
|
||||
options.onError(error);
|
||||
}).finally(() => {
|
||||
quitAllowed = true;
|
||||
options.continueQuit();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function isDevMode(): boolean {
|
||||
return process.env.NODE_ENV === "development";
|
||||
}
|
||||
@@ -413,13 +477,19 @@ function registerIpcHandlers(): void {
|
||||
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
||||
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
||||
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
|
||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
||||
const currentSettings = controller.getSettings();
|
||||
const validated = validateRendererSettingsUpdate(partial ?? {}, currentSettings);
|
||||
const result = controller.updateSettings(prepareDailyStartSettingsPatch(validated, currentSettings) as Partial<AppSettings>);
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
||||
return createRendererSettings(result);
|
||||
if (hasDailyStartRulePatch(validated)) {
|
||||
await dailyStartScheduler?.reconcile();
|
||||
} else {
|
||||
reconcileDailyStart("Einstellungsänderung");
|
||||
}
|
||||
return createRendererSettings(controller.getSettings());
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
||||
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
||||
@@ -436,28 +506,36 @@ function registerIpcHandlers(): void {
|
||||
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
const command = validateAccountCommand(rawCommand);
|
||||
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
|
||||
return controller.executeAccountCommand(command);
|
||||
const result = await controller.executeAccountCommand(command);
|
||||
reconcileDailyStart("Accountänderung");
|
||||
return result;
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
const command = validateAccountCommand(rawCommand);
|
||||
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
|
||||
return controller.executeAccountCommand(command);
|
||||
const result = await controller.executeAccountCommand(command);
|
||||
reconcileDailyStart("Accountänderung");
|
||||
return result;
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
const command = validateAccountCommand(rawCommand);
|
||||
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
|
||||
return controller.executeAccountCommand(command);
|
||||
const result = await controller.executeAccountCommand(command);
|
||||
reconcileDailyStart("Accountänderung");
|
||||
return result;
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||
const command = validateAccountCommand(rawCommand);
|
||||
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
|
||||
return controller.executeAccountCommand(command);
|
||||
const result = await controller.executeAccountCommand(command);
|
||||
reconcileDailyStart("Accountänderung");
|
||||
return result;
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
|
||||
return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest));
|
||||
@@ -993,6 +1071,7 @@ app.on("second-instance", () => {
|
||||
app.whenReady().then(() => {
|
||||
configureCredentialProtector(safeStorage);
|
||||
controller = new AppController();
|
||||
dailyStartScheduler = new DailyStartScheduler(controller);
|
||||
cleanupStaleSubstDrives();
|
||||
registerIpcHandlers();
|
||||
mainWindow = createWindow();
|
||||
@@ -1003,6 +1082,11 @@ app.whenReady().then(() => {
|
||||
// process — without re-arming it here, any restart (auto-update, reboot,
|
||||
// crash) silently swallowed the planned run.
|
||||
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
|
||||
dailyStartScheduler.begin((error) => {
|
||||
logger.warn(`Täglicher Start konnte nicht abgeglichen werden: ${String(error)}`);
|
||||
});
|
||||
powerMonitor.on("suspend", handlePowerSuspend);
|
||||
powerMonitor.on("resume", handlePowerResume);
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
@@ -1023,16 +1107,22 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
app.on("before-quit", createBeforeQuitHandler({
|
||||
cleanup: () => {
|
||||
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
|
||||
cleanupSchedulerLifecycle(dailyStartScheduler, scheduledStartTimer);
|
||||
scheduledStartTimer = null;
|
||||
stopClipboardWatcher();
|
||||
destroyTray();
|
||||
shutdownDaemon();
|
||||
},
|
||||
shutdown: async () => {
|
||||
if (controller) {
|
||||
try {
|
||||
controller.shutdown();
|
||||
} catch (error) {
|
||||
await controller.shutdown();
|
||||
}
|
||||
},
|
||||
continueQuit: () => app.quit(),
|
||||
onError: (error) => {
|
||||
logger.error(`Fehler beim Shutdown: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
DebridProvider,
|
||||
HistoryEntry,
|
||||
PackageResult,
|
||||
PackageResultStatus
|
||||
} from "../shared/types";
|
||||
import type {
|
||||
NotificationEvent,
|
||||
NotificationEventType,
|
||||
NotificationPriority
|
||||
} from "./notification-outbox";
|
||||
import { projectPackageFailureCategory } from "./package-telemetry";
|
||||
|
||||
export interface PackageResultEnvelope {
|
||||
generation: number;
|
||||
result: PackageResult;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
id: string;
|
||||
stopped: boolean;
|
||||
startedAt: number;
|
||||
completedAt: number;
|
||||
totalDurationSeconds: number;
|
||||
totalPackages: number;
|
||||
completedPackages: number;
|
||||
partialPackages: number;
|
||||
failedPackages: number;
|
||||
cancelledPackages: number;
|
||||
successfulFiles: number;
|
||||
failedFiles: number;
|
||||
cancelledFiles: number;
|
||||
totalBytes: number;
|
||||
downloadedBytes: number;
|
||||
averageDownloadSpeedBps: number;
|
||||
downloadDurationSeconds: number;
|
||||
extractionDurationSeconds: number;
|
||||
remuxDurationSeconds: number;
|
||||
postProcessDurationSeconds: number;
|
||||
extractionFailures: number;
|
||||
remuxFailures: number;
|
||||
downloadFailures: number;
|
||||
offlineFailures: number;
|
||||
cleanupFailures: number;
|
||||
}
|
||||
|
||||
export interface RunResultInput {
|
||||
id: string;
|
||||
stopped: boolean;
|
||||
startedAt: number;
|
||||
completedAt: number;
|
||||
packages: readonly PackageResult[];
|
||||
totalPackages?: number;
|
||||
successfulFiles?: number;
|
||||
failedFiles?: number;
|
||||
cancelledFiles?: number;
|
||||
}
|
||||
|
||||
export interface HistoryEntryContext {
|
||||
generation: number;
|
||||
outputDir: string;
|
||||
urls: string[];
|
||||
provider: DebridProvider | null;
|
||||
}
|
||||
|
||||
export interface RunRemainingSnapshot {
|
||||
remainingBytes: number;
|
||||
openItems: number;
|
||||
openPackages: number;
|
||||
unknownCount: number;
|
||||
finalizingItems: number;
|
||||
speedBps: number;
|
||||
etaSeconds: number;
|
||||
}
|
||||
|
||||
export interface RemainingThresholdDecision {
|
||||
emit: boolean;
|
||||
remainingBytes?: number;
|
||||
}
|
||||
|
||||
export interface RemainingThresholdState {
|
||||
snapshot: RunRemainingSnapshot | null;
|
||||
crossings: number;
|
||||
}
|
||||
|
||||
const SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const IMPORTANT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DIGEST_PACKAGE_LIMIT = 20;
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
|
||||
function finiteNonNegative(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
|
||||
}
|
||||
|
||||
export function evaluateRemainingThreshold(
|
||||
previous: RunRemainingSnapshot | null,
|
||||
current: RunRemainingSnapshot,
|
||||
thresholdBytes: number
|
||||
): RemainingThresholdDecision {
|
||||
const threshold = finiteNonNegative(thresholdBytes);
|
||||
if (!previous
|
||||
|| threshold <= 0
|
||||
|| previous.openItems <= 0
|
||||
|| current.openItems <= 0
|
||||
|| previous.unknownCount > 0
|
||||
|| current.unknownCount > 0
|
||||
|| previous.finalizingItems > 0
|
||||
|| current.finalizingItems > 0
|
||||
|| previous.remainingBytes <= threshold
|
||||
|| current.remainingBytes > threshold) {
|
||||
return { emit: false };
|
||||
}
|
||||
return { emit: true, remainingBytes: current.remainingBytes };
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
let value = finiteNonNegative(bytes);
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${numberFormatter.format(value)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(finiteNonNegative(seconds)));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const remainder = total % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
|
||||
: `${minutes}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: PackageResultStatus): string {
|
||||
if (status === "completed") return "Abgeschlossen";
|
||||
if (status === "partial") return "Teilweise abgeschlossen";
|
||||
if (status === "failed") return "Fehlgeschlagen";
|
||||
return "Abgebrochen";
|
||||
}
|
||||
|
||||
function failurePhaseLabel(result: PackageResult): string {
|
||||
if (result.failurePhase === "download") return "Download";
|
||||
if (result.failurePhase === "extract") return "Entpacken";
|
||||
if (result.failurePhase === "remux") return "Remux";
|
||||
if (result.failurePhase === "cleanup") return "Aufräumen";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function event(
|
||||
id: string,
|
||||
type: NotificationEventType,
|
||||
priority: NotificationPriority,
|
||||
createdAt: number,
|
||||
title: string,
|
||||
description: string,
|
||||
color: number,
|
||||
fields: NotificationEvent["payload"]["fields"]
|
||||
): NotificationEvent {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
priority,
|
||||
createdAt,
|
||||
expiresAt: createdAt + (type === "run_completed" || priority === "error" ? IMPORTANT_TTL_MS : SUCCESS_TTL_MS),
|
||||
attempts: 0,
|
||||
nextAttemptAt: createdAt,
|
||||
payload: { title, description, color, fields }
|
||||
};
|
||||
}
|
||||
|
||||
function packageEventType(status: PackageResultStatus): NotificationEventType {
|
||||
if (status === "completed") return "package_completed";
|
||||
if (status === "partial") return "package_partial";
|
||||
return "package_failed";
|
||||
}
|
||||
|
||||
export function buildPackageNotificationEvent(
|
||||
envelope: PackageResultEnvelope,
|
||||
createdAt: number
|
||||
): NotificationEvent {
|
||||
const { generation, result } = envelope;
|
||||
const type = packageEventType(result.status);
|
||||
const priority: NotificationPriority = result.status === "completed" ? "success" : "error";
|
||||
const title = result.status === "completed"
|
||||
? "✅ Paket fertig"
|
||||
: result.status === "partial"
|
||||
? "⚠️ Paket teilweise fertig"
|
||||
: result.status === "cancelled"
|
||||
? "⏹️ Paket abgebrochen"
|
||||
: "❌ Paket fehlgeschlagen";
|
||||
const fields = [
|
||||
{ name: "Paket", value: result.name || "—", inline: false },
|
||||
{ name: "Ergebnis", value: statusLabel(result.status), inline: true },
|
||||
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
|
||||
{ name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true },
|
||||
{ name: "Zeiten", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false },
|
||||
{ name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true },
|
||||
{ name: "Archive", value: `${result.archiveCount} Gruppen · ${result.partCount} Parts · ${result.outputCount} Ausgaben`, inline: true }
|
||||
];
|
||||
if (result.failurePhase) {
|
||||
const errorCategory = projectPackageFailureCategory(result.failurePhase, result.errorCategory);
|
||||
fields.push({
|
||||
name: "Fehler",
|
||||
value: `${failurePhaseLabel(result)} · ${errorCategory}`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
return event(
|
||||
`package:${result.packageId}:${generation}:${type}`,
|
||||
type,
|
||||
priority,
|
||||
createdAt,
|
||||
title,
|
||||
result.name,
|
||||
priority === "success" ? 0x2ecc71 : 0xe74c3c,
|
||||
fields
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPackageDigestEvents(
|
||||
envelopes: readonly PackageResultEnvelope[],
|
||||
createdAt: number
|
||||
): NotificationEvent[] {
|
||||
const sorted = [...envelopes].sort((left, right) => {
|
||||
const byCompleted = left.result.completedAt - right.result.completedAt;
|
||||
if (byCompleted !== 0) return byCompleted;
|
||||
const byPackage = left.result.packageId.localeCompare(right.result.packageId);
|
||||
return byPackage !== 0 ? byPackage : left.generation - right.generation;
|
||||
});
|
||||
const digestKey = createHash("sha256")
|
||||
.update(sorted.map((entry) => `${entry.result.packageId}:${entry.generation}`).join("|"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const events: NotificationEvent[] = [];
|
||||
for (let offset = 0; offset < sorted.length; offset += DIGEST_PACKAGE_LIMIT) {
|
||||
const chunk = sorted.slice(offset, offset + DIGEST_PACKAGE_LIMIT);
|
||||
const page = Math.floor(offset / DIGEST_PACKAGE_LIMIT) + 1;
|
||||
const totalPages = Math.ceil(sorted.length / DIGEST_PACKAGE_LIMIT);
|
||||
const fields = chunk.map(({ result }) => ({
|
||||
name: result.name || "Paket",
|
||||
value: `${result.successfulFiles} Dateien · ${formatBytes(result.downloadedBytes)} · ${formatDuration(result.totalDurationSeconds)}`,
|
||||
inline: false
|
||||
}));
|
||||
events.push(event(
|
||||
`package-digest:${digestKey}:${page}`,
|
||||
"package_completed",
|
||||
"success",
|
||||
createdAt,
|
||||
totalPages > 1 ? `✅ Paket-Digest ${page}/${totalPages}` : "✅ Paket-Digest",
|
||||
`${sorted.length} Pakete abgeschlossen`,
|
||||
0x2ecc71,
|
||||
fields
|
||||
));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function buildRunResult(input: RunResultInput): RunResult {
|
||||
const packages = [...input.packages];
|
||||
const sum = (select: (result: PackageResult) => number): number => packages.reduce((total, result) => total + finiteNonNegative(select(result)), 0);
|
||||
const downloadedBytes = sum((result) => result.downloadedBytes);
|
||||
const downloadDurationSeconds = sum((result) => result.downloadDurationSeconds);
|
||||
const successfulFiles = input.successfulFiles ?? sum((result) => result.successfulFiles);
|
||||
const failedFiles = input.failedFiles ?? sum((result) => result.failedFiles);
|
||||
const cancelledFiles = input.cancelledFiles ?? sum((result) => result.cancelledFiles);
|
||||
return {
|
||||
id: input.id,
|
||||
stopped: input.stopped,
|
||||
startedAt: finiteNonNegative(input.startedAt),
|
||||
completedAt: finiteNonNegative(input.completedAt),
|
||||
totalDurationSeconds: Math.max(0, Math.floor((finiteNonNegative(input.completedAt) - finiteNonNegative(input.startedAt)) / 1000)),
|
||||
totalPackages: Math.max(packages.length, Math.floor(finiteNonNegative(input.totalPackages))),
|
||||
completedPackages: packages.filter((result) => result.status === "completed").length,
|
||||
partialPackages: packages.filter((result) => result.status === "partial").length,
|
||||
failedPackages: packages.filter((result) => result.status === "failed").length,
|
||||
cancelledPackages: packages.filter((result) => result.status === "cancelled").length,
|
||||
successfulFiles,
|
||||
failedFiles,
|
||||
cancelledFiles,
|
||||
totalBytes: sum((result) => result.totalBytes),
|
||||
downloadedBytes,
|
||||
averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0,
|
||||
downloadDurationSeconds,
|
||||
extractionDurationSeconds: sum((result) => result.extractionDurationSeconds),
|
||||
remuxDurationSeconds: sum((result) => result.remuxDurationSeconds),
|
||||
postProcessDurationSeconds: sum((result) => result.postProcessDurationSeconds),
|
||||
extractionFailures: sum((result) => result.extractionFailures),
|
||||
remuxFailures: sum((result) => result.remuxFailures),
|
||||
downloadFailures: sum((result) => result.downloadFailures),
|
||||
offlineFailures: sum((result) => result.offlineFailures),
|
||||
cleanupFailures: sum((result) => result.cleanupFailures)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRunNotificationEvent(result: RunResult): NotificationEvent {
|
||||
const priority: NotificationPriority = result.stopped || result.failedFiles > 0 || result.partialPackages > 0 || result.failedPackages > 0
|
||||
? "error"
|
||||
: "success";
|
||||
const type: NotificationEventType = result.stopped ? "run_stopped" : "run_completed";
|
||||
const title = result.stopped
|
||||
? "⏹️ Durchlauf gestoppt"
|
||||
: priority === "success"
|
||||
? "🏁 Durchlauf beendet"
|
||||
: "⚠️ Durchlauf mit Fehlern beendet";
|
||||
return event(
|
||||
`run:${result.id}:${type}`,
|
||||
type,
|
||||
priority,
|
||||
result.completedAt,
|
||||
title,
|
||||
result.stopped ? "Offene Dateien bleiben in der Warteschlange." : "Alle Paketresultate sind final.",
|
||||
priority === "success" ? 0x2ecc71 : 0xe67e22,
|
||||
[
|
||||
{ name: "Pakete", value: `${result.completedPackages} fertig · ${result.partialPackages} teilweise · ${result.failedPackages} fehlgeschlagen · ${result.cancelledPackages} abgebrochen`, inline: false },
|
||||
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
|
||||
{ name: "Dauer", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Nachbearbeitung ${formatDuration(result.postProcessDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false },
|
||||
{ name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true },
|
||||
{ name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true },
|
||||
{ name: "Entpackfehler", value: String(result.extractionFailures), inline: true },
|
||||
{ name: "Remuxfehler", value: String(result.remuxFailures), inline: true },
|
||||
{ name: "Downloadfehler", value: String(result.downloadFailures), inline: true },
|
||||
{ name: "Offline", value: String(result.offlineFailures), inline: true },
|
||||
{ name: "Cleanupfehler", value: String(result.cleanupFailures), inline: true }
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRemainingThresholdNotificationEvent(
|
||||
runId: string,
|
||||
crossing: number,
|
||||
snapshot: RunRemainingSnapshot,
|
||||
thresholdBytes: number,
|
||||
createdAt: number
|
||||
): NotificationEvent {
|
||||
return event(
|
||||
`run:${runId}:remaining_threshold_crossed:${crossing}`,
|
||||
"remaining_threshold_crossed",
|
||||
"success",
|
||||
createdAt,
|
||||
"📉 Restmenge erreicht",
|
||||
`Der aktive Durchlauf liegt bei oder unter ${formatBytes(thresholdBytes)}.`,
|
||||
0x3498db,
|
||||
[
|
||||
{ name: "Restmenge", value: formatBytes(snapshot.remainingBytes), inline: true },
|
||||
{ name: "Offene Pakete", value: String(snapshot.openPackages), inline: true },
|
||||
{ name: "Offene Dateien", value: String(snapshot.openItems), inline: true },
|
||||
{ name: "Geschwindigkeit", value: snapshot.speedBps > 0 ? `${formatBytes(snapshot.speedBps)}/s` : "—", inline: true },
|
||||
{ name: "ETA", value: snapshot.etaSeconds >= 0 ? formatDuration(snapshot.etaSeconds) : "—", inline: true }
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildHistoryEntry(
|
||||
result: PackageResult,
|
||||
context: HistoryEntryContext
|
||||
): HistoryEntry {
|
||||
return {
|
||||
id: `hist-${result.packageId}-${context.generation}`,
|
||||
name: result.name,
|
||||
totalBytes: result.totalBytes,
|
||||
downloadedBytes: result.downloadedBytes,
|
||||
fileCount: result.successfulFiles + result.failedFiles + result.cancelledFiles,
|
||||
provider: context.provider,
|
||||
completedAt: result.completedAt,
|
||||
durationSeconds: result.downloadDurationSeconds,
|
||||
status: result.status,
|
||||
outputDir: context.outputDir,
|
||||
urls: [...new Set(context.urls.filter(Boolean))],
|
||||
startedAt: result.startedAt,
|
||||
downloadEndedAt: result.downloadEndedAt,
|
||||
postProcessStartedAt: result.postProcessStartedAt,
|
||||
downloadDurationSeconds: result.downloadDurationSeconds,
|
||||
extractionDurationSeconds: result.extractionDurationSeconds,
|
||||
remuxDurationSeconds: result.remuxDurationSeconds,
|
||||
postProcessDurationSeconds: result.postProcessDurationSeconds,
|
||||
totalDurationSeconds: result.totalDurationSeconds,
|
||||
successfulFiles: result.successfulFiles,
|
||||
failedFiles: result.failedFiles,
|
||||
cancelledFiles: result.cancelledFiles,
|
||||
archiveCount: result.archiveCount,
|
||||
partCount: result.partCount,
|
||||
outputCount: result.outputCount,
|
||||
failurePhase: result.failurePhase,
|
||||
archiveOperations: result.archiveOperations.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] })),
|
||||
remuxOperations: result.remuxOperations.map((operation) => ({ ...operation }))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DiscordEmbedFieldPayload } from "./notify";
|
||||
import { projectPackageFailureCategory } from "./package-telemetry";
|
||||
import type { FailurePhase } from "../shared/types";
|
||||
|
||||
export type NotificationEventType =
|
||||
| "package_completed"
|
||||
| "package_partial"
|
||||
| "package_failed"
|
||||
| "run_completed"
|
||||
| "run_stopped"
|
||||
| "remaining_threshold_crossed"
|
||||
| "download_stalled"
|
||||
| "download_recovered";
|
||||
|
||||
export type NotificationPriority = "success" | "error";
|
||||
|
||||
export interface NotificationEventPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
color?: number;
|
||||
fields: DiscordEmbedFieldPayload[];
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
id: string;
|
||||
type: NotificationEventType;
|
||||
priority: NotificationPriority;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
attempts: number;
|
||||
nextAttemptAt: number;
|
||||
payload: NotificationEventPayload;
|
||||
}
|
||||
|
||||
export interface NotificationOutboxStatus {
|
||||
queued: number;
|
||||
lastSuccessAt: number;
|
||||
lastFailureAt: number;
|
||||
}
|
||||
|
||||
export interface NotificationOutboxOptions {
|
||||
filePath: string;
|
||||
send: (event: NotificationEvent) => Promise<boolean>;
|
||||
onDelivered?: (event: NotificationEvent, deliveredAt: number) => void | Promise<void>;
|
||||
now?: () => number;
|
||||
autoDrain?: boolean;
|
||||
}
|
||||
|
||||
interface PersistedNotificationOutbox {
|
||||
version: 1;
|
||||
events: NotificationEvent[];
|
||||
lastSuccessAt: number;
|
||||
lastFailureAt: number;
|
||||
}
|
||||
|
||||
const EVENT_TYPES = new Set<NotificationEventType>([
|
||||
"package_completed",
|
||||
"package_partial",
|
||||
"package_failed",
|
||||
"run_completed",
|
||||
"run_stopped",
|
||||
"remaining_threshold_crossed",
|
||||
"download_stalled",
|
||||
"download_recovered"
|
||||
]);
|
||||
const MAX_EVENTS = 250;
|
||||
const MAX_RETRY_DELAY_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000;
|
||||
const PACKAGE_FAILURE_EVENT_TYPES = new Set<NotificationEventType>([
|
||||
"package_partial",
|
||||
"package_failed"
|
||||
]);
|
||||
const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
|
||||
["Download", "download"],
|
||||
["Entpacken", "extract"],
|
||||
["Remux", "remux"],
|
||||
["Aufräumen", "cleanup"]
|
||||
]);
|
||||
|
||||
function finiteInteger(value: unknown, fallback = 0): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
|
||||
}
|
||||
|
||||
function sanitizePackageFailureFieldValue(value: string): string {
|
||||
const match = /^(Download|Entpacken|Remux|Aufräumen)(?:\s*·\s*(.*))?$/s.exec(value.trim());
|
||||
if (!match) {
|
||||
return "Unbekannt";
|
||||
}
|
||||
const phase = PACKAGE_FAILURE_PHASES.get(match[1]) ?? null;
|
||||
return `${match[1]} · ${projectPackageFailureCategory(phase, match[2])}`;
|
||||
}
|
||||
|
||||
function sanitizeEvent(value: unknown): NotificationEvent | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const raw = value as Partial<NotificationEvent>;
|
||||
const id = typeof raw.id === "string" ? raw.id.trim().slice(0, 256) : "";
|
||||
const type = EVENT_TYPES.has(raw.type as NotificationEventType) ? raw.type as NotificationEventType : null;
|
||||
const priority = raw.priority === "success" || raw.priority === "error" ? raw.priority : null;
|
||||
const payload = raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)
|
||||
? raw.payload as NotificationEventPayload
|
||||
: null;
|
||||
const title = typeof payload?.title === "string" ? payload.title.slice(0, 4096) : "";
|
||||
if (!id || !type || !priority || !payload || !title) {
|
||||
return null;
|
||||
}
|
||||
const fields = Array.isArray(payload.fields)
|
||||
? payload.fields.slice(0, 25).flatMap((field) => {
|
||||
if (!field || typeof field !== "object") {
|
||||
return [];
|
||||
}
|
||||
const name = typeof field.name === "string" ? field.name.slice(0, 1024) : "";
|
||||
const rawFieldValue = typeof field.value === "string" ? field.value.slice(0, 4096) : "";
|
||||
const fieldValue = name === "Fehler" && PACKAGE_FAILURE_EVENT_TYPES.has(type)
|
||||
? sanitizePackageFailureFieldValue(rawFieldValue)
|
||||
: rawFieldValue;
|
||||
return name && fieldValue ? [{ name, value: fieldValue, inline: Boolean(field.inline) }] : [];
|
||||
})
|
||||
: [];
|
||||
const description = typeof payload.description === "string" ? payload.description.slice(0, 8192) : undefined;
|
||||
const color = Number.isFinite(payload.color)
|
||||
? Math.max(0, Math.min(0xffffff, Math.floor(payload.color as number)))
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
priority,
|
||||
createdAt: finiteInteger(raw.createdAt),
|
||||
expiresAt: finiteInteger(raw.expiresAt),
|
||||
attempts: finiteInteger(raw.attempts),
|
||||
nextAttemptAt: finiteInteger(raw.nextAttemptAt),
|
||||
payload: {
|
||||
title,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(color !== undefined ? { color } : {}),
|
||||
fields
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function oldestIndex(events: NotificationEvent[], predicate: (event: NotificationEvent) => boolean): number {
|
||||
let selected = -1;
|
||||
for (let index = 0; index < events.length; index += 1) {
|
||||
if (!predicate(events[index])) {
|
||||
continue;
|
||||
}
|
||||
if (selected < 0 || events[index].createdAt < events[selected].createdAt) {
|
||||
selected = index;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function retryDelayMs(attempts: number): number {
|
||||
return Math.min(MAX_RETRY_DELAY_MS, 1000 * (2 ** Math.min(30, Math.max(0, attempts - 1))));
|
||||
}
|
||||
|
||||
export class NotificationOutbox {
|
||||
private events: NotificationEvent[] = [];
|
||||
private lastSuccessAt = 0;
|
||||
private lastFailureAt = 0;
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
private readonly filePath: string;
|
||||
private readonly sendEvent: (event: NotificationEvent) => Promise<boolean>;
|
||||
private readonly onDelivered: ((event: NotificationEvent, deliveredAt: number) => void | Promise<void>) | null;
|
||||
private readonly clock: () => number;
|
||||
private readonly autoDrain: boolean;
|
||||
private retryTimer: NodeJS.Timeout | null = null;
|
||||
private shutdownRequested = false;
|
||||
private drainOperation: Promise<void> | null = null;
|
||||
private inFlightEvent: NotificationEvent | null = null;
|
||||
private persistenceRequired = false;
|
||||
private persistenceRetryAttempts = 0;
|
||||
|
||||
public constructor(options: NotificationOutboxOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.sendEvent = options.send;
|
||||
this.onDelivered = options.onDelivered || null;
|
||||
this.clock = options.now || Date.now;
|
||||
this.autoDrain = Boolean(options.autoDrain);
|
||||
this.load();
|
||||
if (this.autoDrain && this.events.length > 0) {
|
||||
this.scheduleDrain(Math.max(0, this.events[0].nextAttemptAt - this.clock()));
|
||||
}
|
||||
}
|
||||
|
||||
public async enqueue(event: NotificationEvent): Promise<void> {
|
||||
await this.runExclusive(async () => {
|
||||
const normalized = sanitizeEvent(event);
|
||||
if (normalized && !this.events.some((queuedEvent) => queuedEvent.id === normalized.id)) {
|
||||
this.events.push(normalized);
|
||||
}
|
||||
await this.persist(this.clock());
|
||||
});
|
||||
if (this.autoDrain) {
|
||||
this.scheduleDrain(0);
|
||||
}
|
||||
}
|
||||
|
||||
public drain(now?: number): Promise<void> {
|
||||
if (this.drainOperation) {
|
||||
return this.drainOperation;
|
||||
}
|
||||
const operation = this.performDrain(now).finally(() => {
|
||||
if (this.drainOperation === operation) {
|
||||
this.drainOperation = null;
|
||||
}
|
||||
});
|
||||
this.drainOperation = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
public async drainForShutdown(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
|
||||
this.shutdownRequested = true;
|
||||
this.clearRetryTimer();
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, Math.max(0, finiteInteger(timeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS)));
|
||||
});
|
||||
const shutdownDrain = Promise.all([this.recoverPersistence(), this.drain()]).then(() => undefined);
|
||||
await Promise.race([shutdownDrain, timeout]);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus(): NotificationOutboxStatus {
|
||||
return {
|
||||
queued: this.events.length,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastFailureAt: this.lastFailureAt
|
||||
};
|
||||
}
|
||||
|
||||
private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationChain.then(operation, operation);
|
||||
this.operationChain = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performDrain(now?: number): Promise<void> {
|
||||
let currentNow = finiteInteger(now ?? this.clock());
|
||||
while (true) {
|
||||
const current = await this.runExclusive(async () => {
|
||||
if (this.persistenceRequired) {
|
||||
await this.persist(currentNow);
|
||||
}
|
||||
this.enforceLimits(currentNow);
|
||||
const next = this.events[0] || null;
|
||||
if (!next) {
|
||||
this.clearRetryTimer();
|
||||
await this.persist(finiteInteger(this.clock(), currentNow));
|
||||
return null;
|
||||
}
|
||||
if (next.nextAttemptAt > currentNow) {
|
||||
await this.persist(currentNow);
|
||||
if (this.autoDrain) {
|
||||
this.scheduleDrain(Math.max(0, next.nextAttemptAt - this.clock()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
this.inFlightEvent = next;
|
||||
return next;
|
||||
});
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
let sent = false;
|
||||
try {
|
||||
sent = await this.sendEvent(current);
|
||||
} catch {
|
||||
sent = false;
|
||||
}
|
||||
const outcomeAt = finiteInteger(this.clock(), currentNow);
|
||||
const delivered = await this.runExclusive(async () => {
|
||||
const index = this.events.indexOf(current);
|
||||
this.inFlightEvent = null;
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
if (!sent) {
|
||||
const queued = this.events[index];
|
||||
queued.attempts += 1;
|
||||
queued.nextAttemptAt = outcomeAt + retryDelayMs(queued.attempts);
|
||||
this.lastFailureAt = outcomeAt;
|
||||
await this.persist(outcomeAt);
|
||||
if (this.autoDrain) {
|
||||
this.scheduleDrain(Math.max(0, queued.nextAttemptAt - this.clock()));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.events.splice(index, 1);
|
||||
this.lastSuccessAt = outcomeAt;
|
||||
await this.persist(outcomeAt);
|
||||
return true;
|
||||
});
|
||||
if (!sent) {
|
||||
return;
|
||||
}
|
||||
if (delivered && this.onDelivered) {
|
||||
try {
|
||||
await this.onDelivered(current, outcomeAt);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
currentNow = finiteInteger(this.clock(), outcomeAt);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDrain(delayMs: number): void {
|
||||
if (this.shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
this.clearRetryTimer();
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.drain().catch(() => {});
|
||||
}, delayMs);
|
||||
this.retryTimer.unref?.();
|
||||
}
|
||||
|
||||
private schedulePersistenceRetry(delayMs: number): void {
|
||||
if (this.shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
this.clearRetryTimer();
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.recoverPersistence()
|
||||
.then(() => {
|
||||
if (!this.shutdownRequested) {
|
||||
void this.drain().catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, delayMs);
|
||||
this.retryTimer.unref?.();
|
||||
}
|
||||
|
||||
private recoverPersistence(): Promise<void> {
|
||||
return this.runExclusive(async () => {
|
||||
if (this.persistenceRequired) {
|
||||
await this.persist(this.clock());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearRetryTimer(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as Partial<PersistedNotificationOutbox>;
|
||||
this.events = Array.isArray(parsed.events)
|
||||
? parsed.events.flatMap((event) => {
|
||||
const normalized = sanitizeEvent(event);
|
||||
return normalized ? [normalized] : [];
|
||||
})
|
||||
: [];
|
||||
this.lastSuccessAt = finiteInteger(parsed.lastSuccessAt);
|
||||
this.lastFailureAt = finiteInteger(parsed.lastFailureAt);
|
||||
this.enforceLimits(this.clock());
|
||||
} catch {
|
||||
this.events = [];
|
||||
this.lastSuccessAt = 0;
|
||||
this.lastFailureAt = 0;
|
||||
}
|
||||
this.persistSync(this.clock());
|
||||
}
|
||||
|
||||
private enforceLimits(now: number): void {
|
||||
this.events = this.events.filter((event) => event === this.inFlightEvent || event.expiresAt > now);
|
||||
while (this.events.length > MAX_EVENTS) {
|
||||
const successIndex = oldestIndex(this.events, (event) => event !== this.inFlightEvent && event.priority === "success");
|
||||
const removeIndex = successIndex >= 0
|
||||
? successIndex
|
||||
: oldestIndex(this.events, (event) => event !== this.inFlightEvent);
|
||||
if (removeIndex < 0) {
|
||||
break;
|
||||
}
|
||||
this.events.splice(removeIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(now: number): Promise<void> {
|
||||
this.persistenceRequired = true;
|
||||
this.enforceLimits(now);
|
||||
const tempPath = `${this.filePath}.tmp`;
|
||||
const state: PersistedNotificationOutbox = {
|
||||
version: 1,
|
||||
events: this.events,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastFailureAt: this.lastFailureAt
|
||||
};
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fsp.writeFile(tempPath, JSON.stringify(state), "utf8");
|
||||
await fsp.rename(tempPath, this.filePath);
|
||||
this.persistenceRequired = false;
|
||||
this.persistenceRetryAttempts = 0;
|
||||
} catch (error) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
this.persistenceRetryAttempts += 1;
|
||||
if (this.autoDrain) {
|
||||
this.schedulePersistenceRetry(retryDelayMs(this.persistenceRetryAttempts));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private persistSync(now: number): void {
|
||||
this.enforceLimits(now);
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||
const tempPath = `${this.filePath}.tmp`;
|
||||
const state: PersistedNotificationOutbox = {
|
||||
version: 1,
|
||||
events: this.events,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastFailureAt: this.lastFailureAt
|
||||
};
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(state), "utf8");
|
||||
fs.renameSync(tempPath, this.filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-3
@@ -4,14 +4,42 @@ export interface NotifyPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
mention?: string;
|
||||
color?: number;
|
||||
fields?: DiscordEmbedFieldPayload[];
|
||||
timestamp?: number | string;
|
||||
}
|
||||
|
||||
export interface DiscordEmbedFieldPayload {
|
||||
name: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordEmbedPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
color: number;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
inline: boolean;
|
||||
}>;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
const NOTIFY_TIMEOUT_MS = 5000;
|
||||
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
||||
const WEBHOOK_USERNAME = "Multi-Debrid Downloader";
|
||||
const MIN_SEND_GAP_MS = 450;
|
||||
const RETRY_DELAYS_MS = [1000, 2500];
|
||||
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
const EMBED_TITLE_MAX_CHARS = 256;
|
||||
const EMBED_DESCRIPTION_MAX_CHARS = 4096;
|
||||
const EMBED_FIELD_NAME_MAX_CHARS = 256;
|
||||
const EMBED_FIELD_VALUE_MAX_CHARS = 1024;
|
||||
const EMBED_FIELDS_MAX = 25;
|
||||
const EMBED_TOTAL_MAX_CHARS = 6000;
|
||||
const DEFAULT_EMBED_COLOR = 0x2f81f7;
|
||||
|
||||
export function isNotifyUrlValid(url: string): boolean {
|
||||
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
||||
@@ -44,15 +72,67 @@ export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS):
|
||||
return cut;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: number | string | undefined): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
||||
}
|
||||
|
||||
function normalizeEmbedColor(value: number | undefined): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_EMBED_COLOR;
|
||||
}
|
||||
return Math.max(0, Math.min(0xffffff, Math.floor(value as number)));
|
||||
}
|
||||
|
||||
function buildDiscordEmbed(payload: NotifyPayload): DiscordEmbedPayload {
|
||||
let remaining = EMBED_TOTAL_MAX_CHARS;
|
||||
const title = truncateContent(String(payload.title || ""), Math.min(EMBED_TITLE_MAX_CHARS, remaining));
|
||||
remaining -= title.length;
|
||||
const description = truncateContent(String(payload.message || ""), Math.min(EMBED_DESCRIPTION_MAX_CHARS, remaining));
|
||||
remaining -= description.length;
|
||||
const fields: DiscordEmbedPayload["fields"] = [];
|
||||
for (const field of (payload.fields || []).slice(0, EMBED_FIELDS_MAX)) {
|
||||
if (remaining < 2) {
|
||||
break;
|
||||
}
|
||||
const name = truncateContent(String(field.name || ""), Math.min(EMBED_FIELD_NAME_MAX_CHARS, remaining - 1));
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
remaining -= name.length;
|
||||
const value = truncateContent(String(field.value || ""), Math.min(EMBED_FIELD_VALUE_MAX_CHARS, remaining));
|
||||
if (!value) {
|
||||
remaining += name.length;
|
||||
continue;
|
||||
}
|
||||
remaining -= value.length;
|
||||
fields.push({ name, value, inline: Boolean(field.inline) });
|
||||
}
|
||||
const timestamp = normalizeTimestamp(payload.timestamp);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
color: normalizeEmbedColor(payload.color),
|
||||
fields,
|
||||
...(timestamp ? { timestamp } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
||||
const mention = normalizeDiscordMention(payload.mention || "");
|
||||
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
||||
return {
|
||||
url: String(url || "").trim(),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
|
||||
body: JSON.stringify({
|
||||
username: WEBHOOK_USERNAME,
|
||||
content: truncateContent(mention),
|
||||
embeds: [buildDiscordEmbed(payload)]
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
ArchiveOperationMetric,
|
||||
FailurePhase,
|
||||
PackageResult,
|
||||
PackageResultStatus,
|
||||
PackageTelemetry,
|
||||
RemuxOperationMetric
|
||||
} from "../shared/types";
|
||||
|
||||
export type PackageFailureCategory =
|
||||
| "Netzwerk"
|
||||
| "Timeout"
|
||||
| "Offline"
|
||||
| "Speicherplatz"
|
||||
| "Berechtigung"
|
||||
| "Download"
|
||||
| "Entpacken"
|
||||
| "Remux"
|
||||
| "Cleanup"
|
||||
| "Unbekannt";
|
||||
|
||||
const packageFailureCategories = new Map<string, PackageFailureCategory>([
|
||||
["netzwerk", "Netzwerk"],
|
||||
["timeout", "Timeout"],
|
||||
["offline", "Offline"],
|
||||
["speicherplatz", "Speicherplatz"],
|
||||
["berechtigung", "Berechtigung"],
|
||||
["download", "Download"],
|
||||
["entpacken", "Entpacken"],
|
||||
["remux", "Remux"],
|
||||
["cleanup", "Cleanup"],
|
||||
["unbekannt", "Unbekannt"]
|
||||
]);
|
||||
|
||||
function finiteNonNegative(value: unknown): number {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : 0;
|
||||
}
|
||||
|
||||
export function durationMsToSeconds(durationMs: number): number {
|
||||
return Math.floor(finiteNonNegative(durationMs) / 1_000);
|
||||
}
|
||||
|
||||
export function durationSecondsBetween(startedAt: number | undefined, completedAt: number | undefined): number {
|
||||
const start = finiteNonNegative(startedAt);
|
||||
const end = finiteNonNegative(completedAt);
|
||||
return start > 0 && end > start ? durationMsToSeconds(end - start) : 0;
|
||||
}
|
||||
|
||||
export function sumOperationDurationSeconds(operations: readonly { durationMs: number }[]): number {
|
||||
return durationMsToSeconds(operations.reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0));
|
||||
}
|
||||
|
||||
export function projectPackageFailureCategory(failurePhase: FailurePhase, detail: unknown): PackageFailureCategory {
|
||||
const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase();
|
||||
const knownCategory = packageFailureCategories.get(normalized);
|
||||
if (knownCategory) {
|
||||
return knownCategory;
|
||||
}
|
||||
if (/\b(?:e?timed?[\s_-]*out|timeout)\b|zeit(?:ü|ue)berschreitung/.test(normalized)) {
|
||||
return "Timeout";
|
||||
}
|
||||
if (/\boffline\b|not[\s_-]*found|nicht[\s_-]*gefunden|dead[\s_-]*link|http\s*404/.test(normalized)) {
|
||||
return "Offline";
|
||||
}
|
||||
if (/\benospc\b|disk[\s_-]*full|no[\s_-]+space[\s_-]+left|not[\s_-]+enough[\s_-]+(?:disk[\s_-]+)?space|insufficient[\s_-]+(?:disk[\s_-]+)?space|speicherplatz|datenträger[^\n]*voll/.test(normalized)) {
|
||||
return "Speicherplatz";
|
||||
}
|
||||
if (/\beacces\b|\beperm\b|permission|access[\s_-]*denied|zugriff[^\n]*verweigert|berechtigung/.test(normalized)) {
|
||||
return "Berechtigung";
|
||||
}
|
||||
if (/network|netzwerk|\beconn|\benet|\behost|\beai_again\b|\bdns\b|socket|connection|verbindung|fetch[\s_-]*failed/.test(normalized)) {
|
||||
return "Netzwerk";
|
||||
}
|
||||
if (failurePhase === "download") return "Download";
|
||||
if (failurePhase === "extract") return "Entpacken";
|
||||
if (failurePhase === "remux") return "Remux";
|
||||
if (failurePhase === "cleanup") return "Cleanup";
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
function classifyStatus(successfulFiles: number, failedFiles: number, cancelledFiles: number, packageCancelled: boolean): PackageResultStatus {
|
||||
if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || packageCancelled)) {
|
||||
return "partial";
|
||||
}
|
||||
if (failedFiles > 0) {
|
||||
return "failed";
|
||||
}
|
||||
if (cancelledFiles > 0 || packageCancelled) {
|
||||
return "cancelled";
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function getFailure(
|
||||
cleanupErrorCategory: string,
|
||||
remuxOperations: readonly RemuxOperationMetric[],
|
||||
remuxFallbackFailures: number,
|
||||
archiveOperations: readonly ArchiveOperationMetric[],
|
||||
downloadErrors: readonly string[]
|
||||
): { failurePhase: FailurePhase; errorCategory: string } {
|
||||
if (cleanupErrorCategory) {
|
||||
return { failurePhase: "cleanup", errorCategory: projectPackageFailureCategory("cleanup", cleanupErrorCategory) };
|
||||
}
|
||||
const failedRemux = remuxOperations.find((operation) => operation.status === "failed");
|
||||
if (failedRemux || remuxFallbackFailures > 0) {
|
||||
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
|
||||
}
|
||||
const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
|
||||
if (failedArchive) {
|
||||
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive.errorCategory) };
|
||||
}
|
||||
const downloadError = downloadErrors.find(Boolean);
|
||||
if (downloadErrors.length > 0) {
|
||||
return { failurePhase: "download", errorCategory: projectPackageFailureCategory("download", downloadError) };
|
||||
}
|
||||
return { failurePhase: null, errorCategory: "" };
|
||||
}
|
||||
|
||||
export function finalizePackageResult(telemetry: PackageTelemetry): PackageResult {
|
||||
const packageEntry = telemetry.package;
|
||||
const archiveOperations = (telemetry.archiveOperations ?? packageEntry.archiveOperations ?? [])
|
||||
.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] }));
|
||||
const remuxOperations = (telemetry.remuxOperations ?? packageEntry.remuxOperations ?? [])
|
||||
.map((operation) => ({ ...operation }));
|
||||
const cleanedCompletedDownloads = Math.max(0, Math.floor(finiteNonNegative(packageEntry.cleanedCompletedItemCount)));
|
||||
const completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length;
|
||||
const failedDownloads = telemetry.items.filter((item) => item.status === "failed");
|
||||
const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length;
|
||||
const failedArchives = archiveOperations.filter((operation) => operation.status === "failed").length;
|
||||
const cancelledArchives = archiveOperations.filter((operation) => operation.status === "cancelled").length;
|
||||
const failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length;
|
||||
const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length;
|
||||
const audioStripFailures = Math.max(0, Math.floor(finiteNonNegative(packageEntry.audioStripSummary?.failed)));
|
||||
const remuxFailures = Math.max(failedRemuxOperations, audioStripFailures);
|
||||
const cleanupErrorCategory = String(telemetry.cleanupErrorCategory ?? packageEntry.cleanupErrorCategory ?? "").trim();
|
||||
const downloadFailureCategories = failedDownloads.map((item) =>
|
||||
projectPackageFailureCategory("download", item.lastError || item.fullStatus)
|
||||
);
|
||||
const offlineFailures = downloadFailureCategories.filter((category) => category === "Offline").length;
|
||||
const cleanupFailures = cleanupErrorCategory ? 1 : 0;
|
||||
const postProcessFailures = failedArchives + remuxFailures + cleanupFailures;
|
||||
const postProcessCancellations = cancelledArchives + cancelledRemuxOperations;
|
||||
const failedFiles = failedDownloads.length + postProcessFailures;
|
||||
const cancelledFiles = cancelledDownloads + postProcessCancellations;
|
||||
const successfulFiles = Math.max(0, completedDownloads - postProcessFailures - postProcessCancellations);
|
||||
const startedAt = finiteNonNegative(packageEntry.downloadStartedAt);
|
||||
const downloadEndedAt = finiteNonNegative(packageEntry.downloadEndedAt) || finiteNonNegative(packageEntry.downloadCompletedAt);
|
||||
const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt);
|
||||
const postProcessCompletedAt = finiteNonNegative(packageEntry.postProcessCompletedAt);
|
||||
const completedAt = finiteNonNegative(packageEntry.terminalAt);
|
||||
const downloadedBytes = finiteNonNegative(packageEntry.cleanedDownloadedBytes)
|
||||
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.downloadedBytes), 0);
|
||||
const totalBytes = finiteNonNegative(packageEntry.cleanedTotalBytes)
|
||||
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.totalBytes ?? item.downloadedBytes), 0);
|
||||
const downloadDurationSeconds = durationSecondsBetween(startedAt, downloadEndedAt);
|
||||
const extractionDurationSeconds = sumOperationDurationSeconds(archiveOperations);
|
||||
const remuxDurationSeconds = sumOperationDurationSeconds(remuxOperations);
|
||||
const postProcessDurationSeconds = durationSecondsBetween(postProcessStartedAt, postProcessCompletedAt);
|
||||
const totalDurationSeconds = durationSecondsBetween(startedAt, completedAt);
|
||||
const failure = getFailure(
|
||||
cleanupErrorCategory,
|
||||
remuxOperations,
|
||||
audioStripFailures,
|
||||
archiveOperations,
|
||||
failedDownloads.map((item) => item.lastError || item.fullStatus)
|
||||
);
|
||||
|
||||
return {
|
||||
packageId: packageEntry.id,
|
||||
name: packageEntry.name,
|
||||
status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, packageEntry.cancelled),
|
||||
startedAt,
|
||||
downloadEndedAt,
|
||||
postProcessStartedAt,
|
||||
completedAt,
|
||||
downloadDurationSeconds,
|
||||
extractionDurationSeconds,
|
||||
remuxDurationSeconds,
|
||||
postProcessDurationSeconds,
|
||||
totalDurationSeconds,
|
||||
totalBytes,
|
||||
downloadedBytes,
|
||||
averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0,
|
||||
successfulFiles,
|
||||
failedFiles,
|
||||
cancelledFiles,
|
||||
downloadFailures: failedDownloads.length,
|
||||
offlineFailures,
|
||||
extractionFailures: failedArchives,
|
||||
remuxFailures,
|
||||
cleanupFailures,
|
||||
archiveCount: archiveOperations.length,
|
||||
partCount: archiveOperations.reduce((total, operation) => total + Math.max(0, Math.floor(finiteNonNegative(operation.partCount))), 0),
|
||||
outputCount: Math.max(0, Math.floor(finiteNonNegative(telemetry.outputCount ?? packageEntry.outputCount))),
|
||||
failurePhase: failure.failurePhase,
|
||||
errorCategory: failure.errorCategory,
|
||||
archiveOperations,
|
||||
remuxOperations
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { AppSettings, RendererSettingsUpdate } from "../shared/types";
|
||||
import { createRendererSettings } from "./renderer-state";
|
||||
import { isValidLocalDate } from "./daily-start-scheduler";
|
||||
|
||||
const DERIVED_KEYS = new Set(["archivePasswordListConfigured", "notifyUrlConfigured", "configuredProviders"]);
|
||||
const DERIVED_KEYS = new Set([
|
||||
"archivePasswordListConfigured",
|
||||
"notifyUrlConfigured",
|
||||
"configuredProviders",
|
||||
"dailyStartLastHandledLocalDate",
|
||||
"dailyStartPendingLocalDate",
|
||||
"dailyStartLastOutcome",
|
||||
"nextDailyStartEpochMs"
|
||||
]);
|
||||
const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]);
|
||||
const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000;
|
||||
|
||||
@@ -82,6 +91,15 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
|
||||
if (!(key in safe)) {
|
||||
invalid();
|
||||
}
|
||||
if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") {
|
||||
invalid();
|
||||
}
|
||||
if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) {
|
||||
invalid();
|
||||
}
|
||||
if (key === "dailyStartFirstLocalDate" && entry !== "" && (typeof entry !== "string" || !isValidLocalDate(entry))) {
|
||||
invalid();
|
||||
}
|
||||
validateTopLevelType(entry, safe[key]);
|
||||
validateJsonValue(entry);
|
||||
output[key] = entry;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode }
|
||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
|
||||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
||||
import { nextDailyStartEpochMs } from "./daily-start-scheduler";
|
||||
|
||||
function maskValue(value: string, keepStart = 3, keepEnd = 3): string {
|
||||
const trimmed = value.trim();
|
||||
@@ -120,6 +121,13 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[]
|
||||
true
|
||||
));
|
||||
}
|
||||
if (settings.deepbridApiKey.trim()) {
|
||||
const status = safeStatus(settings.debridAccountStatuses["svc-deepbrid"], redactions);
|
||||
accounts.push({
|
||||
...singleAccount(settings, "deepbrid-api", "deepbrid", status?.username || "", maskValue(settings.deepbridApiKey), true),
|
||||
status
|
||||
});
|
||||
}
|
||||
if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) {
|
||||
accounts.push(singleAccount(settings, "ddownload-login", "ddownload", settings.ddownloadLogin.trim(), maskValue(settings.ddownloadLogin, 2, 4), true));
|
||||
}
|
||||
@@ -220,6 +228,13 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
|
||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
|
||||
notifyOnPackageFailed: settings.notifyOnPackageFailed,
|
||||
notifyOnRunFinished: settings.notifyOnRunFinished,
|
||||
notifyPackageSuccessMode: settings.notifyPackageSuccessMode,
|
||||
notifyOnRemainingBelow: settings.notifyOnRemainingBelow,
|
||||
notifyRemainingThresholdGb: settings.notifyRemainingThresholdGb,
|
||||
notifyOnDownloadStall: settings.notifyOnDownloadStall,
|
||||
notifyStallAfterSeconds: settings.notifyStallAfterSeconds,
|
||||
notifyStallCooldownMinutes: settings.notifyStallCooldownMinutes,
|
||||
notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery,
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
||||
@@ -244,7 +259,14 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
|
||||
megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes },
|
||||
debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, redactions)])),
|
||||
providerDailyUsageDay: settings.providerDailyUsageDay,
|
||||
scheduledStartEpochMs: settings.scheduledStartEpochMs
|
||||
dailyStartEnabled: settings.dailyStartEnabled,
|
||||
dailyStartMinuteOfDay: settings.dailyStartMinuteOfDay,
|
||||
dailyStartFirstLocalDate: settings.dailyStartFirstLocalDate,
|
||||
dailyStartLastHandledLocalDate: settings.dailyStartLastHandledLocalDate,
|
||||
dailyStartPendingLocalDate: settings.dailyStartPendingLocalDate,
|
||||
dailyStartLastOutcome: settings.dailyStartLastOutcome,
|
||||
scheduledStartEpochMs: settings.scheduledStartEpochMs,
|
||||
nextDailyStartEpochMs: nextDailyStartEpochMs(settings)
|
||||
} as RendererSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppS
|
||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
|
||||
const realDebridAccountIds = new Set(getRealDebridAccountIds(target));
|
||||
const targetDeepbridApiKey = target.deepbridApiKey.trim();
|
||||
const liveDeepbridApiKey = liveSettings.deepbridApiKey.trim();
|
||||
const validAccountIds = new Set([
|
||||
...debridLinkKeyIds,
|
||||
...megaAccountIds,
|
||||
...realDebridAccountIds,
|
||||
...(realDebridAccountIds.size === 0 && (target.realDebridUseWebLogin || target.token.trim()) ? ["svc-realdebrid"] : [])
|
||||
...(realDebridAccountIds.size === 0 && (target.realDebridUseWebLogin || target.token.trim()) ? ["svc-realdebrid"] : []),
|
||||
...(targetDeepbridApiKey && targetDeepbridApiKey === liveDeepbridApiKey ? ["svc-deepbrid"] : [])
|
||||
]);
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
|
||||
@@ -21,6 +21,7 @@ const providers = new Set<DebridProvider>([
|
||||
"megadebrid-web",
|
||||
"bestdebrid",
|
||||
"alldebrid",
|
||||
"deepbrid",
|
||||
"ddownload",
|
||||
"onefichier",
|
||||
"debridlink",
|
||||
@@ -41,6 +42,7 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
deepbrid: "Deepbrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
|
||||
+173
-28
@@ -5,12 +5,14 @@ import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
||||
import { defaultSettings } from "./constants";
|
||||
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
|
||||
import { logger } from "./logger";
|
||||
import { isValidLocalDate } from "./daily-start-scheduler";
|
||||
import { projectPackageFailureCategory } from "./package-telemetry";
|
||||
|
||||
export function migrateProductUserDataDirectory(appDataPath: string): string {
|
||||
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
|
||||
@@ -26,8 +28,8 @@ export function migrateProductUserDataDirectory(appDataPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
|
||||
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
||||
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
|
||||
@@ -40,8 +42,11 @@ const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
|
||||
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
|
||||
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
|
||||
]);
|
||||
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
||||
const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
||||
const VALID_HISTORY_STATUSES = new Set(["completed", "partial", "failed", "cancelled", "deleted"]);
|
||||
const VALID_FAILURE_PHASES = new Set(["download", "extract", "remux", "cleanup"]);
|
||||
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
||||
|
||||
function asText(value: unknown): string {
|
||||
@@ -287,9 +292,10 @@ function normalizeDebridAccountStatuses(
|
||||
megaIds: string[],
|
||||
debridLinkIds: string[],
|
||||
realDebridIds: string[],
|
||||
legacyRealDebridTargetId: string | null
|
||||
legacyRealDebridTargetId: string | null,
|
||||
deepbridConfigured: boolean
|
||||
): Record<string, DebridAccountStatus> {
|
||||
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]);
|
||||
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds, ...(deepbridConfigured ? ["svc-deepbrid"] : [])]);
|
||||
const result: Record<string, DebridAccountStatus> = {};
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
for (const [storedKey, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
@@ -304,7 +310,9 @@ function normalizeDebridAccountStatuses(
|
||||
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
|
||||
continue;
|
||||
}
|
||||
const provider = entry.provider === "debridlink"
|
||||
const provider = key === "svc-deepbrid"
|
||||
? "deepbrid"
|
||||
: entry.provider === "debridlink"
|
||||
? "debridlink"
|
||||
: entry.provider === "realdebrid"
|
||||
? "realdebrid"
|
||||
@@ -416,6 +424,10 @@ function migrateUpdateRepo(raw: string, fallback: string): string {
|
||||
export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
const defaults = defaultSettings();
|
||||
const directorySettings = migrateLegacyDefaultDirectories(settings, defaults);
|
||||
const legacySuccessMode = settings.notifyOnPackageCompleted === true ? "individual" : "digest";
|
||||
const notifyPackageSuccessMode = settings.notifyPackageSuccessMode === "individual" || settings.notifyPackageSuccessMode === "digest"
|
||||
? settings.notifyPackageSuccessMode
|
||||
: legacySuccessMode;
|
||||
const currentUsageDay = getProviderUsageDayKey();
|
||||
const legacyMegaLogin = asText(settings.megaLogin);
|
||||
const legacyMegaPassword = asText(settings.megaPassword);
|
||||
@@ -488,6 +500,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
? providerDailyUsageDayRaw
|
||||
: currentUsageDay;
|
||||
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
|
||||
const deepbridApiKey = asText(settings.deepbridApiKey);
|
||||
const providerDailyUsageBytes = normalizeProviderByteMap(
|
||||
settings.providerDailyUsageBytes,
|
||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
||||
@@ -511,6 +524,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
debridLinkApiKeyIds
|
||||
);
|
||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
||||
const validDailyStartOutcomes = new Set<DailyStartOutcome>(["", "started", "already_active", "empty_queue", "missing_account", "start_failed", "missed"]);
|
||||
const normalized: AppSettings = {
|
||||
language: settings.language === "de" ? "de" : "en",
|
||||
token: asText(settings.token),
|
||||
@@ -535,6 +549,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin),
|
||||
allDebridToken: asText(settings.allDebridToken),
|
||||
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin),
|
||||
deepbridApiKey,
|
||||
ddownloadLogin: asText(settings.ddownloadLogin),
|
||||
ddownloadPassword: asText(settings.ddownloadPassword),
|
||||
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
||||
@@ -607,6 +622,13 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
||||
notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed,
|
||||
notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished,
|
||||
notifyPackageSuccessMode,
|
||||
notifyOnRemainingBelow: settings.notifyOnRemainingBelow !== undefined ? Boolean(settings.notifyOnRemainingBelow) : defaults.notifyOnRemainingBelow,
|
||||
notifyRemainingThresholdGb: clampNumber(settings.notifyRemainingThresholdGb, 50, 1, 100000),
|
||||
notifyOnDownloadStall: settings.notifyOnDownloadStall !== undefined ? Boolean(settings.notifyOnDownloadStall) : defaults.notifyOnDownloadStall,
|
||||
notifyStallAfterSeconds: clampNumber(settings.notifyStallAfterSeconds, 90, 60, 3600),
|
||||
notifyStallCooldownMinutes: clampNumber(settings.notifyStallCooldownMinutes, 10, 5, 1440),
|
||||
notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery !== undefined ? Boolean(settings.notifyOnDownloadRecovery) : defaults.notifyOnDownloadRecovery,
|
||||
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
||||
@@ -641,9 +663,16 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
megaDebridAccountIds,
|
||||
debridLinkApiKeyIds,
|
||||
realDebridAccountIds,
|
||||
legacyRealDebridTargetId
|
||||
legacyRealDebridTargetId,
|
||||
Boolean(deepbridApiKey)
|
||||
),
|
||||
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
||||
dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled,
|
||||
dailyStartMinuteOfDay: clampNumber(settings.dailyStartMinuteOfDay, defaults.dailyStartMinuteOfDay, 0, 1_439),
|
||||
dailyStartFirstLocalDate: isValidLocalDate(asText(settings.dailyStartFirstLocalDate)) ? asText(settings.dailyStartFirstLocalDate) : "",
|
||||
dailyStartLastHandledLocalDate: isValidLocalDate(asText(settings.dailyStartLastHandledLocalDate)) ? asText(settings.dailyStartLastHandledLocalDate) : "",
|
||||
dailyStartPendingLocalDate: isValidLocalDate(asText(settings.dailyStartPendingLocalDate)) ? asText(settings.dailyStartPendingLocalDate) : "",
|
||||
dailyStartLastOutcome: validDailyStartOutcomes.has(settings.dailyStartLastOutcome) ? settings.dailyStartLastOutcome : "",
|
||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
||||
};
|
||||
|
||||
@@ -687,6 +716,8 @@ export interface StoragePaths {
|
||||
sessionFile: string;
|
||||
historyFile: string;
|
||||
statisticsFile: string;
|
||||
notificationOutboxFile: string;
|
||||
notificationHealthFile: string;
|
||||
}
|
||||
|
||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
@@ -695,7 +726,9 @@ export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
||||
sessionFile: path.join(baseDir, "rd_session_state.json"),
|
||||
historyFile: path.join(baseDir, "rd_history.json"),
|
||||
statisticsFile: path.join(baseDir, "rd_statistics.json")
|
||||
statisticsFile: path.join(baseDir, "rd_statistics.json"),
|
||||
notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.json"),
|
||||
notificationHealthFile: path.join(baseDir, "rd_notification_health.json")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -756,6 +789,73 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFailureCategory(phase: FailurePhase, detail: unknown): string {
|
||||
const value = asText(detail);
|
||||
return value ? projectPackageFailureCategory(phase, value) : "";
|
||||
}
|
||||
|
||||
function normalizeArchiveOperations(raw: unknown): ArchiveOperationMetric[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.slice(0, 10_000).flatMap((value) => {
|
||||
const operation = asRecord(value);
|
||||
if (!operation) {
|
||||
return [];
|
||||
}
|
||||
const id = normalizeSessionId(operation.id);
|
||||
const status = asText(operation.status);
|
||||
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id,
|
||||
name: asText(operation.name),
|
||||
itemIds: Array.isArray(operation.itemIds)
|
||||
? operation.itemIds.map(normalizeSessionId).filter(Boolean).slice(0, 100_000)
|
||||
: [],
|
||||
partCount: clampNumber(operation.partCount, 0, 0, 100_000),
|
||||
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
status: status as ArchiveOperationMetric["status"],
|
||||
errorCategory: normalizeFailureCategory("extract", operation.errorCategory)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRemuxOperations(raw: unknown): RemuxOperationMetric[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.slice(0, 10_000).flatMap((value) => {
|
||||
const operation = asRecord(value);
|
||||
if (!operation) {
|
||||
return [];
|
||||
}
|
||||
const id = normalizeSessionId(operation.id);
|
||||
const status = asText(operation.status);
|
||||
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id,
|
||||
fileName: asText(operation.fileName),
|
||||
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
status: status as RemuxOperationMetric["status"],
|
||||
errorCategory: normalizeFailureCategory("remux", operation.errorCategory)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function optionalClampedNumber(record: Record<string, unknown>, key: string, max = Number.MAX_SAFE_INTEGER): number | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(record, key)
|
||||
? clampNumber(record[key], 0, 0, max)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
||||
return parsed;
|
||||
@@ -801,6 +901,9 @@ function readSettingsFile(filePath: string): LoadedSettingsFile | null {
|
||||
if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridWebAccountIds")) {
|
||||
delete (mergedInput as Partial<AppSettings>).realDebridWebAccountIds;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) {
|
||||
delete (mergedInput as Partial<AppSettings>).notifyPackageSuccessMode;
|
||||
}
|
||||
const merged = normalizeSettings(mergedInput);
|
||||
return { settings: merged, needsCredentialRewrite };
|
||||
} catch (error) {
|
||||
@@ -908,6 +1011,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
: [],
|
||||
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
downloadEndedAt: clampNumber(pkg.downloadEndedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
postProcessQueuedAt: clampNumber(pkg.postProcessQueuedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
postProcessStartedAt: clampNumber(pkg.postProcessStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
postProcessCompletedAt: clampNumber(pkg.postProcessCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
terminalAt: clampNumber(pkg.terminalAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
|
||||
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
|
||||
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
|
||||
cleanupErrorCategory: normalizeFailureCategory("cleanup", pkg.cleanupErrorCategory),
|
||||
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||
};
|
||||
@@ -1118,7 +1231,12 @@ function sleepSyncMs(ms: number): void {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function readSessionFile(filePath: string): SessionState | null {
|
||||
interface LoadedSessionFile {
|
||||
session: SessionState;
|
||||
wasRunning: boolean;
|
||||
}
|
||||
|
||||
function readSessionFile(filePath: string): LoadedSessionFile | null {
|
||||
let raw: string | null = null;
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
@@ -1146,11 +1264,13 @@ function readSessionFile(filePath: string): SessionState | null {
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
||||
const normalized = normalizeLoadedSession(parsed);
|
||||
const wasRunning = normalized.running;
|
||||
const session = normalizeLoadedSessionTransientFields(normalized);
|
||||
const pkgCount = Object.keys(session.packages).length;
|
||||
const itemCount = Object.keys(session.items).length;
|
||||
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
||||
return session;
|
||||
return { session, wasRunning };
|
||||
} catch (error) {
|
||||
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
||||
return null;
|
||||
@@ -1267,6 +1387,7 @@ export type SessionLoadStatus =
|
||||
export interface SessionLoadResult {
|
||||
session: SessionState;
|
||||
status: SessionLoadStatus;
|
||||
wasRunning: boolean;
|
||||
}
|
||||
|
||||
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
@@ -1281,7 +1402,7 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
if (!primaryExists) {
|
||||
if (!backupExists && !anyTempExists) {
|
||||
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
||||
return { session: emptySession(), status: "empty-fresh" };
|
||||
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
|
||||
}
|
||||
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
||||
}
|
||||
@@ -1289,60 +1410,60 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
|
||||
|
||||
if (primary) {
|
||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
||||
const primaryPkgCount = Object.keys(primary.session.packages).length;
|
||||
if (primaryPkgCount === 0 && backupExists) {
|
||||
const backup = readSessionFile(backupFile);
|
||||
if (backup) {
|
||||
const backupPkgCount = Object.keys(backup.packages).length;
|
||||
const backupPkgCount = Object.keys(backup.session.packages).length;
|
||||
if (backupPkgCount > 0) {
|
||||
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
||||
try {
|
||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||
} catch {
|
||||
}
|
||||
return { session: backup, status: "recovered-backup" };
|
||||
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { session: primary, status: "ok" };
|
||||
return { session: primary.session, status: "ok", wasRunning: primary.wasRunning };
|
||||
}
|
||||
|
||||
const backup = backupExists ? readSessionFile(backupFile) : null;
|
||||
if (backup) {
|
||||
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
||||
try {
|
||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||
} catch {
|
||||
}
|
||||
return { session: backup, status: "recovered-backup" };
|
||||
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
|
||||
}
|
||||
|
||||
for (const kind of ["sync", "async"] as const) {
|
||||
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
const tmpSession = readSessionFile(tmpPath);
|
||||
if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
|
||||
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
|
||||
if (tmpSession && Object.keys(tmpSession.session.packages).length > 0) {
|
||||
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.session.packages).length} Pakete)`);
|
||||
try {
|
||||
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
const payload = JSON.stringify({ ...tmpSession.session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
||||
} catch {
|
||||
}
|
||||
return { session: tmpSession, status: "recovered-temp" };
|
||||
return { session: tmpSession.session, status: "recovered-temp", wasRunning: tmpSession.wasRunning };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryExists || backupExists || anyTempExists) {
|
||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
|
||||
return { session: emptySession(), status: "empty-unreadable" };
|
||||
return { session: emptySession(), status: "empty-unreadable", wasRunning: false };
|
||||
}
|
||||
|
||||
return { session: emptySession(), status: "empty-fresh" };
|
||||
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
|
||||
}
|
||||
|
||||
export function loadSession(paths: StoragePaths): SessionState {
|
||||
@@ -1470,6 +1591,24 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
|
||||
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
|
||||
const name = asText(entry.name) || "Unbenannt";
|
||||
const providerRaw = asText(entry.provider);
|
||||
const statusRaw = asText(entry.status);
|
||||
const failurePhaseRaw = entry.failurePhase === null ? null : asText(entry.failurePhase);
|
||||
const optionalFields = {
|
||||
startedAt: optionalClampedNumber(entry, "startedAt"),
|
||||
downloadEndedAt: optionalClampedNumber(entry, "downloadEndedAt"),
|
||||
postProcessStartedAt: optionalClampedNumber(entry, "postProcessStartedAt"),
|
||||
downloadDurationSeconds: optionalClampedNumber(entry, "downloadDurationSeconds"),
|
||||
extractionDurationSeconds: optionalClampedNumber(entry, "extractionDurationSeconds"),
|
||||
remuxDurationSeconds: optionalClampedNumber(entry, "remuxDurationSeconds"),
|
||||
postProcessDurationSeconds: optionalClampedNumber(entry, "postProcessDurationSeconds"),
|
||||
totalDurationSeconds: optionalClampedNumber(entry, "totalDurationSeconds"),
|
||||
successfulFiles: optionalClampedNumber(entry, "successfulFiles", 1_000_000),
|
||||
failedFiles: optionalClampedNumber(entry, "failedFiles", 1_000_000),
|
||||
cancelledFiles: optionalClampedNumber(entry, "cancelledFiles", 1_000_000),
|
||||
archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000),
|
||||
partCount: optionalClampedNumber(entry, "partCount", 1_000_000),
|
||||
outputCount: optionalClampedNumber(entry, "outputCount", 1_000_000)
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -1480,9 +1619,15 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
|
||||
provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null,
|
||||
completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER),
|
||||
durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
status: entry.status === "deleted" ? "deleted" : "completed",
|
||||
status: VALID_HISTORY_STATUSES.has(statusRaw) ? statusRaw as HistoryEntry["status"] : "completed",
|
||||
outputDir: asText(entry.outputDir),
|
||||
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined
|
||||
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined,
|
||||
...Object.fromEntries(Object.entries(optionalFields).filter(([, value]) => value !== undefined)),
|
||||
...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw)
|
||||
? { failurePhase: failurePhaseRaw as FailurePhase }
|
||||
: {}),
|
||||
...(Array.isArray(entry.archiveOperations) ? { archiveOperations: normalizeArchiveOperations(entry.archiveOperations) } : {}),
|
||||
...(Array.isArray(entry.remuxOperations) ? { remuxOperations: normalizeRemuxOperations(entry.remuxOperations) } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
@@ -101,6 +101,7 @@ type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||
|
||||
interface BuildSupportBundleOptions {
|
||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
||||
notificationStatus?: NotificationSupportPayload;
|
||||
}
|
||||
|
||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
||||
@@ -145,6 +146,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
const packageIds = Object.keys(snapshot.session.packages);
|
||||
const itemIds = Object.keys(snapshot.session.items);
|
||||
const debugSetup = getDebugSetupCheck(baseDir);
|
||||
const notificationStatus = normalizeNotificationSupportPayload(options.notificationStatus);
|
||||
|
||||
addJson(zip, "overview/meta.json", {
|
||||
appVersion: APP_VERSION,
|
||||
@@ -164,6 +166,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
||||
}
|
||||
});
|
||||
addJson(zip, "overview/notifications.json", notificationStatus);
|
||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
||||
addJson(zip, "overview/self-check.json", debugSetup);
|
||||
addJson(zip, "overview/history.json", {
|
||||
|
||||
@@ -2,16 +2,68 @@ import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||
import { isNotifyUrlValid } from "./notify";
|
||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
||||
import type { DownloadHealthState } from "./download-health-monitor";
|
||||
import type { NotificationOutboxStatus } from "./notification-outbox";
|
||||
|
||||
function hasText(value: unknown): boolean {
|
||||
return String(value || "").trim().length > 0;
|
||||
}
|
||||
|
||||
export interface NotificationSupportPayload {
|
||||
queued: number;
|
||||
lastSuccessAt: number | null;
|
||||
incidentType: DownloadHealthState["incidentType"];
|
||||
incidentAgeMs: number | null;
|
||||
}
|
||||
|
||||
export function normalizeNotificationSupportPayload(
|
||||
value?: Partial<NotificationSupportPayload> | null
|
||||
): NotificationSupportPayload {
|
||||
const rawQueued = value?.queued;
|
||||
const rawLastSuccessAt = value?.lastSuccessAt;
|
||||
const rawIncidentAgeMs = value?.incidentAgeMs;
|
||||
const queued = typeof rawQueued === "number" && Number.isFinite(rawQueued)
|
||||
? Math.max(0, Math.floor(rawQueued))
|
||||
: 0;
|
||||
const lastSuccessAt = typeof rawLastSuccessAt === "number" && Number.isFinite(rawLastSuccessAt) && rawLastSuccessAt > 0
|
||||
? Math.floor(rawLastSuccessAt)
|
||||
: null;
|
||||
const incidentType = value?.incidentType === "scheduler" || value?.incidentType === "no_data"
|
||||
? value.incidentType
|
||||
: null;
|
||||
const incidentAgeMs = incidentType && typeof rawIncidentAgeMs === "number" && Number.isFinite(rawIncidentAgeMs) && rawIncidentAgeMs >= 0
|
||||
? Math.floor(rawIncidentAgeMs)
|
||||
: null;
|
||||
return { queued, lastSuccessAt, incidentType, incidentAgeMs };
|
||||
}
|
||||
|
||||
export function buildNotificationSupportPayload(
|
||||
outbox: Pick<NotificationOutboxStatus, "queued" | "lastSuccessAt">,
|
||||
health: Pick<DownloadHealthState, "incidentType" | "incidentStartedAt">,
|
||||
now: number = Date.now()
|
||||
): NotificationSupportPayload {
|
||||
const queued = Number.isFinite(outbox.queued) ? Math.max(0, Math.floor(outbox.queued)) : 0;
|
||||
const lastSuccessAt = Number.isFinite(outbox.lastSuccessAt) && outbox.lastSuccessAt > 0
|
||||
? Math.floor(outbox.lastSuccessAt)
|
||||
: null;
|
||||
const incidentType = health.incidentType === "scheduler" || health.incidentType === "no_data"
|
||||
? health.incidentType
|
||||
: null;
|
||||
const incidentStartedAt = Number.isFinite(health.incidentStartedAt) && health.incidentStartedAt > 0
|
||||
? Math.floor(health.incidentStartedAt)
|
||||
: 0;
|
||||
const incidentAgeMs = incidentType && incidentStartedAt > 0
|
||||
? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt)
|
||||
: null;
|
||||
return normalizeNotificationSupportPayload({ queued, lastSuccessAt, incidentType, incidentAgeMs });
|
||||
}
|
||||
|
||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||
const realDebridAccounts = getRealDebridAccounts(settings);
|
||||
const enabledRealDebridAccounts = realDebridAccounts.filter((account) => account.enabled);
|
||||
const deepbridStatus = settings.debridAccountStatuses?.["svc-deepbrid"];
|
||||
|
||||
return {
|
||||
realDebrid: {
|
||||
@@ -44,6 +96,22 @@ export function buildAccountSummary(settings: AppSettings): Record<string, unkno
|
||||
tokenConfigured: hasText(settings.allDebridToken),
|
||||
webLoginEnabled: settings.allDebridUseWebLogin
|
||||
},
|
||||
deepbrid: {
|
||||
configured: hasText(settings.deepbridApiKey),
|
||||
apiKeyConfigured: hasText(settings.deepbridApiKey),
|
||||
status: {
|
||||
checked: Boolean(deepbridStatus),
|
||||
valid: deepbridStatus?.valid ?? null,
|
||||
premium: deepbridStatus?.isPremium ?? null,
|
||||
premiumUntilMs: deepbridStatus?.premiumUntilMs ?? null,
|
||||
checkedAt: deepbridStatus?.checkedAt ?? null
|
||||
},
|
||||
usage: {
|
||||
dailyLimitBytes: settings.providerDailyLimitBytes.deepbrid || 0,
|
||||
dailyUsageBytes: settings.providerDailyUsageBytes.deepbrid || 0,
|
||||
totalUsageBytes: settings.providerTotalUsageBytes.deepbrid || 0
|
||||
}
|
||||
},
|
||||
ddownload: {
|
||||
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
||||
},
|
||||
|
||||
+208
-24
@@ -106,6 +106,7 @@ import {
|
||||
DownloadsSidebar,
|
||||
DownloadsSidebarStatus,
|
||||
DownloadsToolbar,
|
||||
type DailyScheduleStartDay,
|
||||
type DownloadsViewActions,
|
||||
type DownloadsViewModel
|
||||
} from "./views/downloads/DownloadsView";
|
||||
@@ -115,6 +116,7 @@ import {
|
||||
buildTargetedAccountCheck,
|
||||
projectAccountRows,
|
||||
resolveHistoryRetentionSelection,
|
||||
normalizeNotificationNumberField,
|
||||
sortAccountRows,
|
||||
formatAccountContextHeading,
|
||||
type AccountAddOption,
|
||||
@@ -329,7 +331,7 @@ interface RendererSettingsDraft extends RendererSettings {
|
||||
notifyUrl: string;
|
||||
}
|
||||
|
||||
function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
||||
export function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
||||
return {
|
||||
...settings,
|
||||
archivePasswordList: current?.archivePasswordList || "",
|
||||
@@ -511,6 +513,15 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
modeLabel: "Web-Login",
|
||||
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
|
||||
},
|
||||
{
|
||||
kind: "deepbrid-api",
|
||||
service: "deepbrid",
|
||||
serviceLabel: "Deepbrid",
|
||||
title: "Deepbrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Direkter Zugriff über API-Key.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
kind: "ddownload-login",
|
||||
service: "ddownload",
|
||||
@@ -549,7 +560,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
|
||||
function findAccountOption(kind: AccountKind): AccountOption {
|
||||
const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind);
|
||||
@@ -590,6 +601,7 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
|
||||
case "bestdebrid-web":
|
||||
return "Cookies.txt-Import";
|
||||
case "alldebrid-api":
|
||||
case "deepbrid-api":
|
||||
case "onefichier-api":
|
||||
return "API-Key";
|
||||
case "ddownload-login":
|
||||
@@ -613,6 +625,7 @@ function getAccountCredentialLabel(kind: AccountKind): string {
|
||||
case "realdebrid-api":
|
||||
case "bestdebrid-api":
|
||||
case "alldebrid-api":
|
||||
case "deepbrid-api":
|
||||
case "onefichier-api":
|
||||
case "debridlink-api":
|
||||
return "API-Key gespeichert";
|
||||
@@ -649,6 +662,12 @@ function normalizeProviderSelectionForSettings(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAccountCreateProviderOrderUpdate(
|
||||
settings: RendererSettings
|
||||
): Pick<RendererSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> {
|
||||
return normalizeProviderSelectionForSettings(settings);
|
||||
}
|
||||
|
||||
function getConfiguredAccountKind(settings: RendererSettings, service: AccountService): AccountKind | null {
|
||||
const configured = new Set(settings.configuredProviders);
|
||||
switch (service) {
|
||||
@@ -665,6 +684,8 @@ function getConfiguredAccountKind(settings: RendererSettings, service: AccountSe
|
||||
case "alldebrid":
|
||||
if (settings.allDebridUseWebLogin) return "alldebrid-web";
|
||||
return configured.has("alldebrid") ? "alldebrid-api" : null;
|
||||
case "deepbrid":
|
||||
return configured.has("deepbrid") ? "deepbrid-api" : null;
|
||||
case "ddownload":
|
||||
return configured.has("ddownload") ? "ddownload-login" : null;
|
||||
case "onefichier":
|
||||
@@ -836,6 +857,8 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||
theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
|
||||
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||
notifyPackageSuccessMode: "digest", notifyOnRemainingBelow: false, notifyRemainingThresholdGb: 50,
|
||||
notifyOnDownloadStall: false, notifyStallAfterSeconds: 90, notifyStallCooldownMinutes: 10, notifyOnDownloadRecovery: true,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||
@@ -857,7 +880,14 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 0,
|
||||
dailyStartFirstLocalDate: "",
|
||||
dailyStartLastHandledLocalDate: "",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "",
|
||||
scheduledStartEpochMs: 0,
|
||||
nextDailyStartEpochMs: 0
|
||||
},
|
||||
accounts: [],
|
||||
session: {
|
||||
@@ -1532,6 +1562,100 @@ export async function runLatestUpdateCheck(
|
||||
await apply(result, generation);
|
||||
}
|
||||
|
||||
interface DailySchedulePersistenceDependencies {
|
||||
updateSettings: (update: RendererSettingsUpdate) => Promise<RendererSettings>;
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
applySettings: (settings: RendererSettings) => void;
|
||||
applySnapshot: (snapshot: UiSnapshot) => void;
|
||||
showError: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatDailyScheduleLocalDate(date: Date): string {
|
||||
const year = String(date.getFullYear()).padStart(4, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatDailyScheduleTime(minuteOfDay: number): string {
|
||||
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
|
||||
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function resolveDailyScheduleInitialTime(
|
||||
settings: Pick<RendererSettings, "dailyStartMinuteOfDay" | "dailyStartFirstLocalDate">,
|
||||
now = new Date()
|
||||
): string {
|
||||
const minuteOfDay = settings.dailyStartFirstLocalDate
|
||||
? settings.dailyStartMinuteOfDay
|
||||
: now.getHours() * 60 + now.getMinutes();
|
||||
return formatDailyScheduleTime(minuteOfDay);
|
||||
}
|
||||
|
||||
export function buildDailyScheduleSettingsUpdate(
|
||||
time: string,
|
||||
startDay: DailyScheduleStartDay,
|
||||
now = new Date()
|
||||
): RendererSettingsUpdate | null {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const firstDate = new Date(now);
|
||||
if (startDay === "tomorrow") {
|
||||
firstDate.setDate(firstDate.getDate() + 1);
|
||||
}
|
||||
return {
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: Number(match[1]) * 60 + Number(match[2]),
|
||||
dailyStartFirstLocalDate: formatDailyScheduleLocalDate(firstDate)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScheduleCancellationSettingsUpdate(
|
||||
settings: Pick<RendererSettings, "dailyStartEnabled" | "scheduledStartEpochMs">
|
||||
): RendererSettingsUpdate {
|
||||
return settings.dailyStartEnabled
|
||||
? { dailyStartEnabled: false }
|
||||
: { scheduledStartEpochMs: 0 };
|
||||
}
|
||||
|
||||
export async function activateDailyScheduleSettings(
|
||||
time: string,
|
||||
startDay: DailyScheduleStartDay,
|
||||
persist: (update: RendererSettingsUpdate) => Promise<boolean>,
|
||||
showError: (message: string) => void,
|
||||
now = new Date()
|
||||
): Promise<boolean> {
|
||||
const update = buildDailyScheduleSettingsUpdate(time, startDay, now);
|
||||
if (!update) {
|
||||
showError("Bitte eine gültige Startzeit auswählen.");
|
||||
return false;
|
||||
}
|
||||
return persist(update);
|
||||
}
|
||||
|
||||
export async function persistDailyScheduleSettingsUpdate(
|
||||
update: RendererSettingsUpdate,
|
||||
operation: "activate" | "cancel",
|
||||
dependencies: DailySchedulePersistenceDependencies
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const settings = await dependencies.updateSettings(update);
|
||||
dependencies.applySettings(settings);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const action = operation === "activate" ? "aktiviert" : "abgebrochen";
|
||||
dependencies.showError(`Zeitplan konnte nicht ${action} werden: ${String(error)}`);
|
||||
try {
|
||||
dependencies.applySnapshot(await dependencies.getSnapshot());
|
||||
} catch (snapshotError) {
|
||||
dependencies.showError(`Zeitplan konnte nicht abgeglichen werden: ${String(snapshotError)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||
const [appVersion, setAppVersion] = useState("");
|
||||
@@ -1548,6 +1672,7 @@ export function App(): ReactElement {
|
||||
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
|
||||
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
||||
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
||||
const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today");
|
||||
const [scheduleCountdown, setScheduleCountdown] = useState("");
|
||||
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
|
||||
const updateCheckGenerationRef = useRef(0);
|
||||
@@ -1771,7 +1896,9 @@ export function App(): ReactElement {
|
||||
}, [settingsDraft.speedLimitKbps]);
|
||||
|
||||
useEffect(() => {
|
||||
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
|
||||
const schedMs = snapshot.settings.dailyStartEnabled
|
||||
? snapshot.settings.nextDailyStartEpochMs
|
||||
: snapshot.settings.scheduledStartEpochMs || 0;
|
||||
if (schedMs <= 0) { setScheduleCountdown(""); return; }
|
||||
const update = (): void => {
|
||||
const remaining = schedMs - Date.now();
|
||||
@@ -1785,7 +1912,7 @@ export function App(): ReactElement {
|
||||
update();
|
||||
const timer = setInterval(update, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [snapshot.settings.scheduledStartEpochMs]);
|
||||
}, [snapshot.settings.dailyStartEnabled, snapshot.settings.nextDailyStartEpochMs, snapshot.settings.scheduledStartEpochMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setRuntimeNow(Date.now()), 1000);
|
||||
@@ -2514,7 +2641,9 @@ export function App(): ReactElement {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const serviceAccountId = null;
|
||||
const serviceAccountId = entry.kind === "deepbrid-api"
|
||||
? accountsOfKind(entry.kind, snapshot.accounts)[0]?.accountId || "svc-deepbrid"
|
||||
: null;
|
||||
rows.push({
|
||||
rowKey: `svc-${entry.service}`,
|
||||
entry,
|
||||
@@ -2535,7 +2664,8 @@ export function App(): ReactElement {
|
||||
rowKey: `svc-${entry.service}`,
|
||||
kind: entry.kind as SingleAccountKind,
|
||||
service: entry.service,
|
||||
provider: entry.provider
|
||||
provider: entry.provider,
|
||||
accountId: serviceAccountId || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2931,8 +3061,9 @@ export function App(): ReactElement {
|
||||
const command = buildAccountCreateCommand(dialogSnapshot);
|
||||
if (!command) throw new Error("Account-Payload ist ungültig");
|
||||
const result = await window.rd.createAccount(command);
|
||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||
applyPersistedSettings(result.settings);
|
||||
const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings));
|
||||
setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts }));
|
||||
applyPersistedSettings(persistedSettings);
|
||||
closeAccountDialog();
|
||||
if (quickAction) {
|
||||
await runAccountQuickAction(quickAction, result.accountId);
|
||||
@@ -3091,7 +3222,13 @@ export function App(): ReactElement {
|
||||
...settingsDraft,
|
||||
disabledProviders: nextDisabledProviders
|
||||
};
|
||||
await persistAccountToggle(nextDraft);
|
||||
const enabled = current.includes(provider);
|
||||
await persistAccountToggle(
|
||||
nextDraft,
|
||||
enabled && entry.kind === "deepbrid-api"
|
||||
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
|
||||
: undefined
|
||||
);
|
||||
showToast(
|
||||
nextDisabledProviders.includes(provider)
|
||||
? `${entry.serviceLabel} deaktiviert`
|
||||
@@ -4809,16 +4946,46 @@ export function App(): ReactElement {
|
||||
});
|
||||
}, [setClipboardWatcherActive, showToast]);
|
||||
|
||||
const applyDailyScheduleSettings = useCallback((settings: RendererSettings): void => {
|
||||
const apply = (current: UiSnapshot): UiSnapshot => ({ ...current, settings });
|
||||
const next = apply(snapshotRef.current);
|
||||
snapshotRef.current = next;
|
||||
masterSnapshotRef.current = masterSnapshotRef.current ? apply(masterSnapshotRef.current) : next;
|
||||
if (latestStateRef.current) {
|
||||
latestStateRef.current = apply(latestStateRef.current);
|
||||
}
|
||||
setSnapshot(next);
|
||||
}, []);
|
||||
|
||||
const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => {
|
||||
masterSnapshotRef.current = state;
|
||||
latestStateRef.current = null;
|
||||
snapshotRef.current = state;
|
||||
setSnapshot(state);
|
||||
}, []);
|
||||
|
||||
const persistDownloadSchedule = useCallback((update: RendererSettingsUpdate, operation: "activate" | "cancel"): Promise<boolean> => (
|
||||
persistDailyScheduleSettingsUpdate(update, operation, {
|
||||
updateSettings: (value) => window.rd.updateSettings(value),
|
||||
getSnapshot: () => window.rd.getSnapshot(),
|
||||
applySettings: applyDailyScheduleSettings,
|
||||
applySnapshot: applyAuthoritativeDailyScheduleSnapshot,
|
||||
showError: (message) => showToast(message, 3200)
|
||||
})
|
||||
), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]);
|
||||
|
||||
const activateDownloadSchedule = useCallback((): void => {
|
||||
if (!scheduleTimeInput) return;
|
||||
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(hours, minutes, 0, 0);
|
||||
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
|
||||
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
|
||||
void activateDailyScheduleSettings(
|
||||
scheduleTimeInput,
|
||||
scheduleStartDay,
|
||||
(update) => persistDownloadSchedule(update, "activate"),
|
||||
(message) => showToast(message, 2800)
|
||||
).then((persisted) => {
|
||||
if (persisted) {
|
||||
setSchedulePickerOpen(false);
|
||||
}, [scheduleTimeInput]);
|
||||
}
|
||||
});
|
||||
}, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput, showToast]);
|
||||
|
||||
const removeActionableDownloads = useCallback((): void => {
|
||||
const ids = new Set(downloadsViewCore.actionableSelectedIds);
|
||||
@@ -4849,10 +5016,16 @@ export function App(): ReactElement {
|
||||
reconnectSeconds: snapshot.reconnectSeconds,
|
||||
reconnectReason: snapshot.session.reconnectReason,
|
||||
clipboardWatcher: snapshot.clipboardActive,
|
||||
scheduleActive: snapshot.settings.scheduledStartEpochMs > 0,
|
||||
scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0,
|
||||
scheduleOpen: schedulePickerOpen,
|
||||
scheduleTime: scheduleTimeInput,
|
||||
scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""),
|
||||
scheduleTimeValid: buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay) !== null,
|
||||
scheduleStartDay,
|
||||
scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled
|
||||
? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay)
|
||||
: snapshot.settings.scheduledStartEpochMs > 0
|
||||
? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: ""),
|
||||
packageSpeedBps: downloadPackageSpeeds,
|
||||
editingPackageId,
|
||||
editingName,
|
||||
@@ -4876,7 +5049,7 @@ export function App(): ReactElement {
|
||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
|
||||
const resetColumnLayout = useCallback((): void => {
|
||||
if (columnDragSettleTimerRef.current !== null) {
|
||||
@@ -4917,12 +5090,17 @@ export function App(): ReactElement {
|
||||
},
|
||||
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
|
||||
onToggleSchedule: () => {
|
||||
setSchedulePickerOpen((current) => !current);
|
||||
setScheduleTimeInput("");
|
||||
if (!schedulePickerOpen) {
|
||||
const now = new Date();
|
||||
setScheduleTimeInput(resolveDailyScheduleInitialTime(snapshot.settings, now));
|
||||
setScheduleStartDay("today");
|
||||
}
|
||||
setSchedulePickerOpen(!schedulePickerOpen);
|
||||
},
|
||||
onScheduleTimeChange: setScheduleTimeInput,
|
||||
onScheduleStartDayChange: setScheduleStartDay,
|
||||
onActivateSchedule: activateDownloadSchedule,
|
||||
onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); },
|
||||
onCancelSchedule: () => { void persistDownloadSchedule(buildScheduleCancellationSettingsUpdate(snapshot.settings), "cancel"); },
|
||||
onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds),
|
||||
onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds),
|
||||
onRenameSelection: () => {
|
||||
@@ -5150,6 +5328,7 @@ export function App(): ReactElement {
|
||||
},
|
||||
dailyLimitBytes: row.dailyLimitBytes,
|
||||
dailyUsageBytes: row.dailyUsedBytes,
|
||||
totalUsageBytes: row.totalUsedBytes,
|
||||
username: row.username,
|
||||
credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected",
|
||||
canCheck: row.checkable
|
||||
@@ -5451,6 +5630,11 @@ export function App(): ReactElement {
|
||||
setBool(fieldId as keyof RendererSettingsDraft, value);
|
||||
return;
|
||||
}
|
||||
const notificationNumber = normalizeNotificationNumberField(fieldId, value);
|
||||
if (notificationNumber !== undefined) {
|
||||
setNum(fieldId as keyof RendererSettingsDraft, notificationNumber);
|
||||
return;
|
||||
}
|
||||
const numericLimits: Partial<Record<keyof RendererSettingsDraft, [number, number, number]>> = {
|
||||
maxParallel: [1, 50, 1],
|
||||
retryLimit: [0, 99, 0],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AccountDeleteCommand, AccountReplaceCommand, AccountSecretRequest, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types";
|
||||
|
||||
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "deepbrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||
export type AccountKind = RendererAccountKind;
|
||||
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ACCOUNT_SERVICE_ICONS = {
|
||||
"megadebrid-web": "./provider-icons/mega-debrid.png",
|
||||
bestdebrid: "./provider-icons/bestdebrid.ico",
|
||||
alldebrid: "./provider-icons/alldebrid.png",
|
||||
deepbrid: "./provider-icons/deepbrid.png",
|
||||
ddownload: "./provider-icons/ddownload.ico",
|
||||
onefichier: "./provider-icons/onefichier.png",
|
||||
debridlink: "./provider-icons/debrid-link.ico",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const providerLabels: Record<DebridProvider, string> = {
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
deepbrid: "Deepbrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
@@ -56,7 +57,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str
|
||||
const normalized = hoster.trim().toLowerCase();
|
||||
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
|
||||
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
|
||||
if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" };
|
||||
if (normalized === "1fichier") return { compact: "1Fichier", title: "1Fichier", iconSrc: hosterIconSources.onefichier };
|
||||
return { compact: hoster, title: hoster };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const hosterIconSources: Readonly<Record<string, string>> = {
|
||||
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
|
||||
onefichier: "./provider-icons/onefichier.png",
|
||||
ddownload: "./provider-icons/ddownload.ico"
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ const pairs = [
|
||||
["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
|
||||
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
||||
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
|
||||
["Erfolgsmeldungen senden", "Send success notifications"], ["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"], ["Jedes Paket einzeln", "Each package individually"],
|
||||
["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"], ["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"], ["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"],
|
||||
["Melden, wenn Downloads stillstehen", "Notify when downloads stall"], ["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"], ["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"], ["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"],
|
||||
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
|
||||
["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."],
|
||||
["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"],
|
||||
@@ -60,7 +63,7 @@ const pairs = [
|
||||
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
|
||||
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
|
||||
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
|
||||
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
|
||||
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."],
|
||||
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
|
||||
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
|
||||
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
|
||||
@@ -208,7 +211,7 @@ const prefixedPairs = [
|
||||
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
|
||||
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
|
||||
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
|
||||
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
|
||||
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "]
|
||||
] as const;
|
||||
|
||||
export function normalizeLanguage(value: unknown): AppLanguage {
|
||||
|
||||
@@ -12,6 +12,8 @@ import "./downloads.css";
|
||||
|
||||
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||
|
||||
export type DailyScheduleStartDay = "today" | "tomorrow";
|
||||
|
||||
export interface DownloadsStatusModel {
|
||||
packages: number;
|
||||
links: number;
|
||||
@@ -40,6 +42,8 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
|
||||
scheduleActive: boolean;
|
||||
scheduleOpen: boolean;
|
||||
scheduleTime: string;
|
||||
scheduleTimeValid: boolean;
|
||||
scheduleStartDay: DailyScheduleStartDay;
|
||||
scheduleLabel: string;
|
||||
packageSpeedBps: Record<string, number>;
|
||||
editingPackageId: string | null;
|
||||
@@ -65,6 +69,7 @@ export interface DownloadsViewActions extends DownloadsTableActions {
|
||||
onStopDownloads: () => void;
|
||||
onToggleSchedule: () => void;
|
||||
onScheduleTimeChange: (value: string) => void;
|
||||
onScheduleStartDayChange: (value: DailyScheduleStartDay) => void;
|
||||
onActivateSchedule: () => void;
|
||||
onCancelSchedule: () => void;
|
||||
onMoveSelectionUp: () => void;
|
||||
@@ -125,14 +130,21 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
const hasSelection = model.actionableSelectedIds.length > 0;
|
||||
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
|
||||
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
|
||||
const scheduleSlotOpen = model.scheduleActive || model.scheduleOpen;
|
||||
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
|
||||
return (
|
||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
|
||||
<span className={scheduleSlotClass}>
|
||||
<span {...(!scheduleSlotOpen ? { inert: "true" } : {})} aria-hidden={!scheduleSlotOpen} className="downloads-schedule-controls">
|
||||
{model.scheduleActive
|
||||
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
|
||||
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
|
||||
? <><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></>
|
||||
: <><input aria-label="Startzeit" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><select aria-label="Starttag" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleStartDayChange(event.target.value as DailyScheduleStartDay)} value={model.scheduleStartDay}><option value="today">Ab heute</option><option value="tomorrow">Ab morgen</option></select><button disabled={!scheduleSlotOpen || !model.scheduleTimeValid} onClick={actions.onActivateSchedule} type="button">Planen</button></>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="downloads-toolbar-divider" />
|
||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
||||
|
||||
@@ -237,13 +237,45 @@
|
||||
background: var(--ui-border);
|
||||
}
|
||||
|
||||
.downloads-schedule-slot {
|
||||
display: grid;
|
||||
grid-template-columns: 0fr;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: grid-template-columns 180ms ease, opacity 140ms ease;
|
||||
}
|
||||
|
||||
.downloads-schedule-slot.is-open {
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.downloads-schedule-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
width: max-content;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-10px);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.downloads-schedule-controls input {
|
||||
.downloads-schedule-slot.is-open .downloads-schedule-controls {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.downloads-schedule-slot.is-motion-disabled,
|
||||
.downloads-schedule-slot.is-motion-disabled .downloads-schedule-controls {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.downloads-schedule-controls input,
|
||||
.downloads-schedule-controls select {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import {
|
||||
createHistoryTableColumnWidths,
|
||||
formatHistoryDuration,
|
||||
getHistoryTableGridTemplate,
|
||||
getHistoryTableMinWidth,
|
||||
HISTORY_TABLE_COLUMN_IDS,
|
||||
@@ -69,6 +70,12 @@ const HISTORY_DISCLOSURE_DURATION_MS = 520;
|
||||
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null;
|
||||
|
||||
function operationStatusLabel(status: "completed" | "failed" | "cancelled"): string {
|
||||
if (status === "completed") return "Abgeschlossen";
|
||||
if (status === "failed") return "Fehlgeschlagen";
|
||||
return "Abgebrochen";
|
||||
}
|
||||
|
||||
function loadHistoryTableColumnWidths(): HistoryTableColumnWidths {
|
||||
try {
|
||||
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY);
|
||||
@@ -183,11 +190,59 @@ function HistoryRowDetails({
|
||||
<dl className="history-details-grid">
|
||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
|
||||
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
|
||||
{row.hasStructuredLifecycle ? (
|
||||
<>
|
||||
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
|
||||
<div><dt>Download beendet</dt><dd>{row.downloadEndedLabel}</dd></div>
|
||||
<div><dt>Nachbearbeitung gestartet</dt><dd>{row.postProcessStartedLabel}</dd></div>
|
||||
<div><dt>Abgeschlossen</dt><dd>{row.completedLabel}</dd></div>
|
||||
<div><dt>Downloaddauer</dt><dd>{row.downloadDurationLabel}</dd></div>
|
||||
<div><dt>Entpackdauer</dt><dd>{row.extractionDurationLabel}</dd></div>
|
||||
<div><dt>Remuxdauer</dt><dd>{row.remuxDurationLabel}</dd></div>
|
||||
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
|
||||
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
|
||||
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
|
||||
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
|
||||
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
|
||||
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
|
||||
</>
|
||||
) : (
|
||||
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
|
||||
)}
|
||||
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
|
||||
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
|
||||
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
|
||||
</dl>
|
||||
{row.hasStructuredLifecycle ? (
|
||||
<div className="history-operation-groups">
|
||||
<section className="history-operation-group">
|
||||
<h3>Archivvorgänge</h3>
|
||||
{row.archiveOperations?.length ? (
|
||||
<ul>
|
||||
{row.archiveOperations.map((operation) => (
|
||||
<li key={operation.id}>
|
||||
<strong>{operation.name}</strong>
|
||||
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p>Keine Archivvorgänge</p>}
|
||||
</section>
|
||||
<section className="history-operation-group">
|
||||
<h3>Remuxvorgänge</h3>
|
||||
{row.remuxOperations?.length ? (
|
||||
<ul>
|
||||
{row.remuxOperations.map((operation) => (
|
||||
<li key={operation.id}>
|
||||
<strong>{operation.fileName}</strong>
|
||||
<span>{formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p>Keine Remuxvorgänge</p>}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,15 @@ export interface HistoryRow extends HistoryViewEntry {
|
||||
durationLabel: string;
|
||||
averageSpeedLabel: string;
|
||||
statusLabel: string;
|
||||
hasStructuredLifecycle: boolean;
|
||||
downloadEndedLabel: string;
|
||||
postProcessStartedLabel: string;
|
||||
downloadDurationLabel: string;
|
||||
extractionDurationLabel: string;
|
||||
remuxDurationLabel: string;
|
||||
postProcessDurationLabel: string;
|
||||
totalDurationLabel: string;
|
||||
failurePhaseLabel: string;
|
||||
}
|
||||
|
||||
export interface HistoryFilterCounts {
|
||||
@@ -108,6 +117,7 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
deepbrid: "Deepbrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
@@ -116,6 +126,8 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
|
||||
const statusLabels: Record<HistoryViewStatus, string> = {
|
||||
completed: "Abgeschlossen",
|
||||
partial: "Teilweise",
|
||||
cancelled: "Abgebrochen",
|
||||
deleted: "Gelöscht",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
@@ -145,7 +157,7 @@ function formatBytes(bytes: number): string {
|
||||
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number): string {
|
||||
export function formatHistoryDuration(durationSeconds: number): string {
|
||||
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
@@ -156,6 +168,19 @@ function formatDuration(durationSeconds: number): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number | undefined): string {
|
||||
const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0);
|
||||
return safe > 0 ? dateFormatter.format(new Date(safe)) : "—";
|
||||
}
|
||||
|
||||
function failurePhaseLabel(entry: HistoryViewEntry): string {
|
||||
if (entry.failurePhase === "download") return "Download";
|
||||
if (entry.failurePhase === "extract") return "Entpacken";
|
||||
if (entry.failurePhase === "remux") return "Remux";
|
||||
if (entry.failurePhase === "cleanup") return "Aufräumen";
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
|
||||
const totalItems = rows.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
|
||||
@@ -232,7 +257,11 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
|
||||
return hostnames.length > 0 ? hostnames.join(", ") : "—";
|
||||
}
|
||||
|
||||
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
|
||||
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds" | "startedAt">): number {
|
||||
const startedAt = Math.max(0, Number.isFinite(entry.startedAt) ? Number(entry.startedAt) : 0);
|
||||
if (startedAt > 0) {
|
||||
return startedAt;
|
||||
}
|
||||
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
|
||||
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
|
||||
return Math.max(0, completedAt - durationMs);
|
||||
@@ -242,19 +271,32 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
|
||||
const hoster = deriveHistoryHoster(entry.urls);
|
||||
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
|
||||
const startAt = deriveHistoryStartAt(entry);
|
||||
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
|
||||
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
|
||||
const downloadDurationSeconds = Math.max(0, entry.downloadDurationSeconds ?? entry.durationSeconds ?? 0);
|
||||
const averageBytesPerSecond = downloadDurationSeconds > 0 ? entry.downloadedBytes / downloadDurationSeconds : 0;
|
||||
const hasStructuredLifecycle = entry.startedAt !== undefined
|
||||
|| entry.downloadEndedAt !== undefined
|
||||
|| entry.postProcessStartedAt !== undefined
|
||||
|| entry.totalDurationSeconds !== undefined;
|
||||
return {
|
||||
...entry,
|
||||
hoster,
|
||||
providerLabel,
|
||||
startAt,
|
||||
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
|
||||
startedLabel: dateFormatter.format(new Date(startAt)),
|
||||
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
|
||||
durationLabel: formatDuration(durationSeconds),
|
||||
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
|
||||
statusLabel: statusLabels[entry.status]
|
||||
startedLabel: formatTimestamp(startAt),
|
||||
completedLabel: formatTimestamp(entry.completedAt),
|
||||
durationLabel: formatHistoryDuration(downloadDurationSeconds),
|
||||
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
|
||||
statusLabel: statusLabels[entry.status],
|
||||
hasStructuredLifecycle,
|
||||
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt),
|
||||
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt),
|
||||
downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0),
|
||||
extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0),
|
||||
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
|
||||
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
|
||||
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0),
|
||||
failurePhaseLabel: failurePhaseLabel(entry)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,18 @@
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.history-status-partial {
|
||||
background: color-mix(in srgb, var(--ui-warning) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
|
||||
color: var(--ui-warning-text);
|
||||
}
|
||||
|
||||
.history-status-cancelled {
|
||||
background: color-mix(in srgb, var(--ui-text-muted) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-text-muted) 48%, var(--ui-border));
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
.history-row-size,
|
||||
.history-row-hoster,
|
||||
.history-row-time {
|
||||
@@ -426,6 +438,52 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.history-operation-groups {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.history-operation-group {
|
||||
background: color-mix(in srgb, var(--ui-panel) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--ui-border) 82%, transparent);
|
||||
border-radius: 6px;
|
||||
min-width: 0;
|
||||
padding: 10px 11px;
|
||||
}
|
||||
|
||||
.history-operation-group h3 {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 11px;
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-operation-group p,
|
||||
.history-operation-group ul {
|
||||
color: var(--ui-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-operation-group ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-operation-group li {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-operation-group strong,
|
||||
.history-operation-group span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.history-copyable {
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
@@ -498,6 +556,10 @@
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.history-operation-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -146,6 +146,7 @@ export interface AccountRowSource {
|
||||
};
|
||||
dailyLimitBytes?: number;
|
||||
dailyUsageBytes?: number;
|
||||
totalUsageBytes?: number;
|
||||
username: string;
|
||||
credentialKind: "password" | "api-key" | "protected";
|
||||
canCheck: boolean;
|
||||
@@ -270,6 +271,22 @@ export interface SettingsFormProjectionInput {
|
||||
themeChoice?: "light" | "dark" | "system";
|
||||
}
|
||||
|
||||
const NOTIFICATION_NUMBER_LIMITS = {
|
||||
notifyRemainingThresholdGb: { min: 1, max: 100000, fallback: 50 },
|
||||
notifyStallAfterSeconds: { min: 60, max: 3600, fallback: 90 },
|
||||
notifyStallCooldownMinutes: { min: 5, max: 1440, fallback: 10 }
|
||||
} as const;
|
||||
|
||||
export function normalizeNotificationNumberField(fieldId: string, value: unknown): number | undefined {
|
||||
const limits = NOTIFICATION_NUMBER_LIMITS[fieldId as keyof typeof NOTIFICATION_NUMBER_LIMITS];
|
||||
if (!limits) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
const normalized = Number.isFinite(parsed) ? Math.floor(parsed) : limits.fallback;
|
||||
return Math.max(limits.min, Math.min(limits.max, normalized));
|
||||
}
|
||||
|
||||
export function buildSettingsFormViewModel({
|
||||
settings,
|
||||
section,
|
||||
@@ -600,7 +617,24 @@ export function buildSettingsFormViewModel({
|
||||
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
|
||||
{ id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted },
|
||||
{ id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed },
|
||||
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished }
|
||||
{
|
||||
id: "notifyPackageSuccessMode",
|
||||
kind: "select",
|
||||
label: "Erfolgsmeldungen senden",
|
||||
value: settings.notifyPackageSuccessMode,
|
||||
disabled: !settings.notifyOnPackageCompleted,
|
||||
options: [
|
||||
{ value: "digest", label: "Gesammelt (alle 2 Minuten)" },
|
||||
{ value: "individual", label: "Jedes Paket einzeln" }
|
||||
]
|
||||
},
|
||||
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn der gesamte Lauf fertig ist", value: settings.notifyOnRunFinished },
|
||||
{ id: "notifyOnRemainingBelow", kind: "switch", label: "Melden, wenn die Restmenge unterschritten wird", value: settings.notifyOnRemainingBelow },
|
||||
{ id: "notifyRemainingThresholdGb", kind: "number", label: "Restmengenschwelle (GB)", value: String(settings.notifyRemainingThresholdGb), min: 1, max: 100000, disabled: !settings.notifyOnRemainingBelow },
|
||||
{ id: "notifyOnDownloadStall", kind: "switch", label: "Melden, wenn Downloads stillstehen", value: settings.notifyOnDownloadStall },
|
||||
{ id: "notifyStallAfterSeconds", kind: "number", label: "Stillstand bestätigen nach (Sek.)", value: String(settings.notifyStallAfterSeconds), min: 60, max: 3600, disabled: !settings.notifyOnDownloadStall },
|
||||
{ id: "notifyStallCooldownMinutes", kind: "number", label: "Frühestens erneut melden nach (Min.)", value: String(settings.notifyStallCooldownMinutes), min: 5, max: 1440, disabled: !settings.notifyOnDownloadStall },
|
||||
{ id: "notifyOnDownloadRecovery", kind: "switch", label: "Melden, wenn Downloads wieder laufen", value: settings.notifyOnDownloadRecovery, disabled: !settings.notifyOnDownloadStall }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -621,12 +655,15 @@ function formatBytes(bytes: number): string {
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatTraffic(limitBytes?: number, usageBytes?: number): string {
|
||||
function formatTraffic(limitBytes?: number, usageBytes?: number, totalUsageBytes?: number): string {
|
||||
const total = Number.isFinite(totalUsageBytes) && totalUsageBytes && totalUsageBytes > 0
|
||||
? ` · Gesamt ${formatBytes(totalUsageBytes)}`
|
||||
: "";
|
||||
if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) {
|
||||
return "Unbeschränkt";
|
||||
return `Unbeschränkt${total}`;
|
||||
}
|
||||
const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0;
|
||||
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig`;
|
||||
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig${total}`;
|
||||
}
|
||||
|
||||
function formatExpiry(premiumUntilMs: number | null): string {
|
||||
@@ -714,7 +751,7 @@ export function projectAccountRows(
|
||||
enabled: source.enabled,
|
||||
selected: selected.has(id),
|
||||
status: { ...status, checkedAgo: formatCheckedAgo(source.status.checkedAt, nowMs) },
|
||||
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
|
||||
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes, source.totalUsageBytes),
|
||||
username: identity.username,
|
||||
email: identity.email,
|
||||
expires: formatExpiry(source.status.premiumUntilMs),
|
||||
|
||||
@@ -50,6 +50,7 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
deepbrid: "Deepbrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
|
||||
+135
-5
@@ -21,6 +21,7 @@ export type DebridProvider =
|
||||
| "megadebrid-web"
|
||||
| "bestdebrid"
|
||||
| "alldebrid"
|
||||
| "deepbrid"
|
||||
| "ddownload"
|
||||
| "onefichier"
|
||||
| "debridlink"
|
||||
@@ -120,7 +121,20 @@ export interface DebridAccountStatus {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
export type NotifyPackageSuccessMode = "digest" | "individual";
|
||||
|
||||
export type DailyStartOutcome = "" | "started" | "already_active" | "empty_queue" | "missing_account" | "start_failed" | "missed";
|
||||
|
||||
export interface DailyStartSettings {
|
||||
dailyStartEnabled: boolean;
|
||||
dailyStartMinuteOfDay: number;
|
||||
dailyStartFirstLocalDate: string;
|
||||
dailyStartLastHandledLocalDate: string;
|
||||
dailyStartPendingLocalDate: string;
|
||||
dailyStartLastOutcome: DailyStartOutcome;
|
||||
}
|
||||
|
||||
export interface AppSettings extends DailyStartSettings {
|
||||
language: AppLanguage;
|
||||
token: string;
|
||||
realDebridUseWebLogin: boolean;
|
||||
@@ -142,6 +156,7 @@ export interface AppSettings {
|
||||
bestDebridUseWebLogin: boolean;
|
||||
allDebridToken: string;
|
||||
allDebridUseWebLogin: boolean;
|
||||
deepbridApiKey: string;
|
||||
ddownloadLogin: string;
|
||||
ddownloadPassword: string;
|
||||
oneFichierApiKey: string;
|
||||
@@ -205,6 +220,13 @@ export interface AppSettings {
|
||||
notifyOnPackageCompleted: boolean;
|
||||
notifyOnPackageFailed: boolean;
|
||||
notifyOnRunFinished: boolean;
|
||||
notifyPackageSuccessMode: NotifyPackageSuccessMode;
|
||||
notifyOnRemainingBelow: boolean;
|
||||
notifyRemainingThresholdGb: number;
|
||||
notifyOnDownloadStall: boolean;
|
||||
notifyStallAfterSeconds: number;
|
||||
notifyStallCooldownMinutes: number;
|
||||
notifyOnDownloadRecovery: boolean;
|
||||
totalDownloadedAllTime: number;
|
||||
totalCompletedFilesAllTime: number;
|
||||
totalRuntimeAllTimeMs: number;
|
||||
@@ -243,6 +265,7 @@ export type RendererAccountKind =
|
||||
| "bestdebrid-web"
|
||||
| "alldebrid-api"
|
||||
| "alldebrid-web"
|
||||
| "deepbrid-api"
|
||||
| "ddownload-login"
|
||||
| "onefichier-api"
|
||||
| "debridlink-api"
|
||||
@@ -262,7 +285,7 @@ export interface RendererAccount {
|
||||
status: DebridAccountStatus | null;
|
||||
}
|
||||
|
||||
export interface RendererSettings {
|
||||
export interface RendererSettings extends DailyStartSettings {
|
||||
language: AppLanguage;
|
||||
realDebridUseWebLogin: boolean;
|
||||
realDebridDisabledAccountIds: string[];
|
||||
@@ -332,6 +355,13 @@ export interface RendererSettings {
|
||||
notifyOnPackageCompleted: boolean;
|
||||
notifyOnPackageFailed: boolean;
|
||||
notifyOnRunFinished: boolean;
|
||||
notifyPackageSuccessMode: NotifyPackageSuccessMode;
|
||||
notifyOnRemainingBelow: boolean;
|
||||
notifyRemainingThresholdGb: number;
|
||||
notifyOnDownloadStall: boolean;
|
||||
notifyStallAfterSeconds: number;
|
||||
notifyStallCooldownMinutes: number;
|
||||
notifyOnDownloadRecovery: boolean;
|
||||
totalDownloadedAllTime: number;
|
||||
totalCompletedFilesAllTime: number;
|
||||
totalRuntimeAllTimeMs: number;
|
||||
@@ -357,9 +387,15 @@ export interface RendererSettings {
|
||||
debridAccountStatuses: Record<string, DebridAccountStatus>;
|
||||
providerDailyUsageDay: string;
|
||||
scheduledStartEpochMs: number;
|
||||
nextDailyStartEpochMs: number;
|
||||
}
|
||||
|
||||
export type RendererSettingsUpdate = Partial<RendererSettings> & {
|
||||
export type RendererSettingsUpdate = Partial<Omit<RendererSettings,
|
||||
"dailyStartLastHandledLocalDate"
|
||||
| "dailyStartPendingLocalDate"
|
||||
| "dailyStartLastOutcome"
|
||||
| "nextDailyStartEpochMs"
|
||||
>> & {
|
||||
archivePasswordList?: string;
|
||||
notifyUrl?: string;
|
||||
};
|
||||
@@ -403,7 +439,7 @@ export interface AccountCommandResult {
|
||||
}
|
||||
|
||||
export interface AccountCredentialCheckInput {
|
||||
kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api";
|
||||
kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api" | "deepbrid-api";
|
||||
accountId?: string;
|
||||
identity?: string;
|
||||
secret?: string;
|
||||
@@ -466,6 +502,73 @@ export interface AudioStripSummary {
|
||||
files: AudioStripFileResult[];
|
||||
}
|
||||
|
||||
export type PackageResultStatus = "completed" | "partial" | "failed" | "cancelled";
|
||||
export type FailurePhase = "download" | "extract" | "remux" | "cleanup" | null;
|
||||
|
||||
export interface ArchiveOperationMetric {
|
||||
id: string;
|
||||
name: string;
|
||||
itemIds: string[];
|
||||
partCount: number;
|
||||
startedAt: number;
|
||||
completedAt: number;
|
||||
durationMs: number;
|
||||
status: "completed" | "failed" | "cancelled";
|
||||
errorCategory: string;
|
||||
}
|
||||
|
||||
export interface RemuxOperationMetric {
|
||||
id: string;
|
||||
fileName: string;
|
||||
startedAt: number;
|
||||
completedAt: number;
|
||||
durationMs: number;
|
||||
status: "completed" | "failed" | "cancelled";
|
||||
errorCategory: string;
|
||||
}
|
||||
|
||||
export interface PackageTelemetry {
|
||||
package: PackageEntry;
|
||||
items: DownloadItem[];
|
||||
archiveOperations?: ArchiveOperationMetric[];
|
||||
remuxOperations?: RemuxOperationMetric[];
|
||||
outputCount?: number;
|
||||
cleanupErrorCategory?: string;
|
||||
}
|
||||
|
||||
export interface PackageResult {
|
||||
packageId: string;
|
||||
name: string;
|
||||
status: PackageResultStatus;
|
||||
startedAt: number;
|
||||
downloadEndedAt: number;
|
||||
postProcessStartedAt: number;
|
||||
completedAt: number;
|
||||
downloadDurationSeconds: number;
|
||||
extractionDurationSeconds: number;
|
||||
remuxDurationSeconds: number;
|
||||
postProcessDurationSeconds: number;
|
||||
totalDurationSeconds: number;
|
||||
totalBytes: number;
|
||||
downloadedBytes: number;
|
||||
averageDownloadSpeedBps: number;
|
||||
successfulFiles: number;
|
||||
failedFiles: number;
|
||||
cancelledFiles: number;
|
||||
downloadFailures: number;
|
||||
offlineFailures: number;
|
||||
extractionFailures: number;
|
||||
remuxFailures: number;
|
||||
cleanupFailures: number;
|
||||
archiveCount: number;
|
||||
partCount: number;
|
||||
outputCount: number;
|
||||
failurePhase: FailurePhase;
|
||||
errorCategory: string;
|
||||
archiveOperations: ArchiveOperationMetric[];
|
||||
remuxOperations: RemuxOperationMetric[];
|
||||
}
|
||||
|
||||
export interface PackageEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -486,6 +589,16 @@ export interface PackageEntry {
|
||||
cleanedProviders?: DebridProvider[];
|
||||
downloadStartedAt?: number;
|
||||
downloadCompletedAt?: number;
|
||||
downloadEndedAt?: number;
|
||||
postProcessQueuedAt?: number;
|
||||
postProcessStartedAt?: number;
|
||||
postProcessCompletedAt?: number;
|
||||
terminalAt?: number;
|
||||
archiveOperations?: ArchiveOperationMetric[];
|
||||
remuxOperations?: RemuxOperationMetric[];
|
||||
outputCount?: number;
|
||||
cleanupErrorCategory?: string;
|
||||
resultGeneration?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -814,9 +927,26 @@ export interface HistoryEntry {
|
||||
provider: DebridProvider | null;
|
||||
completedAt: number;
|
||||
durationSeconds: number;
|
||||
status: "completed" | "deleted";
|
||||
status: PackageResultStatus | "deleted";
|
||||
outputDir: string;
|
||||
urls?: string[];
|
||||
startedAt?: number;
|
||||
downloadEndedAt?: number;
|
||||
postProcessStartedAt?: number;
|
||||
downloadDurationSeconds?: number;
|
||||
extractionDurationSeconds?: number;
|
||||
remuxDurationSeconds?: number;
|
||||
postProcessDurationSeconds?: number;
|
||||
totalDurationSeconds?: number;
|
||||
successfulFiles?: number;
|
||||
failedFiles?: number;
|
||||
cancelledFiles?: number;
|
||||
archiveCount?: number;
|
||||
partCount?: number;
|
||||
outputCount?: number;
|
||||
failurePhase?: FailurePhase;
|
||||
archiveOperations?: ArchiveOperationMetric[];
|
||||
remuxOperations?: RemuxOperationMetric[];
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
|
||||
+131
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkDeepbridAccount, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
|
||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||
import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
@@ -24,6 +24,15 @@ function mockFetchOnce(status: number, body: unknown): void {
|
||||
})) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
function mockDeepbridResponse(status: number, body: unknown, contentType = "application/json"): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(async () => new Response(
|
||||
typeof body === "string" ? body : JSON.stringify(body),
|
||||
{ status, headers: { "content-type": contentType } }
|
||||
));
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -180,7 +189,128 @@ describe("checkRealDebridAccount", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDeepbridAccount", () => {
|
||||
const key = "fixture-deepbrid-check-key-4fG7";
|
||||
|
||||
it("reports a current premium account with safe identity metadata", async () => {
|
||||
const expiration = new Date(NOW + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
mockDeepbridResponse(200, {
|
||||
username: "deep-user",
|
||||
email: "deep-user@example.test",
|
||||
type: "premium",
|
||||
expiration,
|
||||
maxDownloads: 10,
|
||||
maxConnections: 4
|
||||
});
|
||||
|
||||
const status = await checkDeepbridAccount(key, undefined, NOW);
|
||||
|
||||
expect(status).toMatchObject({
|
||||
accountId: "svc-deepbrid",
|
||||
provider: "deepbrid",
|
||||
label: "Deepbrid",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: Date.parse(expiration),
|
||||
username: "deep-user",
|
||||
email: "deep-user@example.test"
|
||||
});
|
||||
expect(status.message).toMatch(/Premium noch/);
|
||||
expect(status.maskedLogin).not.toBe(key);
|
||||
expect(JSON.stringify(status)).not.toContain(key);
|
||||
});
|
||||
|
||||
it("keeps a free account valid without marking it as premium", async () => {
|
||||
mockDeepbridResponse(200, {
|
||||
username: "free-user",
|
||||
email: "free-user@example.test",
|
||||
type: "free",
|
||||
expiration: "",
|
||||
maxDownloads: 2,
|
||||
maxConnections: 1
|
||||
});
|
||||
|
||||
const status = await checkDeepbridAccount(key, undefined, NOW);
|
||||
|
||||
expect(status).toMatchObject({ valid: true, isPremium: false, premiumUntilMs: null });
|
||||
expect(status.message).toMatch(/Free/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, /ungültig/i],
|
||||
[403, /gesperrt/i]
|
||||
])("reports HTTP %i as an honest invalid key status", async (httpStatus, message) => {
|
||||
mockDeepbridResponse(httpStatus, { error: httpStatus });
|
||||
|
||||
const status = await checkDeepbridAccount(key, undefined, NOW);
|
||||
|
||||
expect(status.valid).toBe(false);
|
||||
expect(status.message).toMatch(message);
|
||||
expect(JSON.stringify(status)).not.toContain(key);
|
||||
});
|
||||
|
||||
it("reports HTML instead of account JSON as a failed check", async () => {
|
||||
mockDeepbridResponse(200, "<html>gateway</html>", "text/html");
|
||||
|
||||
const status = await checkDeepbridAccount(key, undefined, NOW);
|
||||
|
||||
expect(status.valid).toBe(false);
|
||||
expect(status.message).toMatch(/Prüfung fehlgeschlagen/);
|
||||
expect(status.message).not.toContain("gateway");
|
||||
});
|
||||
|
||||
it.each([
|
||||
new Error("ECONNRESET fixture-deepbrid-check-key-4fG7"),
|
||||
new DOMException("Timeout fixture-deepbrid-check-key-4fG7", "TimeoutError")
|
||||
])("reports transport failures without reflecting raw errors", async (error) => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw error; }) as unknown as typeof fetch);
|
||||
|
||||
const status = await checkDeepbridAccount(key, undefined, NOW);
|
||||
|
||||
expect(status.valid).toBe(false);
|
||||
expect(status.message).toBe("Prüfung fehlgeschlagen");
|
||||
expect(JSON.stringify(status)).not.toContain(key);
|
||||
});
|
||||
|
||||
it("distinguishes a caller abort from a failed check", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error(`cancel ${key}`));
|
||||
|
||||
const status = await checkDeepbridAccount(key, controller.signal, NOW);
|
||||
|
||||
expect(status.message).toBe("Prüfung abgebrochen");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(status)).not.toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAllDebridAccounts", () => {
|
||||
it("checks one configured Deepbrid account in all scope and only an enabled one in active scope", async () => {
|
||||
const key = "fixture-deepbrid-bulk-key-7hJ2";
|
||||
const fetchMock = mockDeepbridResponse(200, {
|
||||
username: "bulk-user",
|
||||
email: "bulk-user@example.test",
|
||||
type: "free",
|
||||
expiration: "",
|
||||
maxDownloads: 2,
|
||||
maxConnections: 1
|
||||
});
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
deepbridApiKey: key,
|
||||
disabledProviders: ["deepbrid" as const]
|
||||
};
|
||||
|
||||
const active = await checkAllDebridAccounts(settings, undefined, undefined, "active");
|
||||
const all = await checkAllDebridAccounts(settings, undefined, undefined, "all");
|
||||
|
||||
expect(active).toEqual([]);
|
||||
expect(all).toEqual([expect.objectContaining({ accountId: "svc-deepbrid", provider: "deepbrid", valid: true })]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discards a late Real-Debrid result after its account was removed", () => {
|
||||
const removedId = "rda_removedAfterCheck";
|
||||
const lateStatus = { accountId: removedId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: NOW };
|
||||
|
||||
@@ -22,6 +22,7 @@ const SECRET_RETAIN_CASES: Array<{
|
||||
{ kind: "megadebrid-web", identity: "retain-mega-web@example.test", secret: "fixture-retain-mega-web-3cE5", retained: (settings) => settings.megaDebridWebCredentials === "retain-mega-web@example.test:fixture-retain-mega-web-3cE5" },
|
||||
{ kind: "bestdebrid-api", identity: "", secret: "fixture-retain-best-4dF6", retained: (settings) => settings.bestToken === "fixture-retain-best-4dF6" },
|
||||
{ kind: "alldebrid-api", identity: "", secret: "fixture-retain-all-5eG7", retained: (settings) => settings.allDebridToken === "fixture-retain-all-5eG7" },
|
||||
{ kind: "deepbrid-api", identity: "", secret: "fixture-retain-deepbrid-6fH8", retained: (settings) => settings.deepbridApiKey === "fixture-retain-deepbrid-6fH8" },
|
||||
{ kind: "ddownload-login", identity: "retain-dd@example.test", secret: "fixture-retain-dd-6fH8", retained: (settings) => settings.ddownloadPassword === "fixture-retain-dd-6fH8" },
|
||||
{ kind: "onefichier-api", identity: "", secret: "fixture-retain-one-7gJ9", retained: (settings) => settings.oneFichierApiKey === "fixture-retain-one-7gJ9" },
|
||||
{ kind: "debridlink-api", identity: "", secret: "fixture-retain-dl-8hK1", retained: (settings) => settings.debridLinkApiKeys === "fixture-retain-dl-8hK1" },
|
||||
@@ -37,6 +38,7 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
|
||||
"bestdebrid-web",
|
||||
"alldebrid-api",
|
||||
"alldebrid-web",
|
||||
"deepbrid-api",
|
||||
"ddownload-login",
|
||||
"onefichier-api",
|
||||
"debridlink-api",
|
||||
@@ -115,6 +117,7 @@ describe("write-only account commands", () => {
|
||||
debridLinkApiKeys: "fixture-reveal-dl-5eF6",
|
||||
bestToken: "fixture-reveal-best-7gH8",
|
||||
allDebridToken: "fixture-reveal-all-9iJ1",
|
||||
deepbridApiKey: "fixture-reveal-deepbrid-1jK2",
|
||||
ddownloadLogin: "reveal-dd@example.test",
|
||||
ddownloadPassword: "fixture-reveal-dd-2kL3",
|
||||
oneFichierApiKey: "fixture-reveal-one-4mN5",
|
||||
@@ -134,6 +137,8 @@ describe("write-only account commands", () => {
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "debridlink-api", accountId: getDebridLinkApiKeyId("fixture-reveal-dl-5eF6") })).toBe("fixture-reveal-dl-5eF6");
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "bestdebrid-api", accountId: "svc-bestdebrid" })).toBe("fixture-reveal-best-7gH8");
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "alldebrid-api", accountId: "svc-alldebrid" })).toBe("fixture-reveal-all-9iJ1");
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "deepbrid-api", accountId: "svc-deepbrid" })).toBe("fixture-reveal-deepbrid-1jK2");
|
||||
expect(() => api.resolveStoredAccountSecret?.(settings, { kind: "deepbrid-api", accountId: "svc-realdebrid" })).toThrow(/nicht gefunden/i);
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "ddownload-login", accountId: "svc-ddownload" })).toBe("fixture-reveal-dd-2kL3");
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "onefichier-api", accountId: "svc-onefichier" })).toBe("fixture-reveal-one-4mN5");
|
||||
expect(api.resolveStoredAccountSecret?.(settings, { kind: "linksnappy-login", accountId: "svc-linksnappy" })).toBe("fixture-reveal-ls-6pQ7");
|
||||
@@ -150,6 +155,23 @@ describe("write-only account commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an unsaved or stable Deepbrid account and rejects every other account ID for credential checks", () => {
|
||||
expect(validateAccountCredentialCheckInput({ kind: "deepbrid-api" })).toEqual({
|
||||
kind: "deepbrid-api",
|
||||
accountId: undefined,
|
||||
identity: undefined,
|
||||
secret: undefined
|
||||
});
|
||||
expect(validateAccountCredentialCheckInput({ kind: "deepbrid-api", accountId: "svc-deepbrid" })).toEqual({
|
||||
kind: "deepbrid-api",
|
||||
accountId: "svc-deepbrid",
|
||||
identity: undefined,
|
||||
secret: undefined
|
||||
});
|
||||
expect(() => validateAccountCredentialCheckInput({ kind: "deepbrid-api", accountId: "svc-realdebrid" })).toThrow(/ungültig/i);
|
||||
expect(() => validateAccountCredentialCheckInput({ kind: "deepbrid-api", accountId: "deepbrid-other" })).toThrow(/ungültig/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"],
|
||||
["realdebrid-web", "", "", "realDebridUseWebLogin"],
|
||||
@@ -157,6 +179,7 @@ describe("write-only account commands", () => {
|
||||
["bestdebrid-web", "", "", "bestDebridUseWebLogin"],
|
||||
["alldebrid-api", "", "fixture-ad-provider-secret-3hC6", "allDebridToken"],
|
||||
["alldebrid-web", "", "", "allDebridUseWebLogin"],
|
||||
["deepbrid-api", "", "fixture-deepbrid-provider-secret-4jD7", "deepbridApiKey"],
|
||||
["ddownload-login", "dd-safe@example.test", "fixture-dd-provider-secret-4jD7", "ddownloadPassword"],
|
||||
["onefichier-api", "", "fixture-one-provider-secret-5kE8", "oneFichierApiKey"],
|
||||
["linksnappy-login", "ls-safe@example.test", "fixture-ls-provider-secret-6mF9", "linkSnappyPassword"]
|
||||
@@ -181,6 +204,115 @@ describe("write-only account commands", () => {
|
||||
expect(deleted.settings[configuredKey]).toBe(secret ? "" : false);
|
||||
});
|
||||
|
||||
it("manages the single Deepbrid account without exposing or retaining deleted state", () => {
|
||||
const original = "fixture-deepbrid-original-key-7mN1";
|
||||
const replacement = "fixture-deepbrid-replacement-key-8pQ2";
|
||||
const created = applyAccountCommand(defaultSettings(), validateAccountCommand({
|
||||
action: "create",
|
||||
kind: "deepbrid-api",
|
||||
secret: original,
|
||||
dailyLimitBytes: 2 * GIB
|
||||
}));
|
||||
|
||||
expect(created.response).toEqual({ accountId: "svc-deepbrid" });
|
||||
expect(created.settings.deepbridApiKey).toBe(original);
|
||||
expect(created.settings.providerDailyLimitBytes.deepbrid).toBe(2 * GIB);
|
||||
expect(JSON.stringify(created.response)).not.toContain(original);
|
||||
expect(() => applyAccountCommand(created.settings, validateAccountCommand({ action: "create", kind: "deepbrid-api", secret: replacement }))).toThrow(/ungültig/i);
|
||||
|
||||
const retained = applyAccountCommand(created.settings, validateAccountCommand({
|
||||
action: "replace",
|
||||
kind: "deepbrid-api",
|
||||
accountId: "svc-deepbrid",
|
||||
secret: "",
|
||||
dailyLimitBytes: 3 * GIB
|
||||
}));
|
||||
expect(retained.settings.deepbridApiKey).toBe(original);
|
||||
|
||||
const updated = applyAccountCommand(retained.settings, validateAccountCommand({
|
||||
action: "update-secret",
|
||||
kind: "deepbrid-api",
|
||||
accountId: "svc-deepbrid",
|
||||
secret: replacement
|
||||
}));
|
||||
const populated = {
|
||||
...updated.settings,
|
||||
providerDailyUsageBytes: { ...updated.settings.providerDailyUsageBytes, deepbrid: GIB },
|
||||
providerTotalUsageBytes: { ...updated.settings.providerTotalUsageBytes, deepbrid: 5 * GIB },
|
||||
debridAccountStatuses: {
|
||||
...updated.settings.debridAccountStatuses,
|
||||
"svc-deepbrid": {
|
||||
accountId: "svc-deepbrid",
|
||||
provider: "deepbrid" as const,
|
||||
label: "Deepbrid",
|
||||
maskedLogin: "••••",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "OK",
|
||||
checkedAt: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(updated.settings.deepbridApiKey).toBe(replacement);
|
||||
expect(JSON.stringify(updated.response)).not.toContain(replacement);
|
||||
|
||||
const deleted = applyAccountCommand(populated, validateAccountCommand({
|
||||
action: "delete",
|
||||
kind: "deepbrid-api",
|
||||
accountId: "svc-deepbrid"
|
||||
}));
|
||||
expect(deleted.response).toEqual({ accountId: null });
|
||||
expect(deleted.settings.deepbridApiKey).toBe("");
|
||||
expect(deleted.settings.providerDailyLimitBytes).not.toHaveProperty("deepbrid");
|
||||
expect(deleted.settings.providerDailyUsageBytes).not.toHaveProperty("deepbrid");
|
||||
expect(deleted.settings.providerTotalUsageBytes).not.toHaveProperty("deepbrid");
|
||||
expect(deleted.settings.debridAccountStatuses).not.toHaveProperty("svc-deepbrid");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "bestdebrid-api", identity: "", secret: "fixture-status-best-1aB2", provider: "bestdebrid" },
|
||||
{ kind: "alldebrid-api", identity: "", secret: "fixture-status-all-3cD4", provider: "alldebrid" },
|
||||
{ kind: "ddownload-login", identity: "status-dd@example.test", secret: "fixture-status-dd-5eF6", provider: "ddownload" },
|
||||
{ kind: "onefichier-api", identity: "", secret: "fixture-status-one-7gH8", provider: "onefichier" },
|
||||
{ kind: "linksnappy-login", identity: "status-ls@example.test", secret: "fixture-status-ls-9jK1", provider: "linksnappy" },
|
||||
{ kind: "deepbrid-api", identity: "", secret: "fixture-status-deepbrid-2mN3", provider: "deepbrid" }
|
||||
] as const)("deletes only the selected $provider single-account status", ({ kind, identity, secret, provider }) => {
|
||||
const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ action: "create", kind, identity, secret }));
|
||||
const accountId = created.response.accountId!;
|
||||
const foreignStatus = {
|
||||
accountId: "foreign-account",
|
||||
provider: "realdebrid" as const,
|
||||
label: "Foreign",
|
||||
maskedLogin: "fo***gn",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "OK",
|
||||
checkedAt: 2
|
||||
};
|
||||
const selectedStatus = {
|
||||
accountId,
|
||||
provider,
|
||||
label: provider,
|
||||
maskedLogin: "se***ed",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "OK",
|
||||
checkedAt: 1
|
||||
};
|
||||
const deleted = applyAccountCommand({
|
||||
...created.settings,
|
||||
debridAccountStatuses: {
|
||||
[accountId]: selectedStatus,
|
||||
"foreign-account": foreignStatus
|
||||
}
|
||||
}, validateAccountCommand({ action: "delete", kind, accountId }));
|
||||
|
||||
expect(deleted.settings.debridAccountStatuses).toEqual({ "foreign-account": foreignStatus });
|
||||
});
|
||||
|
||||
it("creates an account without returning submitted secrets", () => {
|
||||
const command = validateAccountCommand({
|
||||
action: "create",
|
||||
|
||||
@@ -112,4 +112,23 @@ describe("renderer-safe account editing", () => {
|
||||
accountId: target.type === "mega" ? target.accountId : ""
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the synthetic Deepbrid account id for edit, reveal and delete", () => {
|
||||
const renderer = createRendererState({ ...defaultSettings(), deepbridApiKey: "synthetic-deepbrid-key" });
|
||||
const target: AccountEditTarget = {
|
||||
type: "single",
|
||||
rowKey: "svc-deepbrid",
|
||||
kind: "deepbrid-api",
|
||||
service: "deepbrid",
|
||||
provider: "deepbrid"
|
||||
};
|
||||
const edit = createAccountEditState(target, renderer.accounts);
|
||||
|
||||
expect(buildAccountReplaceCommand({ ...edit, token: "replacement-deepbrid-key" })).toEqual(expect.objectContaining({
|
||||
kind: "deepbrid-api",
|
||||
accountId: "svc-deepbrid",
|
||||
secret: "replacement-deepbrid-key"
|
||||
}));
|
||||
expect(buildAccountDeleteCommand(target).accountId).toBe("svc-deepbrid");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const services = [
|
||||
"megadebrid-web",
|
||||
"bestdebrid",
|
||||
"alldebrid",
|
||||
"deepbrid",
|
||||
"ddownload",
|
||||
"onefichier",
|
||||
"debridlink",
|
||||
@@ -31,4 +32,8 @@ describe("account service icons", () => {
|
||||
it("uses the same Mega-Debrid icon for API and Web accounts", () => {
|
||||
expect(ACCOUNT_SERVICE_ICONS["megadebrid-api"]).toBe(ACCOUNT_SERVICE_ICONS["megadebrid-web"]);
|
||||
});
|
||||
|
||||
it("uses the bundled Deepbrid provider icon", () => {
|
||||
expect(ACCOUNT_SERVICE_ICONS.deepbrid).toBe("./provider-icons/deepbrid.png");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import type { AppSettings, SessionState } from "../src/shared/types";
|
||||
|
||||
const appState = vi.hoisted(() => ({ userDataDir: "C:\\MDD\\Test" }));
|
||||
const bootStorage = vi.hoisted(() => ({
|
||||
settings: null as unknown,
|
||||
loadResult: null as unknown
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: (name: string) => name === "desktop" ? path.join(appState.userDataDir, "Desktop") : appState.userDataDir
|
||||
},
|
||||
BrowserWindow: class {},
|
||||
clipboard: {},
|
||||
dialog: {},
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: {},
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
vi.mock("../src/main/debug-server", async () => {
|
||||
const actual = await vi.importActual<typeof import("../src/main/debug-server")>("../src/main/debug-server");
|
||||
return {
|
||||
...actual,
|
||||
startDebugServer: vi.fn(),
|
||||
stopDebugServer: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/main/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("../src/main/storage")>("../src/main/storage");
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: (...args: Parameters<typeof actual.loadSettings>) => bootStorage.settings
|
||||
? bootStorage.settings as AppSettings
|
||||
: actual.loadSettings(...args),
|
||||
loadSessionWithStatus: (...args: Parameters<typeof actual.loadSessionWithStatus>) => bootStorage.loadResult
|
||||
? bootStorage.loadResult as ReturnType<typeof actual.loadSessionWithStatus>
|
||||
: actual.loadSessionWithStatus(...args)
|
||||
};
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const liveControllers: AppController[] = [];
|
||||
|
||||
function createController(settings: AppSettings): AppController {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-"));
|
||||
tempDirs.push(dir);
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.settings = settings;
|
||||
controller.storagePaths = createStoragePaths(dir);
|
||||
controller.manager = { setSettings: vi.fn() };
|
||||
controller.audit = vi.fn();
|
||||
controller.overlayLiveUsageCounters = vi.fn();
|
||||
controller.pruneRealDebridWebFallbacks = vi.fn();
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
return controller as AppController;
|
||||
}
|
||||
|
||||
function queuedSession(): SessionState {
|
||||
return {
|
||||
...emptySession(),
|
||||
packageOrder: ["pkg"],
|
||||
packages: {
|
||||
pkg: {
|
||||
id: "pkg",
|
||||
name: "Daily queue",
|
||||
outputDir: "C:\\Downloads",
|
||||
extractDir: "C:\\Downloads",
|
||||
status: "queued",
|
||||
itemIds: ["item"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
},
|
||||
items: {
|
||||
item: {
|
||||
id: "item",
|
||||
packageId: "pkg",
|
||||
url: "https://example.test/file",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "file.bin",
|
||||
targetPath: "C:\\Downloads\\file.bin",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function tomorrowLocalDate(): string {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 12, 0, 0, 0);
|
||||
return `${tomorrow.getFullYear().toString().padStart(4, "0")}-${(tomorrow.getMonth() + 1).toString().padStart(2, "0")}-${tomorrow.getDate().toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const controller of liveControllers.splice(0)) {
|
||||
await controller.shutdown();
|
||||
}
|
||||
bootStorage.settings = null;
|
||||
bootStorage.loadResult = null;
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("AppController daily start settings", () => {
|
||||
it("does not clear a legacy one-time schedule for an unprepared daily settings patch", () => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||
});
|
||||
const scheduledStartEpochMs = 1_800_000_000_000;
|
||||
const controller = createController({
|
||||
...defaultSettings(),
|
||||
scheduledStartEpochMs
|
||||
});
|
||||
|
||||
const updated = controller.updateSettings({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23"
|
||||
});
|
||||
|
||||
expect(updated.scheduledStartEpochMs).toBe(scheduledStartEpochMs);
|
||||
});
|
||||
|
||||
it("clears a legacy one-time schedule when an explicit daily rule is saved", () => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||
});
|
||||
const controller = createController({
|
||||
...defaultSettings(),
|
||||
scheduledStartEpochMs: 1_800_000_000_000
|
||||
});
|
||||
|
||||
const updated = controller.updateSettings(prepareDailyStartSettingsPatch({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23"
|
||||
}, controller.getSettings()));
|
||||
|
||||
expect(updated.scheduledStartEpochMs).toBe(0);
|
||||
expect(updated).toMatchObject({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23"
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a legacy one-time schedule when an account mutation saves a complete settings state", async () => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||
});
|
||||
const scheduledStartEpochMs = 1_800_000_000_000;
|
||||
const controller = createController({
|
||||
...defaultSettings(),
|
||||
ddownloadLogin: "account@example.test",
|
||||
ddownloadPassword: "secret",
|
||||
scheduledStartEpochMs
|
||||
});
|
||||
|
||||
await controller.executeAccountCommand({
|
||||
action: "delete",
|
||||
kind: "ddownload-login",
|
||||
accountId: "svc-ddownload"
|
||||
});
|
||||
|
||||
expect(controller.getSettings().scheduledStartEpochMs).toBe(scheduledStartEpochMs);
|
||||
});
|
||||
|
||||
it("preserves a legacy one-time schedule when a complete unchanged settings payload is prepared", () => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||
});
|
||||
const scheduledStartEpochMs = 1_800_000_000_000;
|
||||
const controller = createController({
|
||||
...defaultSettings(),
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23",
|
||||
scheduledStartEpochMs
|
||||
});
|
||||
const current = controller.getSettings();
|
||||
|
||||
const updated = controller.updateSettings(prepareDailyStartSettingsPatch({ ...current }, current));
|
||||
|
||||
expect(updated.scheduledStartEpochMs).toBe(scheduledStartEpochMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppController boot auto-resume", () => {
|
||||
it.each([
|
||||
{ wasRunning: false, expectedAutoResume: false },
|
||||
{ wasRunning: true, expectedAutoResume: true }
|
||||
])("uses persisted running evidence when a daily start is still in the future", async ({ wasRunning, expectedAutoResume }) => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||
});
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-boot-"));
|
||||
tempDirs.push(root);
|
||||
appState.userDataDir = root;
|
||||
bootStorage.settings = {
|
||||
...defaultSettings(),
|
||||
token: "token",
|
||||
autoResumeOnStart: true,
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 0,
|
||||
dailyStartFirstLocalDate: tomorrowLocalDate()
|
||||
} satisfies AppSettings;
|
||||
bootStorage.loadResult = {
|
||||
session: queuedSession(),
|
||||
status: "ok",
|
||||
wasRunning
|
||||
};
|
||||
const controller = new AppController();
|
||||
liveControllers.push(controller);
|
||||
const beginAutoResume = vi.spyOn(controller as any, "beginAutoResume").mockImplementation(() => {});
|
||||
|
||||
controller.onState = vi.fn();
|
||||
|
||||
expect(beginAutoResume).toHaveBeenCalledTimes(expectedAutoResume ? 1 : 0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
||||
import { decryptBackup, encryptBackup } from "../src/main/backup-crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types";
|
||||
|
||||
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl", ...overrides } as unknown as AppSettings;
|
||||
}
|
||||
|
||||
const session: SessionState = {
|
||||
@@ -18,6 +19,19 @@ const statistics: StatisticsLedger = { version: 2, startedAt: 1, days: [], minut
|
||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
|
||||
|
||||
describe("buildBackupPayload — default is settings-only", () => {
|
||||
it("keeps the Deepbrid key only inside the encrypted backup payload", () => {
|
||||
const key = "fixture-deepbrid-backup-key-4eF6";
|
||||
const payload = buildBackupPayload({
|
||||
...baseInput,
|
||||
settings: settings({ deepbridApiKey: key })
|
||||
});
|
||||
const encrypted = encryptBackup(JSON.stringify(payload), "fixture-backup-passphrase-5gH7");
|
||||
|
||||
expect(encrypted.toString("utf8")).not.toContain(key);
|
||||
const restored = JSON.parse(decryptBackup(encrypted, "fixture-backup-passphrase-5gH7")) as { settings: AppSettings };
|
||||
expect(restored.settings.deepbridApiKey).toBe(key);
|
||||
});
|
||||
|
||||
it("exports the full statistics ledger instead of the renderer projection", () => {
|
||||
const source = readFileSync(new URL("../src/main/app-controller.ts", import.meta.url), "utf8");
|
||||
const exportBlock = source.slice(source.indexOf("public exportBackup"), source.indexOf("public async exportOnlineBackup"));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { collectAccountStatusRedactionValues, sanitizeAccountStatusText } from "
|
||||
import {
|
||||
configureCredentialProtector,
|
||||
CredentialProtector,
|
||||
needsPersistedSettingsRewrite,
|
||||
protectPersistedSettings,
|
||||
projectSettingsForRenderer,
|
||||
restorePersistedSettings
|
||||
@@ -52,6 +53,31 @@ describe("credential protection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("protects, restores, clears and redacts the Deepbrid API key", () => {
|
||||
const key = "fixture-deepbrid-protection-key-3cD5";
|
||||
const input = { ...defaultSettings(), rememberToken: true, deepbridApiKey: key };
|
||||
const persisted = protectPersistedSettings(input);
|
||||
|
||||
expect(JSON.stringify(persisted)).not.toContain(key);
|
||||
expect(restorePersistedSettings(persisted).deepbridApiKey).toBe(key);
|
||||
expect(projectSettingsForRenderer(input).deepbridApiKey).toBe("••••••••");
|
||||
expect(protectPersistedSettings({ ...input, rememberToken: false }).deepbridApiKey).toBe("");
|
||||
|
||||
const redactions = collectAccountStatusRedactionValues(input);
|
||||
expect(sanitizeAccountStatusText(`Deepbrid api_key=${key}`, redactions)).not.toContain(key);
|
||||
});
|
||||
|
||||
it("requires a persisted settings rewrite only while the Deepbrid key is unprotected", () => {
|
||||
const input = {
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
deepbridApiKey: "fixture-deepbrid-rewrite-key-6rS7"
|
||||
};
|
||||
|
||||
expect(needsPersistedSettingsRewrite(input)).toBe(true);
|
||||
expect(needsPersistedSettingsRewrite(protectPersistedSettings(input))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts plaintext values once so existing settings can be migrated", () => {
|
||||
const input = {
|
||||
...defaultSettings(),
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AppSettings, DailyStartSettings } from "../src/shared/types";
|
||||
import { DailyStartScheduler, nextDailyStartEpochMs, prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "../src/main/daily-start-scheduler";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { createStoragePaths, loadSettings, saveSettings } from "../src/main/storage";
|
||||
|
||||
const originalTimezone = process.env.TZ;
|
||||
process.env.TZ = "Europe/Berlin";
|
||||
|
||||
afterAll(() => {
|
||||
if (originalTimezone === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTimezone;
|
||||
}
|
||||
});
|
||||
|
||||
function settings(overrides: Partial<DailyStartSettings> = {}): DailyStartSettings {
|
||||
return {
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 10 * 60,
|
||||
dailyStartFirstLocalDate: "2026-08-22",
|
||||
dailyStartLastHandledLocalDate: "",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
class FakeDailyStartController {
|
||||
public settings: DailyStartSettings;
|
||||
public running = false;
|
||||
public paused = false;
|
||||
public canStart = true;
|
||||
public items: Record<string, { status: string; packageId: string }> = { queued: { status: "queued", packageId: "package" } };
|
||||
public packages: Record<string, { enabled: boolean; cancelled: boolean }> = {
|
||||
package: { enabled: true, cancelled: false }
|
||||
};
|
||||
public events: string[] = [];
|
||||
public start = vi.fn(async () => {
|
||||
this.events.push("start");
|
||||
});
|
||||
|
||||
public constructor(
|
||||
value: DailyStartSettings,
|
||||
private readonly persist?: (value: DailyStartSettings) => void
|
||||
) {
|
||||
this.settings = value;
|
||||
}
|
||||
|
||||
public getSnapshot() {
|
||||
return {
|
||||
settings: this.settings,
|
||||
session: {
|
||||
running: this.running,
|
||||
paused: this.paused,
|
||||
items: this.items,
|
||||
packages: this.packages
|
||||
},
|
||||
canStart: this.canStart
|
||||
};
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<DailyStartSettings>): DailyStartSettings {
|
||||
Object.assign(this.settings, partial);
|
||||
if (partial.dailyStartPendingLocalDate) {
|
||||
this.events.push(`pending:${partial.dailyStartPendingLocalDate}`);
|
||||
}
|
||||
if (partial.dailyStartLastHandledLocalDate) {
|
||||
this.events.push(`handled:${partial.dailyStartLastHandledLocalDate}`);
|
||||
}
|
||||
this.persist?.(this.settings);
|
||||
return this.settings;
|
||||
}
|
||||
}
|
||||
|
||||
describe("daily start scheduler", () => {
|
||||
it("keeps today's future and past targets on the local calendar date", () => {
|
||||
const futureNow = new Date(2026, 7, 22, 9, 15).getTime();
|
||||
const pastNow = new Date(2026, 7, 22, 12, 30).getTime();
|
||||
const expected = new Date(2026, 7, 22, 10, 0, 0, 0).getTime();
|
||||
|
||||
expect(nextDailyStartEpochMs(settings(), futureNow)).toBe(expected);
|
||||
expect(nextDailyStartEpochMs(settings(), pastNow)).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses tomorrow as the first eligible local date", () => {
|
||||
const now = new Date(2026, 7, 22, 8, 0).getTime();
|
||||
|
||||
expect(nextDailyStartEpochMs(settings({ dailyStartFirstLocalDate: "2026-08-23" }), now))
|
||||
.toBe(new Date(2026, 7, 23, 10, 0, 0, 0).getTime());
|
||||
});
|
||||
|
||||
it("dispatches once on each of five eligible local days", async () => {
|
||||
let now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
for (let day = 22; day <= 26; day += 1) {
|
||||
now = new Date(2026, 7, day, 10, 5).getTime();
|
||||
await scheduler.reconcile();
|
||||
}
|
||||
|
||||
expect(controller.start).toHaveBeenCalledTimes(5);
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-26");
|
||||
});
|
||||
|
||||
it("persists pending before dispatch and coalesces simultaneous reconciles", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
await Promise.all([scheduler.reconcile(), scheduler.reconcile()]);
|
||||
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
expect(controller.events).toEqual([
|
||||
"pending:2026-08-22",
|
||||
"start",
|
||||
"handled:2026-08-22"
|
||||
]);
|
||||
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("started");
|
||||
});
|
||||
|
||||
it("catches an overdue target after a clock jump without dispatching twice", async () => {
|
||||
let now = new Date(2026, 7, 22, 9, 55).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
expect(await scheduler.reconcile()).toBeNull();
|
||||
now = new Date(2026, 7, 22, 11, 10).getTime();
|
||||
expect(await scheduler.reconcile()).toBe("started");
|
||||
expect(await scheduler.reconcile()).toBeNull();
|
||||
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not redispatch when the local calendar moves behind a handled or pending day", async () => {
|
||||
const now = new Date(2026, 7, 22, 11, 0).getTime();
|
||||
const handledController = new FakeDailyStartController(settings({
|
||||
dailyStartLastHandledLocalDate: "2026-08-23"
|
||||
}));
|
||||
const pendingController = new FakeDailyStartController(settings({
|
||||
dailyStartPendingLocalDate: "2026-08-23"
|
||||
}));
|
||||
|
||||
expect(await new DailyStartScheduler(handledController, () => now).reconcile()).toBeNull();
|
||||
expect(await new DailyStartScheduler(pendingController, () => now).reconcile()).toBeNull();
|
||||
|
||||
expect(handledController.start).not.toHaveBeenCalled();
|
||||
expect(pendingController.start).not.toHaveBeenCalled();
|
||||
expect(handledController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-23");
|
||||
expect(pendingController.settings.dailyStartPendingLocalDate).toBe("2026-08-23");
|
||||
expect(nextDailyStartEpochMs(handledController.settings, now)).toBe(new Date(2026, 7, 24, 10, 0).getTime());
|
||||
expect(nextDailyStartEpochMs(pendingController.settings, now)).toBe(new Date(2026, 7, 23, 10, 0).getTime());
|
||||
});
|
||||
|
||||
it("does not regress last handled when an older pending receipt expires", async () => {
|
||||
const now = new Date(2026, 7, 23, 9, 0).getTime();
|
||||
const controller = new FakeDailyStartController(settings({
|
||||
dailyStartLastHandledLocalDate: "2026-08-22",
|
||||
dailyStartPendingLocalDate: "2026-08-21"
|
||||
}));
|
||||
|
||||
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("missed");
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
|
||||
});
|
||||
|
||||
it("treats repeated suspend and resume reconciles as one daily dispatch", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
await scheduler.reconcile();
|
||||
await scheduler.reconcile();
|
||||
await scheduler.reconcile();
|
||||
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finishes a persisted in-flight receipt after restart without redispatching", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-daily-restart-"));
|
||||
const paths = createStoragePaths(dir);
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||
});
|
||||
saveSettings(paths, {
|
||||
...defaultSettings(),
|
||||
...settings({
|
||||
dailyStartPendingLocalDate: "2026-08-22",
|
||||
dailyStartLastOutcome: "start_failed"
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
const restartedController = new FakeDailyStartController(
|
||||
loadSettings(paths),
|
||||
(value) => saveSettings(paths, value as AppSettings)
|
||||
);
|
||||
await new DailyStartScheduler(restartedController, () => now).reconcile();
|
||||
|
||||
expect(restartedController.start).not.toHaveBeenCalled();
|
||||
expect(restartedController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(restartedController.settings.dailyStartPendingLocalDate).toBe("");
|
||||
expect(restartedController.settings.dailyStartLastOutcome).toBe("start_failed");
|
||||
|
||||
const secondRestartController = new FakeDailyStartController(loadSettings(paths));
|
||||
await new DailyStartScheduler(secondRestartController, () => now).reconcile();
|
||||
|
||||
expect(secondRestartController.start).not.toHaveBeenCalled();
|
||||
expect(secondRestartController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(secondRestartController.settings.dailyStartPendingLocalDate).toBe("");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks an empty queue handled without calling start", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
controller.items = {};
|
||||
|
||||
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("empty_queue");
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("empty_queue");
|
||||
});
|
||||
|
||||
it("treats queued items in disabled packages as an empty startable queue", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
controller.packages.package.enabled = false;
|
||||
|
||||
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("empty_queue");
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("empty_queue");
|
||||
});
|
||||
|
||||
it("keeps a missing-account occurrence retryable without creating a dispatch receipt", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
controller.canStart = false;
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
expect(await scheduler.reconcile()).toBe("missing_account");
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("missing_account");
|
||||
|
||||
controller.canStart = true;
|
||||
expect(await scheduler.reconcile()).toBe("started");
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats running and paused sessions as already active", async () => {
|
||||
let now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
controller.running = true;
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
expect(await scheduler.reconcile()).toBe("already_active");
|
||||
controller.running = false;
|
||||
controller.paused = true;
|
||||
now = new Date(2026, 7, 23, 10, 5).getTime();
|
||||
expect(await scheduler.reconcile()).toBe("already_active");
|
||||
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a stale pending day as missed without disabling future days", async () => {
|
||||
const now = new Date(2026, 7, 23, 9, 0).getTime();
|
||||
const controller = new FakeDailyStartController(settings({ dailyStartPendingLocalDate: "2026-08-22" }));
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
expect(await scheduler.reconcile()).toBe("missed");
|
||||
expect(controller.settings.dailyStartEnabled).toBe(true);
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("missed");
|
||||
expect(nextDailyStartEpochMs(controller.settings, now)).toBe(new Date(2026, 7, 23, 10, 0).getTime());
|
||||
});
|
||||
|
||||
it("clears failed dispatch receipts for a later retry", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
controller.start.mockRejectedValueOnce(new Error("start rejected"));
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
await expect(scheduler.reconcile()).rejects.toThrow("start rejected");
|
||||
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("start_failed");
|
||||
|
||||
await scheduler.reconcile();
|
||||
expect(controller.start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps an in-flight receipt across shutdown and does not redispatch it after restart", async () => {
|
||||
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
let resolveStart!: () => void;
|
||||
controller.start.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}));
|
||||
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||
|
||||
const reconcile = scheduler.reconcile();
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22");
|
||||
|
||||
scheduler.end();
|
||||
resolveStart();
|
||||
|
||||
expect(await reconcile).toBeNull();
|
||||
expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22");
|
||||
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
|
||||
expect(controller.settings.dailyStartLastOutcome).toBe("start_failed");
|
||||
|
||||
const restartedController = new FakeDailyStartController({ ...controller.settings });
|
||||
expect(await new DailyStartScheduler(restartedController, () => now).reconcile()).toBe("start_failed");
|
||||
expect(restartedController.start).not.toHaveBeenCalled();
|
||||
expect(restartedController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||
expect(restartedController.settings.dailyStartPendingLocalDate).toBe("");
|
||||
});
|
||||
|
||||
it("constructs each DST target from local calendar fields instead of adding 24 hours", () => {
|
||||
const beforeDst = settings({
|
||||
dailyStartMinuteOfDay: 2 * 60 + 30,
|
||||
dailyStartFirstLocalDate: "2026-03-29"
|
||||
});
|
||||
const firstNow = new Date(2026, 2, 28, 12, 0).getTime();
|
||||
const firstTarget = nextDailyStartEpochMs(beforeDst, firstNow);
|
||||
const nextTarget = nextDailyStartEpochMs({
|
||||
...beforeDst,
|
||||
dailyStartLastHandledLocalDate: "2026-03-29"
|
||||
}, new Date(2026, 2, 29, 12, 0).getTime());
|
||||
|
||||
expect(firstTarget).toBe(new Date(2026, 2, 29, 2, 30, 0, 0).getTime());
|
||||
expect(nextTarget).toBe(new Date(2026, 2, 30, 2, 30, 0, 0).getTime());
|
||||
expect(nextTarget - firstTarget).toBe(23 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("clears a legacy one-time target only when a daily rule value changes", () => {
|
||||
const current = settings({
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 600,
|
||||
dailyStartFirstLocalDate: "2026-08-22"
|
||||
});
|
||||
|
||||
expect(prepareDailyStartSettingsPatch({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 600,
|
||||
dailyStartFirstLocalDate: "2026-08-22"
|
||||
}, current)).toEqual({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 600,
|
||||
dailyStartFirstLocalDate: "2026-08-22",
|
||||
scheduledStartEpochMs: 0
|
||||
});
|
||||
expect(prepareDailyStartSettingsPatch({ dailyStartPendingLocalDate: "2026-08-22" }, current))
|
||||
.toEqual({ dailyStartPendingLocalDate: "2026-08-22" });
|
||||
expect(prepareDailyStartSettingsPatch({
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 600,
|
||||
dailyStartFirstLocalDate: "2026-08-22",
|
||||
scheduledStartEpochMs: 1_800_000_000_000
|
||||
}, current)).toEqual({
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 600,
|
||||
dailyStartFirstLocalDate: "2026-08-22",
|
||||
scheduledStartEpochMs: 1_800_000_000_000
|
||||
});
|
||||
});
|
||||
|
||||
it("defers boot auto-resume for a future daily target unless the saved run was active", () => {
|
||||
const now = new Date(2026, 7, 22, 9, 0).getTime();
|
||||
const future = settings({ dailyStartMinuteOfDay: 10 * 60 });
|
||||
|
||||
expect(shouldDeferAutoResumeToDailyStart(future, false, now)).toBe(true);
|
||||
expect(shouldDeferAutoResumeToDailyStart(future, true, now)).toBe(false);
|
||||
expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartEnabled: false }, false, now)).toBe(false);
|
||||
expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartMinuteOfDay: 8 * 60 }, false, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("reconciles at boot and at most sixty seconds after a target becomes due", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 7, 22, 9, 59, 30));
|
||||
try {
|
||||
const controller = new FakeDailyStartController(settings());
|
||||
const scheduler = new DailyStartScheduler(controller);
|
||||
|
||||
scheduler.begin();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
scheduler.end();
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3109,6 +3109,211 @@ describe("Real-Debrid account rotation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Deepbrid provider chain", () => {
|
||||
const sourceLink = "https://hoster.example/files/synthetic-source";
|
||||
|
||||
function deepbridSuccessResponse(): Response {
|
||||
return new Response(JSON.stringify({
|
||||
error: 0,
|
||||
filename: "deepbrid-file.bin",
|
||||
link: "https://download.example/deepbrid-file.bin?signature=synthetic",
|
||||
size: "2 KB"
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function allDebridSuccessResponse(): Response {
|
||||
return new Response(JSON.stringify({
|
||||
status: "success",
|
||||
data: {
|
||||
link: "https://alldebrid.example/fallback.bin",
|
||||
filename: "fallback.bin",
|
||||
filesize: 4096
|
||||
}
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function deepbridSettings(overrides: Partial<ReturnType<typeof defaultSettings>> = {}) {
|
||||
return {
|
||||
...defaultSettings(),
|
||||
deepbridApiKey: "synthetic-deepbrid-provider-key",
|
||||
providerOrder: ["deepbrid"] as const,
|
||||
providerPrimary: "deepbrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
it("uses Deepbrid with stable source attribution", async () => {
|
||||
globalThis.fetch = vi.fn(async () => deepbridSuccessResponse()) as unknown as typeof fetch;
|
||||
|
||||
const result = await new DebridService(deepbridSettings()).unrestrictLink(sourceLink);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
provider: "deepbrid",
|
||||
providerLabel: "Deepbrid (API)",
|
||||
sourceLabel: "API",
|
||||
sourceAccountId: "svc-deepbrid",
|
||||
sourceAccountLabel: "Deepbrid API",
|
||||
fileName: "deepbrid-file.bin",
|
||||
fileSize: 2048
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Deepbrid through hoster routing before the configured provider order", async () => {
|
||||
const calledUrls: string[] = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calledUrls.push(url);
|
||||
return url.includes("deepbrid.com/api/v1/generate/link") ? deepbridSuccessResponse() : allDebridSuccessResponse();
|
||||
}) as unknown as typeof fetch;
|
||||
const settings = deepbridSettings({
|
||||
allDebridToken: "synthetic-alldebrid-token",
|
||||
providerOrder: ["alldebrid"] as const,
|
||||
providerPrimary: "alldebrid",
|
||||
autoProviderFallback: true,
|
||||
hosterRouting: { rapidgator: "deepbrid" }
|
||||
});
|
||||
|
||||
const result = await new DebridService(settings).unrestrictLink("https://rapidgator.net/file/synthetic/routed.bin.html");
|
||||
|
||||
expect(result.provider).toBe("deepbrid");
|
||||
expect(calledUrls).toHaveLength(1);
|
||||
expect(calledUrls[0]).toContain("deepbrid.com/api/v1/generate/link");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["auth", 401],
|
||||
["link", 400],
|
||||
["rate_limit", 429],
|
||||
["temporary", 503]
|
||||
])("falls back from Deepbrid %s errors when automatic fallback is enabled", async (_classification, status) => {
|
||||
let allDebridCalls = 0;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("deepbrid.com/api/v1/generate/link")) {
|
||||
return new Response(JSON.stringify({ error: status, message: "synthetic failure" }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", "Retry-After": "0" }
|
||||
});
|
||||
}
|
||||
allDebridCalls += 1;
|
||||
return allDebridSuccessResponse();
|
||||
}) as unknown as typeof fetch;
|
||||
const settings = deepbridSettings({
|
||||
allDebridToken: "synthetic-alldebrid-token",
|
||||
providerOrder: ["deepbrid", "alldebrid"] as const,
|
||||
providerSecondary: "alldebrid",
|
||||
autoProviderFallback: true
|
||||
});
|
||||
|
||||
const result = await new DebridService(settings).unrestrictLink(sourceLink);
|
||||
|
||||
expect(result.provider).toBe("alldebrid");
|
||||
expect(allDebridCalls).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["auth", 401],
|
||||
["link", 400],
|
||||
["rate_limit", 429],
|
||||
["temporary", 503]
|
||||
])("keeps Deepbrid %s errors assigned to Deepbrid when fallback is disabled", async (classification, status) => {
|
||||
let allDebridCalls = 0;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("deepbrid.com/api/v1/generate/link")) {
|
||||
return new Response(JSON.stringify({ error: status, message: "synthetic failure" }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", "Retry-After": "0" }
|
||||
});
|
||||
}
|
||||
allDebridCalls += 1;
|
||||
return allDebridSuccessResponse();
|
||||
}) as unknown as typeof fetch;
|
||||
const settings = deepbridSettings({
|
||||
allDebridToken: "synthetic-alldebrid-token",
|
||||
providerOrder: ["deepbrid", "alldebrid"] as const,
|
||||
providerSecondary: "alldebrid"
|
||||
});
|
||||
|
||||
const error = await new DebridService(settings).unrestrictLink(sourceLink).then(() => null, (value: unknown) => value);
|
||||
const message = String(error);
|
||||
|
||||
expect(message).toContain("Deepbrid");
|
||||
expect(message).toContain(classification);
|
||||
expect(message).not.toContain("synthetic-deepbrid-provider-key");
|
||||
expect(message).not.toContain(sourceLink);
|
||||
expect(message).not.toContain("download.example");
|
||||
expect(allDebridCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("skips daily-limited Deepbrid and uses the next configured provider", async () => {
|
||||
const calledUrls: string[] = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calledUrls.push(url);
|
||||
return allDebridSuccessResponse();
|
||||
}) as unknown as typeof fetch;
|
||||
const settings = deepbridSettings({
|
||||
allDebridToken: "synthetic-alldebrid-token",
|
||||
providerOrder: ["deepbrid", "alldebrid"] as const,
|
||||
providerSecondary: "alldebrid",
|
||||
autoProviderFallback: true,
|
||||
providerDailyLimitBytes: { deepbrid: 100 },
|
||||
providerDailyUsageBytes: { deepbrid: 100 },
|
||||
providerDailyUsageDay: getProviderUsageDayKey()
|
||||
});
|
||||
|
||||
const result = await new DebridService(settings).unrestrictLink(sourceLink);
|
||||
|
||||
expect(result.provider).toBe("alldebrid");
|
||||
expect(calledUrls.some((url) => url.includes("deepbrid.com"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps configured 1Fichier and DDownload links on their direct special paths", async () => {
|
||||
const calledUrls: string[] = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calledUrls.push(url);
|
||||
if (url.includes("api.1fichier.com")) {
|
||||
return new Response(JSON.stringify({ url: "https://onefichier-cdn.example/file.bin" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/login.html")) {
|
||||
return new Response('<input name="token" value="synthetic-login-token">', {
|
||||
status: 200,
|
||||
headers: { "Set-Cookie": "page=synthetic; Path=/" }
|
||||
});
|
||||
}
|
||||
if (url === "https://ddownload.com/" && init?.method === "POST") {
|
||||
return new Response("", { status: 200, headers: { "Set-Cookie": "xfss=synthetic-session; Path=/" } });
|
||||
}
|
||||
if (url.endsWith("/abcdefgh")) {
|
||||
return new Response("", { status: 302, headers: { Location: "https://ddownload-cdn.example/file.bin" } });
|
||||
}
|
||||
return deepbridSuccessResponse();
|
||||
}) as unknown as typeof fetch;
|
||||
const settings = deepbridSettings({
|
||||
oneFichierApiKey: "synthetic-onefichier-key",
|
||||
ddownloadLogin: "synthetic-user",
|
||||
ddownloadPassword: "synthetic-password",
|
||||
autoProviderFallback: true
|
||||
});
|
||||
const service = new DebridService(settings);
|
||||
|
||||
const oneFichier = await service.unrestrictLink("https://1fichier.com/?abc12345xyz");
|
||||
const ddownload = await service.unrestrictLink("https://ddownload.com/abcdefgh/file.bin");
|
||||
|
||||
expect(oneFichier.provider).toBe("onefichier");
|
||||
expect(ddownload.provider).toBe("ddownload");
|
||||
expect(calledUrls.some((url) => url.includes("deepbrid.com"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filenameFromRapidgatorUrlPath", () => {
|
||||
it("extracts filename from standard rapidgator URL", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
||||
|
||||
@@ -54,6 +54,7 @@ import { createStoragePaths, saveHistory, saveSettings } from "../src/main/stora
|
||||
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
||||
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import type { NotificationSupportPayload } from "../src/main/support-data";
|
||||
import type { UiSnapshot } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -326,7 +327,16 @@ async function createFixture() {
|
||||
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
startDebugServer(manager, baseDir);
|
||||
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "no_data",
|
||||
incidentAgeMs: 30_000,
|
||||
events: [{ payload: "PRIVATE_DEBUG_EVENT_PAYLOAD" }],
|
||||
url: "https://private.example.test/webhook",
|
||||
mention: "@private"
|
||||
};
|
||||
startDebugServer(manager, baseDir, () => notificationStatus);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await waitForReady(`${baseUrl}/health`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
@@ -334,7 +344,8 @@ async function createFixture() {
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
baseDir
|
||||
baseDir,
|
||||
notificationStatus
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,6 +370,36 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("debug-server", () => {
|
||||
it("serves the exact safe notification DTO in its endpoint and diagnostics", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, unknown>;
|
||||
|
||||
expect(payload).toEqual({
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "no_data",
|
||||
incidentAgeMs: 30_000
|
||||
});
|
||||
|
||||
fixture.notificationStatus.queued = 9;
|
||||
fixture.notificationStatus.lastSuccessAt = 1_700_000_060_000;
|
||||
const currentResponse = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
|
||||
const currentPayload = await currentResponse.json() as Record<string, unknown>;
|
||||
expect(currentPayload).toEqual({
|
||||
queued: 9,
|
||||
lastSuccessAt: 1_700_000_060_000,
|
||||
incidentType: "no_data",
|
||||
incidentAgeMs: 30_000
|
||||
});
|
||||
|
||||
const diagnosticsResponse = await authedFetch(`${fixture.baseUrl}/diagnostics`, fixture.token);
|
||||
const diagnostics = await diagnosticsResponse.json() as Record<string, any>;
|
||||
expect(diagnostics.notifications).toEqual(currentPayload);
|
||||
expect(JSON.stringify([payload, currentPayload, diagnostics.notifications])).not.toMatch(/PRIVATE_|https:\/\/private|@private|events|payload/i);
|
||||
});
|
||||
|
||||
it("serves diagnostics with main, session, and package log tails", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
|
||||
@@ -585,6 +626,7 @@ describe("debug-server", () => {
|
||||
expect(entries).toContain("overview/debug-setup.json");
|
||||
expect(entries).toContain("overview/self-check.json");
|
||||
expect(entries).toContain("overview/trace-config.json");
|
||||
expect(entries).toContain("overview/notifications.json");
|
||||
expect(entries).toContain("logs/audit.log");
|
||||
expect(entries).toContain("logs/rename.log");
|
||||
expect(entries).toContain("logs/trace.log");
|
||||
@@ -593,6 +635,14 @@ describe("debug-server", () => {
|
||||
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
|
||||
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
|
||||
expect(entries).not.toContain("runtime/debug_token.txt");
|
||||
const notifications = JSON.parse(zip.getEntry("overview/notifications.json")?.getData().toString("utf8") || "null");
|
||||
expect(notifications).toEqual({
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "no_data",
|
||||
incidentAgeMs: 30_000
|
||||
});
|
||||
expect(buffer.toString("utf8")).not.toMatch(/PRIVATE_DEBUG_EVENT_PAYLOAD|https:\/\/private|@private/);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEEPBRID_ACCOUNT_ID,
|
||||
DeepbridApiError,
|
||||
DeepbridClient,
|
||||
parseDeepbridSize
|
||||
} from "../src/main/deepbrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const apiKey = "synthetic-deepbrid-key";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8", ...headers }
|
||||
});
|
||||
}
|
||||
|
||||
function jsonBodyFailure(error: unknown): Response {
|
||||
const response = jsonResponse({ unused: true });
|
||||
Object.defineProperty(response, "json", {
|
||||
value: async () => {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("DeepbridClient", () => {
|
||||
it("returns validated account information from the documented user endpoint", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({
|
||||
username: "tester",
|
||||
email: "tester@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 2,
|
||||
fidelity_points: 10
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const user = await new DeepbridClient(apiKey).getUser();
|
||||
|
||||
expect(DEEPBRID_ACCOUNT_ID).toBe("svc-deepbrid");
|
||||
expect(user).toEqual({
|
||||
username: "tester",
|
||||
email: "tester@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 2
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("https://www.deepbrid.com/api/v1/user");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(new Headers(init.headers)).toEqual(new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}));
|
||||
});
|
||||
|
||||
it("normalizes the real string host list", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([
|
||||
"rapidgator.net",
|
||||
"turbobit.net"
|
||||
]));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(new DeepbridClient(apiKey).getHosts()).resolves.toEqual([
|
||||
{ domain: "rapidgator.net", status: "unknown" },
|
||||
{ domain: "turbobit.net", status: "unknown" }
|
||||
]);
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(new Headers(init.headers)).toEqual(new Headers({ Accept: "application/json" }));
|
||||
});
|
||||
|
||||
it("loads public hosts without an API key", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse(["rapidgator.net"]));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(new DeepbridClient("").getHosts()).resolves.toEqual([
|
||||
{ domain: "rapidgator.net", status: "unknown" }
|
||||
]);
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(new Headers(init.headers).has("Authorization")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes the documented domain-to-status host objects", async () => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse([
|
||||
{ "rapidgator.net": "up" },
|
||||
{ "ddownload.com": "down (2026-03-01)" }
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
await expect(new DeepbridClient(apiKey).getHosts()).resolves.toEqual([
|
||||
{ domain: "rapidgator.net", status: "up" },
|
||||
{ domain: "ddownload.com", status: "down (2026-03-01)" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("generates a link with the exact headers and form fields", async () => {
|
||||
const sourceUrl = "https://hoster.example/file/id?token=source-token";
|
||||
const directUrl = "https://download.example/files/archive.zip?token=direct-token";
|
||||
const fetchMock = vi.fn(async () => jsonResponse({
|
||||
error: 0,
|
||||
message: "OK",
|
||||
original_link: sourceUrl,
|
||||
hoster: "hoster",
|
||||
filename: "archive.zip",
|
||||
link: directUrl,
|
||||
stream: "stream-value",
|
||||
size: "1.50 GB"
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const result = await new DeepbridClient(apiKey).unrestrictLink(sourceUrl, undefined, "file password");
|
||||
|
||||
expect(result).toEqual({
|
||||
fileName: "archive.zip",
|
||||
directUrl,
|
||||
fileSize: 1610612736,
|
||||
retriesUsed: 0
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("https://www.deepbrid.com/api/v1/generate/link");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(new Headers(init.headers)).toEqual(new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}));
|
||||
expect(String(init.body)).toBe("link=https%3A%2F%2Fhoster.example%2Ffile%2Fid%3Ftoken%3Dsource-token&pass=file+password");
|
||||
});
|
||||
|
||||
it("uses the final URL path segment when filename is absent", async () => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({
|
||||
error: 0,
|
||||
message: "OK",
|
||||
link: "https://download.example/files/My%20Archive.bin?signature=synthetic",
|
||||
size: "512 KB"
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/fallback")).resolves.toEqual({
|
||||
fileName: "My Archive.bin",
|
||||
directUrl: "https://download.example/files/My%20Archive.bin?signature=synthetic",
|
||||
fileSize: 524288,
|
||||
retriesUsed: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("reduces an API filename to a control-free basename", async () => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({
|
||||
error: 0,
|
||||
message: "OK",
|
||||
filename: "folder/inner\\safe\u0000-name.bin\n",
|
||||
link: "https://download.example/files/fallback.bin",
|
||||
size: "1 KB"
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/api-filename");
|
||||
|
||||
expect(result.fileName).toBe("safe-name.bin");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CON.txt", "_CON.txt"],
|
||||
["NUL", "_NUL"],
|
||||
["bad<name>?.rar. ", "bad_name__.rar"],
|
||||
["normal release 01.zip", "normal release 01.zip"]
|
||||
])("makes the API filename %j Windows-safe", async (filename, expected) => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({
|
||||
error: 0,
|
||||
message: "OK",
|
||||
filename,
|
||||
link: "https://download.example/files/fallback.bin",
|
||||
size: "1 KB"
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/windows-api-name");
|
||||
|
||||
expect(result.fileName).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://download.example/files/COM9.log", "_COM9.log"],
|
||||
["https://download.example/files/bad%3Cname%3E%3F.rar.%20", "bad_name__.rar"],
|
||||
["https://download.example/files/normal-release.zip", "normal-release.zip"],
|
||||
["https://download.example/files/%00%7F", "download.bin"]
|
||||
])("makes the URL fallback from %s Windows-safe", async (directUrl, expected) => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({
|
||||
error: 0,
|
||||
message: "OK",
|
||||
link: directUrl,
|
||||
size: "1 KB"
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/windows-url-name");
|
||||
|
||||
expect(result.fileName).toBe(expected);
|
||||
});
|
||||
|
||||
it("removes decoded path components from a fallback filename", async () => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({
|
||||
error: 0,
|
||||
message: "OK",
|
||||
link: "https://download.example/files/%2E%2E%2Fsafe.bin",
|
||||
size: "1 KB"
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/safe-fallback");
|
||||
|
||||
expect(result.fileName).toBe("safe.bin");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["HTML", new Response("<html>challenge secret</html>", { status: 200, headers: { "Content-Type": "text/html" } })],
|
||||
["invalid JSON", new Response("{not-json}", { status: 200, headers: { "Content-Type": "application/json" } })],
|
||||
["wrong top-level type", jsonResponse("not-an-object")],
|
||||
["missing link", jsonResponse({ error: 0, filename: "missing.bin", size: "1 KB" })],
|
||||
["credential-bearing link", jsonResponse({ link: "https://user:password@download.example/file.bin", size: "1 KB" })]
|
||||
])("classifies %s success responses as malformed", async (_name, response) => {
|
||||
globalThis.fetch = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
const error = await new DeepbridClient(apiKey)
|
||||
.unrestrictLink("https://hoster.example/file/malformed")
|
||||
.then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ classification: "malformed" });
|
||||
});
|
||||
|
||||
it.each(["text/notjson", "application/jsonp", "text/jsonp"])("rejects the non-JSON media type %s", async (contentType) => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
link: "https://download.example/file.bin",
|
||||
filename: "file.bin",
|
||||
size: "1 KB"
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": contentType }
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const error = await new DeepbridClient(apiKey)
|
||||
.unrestrictLink("https://hoster.example/file/media-type")
|
||||
.then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ classification: "malformed" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts a standard +json media type", async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
username: "problem-json-user",
|
||||
email: "problem-json@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 1
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/problem+json; charset=utf-8" }
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(new DeepbridClient(apiKey).getUser()).resolves.toMatchObject({ username: "problem-json-user" });
|
||||
});
|
||||
|
||||
it.each([401, 403])("classifies HTTP %i as auth and does not retry", async (status) => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ error: status, message: "invalid synthetic key" }, status));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const error = await new DeepbridClient(apiKey).getUser().then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ status, code: status, classification: "auth" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns a safe auth error for an empty key without making a request", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const error = await new DeepbridClient(" ").getUser().then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ status: 401, code: 401, classification: "auth" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects and caps Retry-After before retrying a 429 response", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ error: 429, message: "slow down" }, 429, { "Retry-After": "60" }))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
username: "retry-user",
|
||||
email: "retry@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 1
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser();
|
||||
await vi.advanceTimersByTimeAsync(29999);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ username: "retry-user" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries immediately when Retry-After is zero", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ error: 429, message: "retry now" }, 429, { "Retry-After": "0" }))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
username: "immediate-user",
|
||||
email: "immediate@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 1
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ username: "immediate-user" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["HTTP 500", () => jsonResponse({ error: 500, message: "server failed" }, 500)],
|
||||
["network failure", () => Promise.reject(new TypeError("fetch failed synthetic transport"))],
|
||||
["timeout", () => Promise.reject(new DOMException("synthetic timeout", "TimeoutError"))]
|
||||
])("limits %s retries to three total attempts", async (_name, resultFactory) => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn(resultFactory);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser();
|
||||
const rejection = pending.catch((error) => error);
|
||||
await vi.runAllTimersAsync();
|
||||
const error = await rejection;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ classification: "temporary" });
|
||||
});
|
||||
|
||||
it("retries timeout failures while reading a successful JSON body", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonBodyFailure(new DOMException("body timeout one", "TimeoutError")))
|
||||
.mockResolvedValueOnce(jsonBodyFailure(new DOMException("body timeout two", "TimeoutError")))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
username: "body-retry-user",
|
||||
email: "body-retry@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 1
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ username: "body-retry-user" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("propagates caller abort immediately without retrying", async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn((_url: string | URL | Request, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const abortReason = new DOMException("caller cancelled", "AbortError");
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser(controller.signal);
|
||||
controller.abort(abortReason);
|
||||
|
||||
await expect(pending).rejects.toBe(abortReason);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors an abort that occurs as fetch resolves", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = new DOMException("caller cancelled during response", "AbortError");
|
||||
const fetchMock = vi.fn(async () => {
|
||||
controller.abort(abortReason);
|
||||
return jsonResponse({
|
||||
username: "too-late",
|
||||
email: "too-late@example.invalid",
|
||||
type: "premium",
|
||||
expiration: "2027-01-02",
|
||||
maxDownloads: 5,
|
||||
maxConnections: 1
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(new DeepbridClient(apiKey).getUser(controller.signal)).rejects.toBe(abortReason);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates caller abort while reading a successful response body", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = new DOMException("caller cancelled body", "AbortError");
|
||||
let bodyStartedResolve: (() => void) | undefined;
|
||||
const bodyStarted = new Promise<void>((resolve) => {
|
||||
bodyStartedResolve = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const response = jsonResponse({ unused: true });
|
||||
Object.defineProperty(response, "json", {
|
||||
value: () => new Promise<unknown>((_resolve, reject) => {
|
||||
bodyStartedResolve?.();
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
|
||||
})
|
||||
});
|
||||
return response;
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser(controller.signal);
|
||||
await bodyStarted;
|
||||
controller.abort(abortReason);
|
||||
|
||||
await expect(pending).rejects.toBe(abortReason);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates caller abort during a retry pause without another request", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const abortReason = new DOMException("caller cancelled retry pause", "AbortError");
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ error: 500, message: "temporary" }, 500));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const pending = new DeepbridClient(apiKey).getUser(controller.signal);
|
||||
const rejection = pending.catch((error) => error);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
controller.abort(abortReason);
|
||||
const error = await rejection;
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(error).toBe(abortReason);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("classifies a numeric HTTP 200 API error as a safe link error", async () => {
|
||||
const sourceUrl = "https://hoster.example/file/http-200-secret";
|
||||
const directUrl = "https://download.example/http-200-direct-secret";
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({
|
||||
error: 17,
|
||||
message: `${apiKey} ${sourceUrl} ${directUrl}`,
|
||||
link: directUrl
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const error = await new DeepbridClient(apiKey).unrestrictLink(sourceUrl).then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ status: 200, code: 17, classification: "link" });
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
expect(error.message).not.toContain(sourceUrl);
|
||||
expect(error.message).not.toContain(directUrl);
|
||||
});
|
||||
|
||||
it("classifies structured generate failures without leaking secrets", async () => {
|
||||
const sourceUrl = "https://hoster.example/file/source-secret";
|
||||
const directUrl = "https://download.example/direct-secret";
|
||||
const fetchMock = vi.fn(async () => jsonResponse({
|
||||
error: 422,
|
||||
message: `${apiKey} ${sourceUrl} ${directUrl}`,
|
||||
link: directUrl
|
||||
}, 422));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const error = await new DeepbridClient(apiKey).unrestrictLink(sourceUrl).then(() => null, (value) => value);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(error).toBeInstanceOf(DeepbridApiError);
|
||||
expect(error).toMatchObject({ status: 422, code: 422, classification: "link" });
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
expect(error.message).not.toContain(sourceUrl);
|
||||
expect(error.message).not.toContain(directUrl);
|
||||
expect(error.message).not.toContain("source-secret");
|
||||
expect(error.message).not.toContain("direct-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDeepbridSize", () => {
|
||||
it.each([
|
||||
["0 B", 0],
|
||||
["512 KB", 524288],
|
||||
["1.50 GB", 1610612736],
|
||||
["2 TB", 2199023255552],
|
||||
[2048, 2048]
|
||||
])("parses %j as bytes", (value, expected) => {
|
||||
expect(parseDeepbridSize(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["", "unknown", "-1 GB", Number.NaN, null, undefined, {}])("returns null for invalid value %j", (value) => {
|
||||
expect(parseDeepbridSize(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,536 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DownloadHealthMonitor,
|
||||
createDownloadHealthState,
|
||||
evaluateDownloadHealth,
|
||||
loadDownloadHealthState,
|
||||
saveDownloadHealthState,
|
||||
type DownloadHealthSnapshot,
|
||||
type DownloadHealthState
|
||||
} from "../src/main/download-health-monitor";
|
||||
import { NotificationOutbox } from "../src/main/notification-outbox";
|
||||
|
||||
const RUN_FINGERPRINT = "a".repeat(64);
|
||||
const QUEUE_FINGERPRINT = "b".repeat(64);
|
||||
const OTHER_RUN_FINGERPRINT = "c".repeat(64);
|
||||
const OTHER_QUEUE_FINGERPRINT = "d".repeat(64);
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function snapshot(overrides: Partial<DownloadHealthSnapshot> = {}): DownloadHealthSnapshot {
|
||||
return {
|
||||
runActive: true,
|
||||
runFingerprint: RUN_FINGERPRINT,
|
||||
queueFingerprint: QUEUE_FINGERPRINT,
|
||||
openItems: 2,
|
||||
openPackages: 1,
|
||||
knownDownloadedBytes: 4096,
|
||||
activeTasks: 1,
|
||||
startableItems: 0,
|
||||
lastSchedulerTickAt: 0,
|
||||
downloadProgressSequence: 0,
|
||||
itemCompletionSequence: 0,
|
||||
lastPositiveByteAt: 0,
|
||||
technicalRecoveryCount: 0,
|
||||
paused: false,
|
||||
reconnectUntil: 0,
|
||||
nextRetryAt: 0,
|
||||
providerCooldownUntil: 0,
|
||||
blockedOnDisk: false,
|
||||
blockedOnThrottleUntil: 0,
|
||||
activePhaseDeadlineAt: 0,
|
||||
terminalFailure: false,
|
||||
manualStop: false,
|
||||
shuttingDown: false,
|
||||
currentSpeedBps: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(
|
||||
state: DownloadHealthState,
|
||||
current: DownloadHealthSnapshot,
|
||||
now: number,
|
||||
overrides: Partial<Parameters<typeof evaluateDownloadHealth>[3]> = {}
|
||||
) {
|
||||
return evaluateDownloadHealth(state, current, now, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
function sampleTimes(
|
||||
initial: DownloadHealthState,
|
||||
times: number[],
|
||||
current: DownloadHealthSnapshot = snapshot()
|
||||
) {
|
||||
let state = initial;
|
||||
const events = [];
|
||||
for (const now of times) {
|
||||
const result = evaluate(state, current, now);
|
||||
state = result.state;
|
||||
events.push(...result.events);
|
||||
}
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
function alertedState(now = 90_000): DownloadHealthState {
|
||||
return sampleTimes(createDownloadHealthState(), [0, 45_000, now]).state;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("evaluateDownloadHealth", () => {
|
||||
it("keeps a 20 to 30 second silent interval below the alert boundary", () => {
|
||||
const result = sampleTimes(createDownloadHealthState(), [0, 15_000, 30_000]);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.status).toBe("suspect_no_data");
|
||||
expect(result.state.suspiciousDurationMs).toBe(30_000);
|
||||
expect(result.state.suspiciousSamples).toBe(3);
|
||||
});
|
||||
|
||||
it("confirms a no-data stall only after 90 seconds and at least three suspicious samples", () => {
|
||||
const before = sampleTimes(createDownloadHealthState(), [0, 45_000]);
|
||||
const result = evaluate(before.state, snapshot(), 90_000);
|
||||
|
||||
expect(before.events).toEqual([]);
|
||||
expect(result.state.status).toBe("alerted");
|
||||
expect(result.events).toEqual([
|
||||
expect.objectContaining({ type: "download_stalled", priority: "error" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not alert from elapsed time until the third suspicious sample", () => {
|
||||
const result = sampleTimes(createDownloadHealthState(), [0, 90_000]);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.suspiciousDurationMs).toBe(90_000);
|
||||
expect(result.state.suspiciousSamples).toBe(2);
|
||||
});
|
||||
|
||||
it("classifies a startable queue with no scheduler as a scheduler suspicion", () => {
|
||||
const result = evaluate(createDownloadHealthState(), snapshot({
|
||||
activeTasks: 0,
|
||||
startableItems: 2,
|
||||
lastSchedulerTickAt: 0
|
||||
}), 45_000);
|
||||
|
||||
expect(result.state.status).toBe("suspect_scheduler");
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a recent scheduler tick without an active task as healthy startup activity", () => {
|
||||
const result = evaluate(createDownloadHealthState(), snapshot({
|
||||
activeTasks: 0,
|
||||
startableItems: 2,
|
||||
lastSchedulerTickAt: 29_000
|
||||
}), 30_000);
|
||||
|
||||
expect(result.state.status).toBe("healthy");
|
||||
expect(result.state.suspiciousSamples).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pause", { paused: true }],
|
||||
["reconnect", { reconnectUntil: 120_000 }],
|
||||
["future retry", { activeTasks: 0, startableItems: 0, nextRetryAt: 120_000 }],
|
||||
["provider cooldown", { activeTasks: 0, startableItems: 0, providerCooldownUntil: 120_000 }],
|
||||
["disk wait", { blockedOnDisk: true }],
|
||||
["bandwidth throttle", { blockedOnThrottleUntil: 120_000 }],
|
||||
["valid phase deadline", { activePhaseDeadlineAt: 120_000 }]
|
||||
])("freezes accumulated suspicion during %s", (_name, waitState) => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 30_000]);
|
||||
const waiting = evaluate(suspicious.state, snapshot(waitState), 60_000);
|
||||
const resumed = evaluate(waiting.state, snapshot(), 90_000);
|
||||
|
||||
expect(waiting.state.status).toBe("expected_wait");
|
||||
expect(waiting.state.suspiciousDurationMs).toBe(30_000);
|
||||
expect(waiting.state.suspiciousSamples).toBe(2);
|
||||
expect(resumed.events).toEqual([]);
|
||||
expect(resumed.state.suspiciousDurationMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("resets suspicion after a positive byte sequence", () => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 30_000]);
|
||||
const result = evaluate(suspicious.state, snapshot({
|
||||
downloadProgressSequence: 1,
|
||||
lastPositiveByteAt: 45_000
|
||||
}), 45_000);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.status).toBe("healthy");
|
||||
expect(result.state.suspiciousDurationMs).toBe(0);
|
||||
expect(result.state.suspiciousSamples).toBe(0);
|
||||
});
|
||||
|
||||
it("resets suspicion after a successful item completion sequence", () => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 30_000]);
|
||||
const result = evaluate(suspicious.state, snapshot({ itemCompletionSequence: 1 }), 45_000);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.status).toBe("healthy");
|
||||
expect(result.state.suspiciousDurationMs).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores speed, progress, item timestamps and global totals as progress evidence", () => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]);
|
||||
const current = {
|
||||
...snapshot({ currentSpeedBps: 900_000_000 }),
|
||||
progressPercent: 99,
|
||||
updatedAt: 90_000,
|
||||
totalDownloadedBytes: 10_000_000_000
|
||||
} as DownloadHealthSnapshot;
|
||||
const result = evaluate(suspicious.state, current, 90_000);
|
||||
|
||||
expect(result.state.status).toBe("alerted");
|
||||
expect(result.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not treat sequence decreases or a technical recovery attempt as progress", () => {
|
||||
const initial = createDownloadHealthState({
|
||||
downloadProgressSequence: 8,
|
||||
itemCompletionSequence: 3
|
||||
});
|
||||
const result = sampleTimes(initial, [0, 45_000, 90_000], snapshot({
|
||||
downloadProgressSequence: 2,
|
||||
itemCompletionSequence: 1,
|
||||
technicalRecoveryCount: 1
|
||||
}));
|
||||
|
||||
expect(result.state.status).toBe("alerted");
|
||||
expect(result.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires two positive-byte samples before recovering an alerted incident", () => {
|
||||
const alerted = alertedState();
|
||||
const first = evaluate(alerted, snapshot({
|
||||
downloadProgressSequence: 1,
|
||||
lastPositiveByteAt: 105_000
|
||||
}), 105_000);
|
||||
const second = evaluate(first.state, snapshot({
|
||||
downloadProgressSequence: 2,
|
||||
lastPositiveByteAt: 120_000
|
||||
}), 120_000);
|
||||
|
||||
expect(first.state.status).toBe("recovering");
|
||||
expect(first.events).toEqual([]);
|
||||
expect(second.state.status).toBe("healthy");
|
||||
expect(second.events).toEqual([
|
||||
expect.objectContaining({ type: "download_recovered", priority: "success" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("recovers immediately after a successful item completion", () => {
|
||||
const result = evaluate(alertedState(), snapshot({ itemCompletionSequence: 1 }), 105_000);
|
||||
|
||||
expect(result.state.status).toBe("healthy");
|
||||
expect(result.events).toEqual([
|
||||
expect.objectContaining({ type: "download_recovered" })
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["terminal failure", { terminalFailure: true }],
|
||||
["manual stop", { runActive: false, manualStop: true }],
|
||||
["shutdown", { runActive: false, shuttingDown: true }]
|
||||
])("closes an alerted incident without recovery after %s", (_name, endState) => {
|
||||
const result = evaluate(alertedState(), snapshot(endState), 105_000);
|
||||
|
||||
expect(result.state.status).toBe("idle");
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.alertedAt).toBe(0);
|
||||
});
|
||||
|
||||
it("applies a ten-minute cooldown after a delivered incident event", () => {
|
||||
const firstAlert = createDownloadHealthState({
|
||||
...alertedState(),
|
||||
lastAlertAt: 90_000,
|
||||
cooldownUntil: 690_000,
|
||||
lastDeliveredStallEventId: `health:stall:${RUN_FINGERPRINT.slice(0, 16)}:0`
|
||||
});
|
||||
const recovered = evaluate(firstAlert, snapshot({ itemCompletionSequence: 1 }), 105_000).state;
|
||||
const duringCooldown = sampleTimes(recovered, [120_000, 165_000, 210_000]);
|
||||
const afterCooldown = evaluate(duringCooldown.state, snapshot(), 690_000);
|
||||
|
||||
expect(duringCooldown.events).toEqual([]);
|
||||
expect(duringCooldown.state.status).toBe("suspect_no_data");
|
||||
expect(afterCooldown.events).toEqual([
|
||||
expect.objectContaining({ type: "download_stalled" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the cooldown at actual Discord delivery after a buffered outage", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-delivery-ack-"));
|
||||
tempDirs.push(root);
|
||||
const healthFile = path.join(root, "health.json");
|
||||
const outboxFile = path.join(root, "outbox.json");
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const monitor = new DownloadHealthMonitor(healthFile, suspicious);
|
||||
let now = 90_000;
|
||||
let deliveryAvailable = false;
|
||||
const deliveredIds: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath: outboxFile,
|
||||
now: () => now,
|
||||
send: async (queuedEvent) => {
|
||||
if (deliveryAvailable) deliveredIds.push(queuedEvent.id);
|
||||
return deliveryAvailable;
|
||||
},
|
||||
onDelivered: (queuedEvent, deliveredAt) => {
|
||||
return monitor.acknowledgeDelivery(queuedEvent, deliveredAt, 600_000);
|
||||
}
|
||||
});
|
||||
|
||||
const confirmed = await monitor.sample(snapshot(), now, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, (event) => outbox.enqueue(event));
|
||||
await outbox.drain();
|
||||
now = 300_000;
|
||||
const repeated = await monitor.sample(snapshot(), now, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, (event) => outbox.enqueue(event));
|
||||
|
||||
expect(confirmed.events).toHaveLength(1);
|
||||
expect(repeated.events).toEqual([]);
|
||||
expect(outbox.getStatus().queued).toBe(1);
|
||||
expect(monitor.getState().cooldownUntil).toBe(0);
|
||||
|
||||
deliveryAvailable = true;
|
||||
now = 420_000;
|
||||
await outbox.drain(now);
|
||||
|
||||
expect(deliveredIds).toEqual([confirmed.events[0].id]);
|
||||
expect(monitor.getState().lastAlertAt).toBe(420_000);
|
||||
expect(monitor.getState().cooldownUntil).toBe(1_020_000);
|
||||
});
|
||||
|
||||
it("serializes a delivery acknowledgement behind a concurrent recovery sample", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-ack-race-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "health.json");
|
||||
const confirmed = sampleTimes(createDownloadHealthState(), [0, 45_000, 90_000]);
|
||||
const monitor = new DownloadHealthMonitor(filePath, confirmed.state);
|
||||
let releaseEnqueue = () => {};
|
||||
const enqueueBlocked = new Promise<void>((resolve) => { releaseEnqueue = resolve; });
|
||||
const recovery = monitor.sample(snapshot({ itemCompletionSequence: 1 }), 105_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async () => enqueueBlocked);
|
||||
await Promise.resolve();
|
||||
|
||||
const acknowledgement = monitor.acknowledgeDelivery(confirmed.events[0], 300_000, 600_000);
|
||||
releaseEnqueue();
|
||||
await Promise.all([recovery, acknowledgement]);
|
||||
|
||||
expect(monitor.getState().status).toBe("healthy");
|
||||
expect(monitor.getState().lastAlertAt).toBe(300_000);
|
||||
expect(monitor.getState().cooldownUntil).toBe(900_000);
|
||||
expect(monitor.getState().lastDeliveredStallEventId).toBe(confirmed.events[0].id);
|
||||
});
|
||||
|
||||
it("keeps the incident event id stable when outbox persistence rejects the state transition", () => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const firstAttempt = evaluate(suspicious, snapshot(), 90_000);
|
||||
const retryAttempt = evaluate(suspicious, snapshot(), 105_000);
|
||||
|
||||
expect(firstAttempt.events[0].id).toBe(retryAttempt.events[0].id);
|
||||
});
|
||||
|
||||
it("omits identifiers, paths, URLs, providers and accounts from incident and recovery payloads", () => {
|
||||
const incident = evaluate(sampleTimes(createDownloadHealthState(), [0, 45_000]).state, snapshot(), 90_000).events[0];
|
||||
const recovered = evaluate(alertedState(), snapshot({ itemCompletionSequence: 1 }), 105_000).events[0];
|
||||
const serialized = JSON.stringify([incident, recovered]);
|
||||
|
||||
expect(serialized).not.toMatch(/https?:|\\|\/downloads\/|provider|account|item-|package-/i);
|
||||
expect(incident.payload.fields).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Offene Dateien", value: "2" }),
|
||||
expect.objectContaining({ name: "Technische Wiederherstellungen", value: "0" })
|
||||
]));
|
||||
});
|
||||
|
||||
it("honors disabled incident and recovery settings independently", () => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const disabledIncident = evaluate(suspicious, snapshot(), 90_000, { notifyOnStall: false });
|
||||
const disabledRecovery = evaluate(alertedState(), snapshot({ itemCompletionSequence: 1 }), 105_000, { notifyOnRecovery: false });
|
||||
|
||||
expect(disabledIncident.events).toEqual([]);
|
||||
expect(disabledIncident.state.status).toBe("suspect_no_data");
|
||||
expect(disabledRecovery.events).toEqual([]);
|
||||
expect(disabledRecovery.state.status).toBe("healthy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("download health restart persistence", () => {
|
||||
it("preserves a persisted incident while startup is still idle", () => {
|
||||
const persisted = createDownloadHealthState({
|
||||
status: "alerted",
|
||||
runFingerprint: RUN_FINGERPRINT,
|
||||
queueFingerprint: QUEUE_FINGERPRINT,
|
||||
suspiciousDurationMs: 90_000,
|
||||
suspiciousSamples: 3,
|
||||
incidentStartedAt: 10_000,
|
||||
alertedAt: 90_000,
|
||||
restartPending: true
|
||||
});
|
||||
|
||||
const result = evaluate(persisted, snapshot({ runActive: false, openItems: 0 }), 100_000);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.status).toBe("suspended");
|
||||
expect(result.state.runFingerprint).toBe(RUN_FINGERPRINT);
|
||||
expect(result.state.queueFingerprint).toBe(QUEUE_FINGERPRINT);
|
||||
expect(result.state.restartPending).toBe(true);
|
||||
});
|
||||
|
||||
it("requires two fresh samples before re-alerting the same persisted fingerprint", () => {
|
||||
const persisted = createDownloadHealthState({
|
||||
status: "suspect_no_data",
|
||||
runFingerprint: RUN_FINGERPRINT,
|
||||
queueFingerprint: QUEUE_FINGERPRINT,
|
||||
suspiciousDurationMs: 90_000,
|
||||
suspiciousSamples: 3,
|
||||
incidentStartedAt: 10_000,
|
||||
restartPending: true
|
||||
});
|
||||
const first = evaluate(persisted, snapshot(), 100_000);
|
||||
const second = evaluate(first.state, snapshot(), 115_000);
|
||||
|
||||
expect(first.events).toEqual([]);
|
||||
expect(first.state.restartFreshSamples).toBe(1);
|
||||
expect(second.events).toEqual([
|
||||
expect.objectContaining({ type: "download_stalled" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("discards a persisted incident when the queue fingerprint changes", () => {
|
||||
const persisted = createDownloadHealthState({
|
||||
status: "alerted",
|
||||
runFingerprint: RUN_FINGERPRINT,
|
||||
queueFingerprint: QUEUE_FINGERPRINT,
|
||||
suspiciousDurationMs: 90_000,
|
||||
suspiciousSamples: 4,
|
||||
incidentStartedAt: 10_000,
|
||||
alertedAt: 90_000,
|
||||
restartPending: true
|
||||
});
|
||||
const result = evaluate(persisted, snapshot({
|
||||
runFingerprint: OTHER_RUN_FINGERPRINT,
|
||||
queueFingerprint: OTHER_QUEUE_FINGERPRINT
|
||||
}), 100_000);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.state.status).toBe("suspect_no_data");
|
||||
expect(result.state.suspiciousDurationMs).toBe(0);
|
||||
expect(result.state.alertedAt).toBe(0);
|
||||
});
|
||||
|
||||
it("writes an allowlisted atomic state and reloads it with a fresh-sample gate", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-state-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "health.json");
|
||||
const state = {
|
||||
...alertedState(),
|
||||
privateUrl: "https://private.example.test/file",
|
||||
privatePath: "C:\\private\\download.bin",
|
||||
privateAccount: "private@example.test",
|
||||
lastDeliveredStallEventId: "https://private.example.test/stall"
|
||||
} as DownloadHealthState;
|
||||
|
||||
saveDownloadHealthState(filePath, state);
|
||||
const persisted = fs.readFileSync(filePath, "utf8");
|
||||
const loaded = loadDownloadHealthState(filePath);
|
||||
|
||||
expect(persisted).not.toMatch(/private|example\.test|download\.bin/i);
|
||||
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
|
||||
expect(loaded.runFingerprint).toBe(RUN_FINGERPRINT);
|
||||
expect(loaded.queueFingerprint).toBe(QUEUE_FINGERPRINT);
|
||||
expect(loaded.restartPending).toBe(true);
|
||||
expect(loaded.restartFreshSamples).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects malformed queue fingerprints instead of restoring an incident", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-invalid-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "health.json");
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
...alertedState(),
|
||||
queueFingerprint: "https://private.example.test/queue"
|
||||
}), "utf8");
|
||||
|
||||
const loaded = loadDownloadHealthState(filePath);
|
||||
|
||||
expect(loaded).toEqual(createDownloadHealthState());
|
||||
});
|
||||
|
||||
it("does not commit an alert until the outbox accepts the event", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-outbox-failure-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "health.json");
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const monitor = new DownloadHealthMonitor(filePath, suspicious);
|
||||
const eventIds: string[] = [];
|
||||
|
||||
await expect(monitor.sample(snapshot(), 90_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async (event) => {
|
||||
eventIds.push(event.id);
|
||||
throw new Error("outbox unavailable");
|
||||
})).rejects.toThrow("outbox unavailable");
|
||||
await expect(monitor.sample(snapshot(), 105_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async (event) => {
|
||||
eventIds.push(event.id);
|
||||
throw new Error("outbox unavailable");
|
||||
})).rejects.toThrow("outbox unavailable");
|
||||
|
||||
expect(eventIds[0]).toBe(eventIds[1]);
|
||||
expect(monitor.getState().status).toBe("suspect_no_data");
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("persists the alerted state after the outbox accepts the event", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-outbox-success-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "health.json");
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const monitor = new DownloadHealthMonitor(filePath, suspicious);
|
||||
|
||||
const result = await monitor.sample(snapshot(), 90_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async () => undefined);
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(monitor.getState().status).toBe("alerted");
|
||||
expect(loadDownloadHealthState(filePath)).toEqual(expect.objectContaining({
|
||||
status: "alerted",
|
||||
restartPending: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,8 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
||||
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
|
||||
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -126,6 +127,88 @@ describe("resolveUnrestrictTimeoutBudgetMs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Deepbrid download lifecycle", () => {
|
||||
it("treats Deepbrid as the only usable download account", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-deepbrid-only-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
deepbridApiKey: "synthetic-deepbrid-only-key",
|
||||
providerOrder: ["deepbrid"] as const,
|
||||
providerPrimary: "deepbrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
const internal = manager as unknown as { hasUsableDownloadAccount: () => boolean };
|
||||
|
||||
expect(internal.hasUsableDownloadAccount()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["disabled", { disabledProviders: ["deepbrid"] }],
|
||||
["daily-limited", { providerDailyLimitBytes: { deepbrid: 100 }, providerDailyUsageBytes: { deepbrid: 100 } }]
|
||||
])("does not start with %s Deepbrid", (_label, override) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-deepbrid-blocked-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
deepbridApiKey: "synthetic-deepbrid-blocked-key",
|
||||
providerOrder: ["deepbrid"] as const,
|
||||
providerPrimary: "deepbrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
...override
|
||||
} as ReturnType<typeof defaultSettings>;
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
const internal = manager as unknown as { hasUsableDownloadAccount: () => boolean };
|
||||
|
||||
expect(internal.hasUsableDownloadAccount()).toBe(false);
|
||||
});
|
||||
|
||||
it("records actual Deepbrid bytes in provider totals and rolling account statistics", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-deepbrid-statistics-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
deepbridApiKey: "synthetic-deepbrid-statistics-key",
|
||||
providerDailyUsageBytes: { deepbrid: 100 },
|
||||
providerTotalUsageBytes: { deepbrid: 1000 },
|
||||
debridAccountStatuses: {
|
||||
"svc-deepbrid": {
|
||||
accountId: "svc-deepbrid",
|
||||
provider: "deepbrid" as const,
|
||||
label: "Deepbrid",
|
||||
maskedLogin: "sy******ey",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
username: "Synthetic Deepbrid User",
|
||||
message: "Valid",
|
||||
checkedAt: Date.now()
|
||||
}
|
||||
}
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
const internal = manager as unknown as {
|
||||
recordProviderDownloadedBytes: (provider: "deepbrid", bytes: number, accountId: string, accountLabel: string) => void;
|
||||
settings: typeof settings;
|
||||
};
|
||||
|
||||
internal.recordProviderDownloadedBytes("deepbrid", 256, "svc-deepbrid", "Deepbrid API");
|
||||
|
||||
expect(internal.settings.providerDailyUsageBytes.deepbrid).toBe(356);
|
||||
expect(internal.settings.providerTotalUsageBytes.deepbrid).toBe(1256);
|
||||
expect(manager.getStats().rolling24Hours?.accounts).toContainEqual(expect.objectContaining({
|
||||
id: "svc-deepbrid",
|
||||
provider: "deepbrid",
|
||||
label: "Synthetic Deepbrid User",
|
||||
bytes: 256
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("disk write recovery", () => {
|
||||
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
|
||||
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
|
||||
@@ -611,6 +694,14 @@ describe("disk write recovery", () => {
|
||||
deficitBytes: 640
|
||||
}));
|
||||
expect((manager as any).diskReservations.getReservedBytesByVolume().get("remux-volume") ?? 0).toBe(0);
|
||||
expect(pkg.remuxOperations).toHaveLength(1);
|
||||
expect(pkg.remuxOperations?.[0]).toMatchObject({
|
||||
fileName: "Show.S01E01.German.DL.720p.mkv",
|
||||
status: "failed",
|
||||
errorCategory: "Speicherplatz"
|
||||
});
|
||||
expect(pkg.remuxOperations?.[0].completedAt).toBeGreaterThanOrEqual(pkg.remuxOperations?.[0].startedAt || 0);
|
||||
expect(pkg.remuxOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4114,6 +4205,7 @@ describe("download manager", () => {
|
||||
|
||||
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
|
||||
try {
|
||||
const history: HistoryEntry[] = [];
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
@@ -4124,13 +4216,14 @@ describe("download manager", () => {
|
||||
maxParallel: 1
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
createStoragePaths(path.join(root, "state")),
|
||||
{ onHistoryEntry: (entry) => history.push(entry) }
|
||||
);
|
||||
|
||||
const blocker = new Promise<void>((resolve) => {
|
||||
releaseBlockedPostProcess = resolve;
|
||||
});
|
||||
(manager as unknown as { packagePostProcessQueue: Promise<void> }).packagePostProcessQueue = blocker;
|
||||
(manager as any).handlePackagePostProcessing = vi.fn(async () => blocker);
|
||||
|
||||
manager.addPackages([
|
||||
{ name: "first", links: ["https://dummy/first"] },
|
||||
@@ -4151,14 +4244,26 @@ describe("download manager", () => {
|
||||
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
|
||||
}, 6000);
|
||||
|
||||
const pendingPackage = manager.getSnapshot().session.packages[firstPackage];
|
||||
expect(pendingPackage?.downloadStartedAt).toBeGreaterThan(0);
|
||||
expect(pendingPackage?.downloadEndedAt).toBeGreaterThanOrEqual(pendingPackage?.downloadStartedAt || 0);
|
||||
expect(pendingPackage?.downloadEndedAt).toBe(pendingPackage?.downloadCompletedAt);
|
||||
const downloadEndedAt = pendingPackage?.downloadEndedAt || 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
expect(manager.getSnapshot().session.packages[firstPackage]?.downloadEndedAt).toBe(downloadEndedAt);
|
||||
|
||||
if (releaseBlockedPostProcess) {
|
||||
releaseBlockedPostProcess();
|
||||
}
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
await waitFor(() => history.some((entry) => entry.name === "first"), 6000);
|
||||
|
||||
const done = manager.getSnapshot();
|
||||
expect(done.session.items[firstItem]?.status).toBe("completed");
|
||||
expect(done.session.items[secondItem]?.status).toBe("completed");
|
||||
const firstHistory = history.find((entry) => entry.name === "first");
|
||||
expect(firstHistory?.downloadEndedAt).toBe(downloadEndedAt);
|
||||
expect(firstHistory?.totalDurationSeconds).toBeGreaterThan(firstHistory?.downloadDurationSeconds || 0);
|
||||
} finally {
|
||||
if (releaseBlockedPostProcess) {
|
||||
releaseBlockedPostProcess();
|
||||
@@ -10709,6 +10814,16 @@ describe("download manager", () => {
|
||||
const snapshot = manager.getSnapshot();
|
||||
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
||||
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
||||
expect(snapshot.session.packages[packageId]?.archiveOperations).toHaveLength(1);
|
||||
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0]).toMatchObject({
|
||||
name: "episode.zip",
|
||||
itemIds: [itemId],
|
||||
partCount: 1,
|
||||
status: "completed",
|
||||
errorCategory: ""
|
||||
});
|
||||
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(snapshot.session.packages[packageId]?.outputCount).toBe(1);
|
||||
}, 30000);
|
||||
|
||||
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
|
||||
@@ -14269,3 +14384,491 @@ describe("package priority ordering", () => {
|
||||
expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("package lifecycle telemetry boundaries", () => {
|
||||
it("uses item-path provenance for archive identity and leaves unknown part counts at zero", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-identity-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
|
||||
const pkg: PackageEntry = {
|
||||
id: "archive-identity-package",
|
||||
name: "Archive identity",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
status: "completed" as const,
|
||||
itemIds: ["item-a", "item-b"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000
|
||||
};
|
||||
const item = (id: string, directory: string) => ({
|
||||
id,
|
||||
packageId: pkg.id,
|
||||
url: `https://example.test/${id}`,
|
||||
provider: "realdebrid" as const,
|
||||
status: "completed" as const,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 1,
|
||||
progressPercent: 100,
|
||||
fileName: "episode.rar",
|
||||
targetPath: path.join(pkg.outputDir, directory, "episode.rar"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Fertig",
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000
|
||||
});
|
||||
const progress = (current: number) => ({
|
||||
current,
|
||||
total: 3,
|
||||
percent: 100,
|
||||
archiveName: "episode.rar",
|
||||
archivePercent: 100,
|
||||
elapsedMs: 1_000,
|
||||
archiveDone: true,
|
||||
archiveSuccess: true
|
||||
});
|
||||
const state = manager as any;
|
||||
|
||||
state.recordArchiveOperation(pkg, progress(0), [item("item-a", "season-a")]);
|
||||
state.recordArchiveOperation(pkg, progress(1), [item("item-b", "season-b")]);
|
||||
state.recordArchiveOperation(pkg, { ...progress(2), archiveName: "unresolved.rar" }, []);
|
||||
|
||||
const operations = pkg.archiveOperations || [];
|
||||
expect(operations).toHaveLength(3);
|
||||
expect(new Set(operations.map((operation) => operation.id))).toHaveLength(3);
|
||||
expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]);
|
||||
});
|
||||
|
||||
it("projects archive failure details before storing operation telemetry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-failure-category-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
const pkg: PackageEntry = {
|
||||
id: "archive-failure-package",
|
||||
name: "Archive failure",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
status: "failed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000
|
||||
};
|
||||
const progress = (current: number, archiveName: string) => ({
|
||||
current,
|
||||
total: 2,
|
||||
percent: 100,
|
||||
archiveName,
|
||||
archivePercent: 100,
|
||||
elapsedMs: 1_000,
|
||||
archiveDone: true,
|
||||
archiveSuccess: false
|
||||
});
|
||||
const state = manager as unknown as {
|
||||
recordArchiveOperation: (entry: PackageEntry, update: ReturnType<typeof progress>, items: DownloadItem[], errorCategory: string) => void;
|
||||
};
|
||||
|
||||
state.recordArchiveOperation(pkg, progress(0, "private.rar"), [], "C:\\Users\\Alice\\private.rar https://private.example.test/file");
|
||||
state.recordArchiveOperation(pkg, progress(1, "disk-full.rar"), [], "ENOSPC: no space left on device C:\\Users\\Alice\\disk-full.rar");
|
||||
|
||||
expect(pkg.archiveOperations?.map((operation) => operation.errorCategory)).toEqual(["Entpacken", "Speicherplatz"]);
|
||||
});
|
||||
|
||||
it("records queued, slot start and terminal timestamps around real post-processing", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "lifecycle-package";
|
||||
const itemId = "lifecycle-item";
|
||||
const createdAt = Date.now() - 5_000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "Lifecycle",
|
||||
outputDir: path.join(root, "downloads", "lifecycle"),
|
||||
extractDir: path.join(root, "extract", "lifecycle"),
|
||||
status: "completed",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
downloadStartedAt: createdAt,
|
||||
downloadCompletedAt: createdAt + 1_000,
|
||||
downloadEndedAt: createdAt + 1_000,
|
||||
createdAt,
|
||||
updatedAt: createdAt + 1_000
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/lifecycle",
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 65_536,
|
||||
totalBytes: 65_536,
|
||||
progressPercent: 100,
|
||||
fileName: "lifecycle.txt",
|
||||
targetPath: path.join(root, "downloads", "lifecycle", "lifecycle.txt"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Fertig",
|
||||
createdAt,
|
||||
updatedAt: createdAt + 1_000
|
||||
};
|
||||
fs.mkdirSync(path.dirname(session.items[itemId].targetPath), { recursive: true });
|
||||
fs.writeFileSync(session.items[itemId].targetPath, Buffer.alloc(65_536, 1));
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), autoExtract: false },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const state = manager as any;
|
||||
state.runPackageIds.add(packageId);
|
||||
state.handlePackagePostProcessing = vi.fn(async () => undefined);
|
||||
|
||||
await state.runPackagePostProcessing(packageId);
|
||||
|
||||
const pkg = session.packages[packageId];
|
||||
expect(session.items[itemId].status).toBe("completed");
|
||||
expect(pkg.postProcessQueuedAt).toBeGreaterThan(0);
|
||||
expect(pkg.postProcessStartedAt).toBeGreaterThanOrEqual(pkg.postProcessQueuedAt || 0);
|
||||
expect(pkg.postProcessCompletedAt).toBeGreaterThanOrEqual(pkg.postProcessStartedAt || 0);
|
||||
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download health snapshot", () => {
|
||||
function createHealthManager(root: string, settings = defaultSettings()) {
|
||||
const session = emptySession();
|
||||
const packageId = "private-package-id";
|
||||
const itemId = "private-item-id";
|
||||
const now = 100_000;
|
||||
session.running = true;
|
||||
session.runStartedAt = 10_000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "Private package name",
|
||||
outputDir: path.join(root, "private-output"),
|
||||
extractDir: path.join(root, "private-extract"),
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1_000,
|
||||
updatedAt: now
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://private.example.test/file",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Private provider label",
|
||||
providerAccountId: "private-account-id",
|
||||
providerAccountLabel: "private@example.test",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 8192,
|
||||
downloadedBytes: 4096,
|
||||
totalBytes: 16384,
|
||||
progressPercent: 25,
|
||||
fileName: "private-file.bin",
|
||||
targetPath: path.join(root, "private-output", "private-file.bin"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt: 2_000,
|
||||
updatedAt: now
|
||||
};
|
||||
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
|
||||
const state = manager as any;
|
||||
session.running = true;
|
||||
session.items[itemId].status = "downloading";
|
||||
session.items[itemId].speedBps = 8192;
|
||||
session.packages[packageId].status = "downloading";
|
||||
state.runItemIds.add(itemId);
|
||||
state.runPackageIds.add(packageId);
|
||||
return { manager, session, state, packageId, itemId };
|
||||
}
|
||||
|
||||
it("exposes only aggregate run-scope health data and opaque fingerprints", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-snapshot-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, state, packageId, itemId } = createHealthManager(root);
|
||||
state.lastSchedulerTickAt = 99_000;
|
||||
state.activeTasks.set(itemId, {
|
||||
itemId,
|
||||
packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
phase: "downloading",
|
||||
phaseStartedAt: 90_000,
|
||||
phaseDeadlineAt: 0,
|
||||
blockedOnDiskWrite: false,
|
||||
blockedOnDiskSince: 0,
|
||||
blockedOnThrottleUntil: 0
|
||||
});
|
||||
|
||||
const health = manager.getDownloadHealthSnapshot(100_000);
|
||||
const serialized = JSON.stringify(health);
|
||||
|
||||
expect(health).toMatchObject({
|
||||
runActive: true,
|
||||
openItems: 1,
|
||||
openPackages: 1,
|
||||
knownDownloadedBytes: 4096,
|
||||
activeTasks: 1,
|
||||
startableItems: 0,
|
||||
lastSchedulerTickAt: 99_000,
|
||||
currentSpeedBps: 8192
|
||||
});
|
||||
expect(health.runFingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(health.queueFingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(serialized).not.toMatch(/private|example\.test|realdebrid|file\.bin/i);
|
||||
});
|
||||
|
||||
it("keeps the run fingerprint stable across restart time changes for the same generation scope", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-fingerprint-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createHealthManager(root);
|
||||
session.packages[session.packageOrder[0]].resultGeneration = 4;
|
||||
|
||||
const before = manager.getDownloadHealthSnapshot(100_000);
|
||||
session.runStartedAt = 200_000;
|
||||
const after = manager.getDownloadHealthSnapshot(210_000);
|
||||
|
||||
expect(after.runFingerprint).toBe(before.runFingerprint);
|
||||
expect(after.queueFingerprint).toBe(before.queueFingerprint);
|
||||
});
|
||||
|
||||
it("advances progress sequences only for positive byte events and successful item completions", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-sequences-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session, state, packageId, itemId } = createHealthManager(root);
|
||||
|
||||
state.recordSpeed(1024, packageId);
|
||||
state.recordSpeed(0, packageId);
|
||||
state.recordSpeed(-512, packageId);
|
||||
session.totalDownloadedBytes = 10_000_000;
|
||||
session.totalDownloadedBytes = 0;
|
||||
state.recordRunOutcome(itemId, "completed");
|
||||
state.recordRunOutcome(itemId, "completed");
|
||||
|
||||
const health = manager.getDownloadHealthSnapshot(100_000);
|
||||
|
||||
expect(health.downloadProgressSequence).toBe(1);
|
||||
expect(health.lastPositiveByteAt).toBeGreaterThan(0);
|
||||
expect(health.itemCompletionSequence).toBe(1);
|
||||
});
|
||||
|
||||
it("projects retry, cooldown, disk, throttle and valid phase waits without stale disk events", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-waits-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session, state, packageId, itemId } = createHealthManager(root);
|
||||
const active = {
|
||||
itemId,
|
||||
packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
phase: "validating",
|
||||
phaseStartedAt: 90_000,
|
||||
phaseDeadlineAt: 130_000,
|
||||
blockedOnDiskWrite: true,
|
||||
blockedOnDiskSince: 95_000,
|
||||
blockedOnThrottleUntil: 120_000
|
||||
};
|
||||
state.activeTasks.set(itemId, active);
|
||||
state.diskWaitEvents = [{
|
||||
phase: "download",
|
||||
targetPath: "C:\\private\\disk",
|
||||
requiredBytes: 100,
|
||||
availableBytes: 0,
|
||||
reserveBytes: 0,
|
||||
retryAt: 80_000,
|
||||
itemId,
|
||||
packageId
|
||||
}];
|
||||
|
||||
const activeWait = manager.getDownloadHealthSnapshot(100_000);
|
||||
|
||||
expect(activeWait.blockedOnDisk).toBe(true);
|
||||
expect(activeWait.blockedOnThrottleUntil).toBe(120_000);
|
||||
expect(activeWait.activePhaseDeadlineAt).toBe(130_000);
|
||||
|
||||
state.activeTasks.clear();
|
||||
session.items[itemId].status = "queued";
|
||||
state.retryAfterByItem.set(itemId, 140_000);
|
||||
state.providerFailures.set("realdebrid", { count: 20, lastFailAt: 99_000, cooldownUntil: 150_000 });
|
||||
|
||||
const queuedWait = manager.getDownloadHealthSnapshot(100_000);
|
||||
|
||||
expect(queuedWait.startableItems).toBe(0);
|
||||
expect(queuedWait.nextRetryAt).toBe(140_000);
|
||||
expect(queuedWait.providerCooldownUntil).toBe(150_000);
|
||||
expect(queuedWait.blockedOnDisk).toBe(false);
|
||||
});
|
||||
|
||||
it("does not project a partial retry as a global wait while another download is active", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-partial-wait-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session, state, packageId, itemId } = createHealthManager(root);
|
||||
const queuedId = "private-queued-id";
|
||||
session.items[queuedId] = {
|
||||
...session.items[itemId],
|
||||
id: queuedId,
|
||||
status: "queued",
|
||||
downloadedBytes: 0,
|
||||
speedBps: 0
|
||||
};
|
||||
session.packages[packageId].itemIds.push(queuedId);
|
||||
state.runItemIds.add(queuedId);
|
||||
state.activeTasks.set(itemId, {
|
||||
itemId,
|
||||
packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
phase: "downloading",
|
||||
phaseStartedAt: 90_000,
|
||||
phaseDeadlineAt: 0,
|
||||
blockedOnDiskWrite: false,
|
||||
blockedOnDiskSince: 0,
|
||||
blockedOnThrottleUntil: 0
|
||||
});
|
||||
state.retryAfterByItem.set(queuedId, 140_000);
|
||||
|
||||
const health = manager.getDownloadHealthSnapshot(100_000);
|
||||
|
||||
expect(health.activeTasks).toBe(1);
|
||||
expect(health.nextRetryAt).toBe(0);
|
||||
});
|
||||
|
||||
it("freezes health while two active downloads wait in the real global speed-limit queue", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(10_000);
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-global-throttle-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
speedLimitEnabled: true,
|
||||
speedLimitMode: "global" as const,
|
||||
speedLimitKbps: 1
|
||||
};
|
||||
const { manager, session, state, packageId, itemId } = createHealthManager(root, settings);
|
||||
const secondItemId = "private-throttled-item-2";
|
||||
session.items[secondItemId] = {
|
||||
...session.items[itemId],
|
||||
id: secondItemId
|
||||
};
|
||||
session.packages[packageId].itemIds.push(secondItemId);
|
||||
state.runItemIds.add(secondItemId);
|
||||
const firstActive = {
|
||||
itemId,
|
||||
packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
phase: "downloading",
|
||||
phaseStartedAt: 10_000,
|
||||
phaseDeadlineAt: 0,
|
||||
blockedOnDiskWrite: false,
|
||||
blockedOnDiskSince: 0,
|
||||
blockedOnThrottleUntil: 0
|
||||
};
|
||||
const secondActive = {
|
||||
...firstActive,
|
||||
itemId: secondItemId,
|
||||
abortController: new AbortController()
|
||||
};
|
||||
state.activeTasks.set(itemId, firstActive);
|
||||
state.activeTasks.set(secondItemId, secondActive);
|
||||
|
||||
await state.applySpeedLimit(100 * 1024, 0, 10_000, firstActive);
|
||||
const secondWait = state.applySpeedLimit(100 * 1024, 0, 10_000, secondActive);
|
||||
const firstWait = state.applySpeedLimit(100 * 1024, 0, 10_000, firstActive);
|
||||
const waitsSettled = Promise.allSettled([secondWait, firstWait]);
|
||||
await Promise.resolve();
|
||||
|
||||
let healthState = createDownloadHealthState();
|
||||
const events = [];
|
||||
for (const now of [10_000, 55_000, 105_000]) {
|
||||
vi.setSystemTime(now);
|
||||
const result = evaluateDownloadHealth(
|
||||
healthState,
|
||||
manager.getDownloadHealthSnapshot(now),
|
||||
now,
|
||||
{
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}
|
||||
);
|
||||
healthState = result.state;
|
||||
events.push(...result.events);
|
||||
}
|
||||
|
||||
expect(firstActive.blockedOnThrottleUntil).toBeGreaterThan(105_000);
|
||||
expect(secondActive.blockedOnThrottleUntil).toBeGreaterThan(105_000);
|
||||
expect(manager.getDownloadHealthSnapshot(105_000).blockedOnThrottleUntil).toBeGreaterThan(105_000);
|
||||
expect(healthState.status).toBe("expected_wait");
|
||||
expect(healthState.suspiciousDurationMs).toBe(0);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
firstActive.abortController.abort("test-finished");
|
||||
secondActive.abortController.abort("test-finished");
|
||||
await vi.runAllTimersAsync();
|
||||
await waitsSettled;
|
||||
expect(firstActive.blockedOnThrottleUntil).toBe(0);
|
||||
expect(secondActive.blockedOnThrottleUntil).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts one technical recovery when the existing global watchdog restarts stalled tasks", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-watchdog-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, state, packageId, itemId } = createHealthManager(root);
|
||||
const active = {
|
||||
itemId,
|
||||
packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
phase: "downloading",
|
||||
phaseStartedAt: 1,
|
||||
phaseDeadlineAt: 0,
|
||||
blockedOnDiskWrite: false,
|
||||
blockedOnDiskSince: 0,
|
||||
blockedOnThrottleUntil: 0
|
||||
};
|
||||
state.activeTasks.set(itemId, active);
|
||||
state.lastGlobalProgressBytes = 0;
|
||||
state.lastGlobalProgressAt = 1;
|
||||
|
||||
state.runGlobalStallWatchdog(90_000);
|
||||
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("stall");
|
||||
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
DownloadsSidebarStatus,
|
||||
DownloadsToolbar,
|
||||
DownloadsView,
|
||||
type DownloadsViewActions
|
||||
type DownloadsViewActions,
|
||||
type DownloadsViewModel
|
||||
} from "../src/renderer/views/downloads/DownloadsView";
|
||||
import {
|
||||
DownloadsTableHeader,
|
||||
@@ -688,6 +689,7 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
|
||||
onStopDownloads: () => {},
|
||||
onToggleSchedule: () => {},
|
||||
onScheduleTimeChange: () => {},
|
||||
onScheduleStartDayChange: () => {},
|
||||
onActivateSchedule: () => {},
|
||||
onCancelSchedule: () => {},
|
||||
onMoveSelectionUp: () => {},
|
||||
@@ -780,7 +782,7 @@ function dispatchColumnSortPointerGesture(header: ReactElement, label: string, c
|
||||
if (deliverPointerClick) sortButton.props.onClick({ detail: 1 });
|
||||
}
|
||||
|
||||
function withRuntime(input: DownloadsModelInput, overrides: Record<string, unknown> = {}) {
|
||||
function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel {
|
||||
return {
|
||||
...buildDownloadsViewModel(input),
|
||||
running: true,
|
||||
@@ -795,6 +797,8 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
||||
scheduleActive: false,
|
||||
scheduleOpen: false,
|
||||
scheduleTime: "23:30",
|
||||
scheduleTimeValid: true,
|
||||
scheduleStartDay: "today",
|
||||
scheduleLabel: "",
|
||||
packageSpeedBps: { "package-a": 12_000_000 },
|
||||
disclosureRevision: 0,
|
||||
@@ -1192,6 +1196,77 @@ describe("downloads view", () => {
|
||||
expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps closed schedule controls mounted, hidden and unreachable", () => {
|
||||
const toolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { scheduleActive: false, scheduleOpen: false })
|
||||
});
|
||||
const slot = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-slot"));
|
||||
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
|
||||
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
|
||||
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
|
||||
|
||||
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(false);
|
||||
expect(slot.props.className).toContain("is-closed");
|
||||
expect(controls.props["aria-hidden"]).toBe(true);
|
||||
expect(controls.props.inert).toBe("true");
|
||||
expect(renderToStaticMarkup(toolbar)).toContain('inert="true"');
|
||||
expect(timeInput.props.disabled).toBe(true);
|
||||
expect(daySelect.props.disabled).toBe(true);
|
||||
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("collects local time and the selected first start day from the open schedule slot", () => {
|
||||
const calls: string[] = [];
|
||||
const toolbar = DownloadsToolbar({
|
||||
actions: createActions({
|
||||
onScheduleTimeChange: (value) => calls.push(`time:${value}`),
|
||||
onScheduleStartDayChange: (value) => calls.push(`day:${value}`)
|
||||
}),
|
||||
model: withRuntime(createInput(), { scheduleOpen: true, scheduleStartDay: "tomorrow", scheduleTime: "08:15" })
|
||||
});
|
||||
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
|
||||
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
|
||||
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
|
||||
|
||||
timeInput.props.onChange({ target: { value: "09:45" } });
|
||||
daySelect.props.onChange({ target: { value: "today" } });
|
||||
|
||||
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(true);
|
||||
expect(controls.props["aria-hidden"]).toBe(false);
|
||||
expect(controls.props.inert).toBeUndefined();
|
||||
expect(timeInput.props.value).toBe("08:15");
|
||||
expect(daySelect.props.value).toBe("tomorrow");
|
||||
expect(renderToStaticMarkup(toolbar)).toContain("Ab heute");
|
||||
expect(renderToStaticMarkup(toolbar)).toContain("Ab morgen");
|
||||
expect(calls).toEqual(["time:09:45", "day:today"]);
|
||||
});
|
||||
|
||||
it.each(["", "8:15", "24:00", "12:60"])("disables schedule activation for the invalid time %j", (scheduleTime) => {
|
||||
const toolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { scheduleOpen: true, scheduleTime, scheduleTimeValid: false })
|
||||
});
|
||||
|
||||
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => {
|
||||
const closed = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const open = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { scheduleOpen: true })} />);
|
||||
const motionDisabled = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false, scheduleOpen: true })} />);
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
expect(closed).toContain("downloads-schedule-slot is-closed");
|
||||
expect(open).toContain("downloads-schedule-slot is-open");
|
||||
expect(motionDisabled).toContain("downloads-schedule-slot is-open is-motion-disabled");
|
||||
expect(css).toMatch(/\.downloads-schedule-slot\s*\{[^}]*grid-template-columns:\s*0fr;[^}]*opacity:\s*0;[^}]*transition:/s);
|
||||
expect(css).toMatch(/\.downloads-schedule-slot\.is-open\s*\{[^}]*grid-template-columns:\s*1fr;[^}]*opacity:\s*1;/s);
|
||||
expect(css).toMatch(/\.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(-\d+px\);[^}]*transition:\s*transform/s);
|
||||
expect(css).toMatch(/\.downloads-schedule-slot\.is-open \.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(0\);/s);
|
||||
expect(css).toMatch(/\.downloads-schedule-slot\.is-motion-disabled[^\{]*\{[^}]*transition:\s*none !important;/s);
|
||||
});
|
||||
|
||||
it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
@@ -53,6 +53,13 @@ describe("revealHistoryEntry", () => {
|
||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
});
|
||||
|
||||
it.each(["partial", "failed", "cancelled"] as const)("opens a %s history result from the same authoritative directory", async (status) => {
|
||||
const deps = dependencies({ loadHistory: () => [historyEntry({ status })] });
|
||||
|
||||
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
|
||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||
});
|
||||
|
||||
it("ignores every renderer-supplied field except entryId", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
|
||||
@@ -136,6 +136,15 @@ describe("history model", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("labels partial and cancelled package results", () => {
|
||||
const rows = filterHistoryRows([
|
||||
entry({ id: "partial", name: "Teilweise", status: "partial" }),
|
||||
entry({ id: "cancelled", name: "Abgebrochen", status: "cancelled" })
|
||||
], "all", "", now);
|
||||
|
||||
expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]);
|
||||
});
|
||||
|
||||
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
|
||||
const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
|
||||
const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime();
|
||||
@@ -188,6 +197,20 @@ describe("history model", () => {
|
||||
expect(row.providerLabel).toBe("Real-Debrid");
|
||||
});
|
||||
|
||||
it("uses the authoritative lifecycle start instead of reconstructing it from completion", () => {
|
||||
const lifecycleStartedAt = todayStart - 45_000;
|
||||
const lifecycle = entry({
|
||||
id: "lifecycle-start",
|
||||
name: "Lifecycle",
|
||||
startedAt: lifecycleStartedAt,
|
||||
completedAt: todayStart + 60_000,
|
||||
durationSeconds: 5
|
||||
});
|
||||
|
||||
expect(deriveHistoryStartAt(lifecycle)).toBe(lifecycleStartedAt);
|
||||
expect(filterHistoryRows([lifecycle], "all", "", now)[0].startAt).toBe(lifecycleStartedAt);
|
||||
});
|
||||
|
||||
it("prunes removed ids and preserves the original set instance when every id survives", () => {
|
||||
const stable = new Set(["today", "week"]);
|
||||
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
|
||||
@@ -588,6 +611,94 @@ describe("HistoryView", () => {
|
||||
expect(html).toContain("https://rapidgator.net/file/test");
|
||||
});
|
||||
|
||||
it("renders authoritative lifecycle timings, counts, failure phase and operation details", () => {
|
||||
const structured = entry({
|
||||
id: "structured",
|
||||
name: "Strukturiert",
|
||||
status: "partial",
|
||||
startedAt: todayStart,
|
||||
downloadEndedAt: todayStart + 60_000,
|
||||
postProcessStartedAt: todayStart + 65_000,
|
||||
completedAt: todayStart + 90_000,
|
||||
downloadDurationSeconds: 60,
|
||||
extractionDurationSeconds: 12,
|
||||
remuxDurationSeconds: 8,
|
||||
postProcessDurationSeconds: 25,
|
||||
totalDurationSeconds: 90,
|
||||
successfulFiles: 3,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
archiveCount: 1,
|
||||
partCount: 16,
|
||||
outputCount: 10,
|
||||
failurePhase: "remux",
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "show.part01.rar",
|
||||
itemIds: ["item-1"],
|
||||
partCount: 16,
|
||||
startedAt: todayStart + 65_000,
|
||||
completedAt: todayStart + 77_000,
|
||||
durationMs: 12_000,
|
||||
status: "completed",
|
||||
errorCategory: ""
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: todayStart + 77_000,
|
||||
completedAt: todayStart + 85_000,
|
||||
durationMs: 8_000,
|
||||
status: "failed",
|
||||
errorCategory: "ffmpeg"
|
||||
}]
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel([structured], "all", "", [], [structured.id], false, "", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
for (const label of [
|
||||
"Download gestartet",
|
||||
"Download beendet",
|
||||
"Nachbearbeitung gestartet",
|
||||
"Abgeschlossen",
|
||||
"Downloaddauer",
|
||||
"Entpackdauer",
|
||||
"Remuxdauer",
|
||||
"Nachbearbeitungsdauer",
|
||||
"Gesamtdauer",
|
||||
"Erfolgreich / Fehlgeschlagen / Abgebrochen",
|
||||
"Archive / Parts / Ausgaben",
|
||||
"Fehlerphase",
|
||||
"Archivvorgänge",
|
||||
"Remuxvorgänge"
|
||||
]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
expect(html).toContain("show.part01.rar");
|
||||
expect(html).toContain("16 Parts");
|
||||
expect(html).toContain("episode.mkv");
|
||||
expect(html).toContain("ffmpeg");
|
||||
expect(html).not.toContain("Downloaddauer (Altbestand)");
|
||||
});
|
||||
|
||||
it("labels durationSeconds honestly for legacy entries", () => {
|
||||
const legacy = entry({ id: "legacy", name: "Altbestand" });
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel([legacy], "all", "", [], [legacy.id], false, "", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("Downloaddauer (Altbestand)");
|
||||
expect(html).not.toContain("Download beendet");
|
||||
expect(html).not.toContain("Nachbearbeitung gestartet");
|
||||
});
|
||||
|
||||
it("uses the global animation setting for the history disclosure surface", () => {
|
||||
const animated = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, true);
|
||||
const immediate = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, false);
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("1Fichier public metadata", () => {
|
||||
expect(isOneFichierLink("https://desfichiers.net/?abc12345")).toBe(true);
|
||||
expect(isOneFichierLink("https://piecejointe.net/?abc12345")).toBe(true);
|
||||
expect(extractHosterFromUrl("https://dl4free.com/?abc12345")).toBe("1fichier");
|
||||
expect(formatHosterLabel("1fichier")).toEqual({ compact: "1F", title: "1Fichier" });
|
||||
expect(formatHosterLabel("1fichier")).toEqual({ compact: "1Fichier", title: "1Fichier", iconSrc: "./provider-icons/onefichier.png" });
|
||||
});
|
||||
|
||||
it("distinguishes online, missing and private links without inventing metadata", async () => {
|
||||
|
||||
@@ -16,6 +16,40 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText("Animations", "de")).toBe("Animationen");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Deepbrid API", "Deepbrid API"],
|
||||
["Direkter Zugriff über API-Key.", "Direct access via API key."],
|
||||
["Deepbrid gespeichert", "Deepbrid saved"]
|
||||
])("translates Deepbrid account UI text %s", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Erfolgsmeldungen senden", "Send success notifications"],
|
||||
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
|
||||
["Jedes Paket einzeln", "Each package individually"],
|
||||
["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"],
|
||||
["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"],
|
||||
["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"],
|
||||
["Melden, wenn Downloads stillstehen", "Notify when downloads stall"],
|
||||
["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"],
|
||||
["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"],
|
||||
["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"]
|
||||
])("translates notification center setting %s in both directions", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Starttag", "Start day"],
|
||||
["Ab heute", "Starting today"],
|
||||
["Ab morgen", "Starting tomorrow"],
|
||||
["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."]
|
||||
])("translates daily schedule text %s in both directions", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it("translates dynamic update and pagination text", () => {
|
||||
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
||||
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const electron = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const powerMonitor = { on: vi.fn(), removeListener: vi.fn() };
|
||||
return {
|
||||
handlers,
|
||||
powerMonitor,
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => "C:\\MDD\\Test"),
|
||||
getAppPath: vi.fn(() => "C:\\MDD\\App"),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
on: vi.fn((name: string, handler: (...args: unknown[]) => void) => { handlers.set(name, handler); }),
|
||||
whenReady: vi.fn(() => new Promise<void>(() => {})),
|
||||
quit: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
setPath: vi.fn()
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: electron.app,
|
||||
BrowserWindow: class {
|
||||
public static getAllWindows(): unknown[] { return []; }
|
||||
},
|
||||
clipboard: {},
|
||||
dialog: {},
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
nativeTheme: { themeSource: "system" },
|
||||
powerMonitor: electron.powerMonitor,
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import {
|
||||
DownloadHealthMonitor,
|
||||
createDownloadHealthState,
|
||||
loadDownloadHealthState,
|
||||
saveDownloadHealthState,
|
||||
type DownloadHealthSnapshot
|
||||
} from "../src/main/download-health-monitor";
|
||||
import { NotificationOutbox, type NotificationEvent } from "../src/main/notification-outbox";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve = () => {};
|
||||
const promise = new Promise<void>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function shutdownEvent(id: string, priority: "success" | "error" = "success"): NotificationEvent {
|
||||
return {
|
||||
id,
|
||||
type: "package_completed",
|
||||
priority,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 6 * 60 * 60 * 1000,
|
||||
attempts: 0,
|
||||
nextAttemptAt: Date.now(),
|
||||
payload: { title: "Paket-Digest", fields: [] }
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("main shutdown lifecycle", () => {
|
||||
it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => {
|
||||
const drain = deferred();
|
||||
const manager = { prepareForShutdown: vi.fn() };
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = { drainForShutdown: vi.fn(() => drain.promise) };
|
||||
controller.manager = manager;
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
|
||||
const shutdown = controller.shutdown();
|
||||
|
||||
expect(shutdown).toBeInstanceOf(Promise);
|
||||
const drainBudget = controller.notificationOutbox.drainForShutdown.mock.calls[0][0];
|
||||
expect(drainBudget).toBeGreaterThan(0);
|
||||
expect(drainBudget).toBeLessThanOrEqual(3000);
|
||||
expect(manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
||||
drain.resolve();
|
||||
await shutdown;
|
||||
});
|
||||
|
||||
it("uses one three-second deadline even when a running health evaluation never settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const runningEvaluation = deferred();
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
|
||||
controller.downloadHealthEvaluation = runningEvaluation.promise;
|
||||
controller.downloadHealthMonitor = null;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) };
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(),
|
||||
prepareForShutdown: vi.fn(),
|
||||
flushNotificationsForShutdown: vi.fn(async () => undefined)
|
||||
};
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
let completed = false;
|
||||
|
||||
const shutdown = controller.shutdown().then(() => { completed = true; });
|
||||
await vi.advanceTimersByTimeAsync(2999);
|
||||
expect(completed).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const completedAtDeadline = completed;
|
||||
runningEvaluation.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
await shutdown;
|
||||
|
||||
expect(completedAtDeadline).toBe(true);
|
||||
expect(controller.manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("persists a digest completed inside the shared shutdown window while an earlier send is blocked", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shutdown-outbox-"));
|
||||
const filePath = path.join(root, "outbox.json");
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const blockedSend = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const lateEnqueued = deferred();
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: Date.now,
|
||||
send: async (event) => {
|
||||
if (event.id === "already-sending") {
|
||||
markSendStarted();
|
||||
return blockedSend;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
await outbox.enqueue(shutdownEvent("already-sending", "error"));
|
||||
const activeDrain = outbox.drain();
|
||||
await sendStarted;
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.downloadHealthTimer = null;
|
||||
controller.downloadHealthEvaluation = null;
|
||||
controller.downloadHealthMonitor = null;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = outbox;
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(),
|
||||
prepareForShutdown: vi.fn(),
|
||||
flushNotificationsForShutdown: vi.fn(() => new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
void outbox.enqueue(shutdownEvent("late-digest")).then(() => {
|
||||
lateEnqueued.resolve();
|
||||
resolve();
|
||||
});
|
||||
}, 500);
|
||||
}))
|
||||
};
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
let completed = false;
|
||||
|
||||
const shutdown = controller.shutdown().then(() => { completed = true; });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await lateEnqueued.promise;
|
||||
const stateDuringWindow = JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[] };
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
const completedAtDeadline = completed;
|
||||
releaseSend(true);
|
||||
await vi.runAllTimersAsync();
|
||||
await activeDrain;
|
||||
await shutdown;
|
||||
|
||||
expect(stateDuringWindow.events.map((event) => event.id)).toContain("late-digest");
|
||||
expect(completedAtDeadline).toBe(true);
|
||||
expect(controller.manager.prepareForShutdown.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(controller.manager.flushNotificationsForShutdown.mock.invocationCallOrder[0]);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prevents quit once, waits for shutdown, then allows exactly one loop-free quit", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const shutdown = deferred();
|
||||
const cleanup = vi.fn();
|
||||
const continueQuit = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const optionsShutdown = vi.fn(() => shutdown.promise);
|
||||
const handler = main.createBeforeQuitHandler({
|
||||
cleanup,
|
||||
shutdown: optionsShutdown,
|
||||
continueQuit,
|
||||
onError
|
||||
});
|
||||
const first = { preventDefault: vi.fn() };
|
||||
const repeated = { preventDefault: vi.fn() };
|
||||
const resumed = { preventDefault: vi.fn() };
|
||||
|
||||
handler(first);
|
||||
handler(repeated);
|
||||
expect(first.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(repeated.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup.mock.invocationCallOrder[0]).toBeLessThan(optionsShutdown.mock.invocationCallOrder[0]);
|
||||
expect(continueQuit).not.toHaveBeenCalled();
|
||||
|
||||
shutdown.resolve();
|
||||
await vi.waitFor(() => expect(continueQuit).toHaveBeenCalledTimes(1));
|
||||
handler(resumed);
|
||||
expect(resumed.preventDefault).not.toHaveBeenCalled();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the daily scheduler, clears the legacy timer, and removes power listeners before controller shutdown", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
electron.powerMonitor.removeListener.mockClear();
|
||||
const main = await import("../src/main/main");
|
||||
const scheduler = { end: vi.fn() };
|
||||
const timer = setTimeout(() => {}, 60_000);
|
||||
const shutdown = vi.fn(async () => undefined);
|
||||
const handler = main.createBeforeQuitHandler({
|
||||
cleanup: () => main.cleanupSchedulerLifecycle(scheduler, timer),
|
||||
shutdown,
|
||||
continueQuit: vi.fn(),
|
||||
onError: vi.fn()
|
||||
});
|
||||
|
||||
handler({ preventDefault: vi.fn() });
|
||||
await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(scheduler.end).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(electron.powerMonitor.removeListener).toHaveBeenCalledWith("suspend", expect.any(Function));
|
||||
expect(electron.powerMonitor.removeListener).toHaveBeenCalledWith("resume", expect.any(Function));
|
||||
expect(scheduler.end.mock.invocationCallOrder[0]).toBeLessThan(shutdown.mock.invocationCallOrder[0]);
|
||||
expect(electron.powerMonitor.removeListener.mock.invocationCallOrder[1]).toBeLessThan(shutdown.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("waits for a running health sample and persistently closes an alerted incident before shutdown returns", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-shutdown-"));
|
||||
try {
|
||||
const filePath = path.join(root, "health.json");
|
||||
const runFingerprint = "a".repeat(64);
|
||||
const queueFingerprint = "b".repeat(64);
|
||||
saveDownloadHealthState(filePath, createDownloadHealthState({
|
||||
status: "alerted",
|
||||
runFingerprint,
|
||||
queueFingerprint,
|
||||
suspiciousDurationMs: 90_000,
|
||||
suspiciousSamples: 3,
|
||||
incidentStartedAt: 10_000,
|
||||
alertedAt: 90_000,
|
||||
lastAlertAt: 90_000,
|
||||
cooldownUntil: 690_000
|
||||
}));
|
||||
let shuttingDown = false;
|
||||
const healthSnapshot = (completionSequence: number): DownloadHealthSnapshot => ({
|
||||
runActive: true,
|
||||
runFingerprint,
|
||||
queueFingerprint,
|
||||
openItems: 1,
|
||||
openPackages: 1,
|
||||
knownDownloadedBytes: 4096,
|
||||
activeTasks: 1,
|
||||
startableItems: 0,
|
||||
lastSchedulerTickAt: 100_000,
|
||||
downloadProgressSequence: 0,
|
||||
itemCompletionSequence: completionSequence,
|
||||
lastPositiveByteAt: 0,
|
||||
technicalRecoveryCount: 0,
|
||||
paused: false,
|
||||
reconnectUntil: 0,
|
||||
nextRetryAt: 0,
|
||||
providerCooldownUntil: 0,
|
||||
blockedOnDisk: false,
|
||||
blockedOnThrottleUntil: 0,
|
||||
activePhaseDeadlineAt: 0,
|
||||
terminalFailure: false,
|
||||
manualStop: false,
|
||||
shuttingDown,
|
||||
currentSpeedBps: 0
|
||||
});
|
||||
const runningEvaluation = deferred();
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
|
||||
controller.downloadHealthTimer.unref?.();
|
||||
controller.downloadHealthMonitor = new DownloadHealthMonitor(filePath);
|
||||
controller.downloadHealthEvaluation = runningEvaluation.promise.finally(() => {
|
||||
controller.downloadHealthEvaluation = null;
|
||||
});
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = {
|
||||
enqueue: vi.fn(async () => undefined),
|
||||
drainForShutdown: vi.fn(async () => undefined)
|
||||
};
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(() => { shuttingDown = true; }),
|
||||
getDownloadHealthSnapshot: vi.fn(() => healthSnapshot(1)),
|
||||
flushNotificationsForShutdown: vi.fn(async () => undefined),
|
||||
prepareForShutdown: vi.fn()
|
||||
};
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = {
|
||||
historyRetentionMode: "never",
|
||||
notifyUrl: "https://discord.example.test/webhook",
|
||||
notifyOnDownloadStall: true,
|
||||
notifyOnDownloadRecovery: true,
|
||||
notifyStallAfterSeconds: 90,
|
||||
notifyStallCooldownMinutes: 10
|
||||
};
|
||||
|
||||
const shutdown = controller.shutdown();
|
||||
|
||||
expect(controller.downloadHealthTimer).toBeNull();
|
||||
expect(controller.notificationOutbox.drainForShutdown).not.toHaveBeenCalled();
|
||||
runningEvaluation.resolve();
|
||||
await shutdown;
|
||||
|
||||
const closed = loadDownloadHealthState(filePath);
|
||||
expect(closed.status).toBe("idle");
|
||||
expect(closed.alertedAt).toBe(0);
|
||||
expect(closed.restartPending).toBe(false);
|
||||
|
||||
const recoveredEvents: unknown[] = [];
|
||||
const restarted = new DownloadHealthMonitor(filePath);
|
||||
await restarted.sample(healthSnapshot(1), 120_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async (event) => { recoveredEvents.push(event); });
|
||||
await restarted.sample(healthSnapshot(2), 135_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async (event) => { recoveredEvents.push(event); });
|
||||
|
||||
expect(recoveredEvents).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,973 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox";
|
||||
import { buildPackageNotificationEvent } from "../src/main/notification-events";
|
||||
import { buildNotifyRequest, sendNotification } from "../src/main/notify";
|
||||
import { finalizePackageResult } from "../src/main/package-telemetry";
|
||||
import type { DownloadItem, PackageEntry, PackageResult, PackageTelemetry, RemuxOperationMetric } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createOutboxFile(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-notification-outbox-"));
|
||||
tempDirs.push(dir);
|
||||
return path.join(dir, "notification-outbox.json");
|
||||
}
|
||||
|
||||
function event(id: string, overrides: Partial<NotificationEvent> = {}): NotificationEvent {
|
||||
return {
|
||||
id,
|
||||
type: "package_failed",
|
||||
priority: "error",
|
||||
createdAt: 1000,
|
||||
expiresAt: 86401000,
|
||||
attempts: 0,
|
||||
nextAttemptAt: 1000,
|
||||
payload: {
|
||||
title: "Paket fehlgeschlagen",
|
||||
description: "Eine Datei ist fehlgeschlagen.",
|
||||
fields: []
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function persisted(filePath: string): { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number } {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number };
|
||||
}
|
||||
|
||||
const privateFailureDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
|
||||
const privateFailureValues = [
|
||||
"https://private.example.test/hook",
|
||||
"C:/Private/target",
|
||||
"alice@example.test",
|
||||
"token=SUPERSECRET"
|
||||
];
|
||||
|
||||
function privateFailureTelemetry(source: "fullStatus" | "remux" | "cleanup"): PackageTelemetry {
|
||||
const item: DownloadItem = {
|
||||
id: "item-private",
|
||||
packageId: "pkg-private",
|
||||
url: "https://download.example.test/file",
|
||||
provider: "realdebrid",
|
||||
status: source === "fullStatus" ? "failed" : "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: source === "fullStatus" ? 0 : 1000,
|
||||
totalBytes: 1000,
|
||||
progressPercent: source === "fullStatus" ? 0 : 100,
|
||||
fileName: "file.bin",
|
||||
targetPath: "C:\\Downloads\\Paket\\file.bin",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: source === "fullStatus" ? privateFailureDetails : "Fertig",
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000
|
||||
};
|
||||
const packageEntry: PackageEntry = {
|
||||
id: "pkg-private",
|
||||
name: "Paket",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
extractDir: "C:\\Downloads\\Paket",
|
||||
status: "failed",
|
||||
itemIds: [item.id],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
downloadStartedAt: 1000,
|
||||
downloadCompletedAt: 2000,
|
||||
downloadEndedAt: 2000,
|
||||
postProcessQueuedAt: 2000,
|
||||
postProcessStartedAt: 2000,
|
||||
postProcessCompletedAt: 3000,
|
||||
terminalAt: 3000,
|
||||
createdAt: 1000,
|
||||
updatedAt: 3000
|
||||
};
|
||||
const remuxOperations: RemuxOperationMetric[] = source === "remux" ? [{
|
||||
id: "remux-private",
|
||||
fileName: "file.mkv",
|
||||
startedAt: 2000,
|
||||
completedAt: 3000,
|
||||
durationMs: 1000,
|
||||
status: "failed",
|
||||
errorCategory: privateFailureDetails
|
||||
}] : [];
|
||||
return {
|
||||
package: packageEntry,
|
||||
items: [item],
|
||||
archiveOperations: [],
|
||||
remuxOperations,
|
||||
outputCount: 0,
|
||||
cleanupErrorCategory: source === "cleanup" ? privateFailureDetails : ""
|
||||
};
|
||||
}
|
||||
|
||||
describe("NotificationOutbox", () => {
|
||||
it("sends due events serially in stable enqueue order and removes each success", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const sent: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => 1000,
|
||||
send: async (queuedEvent) => {
|
||||
sent.push(queuedEvent.id);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await outbox.enqueue(event("first"));
|
||||
await outbox.enqueue(event("second", { createdAt: 900 }));
|
||||
await outbox.enqueue(event("third"));
|
||||
await outbox.drain(1000);
|
||||
|
||||
expect(sent).toEqual(["first", "second", "third"]);
|
||||
expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 1000, lastFailureAt: 0 });
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
});
|
||||
|
||||
it("backs off a failed event without allowing later events to overtake it", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let now = 1000;
|
||||
const outcomes = [false, true, true];
|
||||
const sent: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => now,
|
||||
send: async (queuedEvent) => {
|
||||
sent.push(queuedEvent.id);
|
||||
return outcomes.shift() ?? true;
|
||||
}
|
||||
});
|
||||
|
||||
await outbox.enqueue(event("first"));
|
||||
await outbox.enqueue(event("second"));
|
||||
await outbox.drain();
|
||||
expect(sent).toEqual(["first"]);
|
||||
expect(persisted(filePath).events[0]).toMatchObject({ id: "first", attempts: 1, nextAttemptAt: 2000 });
|
||||
expect(outbox.getStatus()).toEqual({ queued: 2, lastSuccessAt: 0, lastFailureAt: 1000 });
|
||||
|
||||
now = 1999;
|
||||
await outbox.drain();
|
||||
expect(sent).toEqual(["first"]);
|
||||
now = 2000;
|
||||
await outbox.drain();
|
||||
expect(sent).toEqual(["first", "first", "second"]);
|
||||
});
|
||||
|
||||
it("uses the actual failure time for retry backoff", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let now = 1000;
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => now,
|
||||
send: async () => {
|
||||
now = 4500;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
await outbox.enqueue(event("late-failure"));
|
||||
await outbox.drain();
|
||||
|
||||
expect(persisted(filePath).events[0]).toMatchObject({ attempts: 1, nextAttemptAt: 5500 });
|
||||
expect(outbox.getStatus().lastFailureAt).toBe(4500);
|
||||
});
|
||||
|
||||
it("rechecks expiration after each send before delivering the next event", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let now = 1000;
|
||||
const sent: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => now,
|
||||
send: async (queuedEvent) => {
|
||||
sent.push(queuedEvent.id);
|
||||
now = 2000;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await outbox.enqueue(event("first", { expiresAt: 5000 }));
|
||||
await outbox.enqueue(event("expires-during-send", { expiresAt: 1500 }));
|
||||
await outbox.drain();
|
||||
|
||||
expect(sent).toEqual(["first"]);
|
||||
expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 2000, lastFailureAt: 0 });
|
||||
});
|
||||
|
||||
it("caps exponential retry backoff at ten minutes after many attempts", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let now = 1000;
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => now,
|
||||
send: async () => {
|
||||
now = 2000;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
await outbox.enqueue(event("many-attempts", { attempts: 20 }));
|
||||
await outbox.drain();
|
||||
|
||||
expect(persisted(filePath).events[0]).toMatchObject({ attempts: 21, nextAttemptAt: 602000 });
|
||||
});
|
||||
|
||||
it("restores a future retry timer and reads changed URL and mention only when retrying", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const firstUrl = "https://discord.example.test/api/webhooks/first";
|
||||
const secondUrl = "https://discord.example.test/api/webhooks/second";
|
||||
let settings = { url: firstUrl, mention: "111111" };
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
const sender = (queuedEvent: NotificationEvent): Promise<boolean> => sendNotification(settings.url, {
|
||||
title: queuedEvent.payload.title,
|
||||
message: queuedEvent.payload.description || "",
|
||||
mention: settings.mention,
|
||||
fields: queuedEvent.payload.fields,
|
||||
timestamp: queuedEvent.createdAt
|
||||
}, fetchFn, async () => {});
|
||||
const firstProcess = new NotificationOutbox({ filePath, send: sender });
|
||||
|
||||
await firstProcess.enqueue(event("restart-retry"));
|
||||
await firstProcess.drain();
|
||||
expect(persisted(filePath).events[0]).toMatchObject({ attempts: 1, nextAttemptAt: 2000 });
|
||||
|
||||
settings = { url: secondUrl, mention: "222222" };
|
||||
const restartedProcess = new NotificationOutbox({ filePath, send: sender, autoDrain: true });
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(fetchFn.mock.calls[0][0]).toBe(firstUrl);
|
||||
expect(fetchFn.mock.calls[1][0]).toBe(secondUrl);
|
||||
expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body)).content).toBe("<@111111>");
|
||||
expect(JSON.parse(String(fetchFn.mock.calls[1][1]?.body)).content).toBe("<@222222>");
|
||||
await restartedProcess.drain();
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
});
|
||||
|
||||
it("automatically drains new events and retries them at the persisted deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const outcomes = [false, true];
|
||||
const send = vi.fn().mockImplementation(async () => outcomes.shift() ?? true);
|
||||
const outbox = new NotificationOutbox({ filePath, send, autoDrain: true });
|
||||
|
||||
await outbox.enqueue(event("automatic"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await outbox.drain(1000);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(outbox.getStatus().queued).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(outbox.getStatus().queued).toBe(0);
|
||||
});
|
||||
|
||||
it("retries a temporary persistence failure before sending without another enqueue", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const renameFile = fsp.rename.bind(fsp);
|
||||
let renameAttempts = 0;
|
||||
let markRetryStarted = () => {};
|
||||
const retryStarted = new Promise<void>((resolve) => { markRetryStarted = resolve; });
|
||||
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
|
||||
renameAttempts += 1;
|
||||
if (renameAttempts === 1) {
|
||||
throw new Error("temporary rename failure");
|
||||
}
|
||||
if (renameAttempts === 2) {
|
||||
markRetryStarted();
|
||||
}
|
||||
await renameFile(oldPath, newPath);
|
||||
});
|
||||
const persistedBeforeSend: string[][] = [];
|
||||
let markSendStarted = () => {};
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const send = vi.fn().mockImplementation(async () => {
|
||||
persistedBeforeSend.push(fs.existsSync(filePath)
|
||||
? persisted(filePath).events.map((queuedEvent) => queuedEvent.id)
|
||||
: []);
|
||||
markSendStarted();
|
||||
return sendResult;
|
||||
});
|
||||
const outbox = new NotificationOutbox({ filePath, send, autoDrain: true });
|
||||
|
||||
try {
|
||||
await expect(outbox.enqueue(event("persistence-retry"))).rejects.toThrow("temporary rename failure");
|
||||
expect(outbox.getStatus().queued).toBe(1);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await retryStarted;
|
||||
await sendStarted;
|
||||
const retryDrain = outbox.drain();
|
||||
releaseSend(true);
|
||||
await retryDrain;
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(persistedBeforeSend).toEqual([["persistence-retry"]]);
|
||||
expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 2000, lastFailureAt: 0 });
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
} finally {
|
||||
rename.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("backs off repeated persistence retries without a busy loop", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const renameFile = fsp.rename.bind(fsp);
|
||||
const removeFile = fsp.rm.bind(fsp);
|
||||
let renameAttempts = 0;
|
||||
let cleanupAttempts = 0;
|
||||
let markSecondAttemptStarted = () => {};
|
||||
let markSecondCleanupCompleted = () => {};
|
||||
const secondAttemptStarted = new Promise<void>((resolve) => { markSecondAttemptStarted = resolve; });
|
||||
const secondCleanupCompleted = new Promise<void>((resolve) => { markSecondCleanupCompleted = resolve; });
|
||||
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
|
||||
renameAttempts += 1;
|
||||
if (renameAttempts === 1) {
|
||||
throw new Error("first temporary rename failure");
|
||||
}
|
||||
if (renameAttempts === 2) {
|
||||
markSecondAttemptStarted();
|
||||
throw new Error("second temporary rename failure");
|
||||
}
|
||||
await renameFile(oldPath, newPath);
|
||||
});
|
||||
const remove = vi.spyOn(fsp, "rm").mockImplementation(async (targetPath, options) => {
|
||||
await removeFile(targetPath, options);
|
||||
cleanupAttempts += 1;
|
||||
if (cleanupAttempts === 2) {
|
||||
markSecondCleanupCompleted();
|
||||
}
|
||||
});
|
||||
const sentAttempts: number[] = [];
|
||||
let markSendStarted = () => {};
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const send = vi.fn().mockImplementation(async (queuedEvent: NotificationEvent) => {
|
||||
sentAttempts.push(queuedEvent.attempts);
|
||||
markSendStarted();
|
||||
return sendResult;
|
||||
});
|
||||
const outbox = new NotificationOutbox({ filePath, send, autoDrain: true });
|
||||
|
||||
try {
|
||||
await expect(outbox.enqueue(event("persistence-backoff"))).rejects.toThrow("first temporary rename failure");
|
||||
expect(rename).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(rename).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await secondAttemptStarted;
|
||||
await secondCleanupCompleted;
|
||||
await Promise.resolve();
|
||||
expect(rename).toHaveBeenCalledTimes(2);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(rename).toHaveBeenCalledTimes(2);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await sendStarted;
|
||||
const successfulRetryDrain = outbox.drain();
|
||||
releaseSend(true);
|
||||
await successfulRetryDrain;
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(sentAttempts).toEqual([0]);
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
} finally {
|
||||
remove.mockRestore();
|
||||
rename.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a retry while an earlier delivery remains blocked", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const renameFile = fsp.rename.bind(fsp);
|
||||
let renameAttempts = 0;
|
||||
let markRecoveryPersisted = () => {};
|
||||
const recoveryPersisted = new Promise<void>((resolve) => { markRecoveryPersisted = resolve; });
|
||||
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
|
||||
const attempt = ++renameAttempts;
|
||||
if (attempt === 2) {
|
||||
throw new Error("temporary blocked rename failure");
|
||||
}
|
||||
await renameFile(oldPath, newPath);
|
||||
if (attempt === 3) {
|
||||
markRecoveryPersisted();
|
||||
}
|
||||
});
|
||||
const sent: string[] = [];
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const blockedSend = new Promise<boolean>(() => {});
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
autoDrain: true,
|
||||
send: async (queuedEvent) => {
|
||||
sent.push(queuedEvent.id);
|
||||
markSendStarted();
|
||||
return blockedSend;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await outbox.enqueue(event("blocked-a"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await sendStarted;
|
||||
await expect(outbox.enqueue(event("late-b"))).rejects.toThrow("temporary blocked rename failure");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["blocked-a"]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await recoveryPersisted;
|
||||
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["blocked-a", "late-b"]);
|
||||
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
|
||||
expect(sent).toEqual(["blocked-a"]);
|
||||
expect(outbox.getStatus().queued).toBe(2);
|
||||
} finally {
|
||||
rename.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists through a temporary file and atomic rename", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const rename = vi.spyOn(fsp, "rename");
|
||||
try {
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
|
||||
await outbox.enqueue(event("atomic"));
|
||||
expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath);
|
||||
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["atomic"]);
|
||||
} finally {
|
||||
rename.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a cleaned empty legacy file atomically during load", () => {
|
||||
const filePath = createOutboxFile();
|
||||
const rename = vi.spyOn(fs, "renameSync");
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
events: [
|
||||
event("expired-private", {
|
||||
expiresAt: 999,
|
||||
payload: {
|
||||
title: "Paket fehlgeschlagen",
|
||||
fields: [{ name: "Fehler", value: `Download · ${privateFailureDetails}`, inline: false }]
|
||||
}
|
||||
}),
|
||||
{ id: "", privateSentinel: privateFailureDetails }
|
||||
],
|
||||
lastSuccessAt: 0,
|
||||
lastFailureAt: 0,
|
||||
privateSentinel: privateFailureDetails
|
||||
}), "utf8");
|
||||
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
expect(outbox.getStatus().queued).toBe(0);
|
||||
expect(JSON.parse(raw).events).toEqual([]);
|
||||
expect(raw).not.toContain("private.example.test");
|
||||
expect(raw).not.toContain("SUPERSECRET");
|
||||
expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath);
|
||||
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
|
||||
rename.mockRestore();
|
||||
});
|
||||
|
||||
it("drops expired events before persisting or sending", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const send = vi.fn().mockResolvedValue(true);
|
||||
const outbox = new NotificationOutbox({ filePath, send, now: () => 2000 });
|
||||
|
||||
await outbox.enqueue(event("expired", { expiresAt: 1999 }));
|
||||
await outbox.drain(2000);
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(outbox.getStatus().queued).toBe(0);
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the queue at 250 and evicts the oldest success before errors", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
|
||||
for (let index = 0; index < 249; index += 1) {
|
||||
await outbox.enqueue(event(`error-${index}`, { createdAt: 1000 + index }));
|
||||
}
|
||||
await outbox.enqueue(event("success-old", { type: "package_completed", priority: "success", createdAt: 500 }));
|
||||
await outbox.enqueue(event("success-new", { type: "package_completed", priority: "success", createdAt: 2000 }));
|
||||
|
||||
const ids = persisted(filePath).events.map((queuedEvent) => queuedEvent.id);
|
||||
expect(ids).toHaveLength(250);
|
||||
expect(ids).not.toContain("success-old");
|
||||
expect(ids).toContain("success-new");
|
||||
expect(ids.filter((id) => id.startsWith("error-"))).toHaveLength(249);
|
||||
});
|
||||
|
||||
it("never persists webhook or mention fields supplied outside the event contract", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
|
||||
const unsafe = {
|
||||
...event("safe"),
|
||||
url: "https://discord.example.test/private-webhook",
|
||||
mention: "@private",
|
||||
payload: {
|
||||
...event("safe").payload,
|
||||
url: "https://discord.example.test/nested-private-webhook",
|
||||
mention: "@nested-private"
|
||||
}
|
||||
} as unknown as NotificationEvent;
|
||||
|
||||
await outbox.enqueue(unsafe);
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
expect(raw).not.toContain("private-webhook");
|
||||
expect(raw).not.toContain("@private");
|
||||
expect(persisted(filePath).events[0]).toEqual(event("safe"));
|
||||
});
|
||||
|
||||
it("keeps private package failure details out of events, requests, and persisted state", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
|
||||
const result: PackageResult = {
|
||||
packageId: "pkg-private",
|
||||
name: "Paket",
|
||||
status: "failed",
|
||||
startedAt: 1000,
|
||||
downloadEndedAt: 2000,
|
||||
postProcessStartedAt: 0,
|
||||
completedAt: 2000,
|
||||
downloadDurationSeconds: 1,
|
||||
extractionDurationSeconds: 0,
|
||||
remuxDurationSeconds: 0,
|
||||
postProcessDurationSeconds: 0,
|
||||
totalDurationSeconds: 1,
|
||||
totalBytes: 1000,
|
||||
downloadedBytes: 0,
|
||||
averageDownloadSpeedBps: 0,
|
||||
successfulFiles: 0,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
downloadFailures: 1,
|
||||
offlineFailures: 0,
|
||||
extractionFailures: 0,
|
||||
remuxFailures: 0,
|
||||
cleanupFailures: 0,
|
||||
archiveCount: 0,
|
||||
partCount: 0,
|
||||
outputCount: 0,
|
||||
failurePhase: "download",
|
||||
errorCategory: privateDetails,
|
||||
archiveOperations: [],
|
||||
remuxOperations: []
|
||||
};
|
||||
const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 1000);
|
||||
const request = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", {
|
||||
title: notificationEvent.payload.title,
|
||||
message: notificationEvent.payload.description || "",
|
||||
color: notificationEvent.payload.color,
|
||||
fields: notificationEvent.payload.fields,
|
||||
timestamp: notificationEvent.createdAt
|
||||
});
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
|
||||
|
||||
await outbox.enqueue(notificationEvent);
|
||||
|
||||
const eventText = JSON.stringify(notificationEvent);
|
||||
const requestText = String(request.init.body);
|
||||
const persistedText = fs.readFileSync(filePath, "utf8");
|
||||
const sensitiveValues = [
|
||||
"https://private.example.test/hook",
|
||||
"C:/Private/target",
|
||||
"alice@example.test",
|
||||
"token=SUPERSECRET"
|
||||
];
|
||||
expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe("Download · Download");
|
||||
for (const sensitiveValue of sensitiveValues) {
|
||||
expect(eventText).not.toContain(sensitiveValue);
|
||||
expect(requestText).not.toContain(sensitiveValue);
|
||||
expect(persistedText).not.toContain(sensitiveValue);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["fullStatus fallback", "fullStatus", "Download · Download"],
|
||||
["remux operation", "remux", "Remux · Remux"],
|
||||
["cleanup failure", "cleanup", "Aufräumen · Cleanup"]
|
||||
] as const)("redacts private %s details through telemetry, Discord request, and persisted outbox", async (_label, source, expectedFailure) => {
|
||||
const filePath = createOutboxFile();
|
||||
const result = finalizePackageResult(privateFailureTelemetry(source));
|
||||
const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 3000);
|
||||
const request = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", {
|
||||
title: notificationEvent.payload.title,
|
||||
message: notificationEvent.payload.description || "",
|
||||
color: notificationEvent.payload.color,
|
||||
fields: notificationEvent.payload.fields,
|
||||
timestamp: notificationEvent.createdAt
|
||||
});
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 3000 });
|
||||
|
||||
await outbox.enqueue(notificationEvent);
|
||||
|
||||
const eventText = JSON.stringify(notificationEvent);
|
||||
const requestText = String(request.init.body);
|
||||
const persistedText = fs.readFileSync(filePath, "utf8");
|
||||
expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe(expectedFailure);
|
||||
for (const sensitiveValue of privateFailureValues) {
|
||||
expect(eventText).not.toContain(sensitiveValue);
|
||||
expect(requestText).not.toContain(sensitiveValue);
|
||||
expect(persistedText).not.toContain(sensitiveValue);
|
||||
}
|
||||
});
|
||||
|
||||
it("projects private failure details from an existing outbox before persisting again", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
|
||||
const legacyEvent = event("legacy-private", {
|
||||
payload: {
|
||||
title: "Paket fehlgeschlagen",
|
||||
description: "Paket",
|
||||
fields: [{ name: "Fehler", value: `Download · ${privateDetails}`, inline: false }]
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
events: [legacyEvent],
|
||||
lastSuccessAt: 0,
|
||||
lastFailureAt: 0
|
||||
}), "utf8");
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
|
||||
|
||||
await outbox.enqueue(event("safe"));
|
||||
|
||||
const persistedText = fs.readFileSync(filePath, "utf8");
|
||||
expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Download · Download");
|
||||
expect(persistedText).not.toContain("https://private.example.test/hook");
|
||||
expect(persistedText).not.toContain("C:/Private/target");
|
||||
expect(persistedText).not.toContain("alice@example.test");
|
||||
expect(persistedText).not.toContain("token=SUPERSECRET");
|
||||
});
|
||||
|
||||
it("redacts a legacy persisted failure before automatic drain and Discord request creation", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const legacyEvent = event("legacy-private-auto-drain", {
|
||||
payload: {
|
||||
title: "Paket fehlgeschlagen",
|
||||
description: "Paket",
|
||||
fields: [{ name: "Fehler", value: `Download · ${privateFailureDetails}`, inline: false }]
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
events: [legacyEvent],
|
||||
lastSuccessAt: 0,
|
||||
lastFailureAt: 0
|
||||
}), "utf8");
|
||||
const sentEvents: NotificationEvent[] = [];
|
||||
const requestBodies: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
autoDrain: true,
|
||||
send: async (queuedEvent) => {
|
||||
sentEvents.push(queuedEvent);
|
||||
requestBodies.push(String(buildNotifyRequest("https://discord.com/api/webhooks/123/abc", {
|
||||
title: queuedEvent.payload.title,
|
||||
message: queuedEvent.payload.description || "",
|
||||
color: queuedEvent.payload.color,
|
||||
fields: queuedEvent.payload.fields,
|
||||
timestamp: queuedEvent.createdAt
|
||||
}).init.body));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await outbox.drain(1000);
|
||||
|
||||
expect(sentEvents).toHaveLength(1);
|
||||
expect(sentEvents[0].payload.fields[0]?.value).toBe("Download · Download");
|
||||
expect(outbox.getStatus().queued).toBe(0);
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
const emittedText = `${JSON.stringify(sentEvents)} ${requestBodies.join(" ")} ${fs.readFileSync(filePath, "utf8")}`;
|
||||
for (const sensitiveValue of privateFailureValues) {
|
||||
expect(emittedText).not.toContain(sensitiveValue);
|
||||
}
|
||||
});
|
||||
|
||||
it("persists an enqueue while an earlier delivery is blocked", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
send: async () => {
|
||||
markSendStarted();
|
||||
return sendResult;
|
||||
},
|
||||
now: () => 1000
|
||||
});
|
||||
await outbox.enqueue(event("blocked"));
|
||||
const draining = outbox.drain();
|
||||
await sendStarted;
|
||||
|
||||
const lateEnqueue = outbox.enqueue(event("late-digest", {
|
||||
type: "package_completed",
|
||||
priority: "success"
|
||||
}));
|
||||
const persistedBeforeRelease = await Promise.race([
|
||||
lateEnqueue.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 25))
|
||||
]);
|
||||
const stateBeforeRelease = persisted(filePath);
|
||||
releaseSend(true);
|
||||
await draining;
|
||||
await lateEnqueue;
|
||||
|
||||
expect(persistedBeforeRelease).toBe(true);
|
||||
expect(stateBeforeRelease.events.map((queuedEvent) => queuedEvent.id)).toContain("late-digest");
|
||||
});
|
||||
|
||||
it("acknowledges a successful in-flight delivery after a parallel enqueue expires it", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let now = 1000;
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const delivered: Array<{ id: string; deliveredAt: number }> = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => now,
|
||||
send: async () => {
|
||||
markSendStarted();
|
||||
return sendResult;
|
||||
},
|
||||
onDelivered: (queuedEvent, deliveredAt) => {
|
||||
delivered.push({ id: queuedEvent.id, deliveredAt });
|
||||
}
|
||||
});
|
||||
await outbox.enqueue(event("expires-in-flight", { expiresAt: 1500 }));
|
||||
const draining = outbox.drain();
|
||||
await sendStarted;
|
||||
|
||||
now = 2000;
|
||||
await outbox.enqueue(event("late", { createdAt: 2000, nextAttemptAt: 3000 }));
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toContain("expires-in-flight");
|
||||
releaseSend(true);
|
||||
await draining;
|
||||
|
||||
expect(delivered).toEqual([{ id: "expires-in-flight", deliveredAt: 2000 }]);
|
||||
expect(outbox.getStatus()).toEqual({ queued: 1, lastSuccessAt: 2000, lastFailureAt: 0 });
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["late"]);
|
||||
});
|
||||
|
||||
it("keeps a failed in-flight delivery queued for retry during a capacity-enforcing enqueue", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const queuedEvents = [
|
||||
event("fails-in-flight", { createdAt: 1 }),
|
||||
...Array.from({ length: 249 }, (_, index) => event(`queued-${index}`, { createdAt: index + 2 }))
|
||||
];
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
events: queuedEvents,
|
||||
lastSuccessAt: 0,
|
||||
lastFailureAt: 0
|
||||
}), "utf8");
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => 1000,
|
||||
send: async () => {
|
||||
markSendStarted();
|
||||
return sendResult;
|
||||
}
|
||||
});
|
||||
const draining = outbox.drain();
|
||||
await sendStarted;
|
||||
|
||||
await outbox.enqueue(event("late-capacity", { createdAt: 10000 }));
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toContain("fails-in-flight");
|
||||
releaseSend(false);
|
||||
await draining;
|
||||
|
||||
const state = persisted(filePath);
|
||||
expect(state.events).toHaveLength(250);
|
||||
expect(state.events[0]).toMatchObject({ id: "fails-in-flight", attempts: 1, nextAttemptAt: 2000 });
|
||||
expect(state.lastFailureAt).toBe(1000);
|
||||
});
|
||||
|
||||
it("acknowledges only successful delivery with its actual completion time", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let now = 1000;
|
||||
const delivered: Array<{ id: string; deliveredAt: number }> = [];
|
||||
const failedOutbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => now,
|
||||
send: async () => {
|
||||
now = 2000;
|
||||
return false;
|
||||
},
|
||||
onDelivered: (queuedEvent, deliveredAt) => {
|
||||
delivered.push({ id: queuedEvent.id, deliveredAt });
|
||||
}
|
||||
});
|
||||
|
||||
await failedOutbox.enqueue(event("failed"));
|
||||
await failedOutbox.drain();
|
||||
expect(delivered).toEqual([]);
|
||||
|
||||
const deliveredFilePath = createOutboxFile();
|
||||
now = 3000;
|
||||
const deliveredOutbox = new NotificationOutbox({
|
||||
filePath: deliveredFilePath,
|
||||
now: () => now,
|
||||
send: async () => true,
|
||||
onDelivered: (queuedEvent, deliveredAt) => {
|
||||
delivered.push({ id: queuedEvent.id, deliveredAt });
|
||||
}
|
||||
});
|
||||
await deliveredOutbox.enqueue(event("delivered", { nextAttemptAt: 3000 }));
|
||||
await deliveredOutbox.drain();
|
||||
|
||||
expect(delivered).toEqual([{ id: "delivered", deliveredAt: 3000 }]);
|
||||
});
|
||||
|
||||
it("does not redeliver or block later events when delivery acknowledgement fails", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const sent: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: () => 1000,
|
||||
send: async (queuedEvent) => {
|
||||
sent.push(queuedEvent.id);
|
||||
return true;
|
||||
},
|
||||
onDelivered: (queuedEvent) => {
|
||||
if (queuedEvent.id === "first") {
|
||||
throw new Error("health state unavailable");
|
||||
}
|
||||
}
|
||||
});
|
||||
await outbox.enqueue(event("first"));
|
||||
await outbox.enqueue(event("second"));
|
||||
|
||||
await expect(outbox.drain()).resolves.toBeUndefined();
|
||||
|
||||
expect(sent).toEqual(["first", "second"]);
|
||||
expect(persisted(filePath).events).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns after the default three-second shutdown budget when sending hangs", async () => {
|
||||
vi.useFakeTimers();
|
||||
const filePath = createOutboxFile();
|
||||
const outbox = new NotificationOutbox({ filePath, send: async () => new Promise<boolean>(() => {}), now: () => 1000 });
|
||||
await outbox.enqueue(event("hanging"));
|
||||
|
||||
let completed = false;
|
||||
const draining = outbox.drainForShutdown().then(() => { completed = true; });
|
||||
await vi.advanceTimersByTimeAsync(2999);
|
||||
expect(completed).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await draining;
|
||||
expect(completed).toBe(true);
|
||||
expect(outbox.getStatus().queued).toBe(1);
|
||||
});
|
||||
|
||||
it("persists a retry during shutdown while an earlier delivery remains blocked", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const filePath = createOutboxFile();
|
||||
const renameFile = fsp.rename.bind(fsp);
|
||||
let renameAttempts = 0;
|
||||
let markRecoveryPersisted = () => {};
|
||||
const recoveryPersisted = new Promise<void>((resolve) => { markRecoveryPersisted = resolve; });
|
||||
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
|
||||
const attempt = ++renameAttempts;
|
||||
if (attempt === 2) {
|
||||
throw new Error("temporary shutdown rename failure");
|
||||
}
|
||||
await renameFile(oldPath, newPath);
|
||||
if (attempt === 3) {
|
||||
markRecoveryPersisted();
|
||||
}
|
||||
});
|
||||
const sent: string[] = [];
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const blockedSend = new Promise<boolean>(() => {});
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
autoDrain: true,
|
||||
send: async (queuedEvent) => {
|
||||
sent.push(queuedEvent.id);
|
||||
markSendStarted();
|
||||
return blockedSend;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await outbox.enqueue(event("shutdown-a"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await sendStarted;
|
||||
await expect(outbox.enqueue(event("shutdown-b"))).rejects.toThrow("temporary shutdown rename failure");
|
||||
|
||||
let completed = false;
|
||||
const shutdown = outbox.drainForShutdown(100).then(() => { completed = true; });
|
||||
await recoveryPersisted;
|
||||
|
||||
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["shutdown-a", "shutdown-b"]);
|
||||
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
|
||||
expect(sent).toEqual(["shutdown-a"]);
|
||||
expect(completed).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(completed).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await shutdown;
|
||||
expect(completed).toBe(true);
|
||||
expect(sent).toEqual(["shutdown-a"]);
|
||||
} finally {
|
||||
rename.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+816
-79
@@ -2,37 +2,42 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/main/notify", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/main/notify")>();
|
||||
return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) };
|
||||
});
|
||||
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { buildRunNotificationEvent, buildRunResult } from "../src/main/notification-events";
|
||||
import type { NotificationEvent } from "../src/main/notification-outbox";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { sendNotification } from "../src/main/notify";
|
||||
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
|
||||
const mockedSend = sendNotification as unknown as ReturnType<typeof vi.fn>;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
mockedSend.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
|
||||
function setup(settings: Partial<AppSettings> = {}): {
|
||||
manager: DownloadManager;
|
||||
session: ReturnType<typeof emptySession>;
|
||||
events: NotificationEvent[];
|
||||
history: HistoryEntry[];
|
||||
} {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const events: NotificationEvent[] = [];
|
||||
const history: HistoryEntry[] = [];
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
@@ -41,113 +46,845 @@ function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySe
|
||||
extractDir: path.join(root, "extract"),
|
||||
notifyUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
notifyOnPackageCompleted: true,
|
||||
notifyOnPackageFailed: true
|
||||
notifyOnPackageFailed: true,
|
||||
notifyOnRunFinished: true,
|
||||
notifyPackageSuccessMode: "individual",
|
||||
autoExtract: false,
|
||||
...settings
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
createStoragePaths(path.join(root, "state")),
|
||||
{
|
||||
enqueueNotification: async (event: NotificationEvent) => {
|
||||
events.push(event);
|
||||
},
|
||||
onHistoryEntry: (entry) => history.push(entry)
|
||||
}
|
||||
);
|
||||
return { manager, session };
|
||||
return { manager, session, events, history };
|
||||
}
|
||||
|
||||
function addPackage(session: ReturnType<typeof emptySession>, itemStatuses: string[]): any {
|
||||
const pkgId = "pkg-1";
|
||||
const pkg: any = {
|
||||
id: pkgId,
|
||||
name: "Test.Show.S01",
|
||||
outputDir: "C:/out",
|
||||
extractDir: "C:/extract",
|
||||
function addPackage(
|
||||
session: ReturnType<typeof emptySession>,
|
||||
statuses: Array<"completed" | "failed" | "cancelled" | "queued"> = ["completed"],
|
||||
packageId = "pkg-1"
|
||||
): PackageEntry {
|
||||
const startedAt = Date.now() - 30_000;
|
||||
const pkg: PackageEntry = {
|
||||
id: packageId,
|
||||
name: `Test ${packageId}`,
|
||||
outputDir: `C:/out/${packageId}`,
|
||||
extractDir: `C:/extract/${packageId}`,
|
||||
status: "queued",
|
||||
itemIds: itemStatuses.map((_s, i) => `it-${i}`),
|
||||
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
downloadStartedAt: startedAt,
|
||||
downloadCompletedAt: startedAt + 10_000,
|
||||
downloadEndedAt: startedAt + 10_000,
|
||||
createdAt: startedAt,
|
||||
updatedAt: startedAt + 10_000
|
||||
};
|
||||
session.packages[pkgId] = pkg;
|
||||
session.packageOrder.push(pkgId);
|
||||
itemStatuses.forEach((status, i) => {
|
||||
session.items[`it-${i}`] = {
|
||||
id: `it-${i}`,
|
||||
packageId: pkgId,
|
||||
url: `https://dummy/${i}`,
|
||||
provider: null,
|
||||
session.packages[packageId] = pkg;
|
||||
session.packageOrder.push(packageId);
|
||||
statuses.forEach((status, index) => {
|
||||
const itemId = `${packageId}-item-${index}`;
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: `https://dummy/${packageId}/${index}`,
|
||||
provider: "realdebrid",
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: `f${i}.rar`,
|
||||
targetPath: "",
|
||||
downloadedBytes: status === "completed" ? 1_000 : 0,
|
||||
totalBytes: 1_000,
|
||||
progressPercent: status === "completed" ? 100 : 0,
|
||||
fileName: `${packageId}-${index}.rar`,
|
||||
targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
} as any;
|
||||
lastError: status === "failed" ? "offline" : "",
|
||||
fullStatus: status === "completed" ? "Fertig" : status === "failed" ? "Offline" : "Wartet",
|
||||
createdAt: startedAt,
|
||||
updatedAt: startedAt + 10_000
|
||||
};
|
||||
});
|
||||
return pkg;
|
||||
}
|
||||
|
||||
describe("refreshPackageStatus failed-transition notify", () => {
|
||||
it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => {
|
||||
const { manager, session } = setup();
|
||||
function internal(manager: DownloadManager): any {
|
||||
return manager as any;
|
||||
}
|
||||
|
||||
async function flushNotifications(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("authoritative package completion", () => {
|
||||
it("waits for main, deferred, hybrid and file operations before emitting one package result", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
state.packagePostProcessTasks.set(pkg.id, Promise.resolve());
|
||||
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
|
||||
state.packageHybridPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
|
||||
state.packageFileOpChain.set(pkg.id, Promise.resolve());
|
||||
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
expect(events).toHaveLength(0);
|
||||
expect(history).toHaveLength(0);
|
||||
|
||||
state.packagePostProcessTasks.delete(pkg.id);
|
||||
state.packageDeferredPostProcessTasks.delete(pkg.id);
|
||||
state.packageHybridPostProcessTasks.delete(pkg.id);
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
state.packageFileOpChain.delete(pkg.id);
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(pkg.postProcessCompletedAt).toBeGreaterThan(0);
|
||||
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
|
||||
});
|
||||
|
||||
it("keeps postprocess start unset when a queued package never receives a slot", async () => {
|
||||
const { manager, session, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
pkg.postProcessQueuedAt = Date.now() - 5000;
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].postProcessStartedAt).toBe(0);
|
||||
expect(history[0].postProcessDurationSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it("records exactly one business history entry when a finalized package is manually deleted", async () => {
|
||||
const { manager, session, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
manager.cancelPackage(pkg.id);
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({ id: `hist-${pkg.id}-1`, status: "completed" });
|
||||
});
|
||||
|
||||
it("turns a deferred remux failure into one immediate failed package event", async () => {
|
||||
const { manager, session, events, history } = setup({ notifyPackageSuccessMode: "digest" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
pkg.remuxOperations = [{
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: 10_000,
|
||||
completedAt: 14_000,
|
||||
durationMs: 4_000,
|
||||
status: "failed",
|
||||
errorCategory: "ffmpeg"
|
||||
}];
|
||||
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
|
||||
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
state.packageDeferredPostProcessTasks.delete(pkg.id);
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_failed"]);
|
||||
expect(events[0].priority).toBe("error");
|
||||
expect(history[0]).toMatchObject({ status: "failed", failurePhase: "remux", failedFiles: 1 });
|
||||
});
|
||||
|
||||
it("emits a partial package result when the terminal downloads are mixed", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session, ["completed", "failed"]);
|
||||
session.running = true;
|
||||
internal(manager).runPackageIds.add(pkg.id);
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
internal(manager).refreshPackageStatus(pkg);
|
||||
await flushNotifications();
|
||||
|
||||
expect(pkg.status).toBe("failed");
|
||||
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen");
|
||||
expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2");
|
||||
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
|
||||
expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 });
|
||||
});
|
||||
|
||||
it("notifies an all-failed package and dedups repeat refreshes", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed", "failed"]);
|
||||
it("creates a new result generation when extraction is retried", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-1",
|
||||
name: "episode.rar",
|
||||
itemIds: [...pkg.itemIds],
|
||||
partCount: 1,
|
||||
startedAt: 10_000,
|
||||
completedAt: 12_000,
|
||||
durationMs: 2_000,
|
||||
status: "failed",
|
||||
errorCategory: "crc_error"
|
||||
}];
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
const firstId = events[0]?.id;
|
||||
|
||||
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
manager.retryExtraction(pkg.id);
|
||||
expect(postProcess).toHaveBeenCalledWith(pkg.id);
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-2",
|
||||
name: "episode.rar",
|
||||
itemIds: [...pkg.itemIds],
|
||||
partCount: 1,
|
||||
startedAt: 20_000,
|
||||
completedAt: 23_000,
|
||||
durationMs: 3_000,
|
||||
status: "completed",
|
||||
errorCategory: ""
|
||||
}];
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)";
|
||||
pkg.status = "completed";
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1].id).not.toBe(firstId);
|
||||
expect(events[1].type).toBe("package_completed");
|
||||
expect(history).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("continues the persisted result generation after an extraction retry following restart", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
pkg.resultGeneration = 7;
|
||||
pkg.terminalAt = Date.now() - 1_000;
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-restart-failed",
|
||||
name: "episode.rar",
|
||||
itemIds: [...pkg.itemIds],
|
||||
partCount: 1,
|
||||
startedAt: 10_000,
|
||||
completedAt: 12_000,
|
||||
durationMs: 2_000,
|
||||
status: "failed",
|
||||
errorCategory: "crc_error"
|
||||
}];
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||
|
||||
manager.retryExtraction(pkg.id);
|
||||
expect(pkg.resultGeneration).toBe(8);
|
||||
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-restart-completed",
|
||||
name: "episode.rar",
|
||||
itemIds: [...pkg.itemIds],
|
||||
partCount: 1,
|
||||
startedAt: 20_000,
|
||||
completedAt: 23_000,
|
||||
durationMs: 3_000,
|
||||
status: "completed",
|
||||
errorCategory: ""
|
||||
}];
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)";
|
||||
pkg.status = "completed";
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].id).toContain(":8:");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].id).toBe(`hist-${pkg.id}-8`);
|
||||
});
|
||||
|
||||
it("moves a pending success digest into the outbox before shutdown", async () => {
|
||||
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
await state.flushNotificationsForShutdown?.();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
|
||||
expect(events[0].payload.title).toContain("Paket-Digest");
|
||||
});
|
||||
|
||||
it("persists a success digest that finalizes after shutdown flushing has started", async () => {
|
||||
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
await state.flushNotificationsForShutdown();
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
|
||||
expect(events[0].payload.title).toContain("Paket-Digest");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["digest", 4],
|
||||
["individual", 80]
|
||||
] as const)("delivers 80 successful packages in %s mode without loss", async (mode, expectedEvents) => {
|
||||
const { manager, session, events } = setup({ notifyPackageSuccessMode: mode });
|
||||
const state = internal(manager);
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
const pkg = addPackage(session, ["completed"], `bulk-package-${String(index).padStart(2, "0")}`);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
}
|
||||
if (mode === "digest") {
|
||||
state.flushPackageSuccessDigest();
|
||||
}
|
||||
await state.notificationEnqueueChain;
|
||||
|
||||
expect(events).toHaveLength(expectedEvents);
|
||||
expect(new Set(events.map((event) => event.id))).toHaveLength(expectedEvents);
|
||||
if (mode === "digest") {
|
||||
expect(events.map((event) => event.payload.fields.length)).toEqual([20, 20, 20, 20]);
|
||||
expect(events.every((event) => event.payload.description === "80 Pakete abgeschlossen")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("authoritative run completion", () => {
|
||||
it.each([
|
||||
["successful", 0, "success"],
|
||||
["failed", 1, "error"]
|
||||
] as const)("keeps a %s run_completed event for 24 hours", (_label, failedFiles, priority) => {
|
||||
const notification = buildRunNotificationEvent(buildRunResult({
|
||||
id: `run-${priority}`,
|
||||
stopped: false,
|
||||
startedAt: 1000,
|
||||
completedAt: 2000,
|
||||
packages: [],
|
||||
failedFiles
|
||||
}));
|
||||
|
||||
expect(notification.type).toBe("run_completed");
|
||||
expect(notification.priority).toBe(priority);
|
||||
expect(notification.expiresAt - notification.createdAt).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("emits run_stopped without run_completed for a manual stop", async () => {
|
||||
const { manager, session, events } = setup();
|
||||
const pkg = addPackage(session, ["completed", "queued"]);
|
||||
const state = internal(manager);
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 10_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
manager.stop();
|
||||
await flushNotifications();
|
||||
|
||||
expect(pkg.status).toBe("failed");
|
||||
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||
expect(events.map((event) => event.type)).toEqual(["run_stopped"]);
|
||||
expect(events.some((event) => event.type === "run_completed")).toBe(false);
|
||||
});
|
||||
|
||||
it("stays silent outside a run (startup recovery must not spam)", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed"]);
|
||||
session.running = false;
|
||||
it("builds run_stopped file counters only from finalized package results", async () => {
|
||||
const { manager, session, events } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
events.length = 0;
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
|
||||
expect(pkg.status).toBe("failed");
|
||||
expect(mockedSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify while items are still pending", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed", "queued"]);
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 10_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "failed"]]);
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
manager.stop();
|
||||
await flushNotifications();
|
||||
|
||||
expect(pkg.status).toBe("queued");
|
||||
expect(mockedSend).not.toHaveBeenCalled();
|
||||
const stopped = events.find((event) => event.type === "run_stopped");
|
||||
expect(stopped).toBeDefined();
|
||||
expect(stopped?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
|
||||
});
|
||||
|
||||
it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed", "failed"]);
|
||||
it("waits for failed extraction package results before emitting the final run summary", async () => {
|
||||
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
session.running = true;
|
||||
mockedSend.mockResolvedValueOnce(false);
|
||||
session.runStartedAt = Date.now() - 20_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
|
||||
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-failed",
|
||||
name: "episode.part01.rar",
|
||||
itemIds: [...pkg.itemIds],
|
||||
partCount: 16,
|
||||
startedAt: 10_000,
|
||||
completedAt: 18_000,
|
||||
durationMs: 8_000,
|
||||
status: "failed",
|
||||
errorCategory: "wrong_password"
|
||||
}];
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
state.finishRun();
|
||||
await flushNotifications();
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false);
|
||||
state.packageDeferredPostProcessTasks.delete(pkg.id);
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_failed", "run_completed"]);
|
||||
const runEvent = events[1];
|
||||
expect(runEvent.payload.fields.some((field) => field.name === "Entpackfehler" && field.value === "1")).toBe(true);
|
||||
expect(runEvent.payload.fields.some((field) => field.name === "Dateien" && field.value === "0 erfolgreich · 1 fehlgeschlagen · 0 abgebrochen")).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes successful package digests before run_completed", async () => {
|
||||
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 20_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
|
||||
|
||||
state.finishRun();
|
||||
state.tryFinalizePackageResult?.(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
|
||||
expect(events[0].payload.title).toContain("Paket-Digest");
|
||||
});
|
||||
|
||||
it("keeps a finalized success in the digest after package_done removes its session package", async () => {
|
||||
const { manager, session, events } = setup({
|
||||
notifyPackageSuccessMode: "digest",
|
||||
completedCleanupPolicy: "package_done"
|
||||
});
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 20_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
state.applyPackageDoneCleanup(pkg.id);
|
||||
expect(session.packages[pkg.id]).toBeUndefined();
|
||||
state.finishRun();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
|
||||
expect(events[0].payload.title).toContain("Paket-Digest");
|
||||
});
|
||||
|
||||
it("keeps the active run generation after package_done cleanup removes a generation-seven package", async () => {
|
||||
const { manager, session, events, history } = setup({ completedCleanupPolicy: "package_done" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
pkg.resultGeneration = 7;
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 20_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
|
||||
state.beginActiveRunContext?.(state.runPackageIds, session.runStartedAt);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
const currentResult = state.finalizedPackageResults.get(`${pkg.id}:7`);
|
||||
expect(currentResult).toBeDefined();
|
||||
state.finalizedPackageResults.set(`${pkg.id}:1`, {
|
||||
...currentResult,
|
||||
status: "failed",
|
||||
successfulFiles: 0,
|
||||
failedFiles: 9,
|
||||
failurePhase: "download",
|
||||
errorCategory: "stale"
|
||||
});
|
||||
state.applyPackageDoneCleanup(pkg.id);
|
||||
expect(session.packages[pkg.id]).toBeUndefined();
|
||||
|
||||
state.finishRun();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
|
||||
const completedRun = events.find((event) => event.type === "run_completed");
|
||||
expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].id).toBe(`hist-${pkg.id}-7`);
|
||||
});
|
||||
|
||||
it("registers a postprocess-only start before a package task without download items completes", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
for (const itemId of pkg.itemIds) {
|
||||
delete session.items[itemId];
|
||||
}
|
||||
pkg.itemIds = [];
|
||||
pkg.status = "completed";
|
||||
let releasePostProcess = (): void => {};
|
||||
const postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
|
||||
|
||||
const postProcess = state.runPackagePostProcessing(pkg.id);
|
||||
await Promise.resolve();
|
||||
await manager.start();
|
||||
releasePostProcess();
|
||||
await postProcess;
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({ name: pkg.name, status: "completed", fileCount: 0 });
|
||||
});
|
||||
|
||||
it("updates an active run from generation one to generation two after resetting the same package", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 20_000;
|
||||
state.runItemIds = new Set(pkg.itemIds);
|
||||
state.runPackageIds = new Set([pkg.id]);
|
||||
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
|
||||
state.beginActiveRunContext(state.runPackageIds, session.runStartedAt);
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
const generationOne = state.finalizedPackageResults.get(`${pkg.id}:1`);
|
||||
state.finalizedPackageResults.set(`${pkg.id}:1`, {
|
||||
...generationOne,
|
||||
status: "failed",
|
||||
successfulFiles: 0,
|
||||
failedFiles: 9,
|
||||
failurePhase: "download",
|
||||
errorCategory: "stale"
|
||||
});
|
||||
await flushNotifications();
|
||||
events.length = 0;
|
||||
history.length = 0;
|
||||
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
await manager.resetPackage(pkg.id);
|
||||
expect(pkg.resultGeneration).toBe(2);
|
||||
const item = session.items[pkg.itemIds[0]];
|
||||
item.status = "completed";
|
||||
item.downloadedBytes = 1_000;
|
||||
item.totalBytes = 1_000;
|
||||
item.progressPercent = 100;
|
||||
item.fullStatus = "Fertig";
|
||||
pkg.status = "completed";
|
||||
state.runOutcomes.set(item.id, "completed");
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
state.finishRun();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(events.find((event) => event.type === "package_completed")?.id).toContain(":2:");
|
||||
const completedRun = events.find((event) => event.type === "run_completed");
|
||||
expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].id).toBe(`hist-${pkg.id}-2`);
|
||||
expect([...state.finalizedPackageResults.keys()].filter((key) => key.startsWith(`${pkg.id}:`))).toEqual([`${pkg.id}:2`]);
|
||||
});
|
||||
|
||||
it("tracks a main postprocess task created by triggerPendingExtractions after start begins", async () => {
|
||||
const { manager, session, events, history } = setup({ autoExtract: true });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
pkg.status = "completed";
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Fertig";
|
||||
let releasePostProcess = (): void => {};
|
||||
const postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
|
||||
|
||||
await manager.start();
|
||||
const postProcess = state.packagePostProcessTasks.get(pkg.id);
|
||||
expect(postProcess).toBeDefined();
|
||||
releasePostProcess();
|
||||
await postProcess;
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].name).toBe(pkg.name);
|
||||
});
|
||||
|
||||
it("tracks a deferred-only startup task at creation without an active run", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
pkg.status = "completed";
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (1.0s)";
|
||||
state.executeDeferredPostExtraction = vi.fn(async () => undefined);
|
||||
|
||||
await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, true, 1);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].name).toBe(pkg.name);
|
||||
});
|
||||
|
||||
it("keeps stopped package postprocessing suppressed when a later start only runs another package", async () => {
|
||||
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
|
||||
const packageA = addPackage(session, ["queued"], "stopped-package");
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
const packageAItem = session.items[packageA.itemIds[0]];
|
||||
packageAItem.status = "completed";
|
||||
packageAItem.downloadedBytes = 1_000;
|
||||
packageAItem.totalBytes = 1_000;
|
||||
packageAItem.progressPercent = 100;
|
||||
packageAItem.fullStatus = "Fertig";
|
||||
packageA.status = "completed";
|
||||
let releasePostProcess = (): void => {};
|
||||
const postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
|
||||
const latePostProcess = state.runPackagePostProcessing(packageA.id);
|
||||
await Promise.resolve();
|
||||
|
||||
manager.stop();
|
||||
await flushNotifications();
|
||||
|
||||
const packageB = addPackage(session, ["queued"], "follow-up-package");
|
||||
await manager.start();
|
||||
expect(session.running).toBe(true);
|
||||
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
|
||||
|
||||
releasePostProcess();
|
||||
await latePostProcess;
|
||||
await flushNotifications();
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps an earlier run-owned postprocess result alive when a later run is stopped", async () => {
|
||||
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
|
||||
const packageA = addPackage(session, ["queued"], "earlier-run-package");
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
const packageAItem = session.items[packageA.itemIds[0]];
|
||||
packageAItem.status = "completed";
|
||||
packageAItem.downloadedBytes = 1_000;
|
||||
packageAItem.totalBytes = 1_000;
|
||||
packageAItem.progressPercent = 100;
|
||||
packageAItem.fullStatus = "Fertig";
|
||||
packageA.status = "completed";
|
||||
state.runOutcomes.set(packageAItem.id, "completed");
|
||||
let releasePostProcess = (): void => {};
|
||||
const postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
|
||||
const packageAPostProcess = state.runPackagePostProcessing(packageA.id);
|
||||
await Promise.resolve();
|
||||
state.finishRun();
|
||||
|
||||
const packageB = addPackage(session, ["queued"], "later-run-package");
|
||||
await manager.start();
|
||||
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
|
||||
manager.stop();
|
||||
|
||||
releasePostProcess();
|
||||
await packageAPostProcess;
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
|
||||
expect(history.map((entry) => entry.name)).toEqual([packageA.name]);
|
||||
});
|
||||
|
||||
it("keeps run A ownership when its real deferred follow-up starts during run B before run B stops", async () => {
|
||||
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true, maxParallelExtract: 1 });
|
||||
const packageA = addPackage(session, ["queued"], "deferred-owner-package");
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
const packageAItem = session.items[packageA.itemIds[0]];
|
||||
packageAItem.status = "completed";
|
||||
packageAItem.downloadedBytes = 1_000;
|
||||
packageAItem.totalBytes = 1_000;
|
||||
packageAItem.progressPercent = 100;
|
||||
packageAItem.fullStatus = "Fertig";
|
||||
packageA.status = "completed";
|
||||
state.runOutcomes.set(packageAItem.id, "completed");
|
||||
state.packagePostProcessActive = 1;
|
||||
|
||||
let releaseCollection = (): void => {};
|
||||
const collectionGate = new Promise<void>((resolve) => {
|
||||
releaseCollection = resolve;
|
||||
});
|
||||
const collect = vi.spyOn(state, "collectMkvFilesToLibrary").mockImplementation(async () => collectionGate);
|
||||
const packageAMainPostProcess = state.runPackagePostProcessing(packageA.id);
|
||||
await vi.waitFor(() => expect(state.packagePostProcessWaiters).toHaveLength(1));
|
||||
state.finishRun();
|
||||
|
||||
const packageB = addPackage(session, ["queued"], "active-run-package");
|
||||
await manager.start();
|
||||
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
|
||||
|
||||
state.releasePostProcessSlot();
|
||||
await packageAMainPostProcess;
|
||||
await vi.waitFor(() => expect(collect).toHaveBeenCalled());
|
||||
const deferredTasks = [...(state.packageDeferredPostProcessTasks.get(packageA.id) || [])];
|
||||
expect(deferredTasks).toHaveLength(1);
|
||||
|
||||
manager.stop();
|
||||
releaseCollection();
|
||||
await Promise.allSettled(deferredTasks);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
|
||||
expect(history.map((entry) => entry.name)).toEqual([packageA.name]);
|
||||
});
|
||||
|
||||
it("does not reactivate a suppressed foreign package when another start recovers it from disk", async () => {
|
||||
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
|
||||
const packageA = addPackage(session, ["queued"], "suppressed-recovery-package");
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
manager.stop();
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-recovery-"));
|
||||
tempDirs.push(recoveryDir);
|
||||
const recoveredPath = path.join(recoveryDir, "recovered-package.rar");
|
||||
fs.writeFileSync(recoveredPath, Buffer.alloc(1_000, 7));
|
||||
const packageAItem = session.items[packageA.itemIds[0]];
|
||||
packageA.enabled = false;
|
||||
packageA.status = "failed";
|
||||
packageAItem.status = "failed";
|
||||
packageAItem.targetPath = recoveredPath;
|
||||
packageAItem.downloadedBytes = 0;
|
||||
packageAItem.totalBytes = 1_000;
|
||||
packageAItem.progressPercent = 0;
|
||||
packageAItem.fullStatus = "Resume-Link erneuern";
|
||||
packageAItem.lastError = "download_underflow";
|
||||
|
||||
const packageB = addPackage(session, ["queued"], "recovery-run-package");
|
||||
await manager.start();
|
||||
await Promise.allSettled([...state.packagePostProcessTasks.values()]);
|
||||
await flushNotifications();
|
||||
|
||||
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
|
||||
expect(history).toHaveLength(0);
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("suppresses a stopped postprocess-only generation and allows an explicit package retry", async () => {
|
||||
const { manager, session, events, history } = setup({ autoExtract: true, autoExtractWhenStopped: true });
|
||||
const pkg = addPackage(session, ["completed"], "postprocess-only-package");
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
let releasePostProcess = (): void => {};
|
||||
let postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
|
||||
|
||||
await manager.start();
|
||||
const stoppedPostProcess = state.packagePostProcessTasks.get(pkg.id);
|
||||
expect(stoppedPostProcess).toBeDefined();
|
||||
expect(state.runItemIds.size).toBe(0);
|
||||
manager.stop();
|
||||
|
||||
releasePostProcess();
|
||||
await stoppedPostProcess;
|
||||
await flushNotifications();
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
|
||||
expect(history).toHaveLength(0);
|
||||
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
manager.retryExtraction(pkg.id);
|
||||
const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id);
|
||||
expect(retriedPostProcess).toBeDefined();
|
||||
releasePostProcess();
|
||||
await retriedPostProcess;
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
|
||||
expect(history.map((entry) => entry.name)).toEqual([pkg.name]);
|
||||
});
|
||||
|
||||
it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => {
|
||||
const { manager, session, events, history } = setup();
|
||||
const packageA = addPackage(session, ["completed"], "package-a");
|
||||
const packageB = addPackage(session, ["completed"], "package-b");
|
||||
const state = internal(manager);
|
||||
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 20_000;
|
||||
state.runItemIds = new Set(packageA.itemIds);
|
||||
state.runPackageIds = new Set([packageA.id]);
|
||||
state.runOutcomes = new Map([[packageA.itemIds[0], "completed"]]);
|
||||
state.packageDeferredPostProcessTasks.set(packageA.id, new Set([Promise.resolve()]));
|
||||
state.finishRun();
|
||||
|
||||
session.running = true;
|
||||
session.runStartedAt = Date.now() - 5_000;
|
||||
state.runItemIds = new Set(packageB.itemIds);
|
||||
state.runPackageIds = new Set([packageB.id]);
|
||||
state.runOutcomes = new Map([[packageB.itemIds[0], "completed"]]);
|
||||
state.finishRun();
|
||||
|
||||
state.packageDeferredPostProcessTasks.delete(packageA.id);
|
||||
state.tryFinalizePackageResult(packageA.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(2);
|
||||
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(2);
|
||||
expect(new Set(events.filter((event) => event.type === "package_completed").map((event) => event.id)).size).toBe(2);
|
||||
expect(new Set(events.filter((event) => event.type === "run_completed").map((event) => event.id)).size).toBe(2);
|
||||
expect(history.map((entry) => entry.name).sort()).toEqual([packageA.name, packageB.name].sort());
|
||||
});
|
||||
});
|
||||
|
||||
+48
-8
@@ -53,24 +53,64 @@ describe("truncateContent", () => {
|
||||
});
|
||||
|
||||
describe("buildNotifyRequest", () => {
|
||||
it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => {
|
||||
const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
|
||||
it("builds a bounded Discord embed with the product username", () => {
|
||||
const req = buildNotifyRequest(` ${WEBHOOK} `, {
|
||||
title: "Paket fehlgeschlagen",
|
||||
message: "Eine Datei konnte nicht verarbeitet werden.",
|
||||
color: 0xe74c3c,
|
||||
fields: [
|
||||
{ name: "Ergebnis", value: "Fehlgeschlagen", inline: true },
|
||||
{ name: "Dateien", value: "0 erfolgreich, 1 fehlgeschlagen", inline: true }
|
||||
],
|
||||
timestamp: 1000
|
||||
});
|
||||
expect(req.url).toBe(WEBHOOK);
|
||||
expect(req.init.method).toBe("POST");
|
||||
expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)");
|
||||
expect(body.username).toBe("Real-Debrid Downloader");
|
||||
expect(body).toEqual({
|
||||
username: "Multi-Debrid Downloader",
|
||||
content: "",
|
||||
embeds: [{
|
||||
title: "Paket fehlgeschlagen",
|
||||
description: "Eine Datei konnte nicht verarbeitet werden.",
|
||||
color: 0xe74c3c,
|
||||
fields: [
|
||||
{ name: "Ergebnis", value: "Fehlgeschlagen", inline: true },
|
||||
{ name: "Dateien", value: "0 erfolgreich, 1 fehlgeschlagen", inline: true }
|
||||
],
|
||||
timestamp: "1970-01-01T00:00:01.000Z"
|
||||
}]
|
||||
});
|
||||
});
|
||||
it("prepends the mention so Discord pings (bare ID gets wrapped)", () => {
|
||||
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("<@123456789012345678> **T**\nM");
|
||||
expect(body.content).toBe("<@123456789012345678>");
|
||||
expect(body.embeds[0]).toMatchObject({ title: "T", description: "M" });
|
||||
});
|
||||
it("sends no mention prefix when the field is empty", () => {
|
||||
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("**T**\nM");
|
||||
expect(body.content).toBe("");
|
||||
});
|
||||
it("enforces Discord title, description, field, and total embed limits", () => {
|
||||
const req = buildNotifyRequest(WEBHOOK, {
|
||||
title: "T".repeat(400),
|
||||
message: "M".repeat(5000),
|
||||
fields: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `${index}-${"N".repeat(300)}`,
|
||||
value: "V".repeat(1400)
|
||||
}))
|
||||
});
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
const embed = body.embeds[0] as { title: string; description: string; fields: Array<{ name: string; value: string }> };
|
||||
expect(embed.title.length).toBeLessThanOrEqual(256);
|
||||
expect(embed.description.length).toBeLessThanOrEqual(4096);
|
||||
expect(embed.fields.length).toBeLessThanOrEqual(25);
|
||||
expect(embed.fields.every((field) => field.name.length <= 256 && field.value.length <= 1024)).toBe(true);
|
||||
const total = embed.title.length + embed.description.length + embed.fields.reduce((sum, field) => sum + field.name.length + field.value.length, 0);
|
||||
expect(total).toBeLessThanOrEqual(6000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +147,7 @@ describe("sendNotification", () => {
|
||||
it("serializes concurrent sends in order (burst protection)", async () => {
|
||||
const order: string[] = [];
|
||||
const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
order.push(JSON.parse(String(init.body)).content);
|
||||
order.push(JSON.parse(String(init.body)).embeds[0].title);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const sends = [
|
||||
@@ -116,7 +156,7 @@ describe("sendNotification", () => {
|
||||
sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep)
|
||||
];
|
||||
await expect(Promise.all(sends)).resolves.toEqual([true, true, true]);
|
||||
expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]);
|
||||
expect(order).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
it("does not call fetch for an invalid URL", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
|
||||
@@ -20,6 +20,7 @@ afterEach(async () => {
|
||||
function settings(): AppSettings {
|
||||
return {
|
||||
token: "rd-secret-token",
|
||||
deepbridApiKey: "fixture-deepbrid-online-key-6jK8",
|
||||
megaLogin: "backup-user",
|
||||
megaPassword: "backup-password",
|
||||
outputDir: "D:\\Downloads",
|
||||
@@ -45,6 +46,7 @@ describe("online backup key", () => {
|
||||
const parsed = parseOnlineBackupKey(created.key);
|
||||
|
||||
expect(serialized).not.toContain("rd-secret-token");
|
||||
expect(serialized).not.toContain("fixture-deepbrid-online-key-6jK8");
|
||||
expect(serialized).not.toContain("backup-password");
|
||||
expect(serialized).not.toContain(parsed.masterKey.toString("base64url"));
|
||||
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ArchiveOperationMetric,
|
||||
DownloadItem,
|
||||
PackageEntry,
|
||||
PackageTelemetry,
|
||||
RemuxOperationMetric
|
||||
} from "../src/shared/types";
|
||||
import {
|
||||
durationMsToSeconds,
|
||||
durationSecondsBetween,
|
||||
finalizePackageResult
|
||||
} from "../src/main/package-telemetry";
|
||||
import { buildRunResult } from "../src/main/notification-events";
|
||||
import { normalizeLoadedSession } from "../src/main/storage";
|
||||
|
||||
function packageEntry(overrides: Partial<PackageEntry> = {}): PackageEntry {
|
||||
return {
|
||||
id: "pkg-1",
|
||||
name: "Paket",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
extractDir: "C:\\Downloads\\Paket",
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
downloadStartedAt: 1_000,
|
||||
downloadCompletedAt: 121_000,
|
||||
downloadEndedAt: 121_000,
|
||||
postProcessQueuedAt: 122_000,
|
||||
postProcessStartedAt: 130_000,
|
||||
postProcessCompletedAt: 160_000,
|
||||
terminalAt: 166_000,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 166_000,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function downloadItem(id: string, status: DownloadItem["status"] = "completed"): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
packageId: "pkg-1",
|
||||
url: `https://example.test/${id}`,
|
||||
provider: "realdebrid",
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: status === "completed" ? 1_000 : 0,
|
||||
totalBytes: 1_000,
|
||||
progressPercent: status === "completed" ? 100 : 0,
|
||||
fileName: `${id}.rar`,
|
||||
targetPath: `C:\\Downloads\\Paket\\${id}.rar`,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: status === "failed" ? "download-error" : "",
|
||||
fullStatus: "",
|
||||
createdAt: 1_000,
|
||||
updatedAt: 121_000
|
||||
};
|
||||
}
|
||||
|
||||
function archiveOperation(overrides: Partial<ArchiveOperationMetric> = {}): ArchiveOperationMetric {
|
||||
return {
|
||||
id: "archive-1",
|
||||
name: "Paket.part01.rar",
|
||||
itemIds: ["item-1"],
|
||||
partCount: 1,
|
||||
startedAt: 130_000,
|
||||
completedAt: 160_000,
|
||||
durationMs: 30_000,
|
||||
status: "completed",
|
||||
errorCategory: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function remuxOperation(overrides: Partial<RemuxOperationMetric> = {}): RemuxOperationMetric {
|
||||
return {
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: 150_000,
|
||||
completedAt: 155_000,
|
||||
durationMs: 5_000,
|
||||
status: "completed",
|
||||
errorCategory: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function telemetry(overrides: Partial<PackageTelemetry> = {}): PackageTelemetry {
|
||||
const items = [downloadItem("item-1")];
|
||||
return {
|
||||
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||
items,
|
||||
archiveOperations: [],
|
||||
remuxOperations: [],
|
||||
outputCount: 0,
|
||||
cleanupErrorCategory: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("package lifecycle telemetry", () => {
|
||||
it("finalizes a successful package from explicit timestamps and operation durations", () => {
|
||||
const items = [downloadItem("item-1"), downloadItem("item-2")];
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||
items,
|
||||
archiveOperations: [archiveOperation({ itemIds: items.map((item) => item.id), partCount: 2 })],
|
||||
remuxOperations: [remuxOperation()],
|
||||
outputCount: 2
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
packageId: "pkg-1",
|
||||
status: "completed",
|
||||
downloadDurationSeconds: 120,
|
||||
extractionDurationSeconds: 30,
|
||||
remuxDurationSeconds: 5,
|
||||
postProcessDurationSeconds: 30,
|
||||
totalDurationSeconds: 165,
|
||||
successfulFiles: 2,
|
||||
failedFiles: 0,
|
||||
cancelledFiles: 0,
|
||||
archiveCount: 1,
|
||||
partCount: 2,
|
||||
outputCount: 2,
|
||||
failurePhase: null,
|
||||
averageDownloadSpeedBps: 16
|
||||
}));
|
||||
});
|
||||
|
||||
it("counts a failed 16-part archive as one failed file and produces a partial result", () => {
|
||||
const items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`));
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||
items,
|
||||
archiveOperations: [archiveOperation({
|
||||
itemIds: items.map((item) => item.id),
|
||||
partCount: 16,
|
||||
status: "failed",
|
||||
errorCategory: "checksum"
|
||||
})]
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: "partial",
|
||||
downloadDurationSeconds: 120,
|
||||
extractionDurationSeconds: 30,
|
||||
totalDurationSeconds: 165,
|
||||
successfulFiles: 15,
|
||||
failedFiles: 1,
|
||||
partCount: 16,
|
||||
archiveCount: 1,
|
||||
failurePhase: "extract",
|
||||
errorCategory: "Entpacken"
|
||||
}));
|
||||
});
|
||||
|
||||
it("classifies a package with no successful files and a download failure as failed", () => {
|
||||
const item = downloadItem("item-1", "failed");
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ status: "failed", itemIds: [item.id] }),
|
||||
items: [item]
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: "failed",
|
||||
successfulFiles: 0,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
failurePhase: "download",
|
||||
errorCategory: "Download"
|
||||
}));
|
||||
});
|
||||
|
||||
it("includes immediately cleaned successes and bytes in a mixed package result", () => {
|
||||
const failedItem = downloadItem("item-3", "failed");
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({
|
||||
status: "failed",
|
||||
itemIds: [failedItem.id],
|
||||
cleanedCompletedItemCount: 2,
|
||||
cleanedDownloadedBytes: 2_000,
|
||||
cleanedTotalBytes: 2_000
|
||||
}),
|
||||
items: [failedItem]
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: "partial",
|
||||
successfulFiles: 2,
|
||||
failedFiles: 1,
|
||||
downloadedBytes: 2_000,
|
||||
totalBytes: 3_000
|
||||
}));
|
||||
});
|
||||
|
||||
it("classifies a failed download without error text as a download-phase failure", () => {
|
||||
const item = { ...downloadItem("item-1", "failed"), lastError: "", fullStatus: "" };
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ status: "failed", itemIds: [item.id] }),
|
||||
items: [item]
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: "failed",
|
||||
failedFiles: 1,
|
||||
failurePhase: "download",
|
||||
errorCategory: "Download"
|
||||
}));
|
||||
});
|
||||
|
||||
it("projects private download failure details to a fixed category", () => {
|
||||
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
|
||||
const item = {
|
||||
...downloadItem("item-1", "failed"),
|
||||
lastError: privateDetails,
|
||||
fullStatus: privateDetails
|
||||
};
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ status: "failed", itemIds: [item.id] }),
|
||||
items: [item]
|
||||
}));
|
||||
|
||||
expect(result.errorCategory).toBe("Download");
|
||||
expect(result.errorCategory).not.toContain("private.example.test");
|
||||
expect(result.errorCategory).not.toContain("C:/Private/target");
|
||||
expect(result.errorCategory).not.toContain("alice@example.test");
|
||||
expect(result.errorCategory).not.toContain("SUPERSECRET");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Host ist offline", "Offline"],
|
||||
["Request ETIMEDOUT", "Timeout"],
|
||||
["socket ECONNRESET", "Netzwerk"],
|
||||
["ENOSPC: no space left on device", "Speicherplatz"],
|
||||
["EACCES: permission denied", "Berechtigung"],
|
||||
["nicht näher klassifizierbar", "Download"]
|
||||
])("maps download failure detail %s to %s", (detail, expectedCategory) => {
|
||||
const item = { ...downloadItem("item-1", "failed"), lastError: detail };
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ status: "failed", itemIds: [item.id] }),
|
||||
items: [item]
|
||||
}));
|
||||
|
||||
expect(result.errorCategory).toBe(expectedCategory);
|
||||
});
|
||||
|
||||
it("classifies a package with only cancelled work as cancelled", () => {
|
||||
const item = downloadItem("item-1", "cancelled");
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({ status: "cancelled", cancelled: true, itemIds: [item.id] }),
|
||||
items: [item]
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: "cancelled",
|
||||
successfulFiles: 0,
|
||||
failedFiles: 0,
|
||||
cancelledFiles: 1,
|
||||
failurePhase: null
|
||||
}));
|
||||
});
|
||||
|
||||
it("reports zero postprocess durations when no postprocess phase ran", () => {
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({
|
||||
downloadCompletedAt: 61_000,
|
||||
downloadEndedAt: 61_000,
|
||||
postProcessQueuedAt: undefined,
|
||||
postProcessStartedAt: undefined,
|
||||
postProcessCompletedAt: undefined,
|
||||
terminalAt: 61_000,
|
||||
updatedAt: 61_000
|
||||
})
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
downloadDurationSeconds: 60,
|
||||
extractionDurationSeconds: 0,
|
||||
remuxDurationSeconds: 0,
|
||||
postProcessDurationSeconds: 0,
|
||||
totalDurationSeconds: 60
|
||||
}));
|
||||
});
|
||||
|
||||
it("retains legacy download timing after session normalization", () => {
|
||||
const item = { ...downloadItem("item-1"), downloadedBytes: 240_000, totalBytes: 240_000 };
|
||||
const legacyPackage = packageEntry({
|
||||
itemIds: [item.id],
|
||||
downloadStartedAt: 1_000,
|
||||
downloadCompletedAt: 121_000,
|
||||
terminalAt: 121_000,
|
||||
updatedAt: 121_000
|
||||
});
|
||||
delete legacyPackage.downloadEndedAt;
|
||||
const normalized = normalizeLoadedSession({
|
||||
version: 2,
|
||||
packageOrder: [legacyPackage.id],
|
||||
packages: { [legacyPackage.id]: legacyPackage },
|
||||
items: { [item.id]: item },
|
||||
runStartedAt: 1_000,
|
||||
totalDownloadedBytes: 240_000,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: 121_000
|
||||
});
|
||||
const normalizedPackage = normalized.packages[legacyPackage.id];
|
||||
const result = finalizePackageResult({
|
||||
package: normalizedPackage,
|
||||
items: normalizedPackage.itemIds.map((itemId) => normalized.items[itemId])
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
downloadEndedAt: 121_000,
|
||||
downloadDurationSeconds: 120,
|
||||
averageDownloadSpeedBps: 2_000
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses cleanup, remux, extract, and download as deterministic failure precedence", () => {
|
||||
const failedItem = downloadItem("item-1", "failed");
|
||||
const failedArchive = archiveOperation({ status: "failed", errorCategory: "archive-error" });
|
||||
const failedRemux = remuxOperation({ status: "failed", errorCategory: "remux-error" });
|
||||
const failures = [
|
||||
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })),
|
||||
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })),
|
||||
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })),
|
||||
finalizePackageResult(telemetry({ items: [failedItem] }))
|
||||
];
|
||||
|
||||
expect(failures.map(({ failurePhase, errorCategory }) => ({ failurePhase, errorCategory }))).toEqual([
|
||||
{ failurePhase: "cleanup", errorCategory: "Cleanup" },
|
||||
{ failurePhase: "remux", errorCategory: "Remux" },
|
||||
{ failurePhase: "extract", errorCategory: "Entpacken" },
|
||||
{ failurePhase: "download", errorCategory: "Download" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains additive failure counters when cleanup is the primary failure phase", () => {
|
||||
const failedItem = { ...downloadItem("item-1", "failed"), lastError: "Host ist offline" };
|
||||
const packageResult = finalizePackageResult(telemetry({
|
||||
items: [failedItem],
|
||||
archiveOperations: [archiveOperation({ status: "failed", errorCategory: "archive-error" })],
|
||||
remuxOperations: [remuxOperation({ status: "failed", errorCategory: "remux-error" })],
|
||||
cleanupErrorCategory: "cleanup-error"
|
||||
}));
|
||||
const runResult = buildRunResult({
|
||||
id: "mixed-failures",
|
||||
stopped: false,
|
||||
startedAt: 1_000,
|
||||
completedAt: 166_000,
|
||||
packages: [packageResult]
|
||||
});
|
||||
|
||||
expect(packageResult).toEqual(expect.objectContaining({
|
||||
failurePhase: "cleanup",
|
||||
downloadFailures: 1,
|
||||
offlineFailures: 1,
|
||||
extractionFailures: 1,
|
||||
remuxFailures: 1,
|
||||
cleanupFailures: 1
|
||||
}));
|
||||
expect(runResult).toEqual(expect.objectContaining({
|
||||
downloadFailures: 1,
|
||||
offlineFailures: 1,
|
||||
extractionFailures: 1,
|
||||
remuxFailures: 1,
|
||||
cleanupFailures: 1
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses audio-strip outcomes when no individual remux operation was recorded", () => {
|
||||
const items = [downloadItem("item-1"), downloadItem("item-2")];
|
||||
const result = finalizePackageResult(telemetry({
|
||||
package: packageEntry({
|
||||
itemIds: items.map((item) => item.id),
|
||||
audioStripSummary: {
|
||||
at: 160_000,
|
||||
candidates: 2,
|
||||
remuxed: 1,
|
||||
keptSingle: 0,
|
||||
skippedNoGerman: 0,
|
||||
skippedNoTool: 0,
|
||||
failed: 1,
|
||||
files: []
|
||||
}
|
||||
}),
|
||||
items
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: "partial",
|
||||
successfulFiles: 1,
|
||||
failedFiles: 1,
|
||||
failurePhase: "remux"
|
||||
}));
|
||||
});
|
||||
|
||||
it("clamps invalid and reversed durations instead of producing negative or non-finite values", () => {
|
||||
expect(durationMsToSeconds(1_999)).toBe(1);
|
||||
expect(durationMsToSeconds(-1)).toBe(0);
|
||||
expect(durationMsToSeconds(Number.NaN)).toBe(0);
|
||||
expect(durationSecondsBetween(5_000, 4_000)).toBe(0);
|
||||
expect(durationSecondsBetween(undefined, 5_000)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getRealDebridAccountDailyUsageBytes,
|
||||
getRealDebridAccountTotalUsageBytes,
|
||||
isRealDebridAccountDailyLimitReached,
|
||||
resetProviderDailyUsage,
|
||||
resetRealDebridAccountDailyUsage
|
||||
} from "../src/shared/provider-daily-limits";
|
||||
|
||||
@@ -67,3 +68,17 @@ describe("Real-Debrid account usage", () => {
|
||||
expect(next.realDebridAccountDailyUsageBytes).toEqual({ rda_two: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Deepbrid provider usage", () => {
|
||||
it("resets only Deepbrid daily usage while preserving other providers", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
providerDailyUsageBytes: { deepbrid: 300, alldebrid: 900 }
|
||||
};
|
||||
|
||||
const next = resetProviderDailyUsage(settings, "deepbrid");
|
||||
|
||||
expect(next.providerDailyUsageBytes).toEqual({ alldebrid: 900 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDebridLinkTerminalFailure } from "../src/main/download-manager";
|
||||
import { classifyProviderUnrestrictBackoff, isProviderUnrestrictFailure, parseDebridLinkTerminalFailure } from "../src/main/download-manager";
|
||||
|
||||
describe("provider error classification", () => {
|
||||
it("does not relabel an aggregated Real-Debrid failure as Debrid-Link", () => {
|
||||
@@ -13,4 +13,15 @@ describe("provider error classification", () => {
|
||||
kind: "no_active_key"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Deepbrid-Anfrage fehlgeschlagen (rate_limit, HTTP 429, Code 429)", "busy"],
|
||||
["Deepbrid-Anfrage fehlgeschlagen (temporary, HTTP 503, Code 503)", "temporary"],
|
||||
["Deepbrid-Anfrage fehlgeschlagen (auth, HTTP 401, Code 401)", null],
|
||||
["Deepbrid-Anfrage fehlgeschlagen (link, HTTP 400, Code 17)", null],
|
||||
["Deepbrid-Anfrage fehlgeschlagen (malformed, HTTP 200, Code 200)", null]
|
||||
])("classifies Deepbrid backoff precisely for %s", (message, expected) => {
|
||||
expect(classifyProviderUnrestrictBackoff(message)).toBe(expected);
|
||||
expect(isProviderUnrestrictFailure(message)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import {
|
||||
evaluateRemainingThreshold,
|
||||
type RunRemainingSnapshot
|
||||
} from "../src/main/notification-events";
|
||||
import type { NotificationEvent } from "../src/main/notification-outbox";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import type { AppSettings, PackageEntry } from "../src/shared/types";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function snapshot(overrides: Partial<RunRemainingSnapshot> = {}): RunRemainingSnapshot {
|
||||
return {
|
||||
remainingBytes: 51 * GIB,
|
||||
openItems: 2,
|
||||
openPackages: 1,
|
||||
unknownCount: 0,
|
||||
finalizingItems: 0,
|
||||
speedBps: 1024 ** 2,
|
||||
etaSeconds: 51 * 1024,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function setupManager(settings: Partial<AppSettings> = {}, session = emptySession()): {
|
||||
manager: DownloadManager;
|
||||
session: ReturnType<typeof emptySession>;
|
||||
events: NotificationEvent[];
|
||||
settings: AppSettings;
|
||||
} {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-remaining-"));
|
||||
tempDirs.push(root);
|
||||
const events: NotificationEvent[] = [];
|
||||
const resolvedSettings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "out"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
notifyUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
notifyOnPackageCompleted: false,
|
||||
notifyOnPackageFailed: false,
|
||||
notifyOnRunFinished: false,
|
||||
notifyOnRemainingBelow: true,
|
||||
notifyRemainingThresholdGb: 50,
|
||||
autoExtract: false,
|
||||
...settings
|
||||
};
|
||||
const manager = new DownloadManager(
|
||||
resolvedSettings,
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state")),
|
||||
{
|
||||
enqueueNotification: async (event: NotificationEvent) => {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
);
|
||||
return { manager, session, events, settings: resolvedSettings };
|
||||
}
|
||||
|
||||
function addPackage(
|
||||
session: ReturnType<typeof emptySession>,
|
||||
packageId: string,
|
||||
totalBytes: number | null,
|
||||
downloadedBytes = 0,
|
||||
enabled = true
|
||||
): PackageEntry {
|
||||
const now = Date.now();
|
||||
const itemId = `${packageId}-item`;
|
||||
const pkg: PackageEntry = {
|
||||
id: packageId,
|
||||
name: packageId,
|
||||
outputDir: `C:/out/${packageId}`,
|
||||
extractDir: `C:/extract/${packageId}`,
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled,
|
||||
priority: "normal",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
session.packages[packageId] = pkg;
|
||||
session.packageOrder.push(packageId);
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: `https://dummy/${packageId}`,
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
progressPercent: totalBytes && totalBytes > 0 ? Math.floor((downloadedBytes / totalBytes) * 100) : 0,
|
||||
fileName: `${packageId}.bin`,
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
return pkg;
|
||||
}
|
||||
|
||||
function internal(manager: DownloadManager): any {
|
||||
return manager as any;
|
||||
}
|
||||
|
||||
async function flushNotifications(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("remaining threshold evaluation", () => {
|
||||
it("emits when known remaining bytes cross from above to exactly the threshold", () => {
|
||||
const previous = snapshot();
|
||||
const current = snapshot({ remainingBytes: 50 * GIB, etaSeconds: 50 * 1024 });
|
||||
|
||||
expect(evaluateRemainingThreshold(previous, current, 50 * GIB)).toEqual({
|
||||
emit: true,
|
||||
remainingBytes: 50 * GIB
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks a crossing when either snapshot contains an unknown open size", () => {
|
||||
const above = snapshot();
|
||||
const below = snapshot({ remainingBytes: 49 * GIB, etaSeconds: 49 * 1024 });
|
||||
|
||||
expect(evaluateRemainingThreshold(above, { ...below, unknownCount: 1 }, 50 * GIB)).toEqual({ emit: false });
|
||||
expect(evaluateRemainingThreshold({ ...above, unknownCount: 1 }, below, 50 * GIB)).toEqual({ emit: false });
|
||||
});
|
||||
|
||||
it("suppresses a crossing when no open run item remains", () => {
|
||||
expect(evaluateRemainingThreshold(
|
||||
snapshot(),
|
||||
snapshot({ remainingBytes: 0, openItems: 0, openPackages: 0, etaSeconds: 0 }),
|
||||
50 * GIB
|
||||
)).toEqual({ emit: false });
|
||||
});
|
||||
|
||||
it("emits once per crossing and re-arms only after remaining work rises above the threshold", () => {
|
||||
const above = snapshot();
|
||||
const below = snapshot({ remainingBytes: 49 * GIB, etaSeconds: 49 * 1024 });
|
||||
|
||||
expect(evaluateRemainingThreshold(above, below, 50 * GIB).emit).toBe(true);
|
||||
expect(evaluateRemainingThreshold(below, below, 50 * GIB).emit).toBe(false);
|
||||
expect(evaluateRemainingThreshold(below, above, 50 * GIB).emit).toBe(false);
|
||||
expect(evaluateRemainingThreshold(above, below, 50 * GIB).emit).toBe(true);
|
||||
});
|
||||
|
||||
it("does not synthesize a crossing for a new or restored run that first appears below the threshold", () => {
|
||||
const below = snapshot({ remainingBytes: 49 * GIB, etaSeconds: 49 * 1024 });
|
||||
|
||||
expect(evaluateRemainingThreshold(null, below, 50 * GIB)).toEqual({ emit: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("run-scoped remaining notifications", () => {
|
||||
it("waits for real HTTP finalization before crossing and keeps genuine remaining work in the event", async () => {
|
||||
const payload = Buffer.alloc(256 * 1024, 7);
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Accept-Ranges", "bytes");
|
||||
response.setHeader("Content-Length", String(payload.length));
|
||||
response.end(payload);
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
|
||||
const { manager, session, events, settings } = setupManager({
|
||||
notifyRemainingThresholdGb: (128 * 1024) / GIB
|
||||
});
|
||||
const downloadingPackage = addPackage(session, "http-final-package", payload.length);
|
||||
downloadingPackage.outputDir = path.join(settings.outputDir, downloadingPackage.id);
|
||||
downloadingPackage.extractDir = path.join(settings.extractDir, downloadingPackage.id);
|
||||
const remainingPackage = addPackage(session, "genuine-remaining-package", 64 * 1024);
|
||||
remainingPackage.outputDir = path.join(settings.outputDir, remainingPackage.id);
|
||||
remainingPackage.extractDir = path.join(settings.extractDir, remainingPackage.id);
|
||||
const downloadingItem = session.items[downloadingPackage.itemIds[0]];
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
state.debridService.unrestrictLink = vi.fn(async () => ({
|
||||
fileName: downloadingItem.fileName,
|
||||
directUrl: `http://127.0.0.1:${address.port}/download`,
|
||||
fileSize: payload.length,
|
||||
retriesUsed: 0,
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid"
|
||||
}));
|
||||
const eventStatuses: string[] = [];
|
||||
state.enqueueNotificationCallback = async (event: NotificationEvent) => {
|
||||
events.push(event);
|
||||
eventStatuses.push(downloadingItem.status);
|
||||
};
|
||||
|
||||
try {
|
||||
await manager.start();
|
||||
const active = {
|
||||
itemId: downloadingItem.id,
|
||||
packageId: downloadingPackage.id,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
stallRetries: 0,
|
||||
genericErrorRetries: 0,
|
||||
unrestrictRetries: 0
|
||||
};
|
||||
state.activeTasks.set(downloadingItem.id, active);
|
||||
|
||||
await state.processItem(active);
|
||||
await flushNotifications();
|
||||
|
||||
expect(downloadingItem.status).toBe("completed");
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
|
||||
expect(eventStatuses).toEqual(["completed"]);
|
||||
expect(events[0].payload.fields).toContainEqual({ name: "Restmenge", value: "64 KB", inline: true });
|
||||
expect(events[0].payload.fields).toContainEqual({ name: "Offene Dateien", value: "1", inline: true });
|
||||
} finally {
|
||||
manager.stop();
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("creates a stable run context when public download work joins a postprocess-only start", async () => {
|
||||
const { manager, session, events } = setupManager();
|
||||
const postprocessPackage = addPackage(session, "postprocess-only-package", GIB, GIB);
|
||||
const postprocessItem = session.items[postprocessPackage.itemIds[0]];
|
||||
postprocessItem.status = "completed";
|
||||
postprocessItem.progressPercent = 100;
|
||||
postprocessItem.fullStatus = "Fertig";
|
||||
postprocessPackage.status = "completed";
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
let releasePostprocess = (): void => {};
|
||||
const postprocessGate = new Promise<void>((resolve) => {
|
||||
releasePostprocess = resolve;
|
||||
});
|
||||
state.handlePackagePostProcessing = vi.fn(async () => postprocessGate);
|
||||
const postprocessTask = state.runPackagePostProcessing(postprocessPackage.id);
|
||||
await Promise.resolve();
|
||||
|
||||
await manager.start();
|
||||
expect(session.running).toBe(true);
|
||||
expect(state.activeRunContextId).toBeNull();
|
||||
|
||||
manager.addPackages([{
|
||||
name: "late-download-package",
|
||||
links: ["https://dummy/late-download.bin"],
|
||||
fileNames: ["late-download.bin"]
|
||||
}]);
|
||||
const latePackageId = session.packageOrder.find((packageId) => packageId !== postprocessPackage.id);
|
||||
if (!latePackageId) {
|
||||
throw new Error("late package missing");
|
||||
}
|
||||
const latePackage = session.packages[latePackageId];
|
||||
const lateItem = session.items[latePackage.itemIds[0]];
|
||||
lateItem.totalBytes = 51 * GIB;
|
||||
await manager.startItems([lateItem.id]);
|
||||
const runContextId = state.activeRunContextId;
|
||||
expect(runContextId).toEqual(expect.any(String));
|
||||
expect(state.runContexts.get(runContextId)?.packageGenerations.has(latePackage.id)).toBe(true);
|
||||
|
||||
lateItem.downloadedBytes = 2 * GIB;
|
||||
manager.setPackagePriority(latePackage.id, "high");
|
||||
await flushNotifications();
|
||||
|
||||
expect(state.activeRunContextId).toBe(runContextId);
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
|
||||
|
||||
manager.stop();
|
||||
releasePostprocess();
|
||||
await postprocessTask;
|
||||
});
|
||||
|
||||
it("derives speed and ETA only from open enabled current run items", async () => {
|
||||
const { manager, session } = setupManager();
|
||||
const activePackage = addPackage(session, "scoped-speed-package", 60 * GIB, 10 * GIB);
|
||||
const disabledPackage = addPackage(session, "disabled-speed-package", 100 * GIB);
|
||||
const removedPackage = addPackage(session, "removed-speed-package", 200 * GIB);
|
||||
const activeItem = session.items[activePackage.itemIds[0]];
|
||||
const disabledItem = session.items[disabledPackage.itemIds[0]];
|
||||
const removedItem = session.items[removedPackage.itemIds[0]];
|
||||
activeItem.speedBps = 2 * GIB;
|
||||
disabledItem.speedBps = 100 * GIB;
|
||||
removedItem.speedBps = 200 * GIB;
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
disabledPackage.enabled = false;
|
||||
delete session.items[removedItem.id];
|
||||
state.speedBytesLastWindow = 500 * GIB;
|
||||
|
||||
expect(state.buildRunRemainingSnapshot()).toEqual({
|
||||
remainingBytes: 50 * GIB,
|
||||
openItems: 1,
|
||||
openPackages: 1,
|
||||
unknownCount: 0,
|
||||
finalizingItems: 0,
|
||||
speedBps: 2 * GIB,
|
||||
etaSeconds: 25
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates known remainder, speed and ETA only from open enabled items in the active run", async () => {
|
||||
const { manager, session } = setupManager();
|
||||
const active = addPackage(session, "active-package", 60 * GIB, 9 * GIB);
|
||||
const disabled = addPackage(session, "disabled-package", null, 0, false);
|
||||
const notStarted = addPackage(session, "not-started-package", 400 * GIB);
|
||||
const completedItemId = `${active.id}-completed-item`;
|
||||
active.itemIds.push(completedItemId);
|
||||
session.items[completedItemId] = {
|
||||
...session.items[active.itemIds[0]],
|
||||
id: completedItemId,
|
||||
status: "completed",
|
||||
downloadedBytes: 300 * GIB,
|
||||
totalBytes: 300 * GIB,
|
||||
progressPercent: 100,
|
||||
fullStatus: "Fertig"
|
||||
};
|
||||
const overrunItemId = `${active.id}-overrun-item`;
|
||||
active.itemIds.push(overrunItemId);
|
||||
session.items[overrunItemId] = {
|
||||
...session.items[active.itemIds[0]],
|
||||
id: overrunItemId,
|
||||
downloadedBytes: 2 * GIB,
|
||||
totalBytes: GIB,
|
||||
progressPercent: 100
|
||||
};
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start({ excludePackageIds: new Set([notStarted.id]) });
|
||||
session.items[active.itemIds[0]].speedBps = GIB;
|
||||
state.speedBytesLastWindow = GIB;
|
||||
|
||||
expect(state.buildRunRemainingSnapshot()).toEqual({
|
||||
remainingBytes: 51 * GIB,
|
||||
openItems: 2,
|
||||
openPackages: 1,
|
||||
unknownCount: 0,
|
||||
finalizingItems: 0,
|
||||
speedBps: GIB,
|
||||
etaSeconds: 51
|
||||
});
|
||||
expect(state.runPackageIds).toEqual(new Set([active.id]));
|
||||
expect(state.runPackageIds.has(disabled.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("enqueues one complete event per crossing and re-arms after new work raises the remainder", async () => {
|
||||
const { manager, session, events } = setupManager();
|
||||
const pkg = addPackage(session, "crossing-package", 51 * GIB);
|
||||
const item = session.items[pkg.itemIds[0]];
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
item.speedBps = GIB;
|
||||
state.speedBytesLastWindow = GIB;
|
||||
item.downloadedBytes = GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
state.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
|
||||
expect(events[0].payload.fields).toEqual([
|
||||
{ name: "Restmenge", value: "50 GB", inline: true },
|
||||
{ name: "Offene Pakete", value: "1", inline: true },
|
||||
{ name: "Offene Dateien", value: "1", inline: true },
|
||||
{ name: "Geschwindigkeit", value: "1 GB/s", inline: true },
|
||||
{ name: "ETA", value: "0:50", inline: true }
|
||||
]);
|
||||
|
||||
item.downloadedBytes = 0;
|
||||
state.evaluateRemainingNotification();
|
||||
item.downloadedBytes = 2 * GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(2);
|
||||
expect(new Set(events.map((event) => event.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("waits for unknown sizes to become known above the threshold before allowing a crossing", async () => {
|
||||
const { manager, session, events } = setupManager();
|
||||
const pkg = addPackage(session, "unknown-package", null);
|
||||
const item = session.items[pkg.itemIds[0]];
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
item.totalBytes = 49 * GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
item.totalBytes = 60 * GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
item.downloadedBytes = 11 * GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows the same package to cross again in a new run", async () => {
|
||||
const { manager, session, events } = setupManager();
|
||||
const pkg = addPackage(session, "new-run-package", 51 * GIB);
|
||||
const item = session.items[pkg.itemIds[0]];
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
item.downloadedBytes = 2 * GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
manager.stop();
|
||||
|
||||
item.downloadedBytes = 0;
|
||||
await manager.start();
|
||||
item.downloadedBytes = 2 * GIB;
|
||||
state.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(2);
|
||||
expect(new Set(events.map((event) => event.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("does not duplicate a prior crossing when a restored queue first appears below the threshold", async () => {
|
||||
const first = setupManager();
|
||||
const pkg = addPackage(first.session, "restart-package", 51 * GIB);
|
||||
const item = first.session.items[pkg.itemIds[0]];
|
||||
const firstState = internal(first.manager);
|
||||
vi.spyOn(firstState, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await first.manager.start();
|
||||
item.downloadedBytes = 2 * GIB;
|
||||
firstState.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
expect(first.events).toHaveLength(1);
|
||||
|
||||
const restored = setupManager({}, first.session);
|
||||
const restoredState = internal(restored.manager);
|
||||
vi.spyOn(restoredState, "ensureScheduler").mockResolvedValue(undefined);
|
||||
await restored.manager.start();
|
||||
restoredState.evaluateRemainingNotification();
|
||||
await flushNotifications();
|
||||
|
||||
expect(restored.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("suppresses a threshold event when the same evaluation completes the last open item", async () => {
|
||||
const { manager, session, events } = setupManager();
|
||||
const pkg = addPackage(session, "final-package", 51 * GIB);
|
||||
const item = session.items[pkg.itemIds[0]];
|
||||
const state = internal(manager);
|
||||
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
await manager.start();
|
||||
item.status = "completed";
|
||||
item.downloadedBytes = 51 * GIB;
|
||||
item.progressPercent = 100;
|
||||
item.fullStatus = "Fertig";
|
||||
pkg.status = "completed";
|
||||
state.runOutcomes.set(item.id, "completed");
|
||||
state.evaluateRemainingNotification();
|
||||
state.finishRun();
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RendererSettings, UiSnapshot } from "../src/shared/types";
|
||||
import {
|
||||
activateDailyScheduleSettings,
|
||||
buildScheduleCancellationSettingsUpdate,
|
||||
buildDailyScheduleSettingsUpdate,
|
||||
persistDailyScheduleSettingsUpdate,
|
||||
resolveDailyScheduleInitialTime
|
||||
} from "../src/renderer/App";
|
||||
|
||||
describe("daily schedule settings form", () => {
|
||||
it("maps local time and the chosen start day to the recurring schedule settings", () => {
|
||||
const now = new Date(2026, 7, 22, 18, 30, 0, 0);
|
||||
|
||||
expect(buildDailyScheduleSettingsUpdate("08:15", "today", now)).toEqual({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 8 * 60 + 15,
|
||||
dailyStartFirstLocalDate: "2026-08-22"
|
||||
});
|
||||
expect(buildDailyScheduleSettingsUpdate("23:45", "tomorrow", now)).toEqual({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 23 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23"
|
||||
});
|
||||
});
|
||||
|
||||
it("prefills a previously saved disabled rule and uses local time only for the empty default", () => {
|
||||
const now = new Date(2026, 7, 22, 18, 30, 0, 0);
|
||||
const disabledSavedRule = {
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 8 * 60 + 15,
|
||||
dailyStartFirstLocalDate: "2026-08-21"
|
||||
};
|
||||
|
||||
expect(resolveDailyScheduleInitialTime(disabledSavedRule, now)).toBe("08:15");
|
||||
expect(resolveDailyScheduleInitialTime({
|
||||
dailyStartMinuteOfDay: 0,
|
||||
dailyStartFirstLocalDate: ""
|
||||
}, now)).toBe("18:30");
|
||||
});
|
||||
|
||||
it.each(["", "8:15", "24:00", "12:60"])("reports the invalid time %j when activation is invoked directly", async (time) => {
|
||||
const persist = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
await expect(activateDailyScheduleSettings(
|
||||
time,
|
||||
"today",
|
||||
persist,
|
||||
showError,
|
||||
new Date(2026, 7, 22, 18, 30, 0, 0)
|
||||
)).resolves.toBe(false);
|
||||
|
||||
expect(showError).toHaveBeenCalledWith("Bitte eine gültige Startzeit auswählen.");
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies persisted settings after a successful activation", async () => {
|
||||
const persisted = { dailyStartEnabled: true } as RendererSettings;
|
||||
const updateSettings = vi.fn().mockResolvedValue(persisted);
|
||||
const applySettings = vi.fn();
|
||||
const getSnapshot = vi.fn();
|
||||
const applySnapshot = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const update = {
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 495,
|
||||
dailyStartFirstLocalDate: "2026-08-23"
|
||||
};
|
||||
|
||||
await expect(persistDailyScheduleSettingsUpdate(update, "activate", {
|
||||
updateSettings,
|
||||
getSnapshot,
|
||||
applySettings,
|
||||
applySnapshot,
|
||||
showError
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(update);
|
||||
expect(applySettings).toHaveBeenCalledWith(persisted);
|
||||
expect(getSnapshot).not.toHaveBeenCalled();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the active schedule through its owning settings field", () => {
|
||||
expect(buildScheduleCancellationSettingsUpdate({
|
||||
dailyStartEnabled: true,
|
||||
scheduledStartEpochMs: 1_800_000_000_000
|
||||
})).toEqual({ dailyStartEnabled: false });
|
||||
expect(buildScheduleCancellationSettingsUpdate({
|
||||
dailyStartEnabled: false,
|
||||
scheduledStartEpochMs: 1_800_000_000_000
|
||||
})).toEqual({ scheduledStartEpochMs: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["activate" as const, "Zeitplan konnte nicht aktiviert werden: Error: Speichern fehlgeschlagen"],
|
||||
["cancel" as const, "Zeitplan konnte nicht abgebrochen werden: Error: Speichern fehlgeschlagen"]
|
||||
])("shows %s failures before reconciling the authoritative snapshot", async (operation, expectedMessage) => {
|
||||
const authoritative = { settings: { dailyStartEnabled: false } } as UiSnapshot;
|
||||
const sequence: string[] = [];
|
||||
const updateSettings = vi.fn().mockRejectedValue(new Error("Speichern fehlgeschlagen"));
|
||||
const getSnapshot = vi.fn(async () => {
|
||||
sequence.push("snapshot");
|
||||
return authoritative;
|
||||
});
|
||||
const applySnapshot = vi.fn((snapshot: UiSnapshot) => {
|
||||
sequence.push(`apply:${String(snapshot.settings.dailyStartEnabled)}`);
|
||||
});
|
||||
const showError = vi.fn((message: string) => {
|
||||
sequence.push(`error:${message}`);
|
||||
});
|
||||
|
||||
await expect(persistDailyScheduleSettingsUpdate({ dailyStartEnabled: false }, operation, {
|
||||
updateSettings,
|
||||
getSnapshot,
|
||||
applySettings: vi.fn(),
|
||||
applySnapshot,
|
||||
showError
|
||||
})).resolves.toBe(false);
|
||||
|
||||
expect(showError).toHaveBeenCalledWith(expectedMessage);
|
||||
expect(getSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(applySnapshot).toHaveBeenCalledWith(authoritative);
|
||||
expect(sequence).toEqual([`error:${expectedMessage}`, "snapshot", "apply:false"]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createRendererSettings } from "../src/main/renderer-state";
|
||||
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
|
||||
|
||||
describe("renderer settings validation", () => {
|
||||
it("accepts every editable notification control while keeping the webhook write-only", () => {
|
||||
const current = { ...defaultSettings(), notifyUrl: "https://notify.example.test/private-hook" };
|
||||
const update = {
|
||||
notifyPackageSuccessMode: "individual",
|
||||
notifyOnRemainingBelow: true,
|
||||
notifyRemainingThresholdGb: 25,
|
||||
notifyOnDownloadStall: true,
|
||||
notifyStallAfterSeconds: 120,
|
||||
notifyStallCooldownMinutes: 15,
|
||||
notifyOnDownloadRecovery: false
|
||||
};
|
||||
const projected = createRendererSettings(current);
|
||||
|
||||
expect(projected).not.toHaveProperty("notifyUrl");
|
||||
expect(projected.notifyUrlConfigured).toBe(true);
|
||||
expect(validateRendererSettingsUpdate(update, current)).toEqual(update);
|
||||
});
|
||||
|
||||
it("rejects unsupported package success modes at the IPC boundary", () => {
|
||||
const current = defaultSettings();
|
||||
|
||||
expect(validateRendererSettingsUpdate({ notifyPackageSuccessMode: "digest" }, current)).toEqual({ notifyPackageSuccessMode: "digest" });
|
||||
expect(validateRendererSettingsUpdate({ notifyPackageSuccessMode: "individual" }, current)).toEqual({ notifyPackageSuccessMode: "individual" });
|
||||
expect(() => validateRendererSettingsUpdate({ notifyPackageSuccessMode: "batched" }, current)).toThrow("Settings-Payload ist ungültig");
|
||||
});
|
||||
|
||||
it("accepts a complete settings save when an account status has no optional email", () => {
|
||||
const current = defaultSettings();
|
||||
current.debridAccountStatuses = {
|
||||
@@ -47,4 +73,54 @@ describe("renderer settings validation", () => {
|
||||
expect(projected.archivePasswordListConfigured).toBe(true);
|
||||
expect(JSON.stringify(projected)).not.toContain(password);
|
||||
});
|
||||
|
||||
it("projects daily start state and accepts only editable calendar controls", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 7, 22, 9, 0));
|
||||
try {
|
||||
const current = {
|
||||
...defaultSettings(),
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23",
|
||||
dailyStartLastHandledLocalDate: "2026-08-22",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "started" as const
|
||||
};
|
||||
const projected = createRendererSettings(current);
|
||||
|
||||
expect(projected).toMatchObject({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||
dailyStartFirstLocalDate: "2026-08-23",
|
||||
dailyStartLastHandledLocalDate: "2026-08-22",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "started"
|
||||
});
|
||||
expect(projected.nextDailyStartEpochMs).toBe(new Date(2026, 7, 23, 18, 45, 0, 0).getTime());
|
||||
expect(validateRendererSettingsUpdate({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 7 * 60 + 30,
|
||||
dailyStartFirstLocalDate: "2026-08-24",
|
||||
dailyStartLastHandledLocalDate: "2026-08-23",
|
||||
dailyStartPendingLocalDate: "2026-08-24",
|
||||
dailyStartLastOutcome: "missed",
|
||||
nextDailyStartEpochMs: 123
|
||||
}, current)).toEqual({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 7 * 60 + 30,
|
||||
dailyStartFirstLocalDate: "2026-08-24"
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid editable daily calendar values at the renderer boundary", () => {
|
||||
const current = defaultSettings();
|
||||
|
||||
expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 1_440 }, current)).toThrow("Settings-Payload ist ungültig");
|
||||
expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 12.5 }, current)).toThrow("Settings-Payload ist ungültig");
|
||||
expect(() => validateRendererSettingsUpdate({ dailyStartFirstLocalDate: "2026-02-29" }, current)).toThrow("Settings-Payload ist ungültig");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,18 @@ const ACCOUNT_FIXTURES: Array<{
|
||||
];
|
||||
|
||||
describe("renderer state serialization", () => {
|
||||
it("projects notification controls with their safe defaults", () => {
|
||||
expect(createRendererState(defaultSettings()).settings).toEqual(expect.objectContaining({
|
||||
notifyPackageSuccessMode: "digest",
|
||||
notifyOnRemainingBelow: false,
|
||||
notifyRemainingThresholdGb: 50,
|
||||
notifyOnDownloadStall: false,
|
||||
notifyStallAfterSeconds: 90,
|
||||
notifyStallCooldownMinutes: 10,
|
||||
notifyOnDownloadRecovery: true
|
||||
}));
|
||||
});
|
||||
|
||||
it("projects distinct Real-Debrid API and Web rows with per-account state", () => {
|
||||
const firstToken = "fixture-renderer-rd-first-1aB2";
|
||||
const secondToken = "fixture-renderer-rd-second-3cD4";
|
||||
|
||||
Binary file not shown.
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
|
||||
import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App";
|
||||
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, createAccountDialogState, createSettingsDraft } from "../src/renderer/App";
|
||||
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
||||
import {
|
||||
buildScopedAccountEnabledState,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
filterAccountAddOptions,
|
||||
getSettingsSaveLabel,
|
||||
getSettingsSelectNavigationIndex,
|
||||
normalizeNotificationNumberField,
|
||||
projectAccountRows,
|
||||
pruneAccountSelection,
|
||||
reconcileAccountAddDraft,
|
||||
@@ -465,6 +466,43 @@ describe("settings model", () => {
|
||||
)).toEqual(["debridlink", "realdebrid", "alldebrid", "bestdebrid"]);
|
||||
});
|
||||
|
||||
it("appends a newly configured Deepbrid provider once for immediate persistence", () => {
|
||||
const settings = createRendererSettings({
|
||||
...defaultSettings(),
|
||||
token: "synthetic-real-debrid-token",
|
||||
debridLinkApiKeys: "synthetic-debrid-link-key",
|
||||
deepbridApiKey: "synthetic-deepbrid-key",
|
||||
providerOrder: ["debridlink", "realdebrid"]
|
||||
});
|
||||
|
||||
expect(buildAccountCreateProviderOrderUpdate(settings)).toEqual({
|
||||
providerOrder: ["debridlink", "realdebrid", "deepbrid"],
|
||||
providerPrimary: "debridlink",
|
||||
providerSecondary: "realdebrid",
|
||||
providerTertiary: "deepbrid"
|
||||
});
|
||||
});
|
||||
|
||||
it("builds the Deepbrid API picker and status row", () => {
|
||||
const dialog = createAccountDialogState("create", "deepbrid-api", createRendererSettings(defaultSettings()));
|
||||
expect(buildAccountAddFields(dialog).find((field) => field.id === "token")?.label).toBe("Token / API-Key");
|
||||
const [row] = projectAccountRows([{
|
||||
identityId: "svc-deepbrid",
|
||||
service: "deepbrid",
|
||||
hoster: "Deepbrid",
|
||||
mode: "API",
|
||||
enabled: true,
|
||||
status: { state: "premium", message: "Premium aktiv", premiumUntilMs: NOW + GIB, username: "deep-user", email: "deep@example.test" },
|
||||
dailyLimitBytes: 10 * GIB,
|
||||
dailyUsageBytes: 3 * GIB,
|
||||
totalUsageBytes: 20 * GIB,
|
||||
username: "",
|
||||
credentialKind: "api-key",
|
||||
canCheck: true
|
||||
}], [], NOW);
|
||||
expect(row).toMatchObject({ hoster: "Deepbrid", mode: "API", icon: "./provider-icons/deepbrid.png", username: "deep-user", email: "deep@example.test", traffic: "7 GiB von 10 GiB übrig · Gesamt 20 GiB", credential: "API-Key", canCheck: true });
|
||||
});
|
||||
|
||||
it("keeps exact rounded limits and migrates edited identity metadata", () => {
|
||||
const login = "member@example.test";
|
||||
const oldId = getMegaDebridAccountId(login);
|
||||
@@ -579,6 +617,108 @@ describe("settings views", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects every notification control in the required order with exact bounds", () => {
|
||||
const form = buildSettingsFormViewModel({
|
||||
settings: {
|
||||
...createRendererSettings(defaultSettings()),
|
||||
archivePasswordList: "",
|
||||
notifyUrl: "https://discord.com/api/webhooks/example",
|
||||
notifyOnPackageCompleted: true,
|
||||
notifyOnRemainingBelow: true,
|
||||
notifyOnDownloadStall: true
|
||||
},
|
||||
section: "allgemein",
|
||||
speedLimitInput: "0",
|
||||
scheduleSpeedInputs: {}
|
||||
});
|
||||
const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? [];
|
||||
|
||||
expect(fields.map((field) => field.id)).toEqual([
|
||||
"notifyUrl",
|
||||
"notifyMention",
|
||||
"notifyOnPackageCompleted",
|
||||
"notifyOnPackageFailed",
|
||||
"notifyPackageSuccessMode",
|
||||
"notifyOnRunFinished",
|
||||
"notifyOnRemainingBelow",
|
||||
"notifyRemainingThresholdGb",
|
||||
"notifyOnDownloadStall",
|
||||
"notifyStallAfterSeconds",
|
||||
"notifyStallCooldownMinutes",
|
||||
"notifyOnDownloadRecovery"
|
||||
]);
|
||||
expect(fields.find((field) => field.id === "notifyPackageSuccessMode")).toEqual({
|
||||
id: "notifyPackageSuccessMode",
|
||||
kind: "select",
|
||||
label: "Erfolgsmeldungen senden",
|
||||
value: "digest",
|
||||
disabled: false,
|
||||
options: [
|
||||
{ value: "digest", label: "Gesammelt (alle 2 Minuten)" },
|
||||
{ value: "individual", label: "Jedes Paket einzeln" }
|
||||
]
|
||||
});
|
||||
expect(fields.find((field) => field.id === "notifyRemainingThresholdGb")).toEqual({
|
||||
id: "notifyRemainingThresholdGb",
|
||||
kind: "number",
|
||||
label: "Restmengenschwelle (GB)",
|
||||
value: "50",
|
||||
min: 1,
|
||||
max: 100000,
|
||||
disabled: false
|
||||
});
|
||||
expect(fields.find((field) => field.id === "notifyStallAfterSeconds")).toEqual({
|
||||
id: "notifyStallAfterSeconds",
|
||||
kind: "number",
|
||||
label: "Stillstand bestätigen nach (Sek.)",
|
||||
value: "90",
|
||||
min: 60,
|
||||
max: 3600,
|
||||
disabled: false
|
||||
});
|
||||
expect(fields.find((field) => field.id === "notifyStallCooldownMinutes")).toEqual({
|
||||
id: "notifyStallCooldownMinutes",
|
||||
kind: "number",
|
||||
label: "Frühestens erneut melden nach (Min.)",
|
||||
value: "10",
|
||||
min: 5,
|
||||
max: 1440,
|
||||
disabled: false
|
||||
});
|
||||
});
|
||||
|
||||
it("disables notification controls that depend on an inactive switch", () => {
|
||||
const form = buildSettingsFormViewModel({
|
||||
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
|
||||
section: "allgemein",
|
||||
speedLimitInput: "0",
|
||||
scheduleSpeedInputs: {}
|
||||
});
|
||||
const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? [];
|
||||
const disabled = Object.fromEntries(fields.map((field) => [field.id, Boolean(field.disabled)]));
|
||||
|
||||
expect(disabled).toMatchObject({
|
||||
notifyPackageSuccessMode: true,
|
||||
notifyRemainingThresholdGb: true,
|
||||
notifyStallAfterSeconds: true,
|
||||
notifyStallCooldownMinutes: true,
|
||||
notifyOnDownloadRecovery: true
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps notification number fields and restores their exact defaults", () => {
|
||||
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "0")).toBe(1);
|
||||
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "100001")).toBe(100000);
|
||||
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "invalid")).toBe(50);
|
||||
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "59")).toBe(60);
|
||||
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "3601")).toBe(3600);
|
||||
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "invalid")).toBe(90);
|
||||
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "4")).toBe(5);
|
||||
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "1441")).toBe(1440);
|
||||
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "invalid")).toBe(10);
|
||||
expect(normalizeNotificationNumberField("maxParallel", "8")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports keyboard navigation in animated settings selects", () => {
|
||||
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
|
||||
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
|
||||
@@ -1165,6 +1305,15 @@ describe("account workspace", () => {
|
||||
});
|
||||
|
||||
describe("settings App integration", () => {
|
||||
it("preserves the write-only webhook during live snapshots and clears it for backup reseeding", () => {
|
||||
const safe = createRendererSettings({ ...defaultSettings(), notifyUrl: "https://private.example.test/hook" });
|
||||
const current = { ...safe, archivePasswordList: "loaded-password", notifyUrl: "https://private.example.test/hook" };
|
||||
|
||||
expect(createSettingsDraft(safe, current).notifyUrl).toBe("https://private.example.test/hook");
|
||||
expect(createSettingsDraft(safe).notifyUrl).toBe("");
|
||||
expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)");
|
||||
});
|
||||
|
||||
it("loads and preserves the stored archive password list in the extraction section", () => {
|
||||
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
|
||||
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
|
||||
|
||||
@@ -32,6 +32,21 @@ function localTime(day: number, hour = 12): number {
|
||||
}
|
||||
|
||||
describe("statistics ledger", () => {
|
||||
it("normalizes and records Deepbrid provider statistics", () => {
|
||||
const now = localTime(10);
|
||||
let ledger = recordStatisticsBytes(createStatisticsLedger(now), "deepbrid", 2_048, now);
|
||||
ledger = recordStatisticsOutcome(ledger, "deepbrid", "completed", now);
|
||||
addStatisticsAccountBytesInPlace(ledger, "deepbrid", 1_024, "svc-deepbrid", "Deepbrid", now);
|
||||
|
||||
const normalized = normalizeStatisticsLedger(ledger, now);
|
||||
expect(normalized.days[0]?.providers.deepbrid).toEqual({ bytes: 2_048, completed: 1, failed: 0 });
|
||||
expect(normalized.minutes[0]?.accounts["svc-deepbrid"]).toEqual({
|
||||
provider: "deepbrid",
|
||||
label: "Deepbrid",
|
||||
bytes: 1_024
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates version one ledgers without inventing minute history", () => {
|
||||
const now = localTime(10);
|
||||
const legacy = {
|
||||
|
||||
+488
-1
@@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
type SettingsSaveMode = "sync" | "async";
|
||||
@@ -22,6 +22,14 @@ async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType<type
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettingsFrom(raw: Record<string, unknown>): AppSettings {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify(raw), "utf8");
|
||||
return loadSettings(paths);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => true,
|
||||
@@ -38,6 +46,225 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("settings storage", () => {
|
||||
it("defaults and round-trips Deepbrid credentials and provider settings", () => {
|
||||
const key = "fixture-deepbrid-storage-key-2aB4";
|
||||
const defaults = defaultSettings();
|
||||
expect(defaults.deepbridApiKey).toBe("");
|
||||
|
||||
const normalized = normalizeSettings({
|
||||
...defaults,
|
||||
deepbridApiKey: ` ${key} `,
|
||||
providerOrder: ["deepbrid", "realdebrid", "deepbrid"],
|
||||
providerPrimary: "deepbrid",
|
||||
providerSecondary: "realdebrid",
|
||||
providerTertiary: "none",
|
||||
disabledProviders: ["deepbrid"],
|
||||
hosterRouting: { rapidgator: "deepbrid" },
|
||||
providerDailyLimitBytes: { deepbrid: 1_024 },
|
||||
providerDailyUsageBytes: { deepbrid: 2_048 },
|
||||
providerTotalUsageBytes: { deepbrid: 4_096 },
|
||||
debridAccountStatuses: {
|
||||
"svc-deepbrid": {
|
||||
accountId: "svc-deepbrid",
|
||||
provider: "deepbrid",
|
||||
label: "Deepbrid",
|
||||
maskedLogin: "••••",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "OK",
|
||||
checkedAt: 1
|
||||
}
|
||||
}
|
||||
} as AppSettings);
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
deepbridApiKey: key,
|
||||
providerOrder: ["deepbrid", "realdebrid"],
|
||||
providerPrimary: "deepbrid",
|
||||
providerSecondary: "realdebrid",
|
||||
providerTertiary: "none",
|
||||
disabledProviders: ["deepbrid"],
|
||||
hosterRouting: { rapidgator: "deepbrid" },
|
||||
providerDailyLimitBytes: { deepbrid: 1_024 },
|
||||
providerDailyUsageBytes: { deepbrid: 2_048 },
|
||||
providerTotalUsageBytes: { deepbrid: 4_096 }
|
||||
});
|
||||
expect(normalized.debridAccountStatuses["svc-deepbrid"]?.provider).toBe("deepbrid");
|
||||
expect(normalizeSettings({
|
||||
...defaults,
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "deepbrid",
|
||||
providerTertiary: "bestdebrid"
|
||||
}).providerSecondary).toBe("deepbrid");
|
||||
expect(normalizeSettings({
|
||||
...defaults,
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "bestdebrid",
|
||||
providerTertiary: "deepbrid"
|
||||
}).providerTertiary).toBe("deepbrid");
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
saveSettings(paths, normalized);
|
||||
expect(loadSettings(paths).deepbridApiKey).toBe(key);
|
||||
});
|
||||
|
||||
it("preserves Deepbrid in normalized session items, packages and history", () => {
|
||||
const outputDir = path.resolve("C:\\Downloads\\Deepbrid");
|
||||
const session = normalizeLoadedSession({
|
||||
...emptySession(),
|
||||
packageOrder: ["pkg-deepbrid"],
|
||||
packages: {
|
||||
"pkg-deepbrid": {
|
||||
id: "pkg-deepbrid",
|
||||
name: "Deepbrid package",
|
||||
outputDir,
|
||||
extractDir: outputDir,
|
||||
status: "completed",
|
||||
itemIds: ["item-deepbrid"],
|
||||
cleanedProviders: ["deepbrid"],
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-deepbrid": {
|
||||
id: "item-deepbrid",
|
||||
packageId: "pkg-deepbrid",
|
||||
url: "https://example.test/deepbrid.bin",
|
||||
provider: "deepbrid",
|
||||
status: "completed",
|
||||
fileName: "deepbrid.bin",
|
||||
targetPath: path.join(outputDir, "deepbrid.bin"),
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
});
|
||||
const history = normalizeHistoryEntry({
|
||||
id: "hist-deepbrid",
|
||||
name: "Deepbrid history",
|
||||
provider: "deepbrid",
|
||||
status: "completed"
|
||||
}, 0);
|
||||
|
||||
expect(session.items["item-deepbrid"]?.provider).toBe("deepbrid");
|
||||
expect(session.packages["pkg-deepbrid"]?.cleanedProviders).toEqual(["deepbrid"]);
|
||||
expect(history?.provider).toBe("deepbrid");
|
||||
});
|
||||
|
||||
it.each([undefined, "megadebrid", "realdebrid", "invalid-provider"])("normalizes svc-deepbrid status with provider %s deterministically to Deepbrid", (provider) => {
|
||||
const key = "fixture-deepbrid-status-key-4pQ5";
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
deepbridApiKey: key,
|
||||
debridAccountStatuses: {
|
||||
"svc-deepbrid": {
|
||||
accountId: "svc-deepbrid",
|
||||
...(provider === undefined ? {} : { provider }),
|
||||
label: "Deepbrid",
|
||||
maskedLogin: "••••",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "OK",
|
||||
checkedAt: 1
|
||||
}
|
||||
}
|
||||
} as AppSettings);
|
||||
|
||||
expect(normalized.debridAccountStatuses["svc-deepbrid"]?.provider).toBe("deepbrid");
|
||||
});
|
||||
|
||||
it("defaults and normalizes persistent daily start calendar fields", () => {
|
||||
const defaults = defaultSettings();
|
||||
|
||||
expect(defaults.dailyStartEnabled).toBe(false);
|
||||
expect(defaults.dailyStartMinuteOfDay).toBe(0);
|
||||
expect(defaults.dailyStartFirstLocalDate).toBe("");
|
||||
expect(defaults.dailyStartLastHandledLocalDate).toBe("");
|
||||
expect(defaults.dailyStartPendingLocalDate).toBe("");
|
||||
expect(defaults.dailyStartLastOutcome).toBe("");
|
||||
|
||||
expect(normalizeSettings({
|
||||
...defaults,
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 1_500,
|
||||
dailyStartFirstLocalDate: "2026-02-29",
|
||||
dailyStartLastHandledLocalDate: "2026-08-21",
|
||||
dailyStartPendingLocalDate: "not-a-day",
|
||||
dailyStartLastOutcome: "unsupported"
|
||||
} as unknown as AppSettings)).toMatchObject({
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: 1_439,
|
||||
dailyStartFirstLocalDate: "",
|
||||
dailyStartLastHandledLocalDate: "2026-08-21",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("reports whether a loaded session was active before transient normalization", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const session = emptySession();
|
||||
session.running = true;
|
||||
saveSession(paths, session);
|
||||
|
||||
const loaded = loadSessionWithStatus(paths);
|
||||
|
||||
expect(loaded.wasRunning).toBe(true);
|
||||
expect(loaded.session.running).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates legacy package success notifications without changing their delivery frequency", () => {
|
||||
expect(loadSettingsFrom({ notifyOnPackageCompleted: true }).notifyPackageSuccessMode).toBe("individual");
|
||||
expect(loadSettingsFrom({}).notifyPackageSuccessMode).toBe("digest");
|
||||
expect(loadSettingsFrom({ notifyOnPackageCompleted: true, notifyPackageSuccessMode: "digest" }).notifyPackageSuccessMode).toBe("digest");
|
||||
});
|
||||
|
||||
it("preserves an explicit individual success mode when legacy package notifications are disabled", () => {
|
||||
expect(loadSettingsFrom({ notifyOnPackageCompleted: false, notifyPackageSuccessMode: "individual" }).notifyPackageSuccessMode).toBe("individual");
|
||||
});
|
||||
|
||||
it("falls back from an invalid persisted success mode according to the legacy package toggle", () => {
|
||||
expect(loadSettingsFrom({ notifyOnPackageCompleted: true, notifyPackageSuccessMode: "invalid" }).notifyPackageSuccessMode).toBe("individual");
|
||||
expect(loadSettingsFrom({ notifyOnPackageCompleted: false, notifyPackageSuccessMode: "invalid" }).notifyPackageSuccessMode).toBe("digest");
|
||||
});
|
||||
|
||||
it("loads notification defaults for new settings", () => {
|
||||
const defaults = loadSettingsFrom({});
|
||||
|
||||
expect(defaults).toEqual(expect.objectContaining({
|
||||
notifyPackageSuccessMode: "digest",
|
||||
notifyOnRemainingBelow: false,
|
||||
notifyRemainingThresholdGb: 50,
|
||||
notifyOnDownloadStall: false,
|
||||
notifyStallAfterSeconds: 90,
|
||||
notifyStallCooldownMinutes: 10,
|
||||
notifyOnDownloadRecovery: true
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["notifyRemainingThresholdGb", 0, 1],
|
||||
["notifyRemainingThresholdGb", 100_001, 100_000],
|
||||
["notifyRemainingThresholdGb", "invalid", 50],
|
||||
["notifyStallAfterSeconds", 59, 60],
|
||||
["notifyStallAfterSeconds", 3_601, 3_600],
|
||||
["notifyStallAfterSeconds", "invalid", 90],
|
||||
["notifyStallCooldownMinutes", 4, 5],
|
||||
["notifyStallCooldownMinutes", 1_441, 1_440],
|
||||
["notifyStallCooldownMinutes", "invalid", 10]
|
||||
] as const)("normalizes %s from %s to %s", (key, input, expected) => {
|
||||
const normalized = normalizeSettings({ ...defaultSettings(), [key]: input } as AppSettings);
|
||||
|
||||
expect(normalized[key]).toBe(expected);
|
||||
});
|
||||
|
||||
it("enables package disclosure motion by default and preserves an explicit opt-out", () => {
|
||||
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
|
||||
delete legacy.animatePackageDisclosure;
|
||||
@@ -939,6 +1166,266 @@ describe("settings storage", () => {
|
||||
expect(normalized.historyRetentionMode).toBe("permanent");
|
||||
});
|
||||
|
||||
it("loads legacy history without inventing structured durations", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||
id: "legacy",
|
||||
name: "Altbestand",
|
||||
totalBytes: 1_000,
|
||||
downloadedBytes: 1_000,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid",
|
||||
completedAt: 10_000,
|
||||
durationSeconds: 9,
|
||||
status: "completed",
|
||||
outputDir: "C:\\Downloads\\Altbestand"
|
||||
}]), "utf8");
|
||||
|
||||
const [loaded] = loadHistory(paths);
|
||||
|
||||
expect(loaded).toEqual(expect.objectContaining({
|
||||
durationSeconds: 9,
|
||||
status: "completed"
|
||||
}));
|
||||
expect(loaded.downloadDurationSeconds).toBeUndefined();
|
||||
expect(loaded.totalDurationSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["completed", "partial", "failed", "cancelled", "deleted"] as const)("preserves the %s history status", (status) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||
id: `history-${status}`,
|
||||
name: "Paket",
|
||||
completedAt: 10_000,
|
||||
durationSeconds: 1,
|
||||
status,
|
||||
outputDir: "C:\\Downloads\\Paket"
|
||||
}]), "utf8");
|
||||
|
||||
expect(loadHistory(paths)[0]?.status).toBe(status);
|
||||
});
|
||||
|
||||
it("clamps expanded history metrics and preserves normalized operations", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||
id: "structured",
|
||||
name: "Paket",
|
||||
completedAt: 50_000,
|
||||
durationSeconds: 1,
|
||||
status: "failed",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
startedAt: -10,
|
||||
downloadEndedAt: 20_000,
|
||||
postProcessStartedAt: 25_000,
|
||||
downloadDurationSeconds: -120,
|
||||
extractionDurationSeconds: 30,
|
||||
remuxDurationSeconds: 5,
|
||||
postProcessDurationSeconds: 35,
|
||||
totalDurationSeconds: 50,
|
||||
successfulFiles: -1,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
archiveCount: 1,
|
||||
partCount: 16,
|
||||
outputCount: 15,
|
||||
failurePhase: "extract",
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "Paket.part01.rar",
|
||||
itemIds: ["item-1", "", "item-2"],
|
||||
partCount: -16,
|
||||
startedAt: 25_000,
|
||||
completedAt: 50_000,
|
||||
durationMs: -30_000,
|
||||
status: "failed",
|
||||
errorCategory: "checksum"
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: 30_000,
|
||||
completedAt: 35_000,
|
||||
durationMs: 5_000,
|
||||
status: "cancelled",
|
||||
errorCategory: "cancelled"
|
||||
}]
|
||||
}]), "utf8");
|
||||
|
||||
expect(loadHistory(paths)[0]).toEqual(expect.objectContaining({
|
||||
status: "failed",
|
||||
startedAt: 0,
|
||||
downloadEndedAt: 20_000,
|
||||
postProcessStartedAt: 25_000,
|
||||
downloadDurationSeconds: 0,
|
||||
extractionDurationSeconds: 30,
|
||||
remuxDurationSeconds: 5,
|
||||
postProcessDurationSeconds: 35,
|
||||
totalDurationSeconds: 50,
|
||||
successfulFiles: 0,
|
||||
failedFiles: 1,
|
||||
cancelledFiles: 0,
|
||||
archiveCount: 1,
|
||||
partCount: 16,
|
||||
outputCount: 15,
|
||||
failurePhase: "extract",
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "Paket.part01.rar",
|
||||
itemIds: ["item-1", "item-2"],
|
||||
partCount: 0,
|
||||
startedAt: 25_000,
|
||||
completedAt: 50_000,
|
||||
durationMs: 0,
|
||||
status: "failed",
|
||||
errorCategory: "Entpacken"
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-1",
|
||||
fileName: "episode.mkv",
|
||||
startedAt: 30_000,
|
||||
completedAt: 35_000,
|
||||
durationMs: 5_000,
|
||||
status: "cancelled",
|
||||
errorCategory: "Remux"
|
||||
}]
|
||||
}));
|
||||
});
|
||||
|
||||
it("preserves package lifecycle timestamps and operation metrics when loading a session", () => {
|
||||
const normalized = normalizeLoadedSession({
|
||||
version: 2,
|
||||
packageOrder: ["pkg-1"],
|
||||
packages: {
|
||||
"pkg-1": {
|
||||
id: "pkg-1",
|
||||
name: "Paket",
|
||||
outputDir: "C:\\Downloads\\Paket",
|
||||
extractDir: "C:\\Downloads\\Paket",
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
resultGeneration: 7,
|
||||
downloadStartedAt: 1_000,
|
||||
downloadCompletedAt: 10_000,
|
||||
downloadEndedAt: 12_000,
|
||||
postProcessQueuedAt: 13_000,
|
||||
postProcessStartedAt: 14_000,
|
||||
postProcessCompletedAt: 20_000,
|
||||
terminalAt: 21_000,
|
||||
archiveOperations: [{
|
||||
id: "archive-1",
|
||||
name: "Paket.rar",
|
||||
itemIds: [],
|
||||
partCount: 1,
|
||||
startedAt: 14_000,
|
||||
completedAt: 18_000,
|
||||
durationMs: 4_000,
|
||||
status: "completed",
|
||||
errorCategory: ""
|
||||
}],
|
||||
remuxOperations: [],
|
||||
outputCount: 1,
|
||||
cleanupErrorCategory: "",
|
||||
createdAt: 1_000,
|
||||
updatedAt: 21_000
|
||||
}
|
||||
},
|
||||
items: {},
|
||||
runStartedAt: 1_000,
|
||||
totalDownloadedBytes: 0,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: 21_000
|
||||
});
|
||||
|
||||
expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({
|
||||
resultGeneration: 7,
|
||||
downloadEndedAt: 12_000,
|
||||
postProcessQueuedAt: 13_000,
|
||||
postProcessStartedAt: 14_000,
|
||||
postProcessCompletedAt: 20_000,
|
||||
terminalAt: 21_000,
|
||||
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
|
||||
remuxOperations: [],
|
||||
outputCount: 1,
|
||||
cleanupErrorCategory: ""
|
||||
}));
|
||||
});
|
||||
|
||||
it("migrates persisted package failure details to safe categories", () => {
|
||||
const normalized = normalizeLoadedSession({
|
||||
version: 2,
|
||||
packageOrder: ["pkg-private"],
|
||||
packages: {
|
||||
"pkg-private": {
|
||||
id: "pkg-private",
|
||||
name: "Private telemetry",
|
||||
outputDir: "C:\\Downloads\\Private",
|
||||
extractDir: "C:\\Downloads\\Private",
|
||||
status: "failed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
archiveOperations: [{
|
||||
id: "archive-private",
|
||||
name: "private.rar",
|
||||
itemIds: [],
|
||||
partCount: 1,
|
||||
startedAt: 1_000,
|
||||
completedAt: 2_000,
|
||||
durationMs: 1_000,
|
||||
status: "failed",
|
||||
errorCategory: "CRC_ERROR in C:\\Users\\Alice\\private.rar"
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-private",
|
||||
fileName: "private.mkv",
|
||||
startedAt: 2_000,
|
||||
completedAt: 3_000,
|
||||
durationMs: 1_000,
|
||||
status: "failed",
|
||||
errorCategory: "ffmpeg failed for https://private.example.test/private.mkv"
|
||||
}, {
|
||||
id: "remux-disk-full",
|
||||
fileName: "disk-full.mkv",
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
durationMs: 1_000,
|
||||
status: "failed",
|
||||
errorCategory: "disk_full at C:\\Users\\Alice\\disk-full.mkv"
|
||||
}],
|
||||
cleanupErrorCategory: "unlink failed for C:\\Users\\Alice\\private.rar",
|
||||
createdAt: 1_000,
|
||||
updatedAt: 4_000
|
||||
}
|
||||
},
|
||||
items: {},
|
||||
runStartedAt: 1_000,
|
||||
totalDownloadedBytes: 0,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: 4_000
|
||||
});
|
||||
const pkg = normalized.packages["pkg-private"];
|
||||
|
||||
expect(pkg.archiveOperations?.[0]?.errorCategory).toBe("Entpacken");
|
||||
expect(pkg.remuxOperations?.map((operation) => operation.errorCategory)).toEqual(["Remux", "Speicherplatz"]);
|
||||
expect(pkg.cleanupErrorCategory).toBe("Cleanup");
|
||||
});
|
||||
|
||||
it("skips adding persisted history entries when history retention is never", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildSupportBundle } from "../src/main/support-bundle";
|
||||
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import type { NotificationSupportPayload } from "../src/main/support-data";
|
||||
import type { SessionState } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
@@ -15,10 +18,10 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function fakeManager(): DownloadManager {
|
||||
function fakeManager(session: SessionState = emptySession()): DownloadManager {
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: { packages: {}, items: {}, packageOrder: [] },
|
||||
session,
|
||||
speedText: "",
|
||||
etaText: "",
|
||||
canStart: false,
|
||||
@@ -71,4 +74,97 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(timerFired).toBe(true);
|
||||
});
|
||||
|
||||
it("writes only the safe notification aggregate and excludes its runtime files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-notifications-"));
|
||||
tempDirs.push(root);
|
||||
const paths = createStoragePaths(root);
|
||||
fs.writeFileSync(paths.notificationOutboxFile, "PRIVATE_OUTBOX_RUNTIME_PAYLOAD", "utf8");
|
||||
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_RUNTIME_PAYLOAD", "utf8");
|
||||
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "scheduler",
|
||||
incidentAgeMs: 45_000,
|
||||
events: [{ payload: "PRIVATE_EVENT_PAYLOAD" }],
|
||||
url: "https://private.example.test/webhook",
|
||||
mention: "@private"
|
||||
};
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", notificationStatus });
|
||||
const zip = new AdmZip(buffer);
|
||||
const entry = zip.getEntry("overview/notifications.json");
|
||||
const payload = JSON.parse(entry?.getData().toString("utf8") || "null");
|
||||
const serializedEntries = zip.getEntries().map((item) => item.getData().toString("utf8")).join("\n");
|
||||
|
||||
expect(payload).toEqual({
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "scheduler",
|
||||
incidentAgeMs: 45_000
|
||||
});
|
||||
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationOutboxFile));
|
||||
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationHealthFile));
|
||||
expect(serializedEntries).not.toMatch(/PRIVATE_|https:\/\/private|@private/);
|
||||
});
|
||||
|
||||
it("excludes legacy raw package failure details after loading persisted sessions", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-package-telemetry-"));
|
||||
tempDirs.push(root);
|
||||
const paths = createStoragePaths(root);
|
||||
const legacySession = emptySession();
|
||||
legacySession.packageOrder = ["pkg-private"];
|
||||
legacySession.packages["pkg-private"] = {
|
||||
id: "pkg-private",
|
||||
name: "Support diagnostics",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
status: "failed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
archiveOperations: [{
|
||||
id: "archive-private",
|
||||
name: "private.rar",
|
||||
itemIds: [],
|
||||
partCount: 1,
|
||||
startedAt: 1_000,
|
||||
completedAt: 2_000,
|
||||
durationMs: 1_000,
|
||||
status: "failed",
|
||||
errorCategory: "ARCHIVE_PRIVATE C:\\Users\\Alice\\private.rar https://private.example.test/archive"
|
||||
}],
|
||||
remuxOperations: [{
|
||||
id: "remux-private",
|
||||
fileName: "private.mkv",
|
||||
startedAt: 2_000,
|
||||
completedAt: 3_000,
|
||||
durationMs: 1_000,
|
||||
status: "failed",
|
||||
errorCategory: "REMUX_PRIVATE C:\\Users\\Alice\\private.mkv https://private.example.test/remux"
|
||||
}],
|
||||
cleanupErrorCategory: "CLEANUP_PRIVATE C:\\Users\\Alice\\private.rar https://private.example.test/cleanup",
|
||||
outputCount: 7,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 3_000
|
||||
};
|
||||
fs.writeFileSync(paths.sessionFile, JSON.stringify(legacySession), "utf8");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(loadSession(paths)), root, { hostDiagnosticsMode: "none" });
|
||||
const zip = new AdmZip(buffer);
|
||||
const payload = JSON.parse(zip.getEntry("overview/packages.json")?.getData().toString("utf8") || "null");
|
||||
const serializedEntries = zip.getEntries().map((entry) => entry.getData().toString("utf8")).join("\n");
|
||||
|
||||
expect(payload).toEqual({
|
||||
count: 1,
|
||||
packages: [expect.objectContaining({
|
||||
name: "Support diagnostics",
|
||||
outputCount: 7,
|
||||
archiveOperations: [expect.objectContaining({ errorCategory: "Entpacken" })],
|
||||
remuxOperations: [expect.objectContaining({ errorCategory: "Remux" })],
|
||||
cleanupErrorCategory: "Cleanup"
|
||||
})]
|
||||
});
|
||||
expect(serializedEntries).not.toMatch(/ARCHIVE_PRIVATE|REMUX_PRIVATE|CLEANUP_PRIVATE|private\.example\.test/);
|
||||
});
|
||||
});
|
||||
|
||||
+132
-2
@@ -1,10 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { buildAccountSummary, buildStatsPayload } from "../src/main/support-data";
|
||||
import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data";
|
||||
import { buildSupportBundle } from "../src/main/support-bundle";
|
||||
import { createStoragePaths } from "../src/main/storage";
|
||||
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||
import { createVisualFixture } from "./visual/fixtures";
|
||||
|
||||
describe("Real-Debrid support summary", () => {
|
||||
it("projects the current private AppController notification state into the safe DTO", () => {
|
||||
const controller = Object.create(AppController.prototype) as AppController;
|
||||
const internals = controller as unknown as {
|
||||
notificationOutbox: { getStatus: () => Record<string, unknown> };
|
||||
downloadHealthMonitor: { getState: () => Record<string, unknown> };
|
||||
};
|
||||
internals.notificationOutbox = {
|
||||
getStatus: () => ({
|
||||
queued: 4,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
lastFailureAt: 1_700_000_010_000,
|
||||
events: [{ payload: "PRIVATE_CONTROLLER_EVENT" }]
|
||||
})
|
||||
};
|
||||
internals.downloadHealthMonitor = {
|
||||
getState: () => ({
|
||||
incidentType: "scheduler",
|
||||
incidentStartedAt: 1_700_000_020_000,
|
||||
runFingerprint: "PRIVATE_CONTROLLER_FINGERPRINT"
|
||||
})
|
||||
};
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_050_000);
|
||||
|
||||
const payload = controller.getNotificationSupportPayload();
|
||||
expect(payload).toEqual({
|
||||
queued: 4,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "scheduler",
|
||||
incidentAgeMs: 30_000
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toMatch(/PRIVATE_|event|payload|fingerprint|lastFailure/i);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("projects only safe notification delivery and incident aggregates", () => {
|
||||
const payload = buildNotificationSupportPayload(
|
||||
{
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
lastFailureAt: 1_700_000_010_000,
|
||||
events: [{ payload: { url: "https://private.example.test/hook", mention: "@private" } }]
|
||||
} as Parameters<typeof buildNotificationSupportPayload>[0],
|
||||
{
|
||||
status: "alerted",
|
||||
incidentType: "no_data",
|
||||
incidentStartedAt: 1_700_000_020_000,
|
||||
runFingerprint: "private-run",
|
||||
url: "https://private.example.test/hook",
|
||||
mention: "@private"
|
||||
} as Parameters<typeof buildNotificationSupportPayload>[1],
|
||||
1_700_000_050_000
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
queued: 7,
|
||||
lastSuccessAt: 1_700_000_000_000,
|
||||
incidentType: "no_data",
|
||||
incidentAgeMs: 30_000
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toMatch(/payload|https?:|mention|private|lastFailure/i);
|
||||
});
|
||||
|
||||
it("reports no active incident when the health state has no incident", () => {
|
||||
expect(buildNotificationSupportPayload(
|
||||
{ queued: 0, lastSuccessAt: 0 },
|
||||
{ incidentType: null, incidentStartedAt: 0 },
|
||||
1_700_000_050_000
|
||||
)).toEqual({ queued: 0, lastSuccessAt: null, incidentType: null, incidentAgeMs: null });
|
||||
});
|
||||
|
||||
it("reports pool counts without exposing account IDs or credentials", () => {
|
||||
const summary = buildAccountSummary({
|
||||
...defaultSettings(),
|
||||
@@ -31,6 +108,35 @@ describe("Real-Debrid support summary", () => {
|
||||
expect(serialized).not.toContain("secret-");
|
||||
});
|
||||
|
||||
it("reports only safe Deepbrid configuration, status and usage aggregates", () => {
|
||||
const settings = defaultSettings();
|
||||
settings.deepbridApiKey = "synthetic-private-deepbrid-key";
|
||||
settings.providerDailyLimitBytes.deepbrid = 10_000;
|
||||
settings.providerDailyUsageBytes.deepbrid = 4_000;
|
||||
settings.providerTotalUsageBytes.deepbrid = 20_000;
|
||||
settings.debridAccountStatuses["svc-deepbrid"] = {
|
||||
accountId: "svc-deepbrid",
|
||||
provider: "deepbrid",
|
||||
label: "Deepbrid",
|
||||
maskedLogin: "synthetic-masked-login",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: 1_800_000_000_000,
|
||||
checkedAt: 1_700_000_000_000,
|
||||
message: "PRIVATE_STATUS_MESSAGE"
|
||||
};
|
||||
|
||||
const deepbrid = buildAccountSummary(settings).deepbrid;
|
||||
const serialized = JSON.stringify(deepbrid);
|
||||
expect(deepbrid).toEqual({
|
||||
configured: true,
|
||||
apiKeyConfigured: true,
|
||||
status: { checked: true, valid: true, premium: true, premiumUntilMs: 1_800_000_000_000, checkedAt: 1_700_000_000_000 },
|
||||
usage: { dailyLimitBytes: 10_000, dailyUsageBytes: 4_000, totalUsageBytes: 20_000 }
|
||||
});
|
||||
expect(serialized).not.toMatch(/synthetic-private|PRIVATE_STATUS/);
|
||||
});
|
||||
|
||||
it("removes rolling account IDs and labels from support statistics", () => {
|
||||
const snapshot = structuredClone(createVisualFixture("empty").snapshot);
|
||||
snapshot.stats.rolling24Hours = {
|
||||
@@ -53,4 +159,28 @@ describe("Real-Debrid support summary", () => {
|
||||
expect(serialized).toContain("realdebrid");
|
||||
expect(serialized).toContain("4096");
|
||||
});
|
||||
|
||||
it("keeps the persisted notification health incident outside support bundles", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-support-health-"));
|
||||
try {
|
||||
const paths = createStoragePaths(root);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_INCIDENT_PAYLOAD", "utf8");
|
||||
const snapshot = structuredClone(createVisualFixture("empty").snapshot);
|
||||
const manager = {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
};
|
||||
|
||||
const buffer = await buildSupportBundle(manager as any, root, { hostDiagnosticsMode: "none" });
|
||||
const zip = new AdmZip(buffer);
|
||||
const entries = zip.getEntries().map((entry) => entry.entryName);
|
||||
|
||||
expect(entries).not.toContain(path.basename(paths.notificationHealthFile));
|
||||
expect(buffer.toString("utf8")).not.toContain("PRIVATE_HEALTH_INCIDENT_PAYLOAD");
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,6 +93,26 @@ describe("visual fixtures", () => {
|
||||
expect(createVisualFixture("dense")).toEqual(dense);
|
||||
});
|
||||
|
||||
it("keeps the complete notification center state deterministic without exposing its webhook", () => {
|
||||
const settings = createVisualFixture("dense").snapshot.settings;
|
||||
|
||||
expect(settings).toEqual(expect.objectContaining({
|
||||
notifyUrlConfigured: true,
|
||||
notifyMention: "@visual",
|
||||
notifyOnPackageCompleted: true,
|
||||
notifyOnPackageFailed: true,
|
||||
notifyPackageSuccessMode: "digest",
|
||||
notifyOnRunFinished: true,
|
||||
notifyOnRemainingBelow: true,
|
||||
notifyRemainingThresholdGb: 75,
|
||||
notifyOnDownloadStall: true,
|
||||
notifyStallAfterSeconds: 120,
|
||||
notifyStallCooldownMinutes: 15,
|
||||
notifyOnDownloadRecovery: true
|
||||
}));
|
||||
expect(settings).not.toHaveProperty("notifyUrl");
|
||||
});
|
||||
|
||||
it("freezes runtime and recurring chart timers across visual frames", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const originalDateNow = Date.now;
|
||||
@@ -172,6 +192,13 @@ describe("visual fixtures", () => {
|
||||
expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].accountId);
|
||||
});
|
||||
|
||||
it("includes the configured synthetic Deepbrid account", () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
expect(dense.snapshot.accounts).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ accountId: "svc-deepbrid", kind: "deepbrid-api", provider: "deepbrid" })
|
||||
]));
|
||||
});
|
||||
|
||||
it("stores every mutable bridge state inside the visual fixture", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const api = createVisualElectronApi(dense);
|
||||
|
||||
@@ -76,6 +76,7 @@ function createSettings(): AppSettings {
|
||||
bestDebridUseWebLogin: false,
|
||||
allDebridToken: "visual-all-debrid-token",
|
||||
allDebridUseWebLogin: false,
|
||||
deepbridApiKey: "visual-deepbrid-key",
|
||||
ddownloadLogin: "visual-ddownload",
|
||||
ddownloadPassword: "visual-password",
|
||||
oneFichierApiKey: "visual-onefichier-key",
|
||||
@@ -139,6 +140,13 @@ function createSettings(): AppSettings {
|
||||
notifyOnPackageCompleted: true,
|
||||
notifyOnPackageFailed: true,
|
||||
notifyOnRunFinished: true,
|
||||
notifyPackageSuccessMode: "digest",
|
||||
notifyOnRemainingBelow: true,
|
||||
notifyRemainingThresholdGb: 75,
|
||||
notifyOnDownloadStall: true,
|
||||
notifyStallAfterSeconds: 120,
|
||||
notifyStallCooldownMinutes: 15,
|
||||
notifyOnDownloadRecovery: true,
|
||||
totalDownloadedAllTime: 987654321000,
|
||||
totalCompletedFilesAllTime: 842,
|
||||
totalRuntimeAllTimeMs: 172800000,
|
||||
@@ -233,6 +241,12 @@ function createSettings(): AppSettings {
|
||||
}
|
||||
},
|
||||
providerDailyUsageDay: "2026-08-10",
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 0,
|
||||
dailyStartFirstLocalDate: "",
|
||||
dailyStartLastHandledLocalDate: "",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "",
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user