diff --git a/CHANGELOG.md b/CHANGELOG.md index a33384b..db78ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fccdd76..3351b7e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 036d669..4bb8547 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,8 @@ The provider names, logos, and favicons bundled with this application are tradem | Real-Debrid | https://fcdn.real-debrid.com/0831/favicons/favicon-32x32.png | 2026-08-09 | `542D80BDDB948F240B100C1872E83B0228BC1BFE5C46E8FFD95AFA419CB11F52` | | 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` | +| 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` | diff --git a/assets/provider-icons/deepbrid.png b/assets/provider-icons/deepbrid.png new file mode 100644 index 0000000..6f05149 Binary files /dev/null and b/assets/provider-icons/deepbrid.png differ diff --git a/package-lock.json b/package-lock.json index cc3b3af..b9bf977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 42917bd..be07f5d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs index 27cba24..e6d1f0b 100644 --- a/scripts/verify_public_release.mjs +++ b/scripts/verify_public_release.mjs @@ -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", diff --git a/src/main/account-check.ts b/src/main/account-check.ts index cd6694e..5d7147a 100644 --- a/src/main/account-check.ts +++ b/src/main/account-check.ts @@ -2,7 +2,8 @@ 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 { logger } from "./logger"; +import { DEEPBRID_ACCOUNT_ID, DeepbridApiError, DeepbridClient } from "./deepbrid"; +import { logger } from "./logger"; import { compactErrorText } from "./utils"; const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; @@ -175,6 +176,57 @@ export async function checkRealDebridAccount( return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` }; } } + +export async function checkDeepbridAccount( + apiKey: string, + signal?: AbortSignal, + now = Date.now() +): Promise { + 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, @@ -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> = [ ...realDebridAccounts.map((account) => () => checkRealDebridAccount( @@ -347,9 +401,10 @@ export async function checkAllDebridAccounts( ? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal) : undefined )), - ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), - ...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)) - ]; + ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), + ...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)), + ...(checkDeepbrid ? [() => checkDeepbridAccount(deepbridApiKey, signal, now)] : []) + ]; const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY); logger.info( diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts index c143e90..ad9bfaa 100644 --- a/src/main/account-commands.ts +++ b/src/main/account-commands.ts @@ -27,6 +27,7 @@ const ACCOUNT_KINDS = new Set([ "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, provider); next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record, provider); next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record, provider); + next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId); return { settings: next, response: { accountId: null } }; } diff --git a/src/main/account-status-sanitizer.ts b/src/main/account-status-sanitizer.ts index 100d78e..b655be6 100644 --- a/src/main/account-status-sanitizer.ts +++ b/src/main/account-status-sanitizer.ts @@ -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); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e8a2979..919cbd4 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -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): Partial { 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 | null = null; + private logDirectory = this.storagePaths.baseDir; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; @@ -148,9 +160,32 @@ export class AppController { } this.initializeLogStorage(); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - const loadResult = loadSessionWithStatus(this.storagePaths); - const session = loadResult.session; - this.megaWebFallback = new MegaWebFallback(() => ({ + 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 })); @@ -161,15 +196,21 @@ export class AppController { allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), - invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), + invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), protectEmptyClobber: loadResult.status === "empty-unreadable", + enqueueNotification: (event) => this.notificationOutbox.enqueue(event), onHistoryEntry: (entry: HistoryEntry) => { this.recordHistoryEntry(entry); } }); - this.manager.on("state", (snapshot: UiSnapshot) => { - this.onStateHandler?.(snapshot); - }); + 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 { 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 @@ -1130,21 +1183,31 @@ export class AppController { return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" }; } - public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { + public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { this.audit("INFO", "Support-Bundle exportiert"); logTraceEvent("INFO", "support", "Support-Bundle erstellt", { packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, 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() }; } - public getSupportBundleDefaultFileName(): string { - return getSupportBundleDefaultFileName(); - } + public getSupportBundleDefaultFileName(): string { + 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; @@ -1169,8 +1232,8 @@ export class AppController { const importedSettingsRecord = importedSettings as unknown as Record; const currentSettingsRecord = this.settings as unknown as Record; const SENSITIVE_KEYS: (keyof AppSettings)[] = [ - "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", - "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", + "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", + "deepbridApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword", "notifyUrl" ]; @@ -1261,19 +1324,64 @@ export class AppController { return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); } - public getItemLogPath(itemId: string): string | null { - return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); - } - - public shutdown(): void { - if (this.runtimeStatsTimer) { + public getItemLogPath(itemId: string): string | null { + return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); + } + + private evaluateDownloadHealth(): Promise { + 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 { + 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; } - stopDebugServer(); - abortActiveUpdateDownload(); - cancelPendingAsyncSaves(); + stopDebugServer(); + 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(); @@ -1291,7 +1399,22 @@ export class AppController { if (this.settings.historyRetentionMode === "session") { clearHistory(this.storagePaths); } - logger.info("App beendet"); + logger.info("App beendet"); + } + + private async waitForShutdownTask(task: Promise, deadlineAt: number): Promise { + const remainingMs = Math.max(0, deadlineAt - Date.now()); + if (remainingMs <= 0) { + return; + } + let timer: NodeJS.Timeout | null = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, remainingMs); + }); + await Promise.race([task.then(() => undefined, () => undefined), timeout]); + if (timer) { + clearTimeout(timer); + } } private getDesktopDirectory(): string | null { diff --git a/src/main/constants.ts b/src/main/constants.ts index 9d4c71c..90e7b56 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -63,9 +63,10 @@ export function defaultSettings(): AppSettings { megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, - allDebridToken: "", - allDebridUseWebLogin: false, - ddownloadLogin: "", + allDebridToken: "", + allDebridUseWebLogin: false, + deepbridApiKey: "", + ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", @@ -125,9 +126,16 @@ export function defaultSettings(): AppSettings { backupIncludeRemoteDiagnostics: false, notifyUrl: "", notifyMention: "", - notifyOnPackageCompleted: false, - notifyOnPackageFailed: false, - notifyOnRunFinished: false, + 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, @@ -150,8 +158,14 @@ export function defaultSettings(): AppSettings { megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, - debridAccountStatuses: {}, - providerDailyUsageDay: getProviderUsageDayKey(), - scheduledStartEpochMs: 0 - }; -} + debridAccountStatuses: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + dailyStartEnabled: false, + dailyStartMinuteOfDay: 0, + dailyStartFirstLocalDate: "", + dailyStartLastHandledLocalDate: "", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "", + scheduledStartEpochMs: 0 + }; +} diff --git a/src/main/credential-protection.ts b/src/main/credential-protection.ts index 9aa662c..40b078d 100644 --- a/src/main/credential-protection.ts +++ b/src/main/credential-protection.ts @@ -18,6 +18,7 @@ const CREDENTIAL_KEYS = [ "megaDebridWebCredentials", "bestToken", "allDebridToken", + "deepbridApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", diff --git a/src/main/daily-start-scheduler.ts b/src/main/daily-start-scheduler.ts new file mode 100644 index 0000000..1a93181 --- /dev/null +++ b/src/main/daily-start-scheduler.ts @@ -0,0 +1,263 @@ +import type { DailyStartOutcome, DailyStartSettings } from "../shared/types"; + +interface DailyStartSnapshot { + settings: DailyStartSettings; + session: { + running: boolean; + paused: boolean; + items: Record; + packages: Record; + }; + canStart: boolean; +} + +interface DailyStartController { + getSnapshot(): DailyStartSnapshot; + updateSettings(partial: Partial): unknown; + start(): Promise; +} + +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; + return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"] + .some((key) => Object.prototype.hasOwnProperty.call(value, key)); +} + +export function prepareDailyStartSettingsPatch( + partial: T, + current: DailyStartSettings +): T & { scheduledStartEpochMs?: number } { + const value = partial as Record; + 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 | null = null; + private reconcileTimer: ReturnType | null = null; + private lifecycleGeneration = 0; + + public constructor( + private readonly controller: DailyStartController, + private readonly now: () => number = Date.now + ) {} + + public reconcile(): Promise { + 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 { + 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"); + } +} diff --git a/src/main/debrid.ts b/src/main/debrid.ts index e7ff58b..b032c2d 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -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"; @@ -595,9 +596,10 @@ const PROVIDER_LABELS: Record = { megadebrid: "Mega-Debrid", "megadebrid-api": "Mega-Debrid API", "megadebrid-web": "Mega-Debrid Web", - bestdebrid: "BestDebrid", - alldebrid: "AllDebrid", - ddownload: "DDownload", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid", + deepbrid: "Deepbrid", + ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link", linksnappy: "LinkSnappy" @@ -4500,9 +4502,12 @@ export class DebridService { if (effectiveProvider === "megadebrid-web") { return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict); } - if (effectiveProvider === "alldebrid") { - return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim()); - } + 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()); } @@ -4652,7 +4657,7 @@ export class DebridService { if (effectiveProvider === "megadebrid-web") { return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal); } - if (effectiveProvider === "alldebrid") { + if (effectiveProvider === "alldebrid") { if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { const result = await this.options.allDebridWebUnrestrict(link, signal); if (!result) { @@ -4662,9 +4667,18 @@ export class DebridService { return result; } const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); - adResult.sourceLabel = "API"; - return adResult; - } + 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); } diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 6806389..e04a8b1 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -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"; @@ -56,7 +56,8 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { 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: "/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(); +let notificationStatusProvider: (() => NotificationSupportPayload) | null = null; export interface DebugServerRuntimeStatus { running: boolean; @@ -92,9 +94,13 @@ function readSupportSettings() { return loadSettings(getStoragePaths()); } -function readSupportHistory() { - return loadHistory(getStoragePaths()); -} +function readSupportHistory() { + return loadHistory(getStoragePaths()); +} + +function readNotificationStatus(): NotificationSupportPayload { + return normalizeNotificationSupportPayload(notificationStatusProvider?.()); +} function extractDebugClientIp(req: http.IncomingMessage): string { const forwarded = req.headers["x-forwarded-for"]; @@ -908,7 +914,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } - if (pathname === "/stats") { + if (pathname === "/stats") { if (!manager) { jsonResponse(res, 503, { error: "Manager not initialized" }); return; @@ -923,8 +929,13 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs } }); - return; - } + return; + } + + if (pathname === "/notifications") { + jsonResponse(res, 200, readNotificationStatus()); + return; + } if (pathname === "/history") { const entries = readSupportHistory(); @@ -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, @@ -1088,7 +1099,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi }, status: buildStatusPayload(snapshot), settings: buildRedactedSettingsPayload(readSupportSettings()), - stats: buildStatsPayload(snapshot), + stats: buildStatsPayload(snapshot), + notifications: readNotificationStatus(), accounts: buildAccountSummary(readSupportSettings()), providers: getProviderRuntimeSnapshot(), history: { @@ -1184,11 +1196,16 @@ function openServerSocket(): Promise { }); } -export function startDebugServer(mgr: DownloadManager, baseDir: string): void { - runtimeBaseDir = baseDir; - manager = mgr; - void openServerSocket(); -} +export function startDebugServer( + mgr: DownloadManager, + baseDir: string, + readCurrentNotificationStatus?: () => NotificationSupportPayload +): void { + runtimeBaseDir = baseDir; + manager = mgr; + notificationStatusProvider = readCurrentNotificationStatus || null; + void openServerSocket(); +} export async function restartDebugServer(): Promise { const old = server; @@ -1256,6 +1273,7 @@ export function clearDebugToken(): void { } export function stopDebugServer(): void { + notificationStatusProvider = null; if (server) { server.close(); try { diff --git a/src/main/deepbrid.ts b/src/main/deepbrid.ts new file mode 100644 index 0000000..f88fe74 --- /dev/null +++ b/src/main/deepbrid.ts @@ -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 { + 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 { + if (signal?.aborted) { + throw callerAbortReason(signal); + } + await new Promise((resolve, reject) => { + let timer: ReturnType | 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 { + 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 = { 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 { + return this.request("/user", { method: "GET" }, validateUser, signal, false, true); + } + + public async getHosts(signal?: AbortSignal): Promise { + return this.request("/hosts", { method: "GET" }, validateHosts, signal, false, false); + } + + public async unrestrictLink(link: string, signal?: AbortSignal, password?: string): Promise { + 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( + path: string, + init: { method: "GET" | "POST"; body?: URLSearchParams }, + validate: (payload: unknown, retriesUsed: number) => T, + signal: AbortSignal | undefined, + linkRequest: boolean, + authenticated: boolean + ): Promise { + 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 = { 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"); + } +} diff --git a/src/main/download-health-monitor.ts b/src/main/download-health-monitor.ts new file mode 100644 index 0000000..47b0148 --- /dev/null +++ b/src/main/download-health-monitor.ts @@ -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([ + "idle", + "suspended", + "expected_wait", + "healthy", + "suspect_scheduler", + "suspect_no_data", + "alerted", + "recovering" +]); +const INCIDENT_TYPES = new Set(["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 { + 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 { + 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; + 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 = 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 { + 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 + ): Promise { + 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(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation); + this.operationChain = result.then(() => undefined, () => undefined); + return result; + } +} diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d128cff..db6ecd8 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1,6 +1,7 @@ -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { v4 as uuidv4 } from "uuid"; import { @@ -13,10 +14,12 @@ import { DownloadSummary, DownloadStatus, DuplicatePolicy, - HistoryEntry, - PackageEntry, - PackagePriority, - ParsedPackageInput, + HistoryEntry, + ArchiveOperationMetric, + PackageEntry, + PackagePriority, + PackageResult, + ParsedPackageInput, SessionState, StatisticsLedger, StartConflictEntry, @@ -59,12 +62,11 @@ function releaseTlsSkip(): void { import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; -import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; +import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; -import { sendNotification } from "./notify"; -import { logger } from "./logger"; +import { logger } from "./logger"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import { createAccountRuntimeEntries } from "./account-runtime-snapshot"; import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace"; @@ -90,6 +92,21 @@ import { saveStatisticsLedger, seedStatisticsDayProviderBytes } from "./statistics-ledger"; +import { finalizePackageResult, projectPackageFailureCategory } from "./package-telemetry"; +import type { NotificationEvent } from "./notification-outbox"; +import type { DownloadHealthSnapshot } from "./download-health-monitor"; +import { + buildHistoryEntry, + buildPackageDigestEvents, + buildPackageNotificationEvent, + buildRemainingThresholdNotificationEvent, + buildRunNotificationEvent, + buildRunResult, + evaluateRemainingThreshold, + type PackageResultEnvelope, + type RemainingThresholdState, + type RunRemainingSnapshot +} from "./notification-events"; type ActiveTask = { itemId: string; @@ -103,8 +120,12 @@ type ActiveTask = { stallRetries?: number; genericErrorRetries?: number; unrestrictRetries?: number; - blockedOnDiskWrite?: boolean; - blockedOnDiskSince?: number; + blockedOnDiskWrite?: boolean; + blockedOnDiskSince?: number; + blockedOnThrottleUntil?: number; + phase?: "validating" | "downloading" | "integrity_check"; + phaseStartedAt?: number; + phaseDeadlineAt?: number; }; const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [ @@ -113,6 +134,7 @@ const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [ "megadebrid-web", "bestdebrid", "alldebrid", + "deepbrid", "ddownload", "onefichier", "debridlink", @@ -444,15 +466,24 @@ function retryLimitToMaxRetries(retryLimit: number): number { type HistoryEntryCallback = (entry: HistoryEntry) => void; -type DownloadManagerOptions = { +type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; allDebridWebUnrestrict?: AllDebridWebUnrestrictor; realDebridWebUnrestrict?: RealDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; invalidateMegaSession?: () => void; - onHistoryEntry?: HistoryEntryCallback; - protectEmptyClobber?: boolean; -}; + onHistoryEntry?: HistoryEntryCallback; + enqueueNotification?: (event: NotificationEvent) => Promise; + protectEmptyClobber?: boolean; +}; + +type RunLifecycleContext = { + id: string; + startedAt: number; + packageGenerations: Map; + downloadsFinished: boolean; + remainingNotification: RemainingThresholdState; +}; function generateHistoryId(): string { return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; @@ -691,11 +722,11 @@ function isPermanentLinkError(errorText: string): boolean { || text.includes("file was deleted"); } -function isUnrestrictFailure(errorText: string): boolean { - const text = String(errorText || "").toLowerCase(); - return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_") - || text.includes("mega-web") || text.includes("mega-debrid") - || text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid") +export function isProviderUnrestrictFailure(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_") + || text.includes("mega-web") || text.includes("mega-debrid") + || text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("deepbrid") || text.includes("kein debrid") || text.includes("session-cookie") || text.includes("session cookie") || text.includes("session blockiert") || text.includes("session expired") || text.includes("invalid session") || text.includes("login ungültig") || text.includes("login liefert") @@ -794,7 +825,7 @@ function isHosterUnavailableError(errorText: string): boolean { return String(errorText || "").toLowerCase().includes("hosternotavailable"); } -function isTemporaryUnrestrictError(errorText: string): boolean { +function isTemporaryUnrestrictError(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("server error") || text.includes("internal server error") @@ -811,8 +842,25 @@ function isTemporaryUnrestrictError(errorText: string): boolean { || text.includes("bad gateway") || text.includes("gateway timeout") || text.includes("cloudflare") - || text.includes("worker error"); -} + || text.includes("worker error"); +} + +export function classifyProviderUnrestrictBackoff(errorText: string): "busy" | "temporary" | null { + const deepbridClassification = String(errorText || "").match(/deepbrid-anfrage fehlgeschlagen \((auth|rate_limit|temporary|link|malformed),/i)?.[1]?.toLowerCase(); + if (deepbridClassification === "rate_limit") { + return "busy"; + } + if (deepbridClassification === "temporary") { + return "temporary"; + } + if (deepbridClassification) { + return null; + } + if (isProviderBusyUnrestrictError(errorText)) { + return "busy"; + } + return isTemporaryUnrestrictError(errorText) ? "temporary" : null; +} export function transientResolveRetryDelayMs(retryCount: number): number { const steps = [3000, 6000, 10000]; @@ -1914,15 +1962,47 @@ export class DownloadManager extends EventEmitter { private runOutcomes = new Map(); - private runCompletedPackages = new Set(); - - private historyRecordedPackages = new Set(); - - private notifiedPackages = new Set(); + private runCompletedPackages = new Set(); + + private historyRecordedPackages = new Set(); + + private finalizedPackageResults = new Map(); + + private runContexts = new Map(); + + private activeRunContextId: string | null = null; + + private standalonePackageResults = new Set(); + + private suppressedPackageResults = new Set(); + + private successDigestResults = new Map(); + + private successDigestTimer: NodeJS.Timeout | null = null; + + private notificationEnqueueChain: Promise = Promise.resolve(); + + private notificationsShuttingDown = false; private itemCount = 0; - private lastSchedulerHeartbeatAt = 0; + private lastSchedulerHeartbeatAt = 0; + + private lastSchedulerTickAt = 0; + + private downloadProgressSequence = 0; + + private itemCompletionSequence = 0; + + private lastPositiveByteAt = 0; + + private technicalRecoveryCount = 0; + + private healthManualStop = false; + + private healthShuttingDown = false; + + private healthTerminalFailure = false; private lastReconnectMarkAt = 0; @@ -1961,7 +2041,9 @@ export class DownloadManager extends EventEmitter { private lastStaleResetAt = 0; - private onHistoryEntryCallback?: HistoryEntryCallback; + private onHistoryEntryCallback?: HistoryEntryCallback; + + private enqueueNotificationCallback?: (event: NotificationEvent) => Promise; public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); @@ -1989,8 +2071,9 @@ export class DownloadManager extends EventEmitter { realDebridWebUnrestrict: options.realDebridWebUnrestrict, bestDebridWebUnrestrict: options.bestDebridWebUnrestrict }); - this.invalidateMegaSessionFn = options.invalidateMegaSession; - this.onHistoryEntryCallback = options.onHistoryEntry; + this.invalidateMegaSessionFn = options.invalidateMegaSession; + this.onHistoryEntryCallback = options.onHistoryEntry; + this.enqueueNotificationCallback = options.enqueueNotification; logger.info(`DownloadManager Init: ${Object.keys(this.session.packages).length} Pakete, ${this.itemCount} Items, cleanupPolicy=${this.settings.completedCleanupPolicy}`); for (const pkg of Object.values(this.session.packages)) { this.ensurePackageLogForPackage(pkg); @@ -2440,11 +2523,118 @@ export class DownloadManager extends EventEmitter { return cloneSession(this.session); } - public getSummary(): DownloadSummary | null { - return this.summary; - } - - public isSessionRunning(): boolean { + public getSummary(): DownloadSummary | null { + return this.summary; + } + + public getDownloadHealthSnapshot(now = nowMs()): DownloadHealthSnapshot { + const openPackages = new Set(); + const queueParts: string[] = []; + const activeTasks: ActiveTask[] = []; + let openItems = 0; + let knownDownloadedBytes = 0; + let currentSpeedBps = 0; + let startableItems = 0; + let nextRetryAt = 0; + let providerCooldownUntil = 0; + + for (const itemId of this.runItemIds) { + queueParts.push(itemId); + const item = this.session.items[itemId]; + if (!item || isFinishedStatus(item.status)) { + continue; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + openItems += 1; + openPackages.add(pkg.id); + knownDownloadedBytes += Math.max(0, Math.floor(Number(item.downloadedBytes) || 0)); + currentSpeedBps += Math.max(0, Math.floor(Number(item.speedBps) || 0)); + const active = this.activeTasks.get(itemId); + if (active && !active.abortController.signal.aborted) { + activeTasks.push(active); + continue; + } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + continue; + } + const retryAt = this.retryAfterByItem.get(itemId) || 0; + const failureKey = this.getProviderFailureKeyForItem(item); + const cooldownAt = this.providerFailures.get(failureKey)?.cooldownUntil || 0; + if (retryAt > now) { + nextRetryAt = nextRetryAt === 0 ? retryAt : Math.min(nextRetryAt, retryAt); + } + if (cooldownAt > now) { + providerCooldownUntil = providerCooldownUntil === 0 + ? cooldownAt + : Math.min(providerCooldownUntil, cooldownAt); + } + if (retryAt <= now && cooldownAt <= now) { + startableItems += 1; + } + } + + const blockedOnDisk = activeTasks.length > 0 && activeTasks.every((active) => Boolean(active.blockedOnDiskWrite)); + const throttleDeadlines = activeTasks.map((active) => active.blockedOnThrottleUntil || 0); + const blockedOnThrottleUntil = throttleDeadlines.length > 0 && throttleDeadlines.every((deadline) => deadline > now) + ? Math.min(...throttleDeadlines) + : 0; + const phaseDeadlines = activeTasks.map((active) => active.phaseDeadlineAt || 0); + const activePhaseDeadlineAt = phaseDeadlines.length > 0 && phaseDeadlines.every((deadline) => deadline > now) + ? Math.min(...phaseDeadlines) + : 0; + const queueFingerprint = createHash("sha256") + .update([...queueParts].sort().join("\n")) + .digest("hex"); + const runParts = [...this.runPackageIds].map((packageId) => { + const generation = Math.max(1, Math.floor(Number(this.session.packages[packageId]?.resultGeneration) || 1)); + return `${packageId}:${generation}`; + }); + const runFingerprint = createHash("sha256") + .update(`${runParts.sort().join("\n")}|${queueFingerprint}`) + .digest("hex"); + + return { + runActive: this.session.running && openItems > 0, + runFingerprint, + queueFingerprint, + openItems, + openPackages: openPackages.size, + knownDownloadedBytes, + activeTasks: activeTasks.length, + startableItems, + lastSchedulerTickAt: this.lastSchedulerTickAt, + downloadProgressSequence: this.downloadProgressSequence, + itemCompletionSequence: this.itemCompletionSequence, + lastPositiveByteAt: this.lastPositiveByteAt, + technicalRecoveryCount: this.technicalRecoveryCount, + paused: this.session.paused, + reconnectUntil: this.session.reconnectUntil, + nextRetryAt: activeTasks.length === 0 && startableItems === 0 ? nextRetryAt : 0, + providerCooldownUntil: activeTasks.length === 0 && startableItems === 0 ? providerCooldownUntil : 0, + blockedOnDisk, + blockedOnThrottleUntil, + activePhaseDeadlineAt, + terminalFailure: this.healthTerminalFailure, + manualStop: this.healthManualStop, + shuttingDown: this.healthShuttingDown, + currentSpeedBps: this.session.running && !this.session.paused ? currentSpeedBps : 0 + }; + } + + private beginHealthRun(): void { + this.healthManualStop = false; + this.healthShuttingDown = false; + this.healthTerminalFailure = false; + } + + public suspendDownloadHealthMonitoring(): void { + this.healthShuttingDown = true; + } + + public isSessionRunning(): boolean { return this.session.running; } @@ -2975,9 +3165,10 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Paket gestoppt"; item.updatedAt = nowMs(); } - } - this.runPackageIds.delete(packageId); - this.runCompletedPackages.delete(packageId); + } + this.runPackageIds.delete(packageId); + this.untrackActiveRunPackage(packageId); + this.runCompletedPackages.delete(packageId); } else { if (pkg.status === "paused") { pkg.status = "queued"; @@ -2999,8 +3190,9 @@ export class DownloadManager extends EventEmitter { } } if (this.session.running) { - if (hasReactivatedRunItems) { - this.runPackageIds.add(packageId); + if (hasReactivatedRunItems) { + this.runPackageIds.add(packageId); + this.trackActiveRunPackage(packageId); } void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`)); } @@ -3089,10 +3281,19 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.runItemIds.clear(); this.runPackageIds.clear(); - this.runOutcomes.clear(); - this.runCompletedPackages.clear(); - this.historyRecordedPackages.clear(); - this.notifiedPackages.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.historyRecordedPackages.clear(); + this.finalizedPackageResults.clear(); + this.runContexts.clear(); + this.activeRunContextId = null; + this.standalonePackageResults.clear(); + this.suppressedPackageResults.clear(); + this.successDigestResults.clear(); + if (this.successDigestTimer) { + clearTimeout(this.successDigestTimer); + this.successDigestTimer = null; + } this.retryAfterByItem.clear(); this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); @@ -3156,6 +3357,7 @@ export class DownloadManager extends EventEmitter { cleanedTotalBytes: 0, cleanedUrls: [], cleanedProviders: [], + resultGeneration: 1, downloadStartedAt: 0, downloadCompletedAt: 0, createdAt: nowMs(), @@ -3199,10 +3401,12 @@ export class DownloadManager extends EventEmitter { packageEntry.itemIds.push(itemId); this.session.items[itemId] = item; this.itemCount += 1; - if (this.session.running) { - this.runItemIds.add(itemId); - this.runPackageIds.add(packageId); - } + if (this.session.running) { + this.runItemIds.add(itemId); + this.runPackageIds.add(packageId); + this.beginPackageResultGeneration(packageId); + this.trackActiveRunPackage(packageId); + } if (looksLikeOpaqueFilename(fileName)) { const existing = unresolvedByLink.get(link) ?? []; existing.push(itemId); @@ -3335,8 +3539,9 @@ export class DownloadManager extends EventEmitter { this.abortPackagePostProcessing(packageId, "skip"); - this.runPackageIds.delete(packageId); - this.runCompletedPackages.delete(packageId); + this.runPackageIds.delete(packageId); + this.untrackActiveRunPackage(packageId); + this.runCompletedPackages.delete(packageId); const items = pkg.itemIds .map((itemId) => this.session.items[itemId]) @@ -3412,8 +3617,9 @@ export class DownloadManager extends EventEmitter { } } this.runCompletedPackages.delete(packageId); - if (this.session.running) { - this.runPackageIds.add(packageId); + if (this.session.running) { + this.runPackageIds.add(packageId); + this.trackActiveRunPackage(packageId); } pkg.status = "queued"; pkg.updatedAt = nowMs(); @@ -3956,7 +4162,7 @@ export class DownloadManager extends EventEmitter { }); } - private async directoryHasAnyFiles(rootDir: string): Promise { + private async directoryHasAnyFiles(rootDir: string): Promise { if (!rootDir) { return false; } @@ -3990,10 +4196,39 @@ export class DownloadManager extends EventEmitter { } } } - return false; - } - - private async removeEmptyDirectoryTree(rootDir: string): Promise { + return false; + } + + private async countPackageOutputFiles(rootDir: string): Promise { + if (!rootDir) { + return 0; + } + const stack = [rootDir]; + let count = 0; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) { + count += 1; + } + } + } + return count; + } + + private async removeEmptyDirectoryTree(rootDir: string): Promise { if (!rootDir) { return 0; } @@ -4423,11 +4658,12 @@ export class DownloadManager extends EventEmitter { const previous = this.packageFileOpChain.get(pkgId); const result = (previous ?? Promise.resolve()).catch(() => undefined).then(fn); this.packageFileOpChain.set(pkgId, result); - return result.finally(() => { - if (this.packageFileOpChain.get(pkgId) === result) { - this.packageFileOpChain.delete(pkgId); - } - }); + return result.finally(() => { + if (this.packageFileOpChain.get(pkgId) === result) { + this.packageFileOpChain.delete(pkgId); + } + this.tryFinalizePackageResult(pkgId); + }); } private async autoRenameExtractedVideoFiles( @@ -4538,12 +4774,13 @@ export class DownloadManager extends EventEmitter { const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag"; let processed = 0; - let failed = 0; - for (const sourcePath of targets) { + let failed = 0; + for (const sourcePath of targets) { if (shouldAbort?.() || signal?.aborted) { return processed; } const sourceName = path.basename(sourcePath); + const remuxStartedAt = nowMs(); let result: VideoProcessResult | null = null; let remuxLease: DiskReservationLease | null = null; try { @@ -4578,6 +4815,18 @@ export class DownloadManager extends EventEmitter { result = { action: "error", reason: "exception", error: "Unbekannter Remux-Status" }; } if (result.action === "aborted") { + if (pkg) { + const completedAt = nowMs(); + pkg.remuxOperations = [...(pkg.remuxOperations || []), { + id: uuidv4(), + fileName: sourceName, + startedAt: remuxStartedAt, + completedAt, + durationMs: Math.max(0, completedAt - remuxStartedAt), + status: "cancelled", + errorCategory: "aborted" + }]; + } return processed; } const langs = (result.audioLanguages || []).join(","); @@ -4619,10 +4868,28 @@ export class DownloadManager extends EventEmitter { // Only strip ".DL." once the file is confirmed German-only (remuxed) or // already single-track. Skips/errors leave the file fully untouched so the // unprocessed state stays visible. - if (result.action === "remuxed" || result.action === "kept-single") { - await this.stripDualLangFromFileName(sourcePath, pkg); - } - } + if (result.action === "remuxed" || result.action === "kept-single") { + await this.stripDualLangFromFileName(sourcePath, pkg); + } + if (pkg) { + const completedAt = nowMs(); + const failedOperation = result.action === "error" || result.action === "skipped-no-space"; + const errorCategory = result.action === "skipped-no-space" + ? projectPackageFailureCategory("remux", "disk_full") + : failedOperation + ? projectPackageFailureCategory("remux", result.error || result.reason) + : ""; + pkg.remuxOperations = [...(pkg.remuxOperations || []), { + id: uuidv4(), + fileName: result.action === "remuxed" || result.action === "kept-single" ? stripDualLangMarker(sourceName) : sourceName, + startedAt: remuxStartedAt, + completedAt, + durationMs: Math.max(0, completedAt - remuxStartedAt), + status: failedOperation ? "failed" : "completed", + errorCategory + }]; + } + } writeSummary(); logger.info(`Tonspur-Bereinigung fertig: ${processed} verarbeitet, ${failed} Fehler von ${targets.length} Kandidaten in ${extractDir}`); if (pkg) { @@ -5241,32 +5508,6 @@ export class DownloadManager extends EventEmitter { return false; } - // Packages whose post-processing (task, deferred pass or hybrid round) is still - // alive must keep their run-membership when the run set is replaced/cleared — - // otherwise their terminal notification is dropped as soon as session.running - // flips false (the notify guard checks running || runPackageIds). - private addTrailingPostProcessPackageIds(target: Set): void { - for (const id of this.packagePostProcessTasks.keys()) { - target.add(id); - } - for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) { - if (!controller.signal.aborted) { - target.add(id); - } - } - for (const [id, tasks] of this.packageHybridPostProcessTasks) { - if (tasks.size > 0) target.add(id); - } - for (const [id, hybridSet] of this.packageHybridPostProcessControllers) { - for (const c of hybridSet) { - if (!c.signal.aborted) { - target.add(id); - break; - } - } - } - } - private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] { const folderCandidates: string[] = []; let currentDir = path.dirname(sourcePath); @@ -5727,17 +5968,16 @@ export class DownloadManager extends EventEmitter { pkg.cleanedTotalBytes = 0; pkg.cleanedUrls = []; pkg.cleanedProviders = []; - pkg.downloadStartedAt = 0; - pkg.downloadCompletedAt = 0; + this.beginPackageResultGeneration(packageId, true); pkg.updatedAt = nowMs(); this.historyRecordedPackages.delete(packageId); - this.notifiedPackages.delete(packageId); - - if (this.session.running) { + + if (this.session.running) { for (const itemId of itemIds) { this.runItemIds.add(itemId); - } - this.runPackageIds.add(packageId); + } + this.runPackageIds.add(packageId); + this.trackActiveRunPackage(packageId); } await Promise.allSettled(postProcessTasks); @@ -5804,21 +6044,22 @@ export class DownloadManager extends EventEmitter { for (const pkgId of affectedPackageIds) { for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task); - this.runCompletedPackages.delete(pkgId); - this.historyRecordedPackages.delete(pkgId); - this.notifiedPackages.delete(pkgId); - + this.runCompletedPackages.delete(pkgId); + this.historyRecordedPackages.delete(pkgId); + const pkg = this.session.packages[pkgId]; if (pkg) { pkg.cancelled = false; pkg.postProcessLabel = undefined; pkg.audioStripSummary = undefined; pkg.downloadCompletedAt = 0; + this.beginPackageResultGeneration(pkgId, false, true); this.refreshPackageStatus(pkg); pkg.updatedAt = nowMs(); } - if (this.session.running) { - this.runPackageIds.add(pkgId); + if (this.session.running) { + this.runPackageIds.add(pkgId); + this.trackActiveRunPackage(pkgId); } } @@ -5911,8 +6152,14 @@ export class DownloadManager extends EventEmitter { } public async startPackages(packageIds: string[]): Promise { + this.beginHealthRun(); this.ensureUsableDownloadAccount(); const targetSet = new Set(packageIds); + for (const packageId of this.packagePostProcessTasks.keys()) { + if (targetSet.has(packageId)) { + this.trackStandalonePackageResult(packageId); + } + } for (const pkgId of targetSet) { const pkg = this.session.packages[pkgId]; @@ -5939,8 +6186,10 @@ export class DownloadManager extends EventEmitter { for (const item of Object.values(this.session.items)) { if (!targetSet.has(item.packageId)) continue; if (item.status === "queued" || item.status === "reconnect_wait") { - this.runItemIds.add(item.id); - this.runPackageIds.add(item.packageId); + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + this.beginPackageResultGeneration(item.packageId); + this.trackActiveRunPackage(item.packageId); } } this.persistSoon(); @@ -5961,9 +6210,8 @@ export class DownloadManager extends EventEmitter { this.emitState(true); return; } - this.runItemIds = new Set(runItems.map((item) => item.id)); - this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -5973,9 +6221,10 @@ export class DownloadManager extends EventEmitter { this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); - this.session.running = true; - this.session.paused = false; - this.session.runStartedAt = nowMs(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -5986,11 +6235,10 @@ export class DownloadManager extends EventEmitter { this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; this.lastGlobalProgressBytes = 0; - this.lastGlobalProgressAt = nowMs(); - this.lastReconnectMarkAt = 0; - this.consecutiveReconnects = 0; - this.globalSpeedLimitQueue = Promise.resolve(); - this.globalSpeedLimitNextAt = 0; + this.lastGlobalProgressAt = nowMs(); + this.lastReconnectMarkAt = 0; + this.consecutiveReconnects = 0; + this.resetGlobalSpeedLimitState(); this.summary = null; this.nonResumableActive = 0; this.persistSoon(); @@ -6006,14 +6254,20 @@ export class DownloadManager extends EventEmitter { } public async startItems(itemIds: string[]): Promise { + this.beginHealthRun(); this.ensureUsableDownloadAccount(); const targetSet = new Set(itemIds); - const affectedPackageIds = new Set(); - for (const itemId of targetSet) { - const item = this.session.items[itemId]; - if (item) affectedPackageIds.add(item.packageId); - } + const affectedPackageIds = new Set(); + for (const itemId of targetSet) { + const item = this.session.items[itemId]; + if (item) affectedPackageIds.add(item.packageId); + } + for (const packageId of this.packagePostProcessTasks.keys()) { + if (affectedPackageIds.has(packageId)) { + this.trackStandalonePackageResult(packageId); + } + } for (const pkgId of affectedPackageIds) { const pkg = this.session.packages[pkgId]; @@ -6044,8 +6298,10 @@ export class DownloadManager extends EventEmitter { const pkg = this.session.packages[item.packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; if (item.status === "queued" || item.status === "reconnect_wait") { - this.runItemIds.add(item.id); - this.runPackageIds.add(item.packageId); + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + this.beginPackageResultGeneration(item.packageId); + this.trackActiveRunPackage(item.packageId); } } this.persistSoon(); @@ -6067,9 +6323,8 @@ export class DownloadManager extends EventEmitter { this.emitState(true); return; } - this.runItemIds = new Set(runItems.map((item) => item.id)); - this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6079,9 +6334,10 @@ export class DownloadManager extends EventEmitter { this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); - this.session.running = true; - this.session.paused = false; - this.session.runStartedAt = nowMs(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -6092,11 +6348,10 @@ export class DownloadManager extends EventEmitter { this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; this.lastGlobalProgressBytes = 0; - this.lastGlobalProgressAt = nowMs(); - this.lastReconnectMarkAt = 0; - this.consecutiveReconnects = 0; - this.globalSpeedLimitQueue = Promise.resolve(); - this.globalSpeedLimitNextAt = 0; + this.lastGlobalProgressAt = nowMs(); + this.lastReconnectMarkAt = 0; + this.consecutiveReconnects = 0; + this.resetGlobalSpeedLimitState(); this.summary = null; this.nonResumableActive = 0; this.persistSoon(); @@ -6115,12 +6370,20 @@ export class DownloadManager extends EventEmitter { if (this.session.running) { return; } + this.beginHealthRun(); this.ensureUsableDownloadAccount(); this.schedulerGeneration += 1; - - this.session.running = true; - - const recoveredItems = await this.recoverRetryableItems("start"); + + this.session.running = true; + const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => { + const pkg = this.session.packages[packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId)); + })); + for (const packageId of this.packagePostProcessTasks.keys()) { + this.trackStandalonePackageResult(packageId); + } + + const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds); await sleep(0); @@ -6161,9 +6424,8 @@ export class DownloadManager extends EventEmitter { }); if (runItems.length === 0) { if (this.packagePostProcessTasks.size > 0) { - this.runItemIds.clear(); - this.runPackageIds.clear(); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runItemIds.clear(); + this.runPackageIds.clear(); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.session.running = true; @@ -6180,9 +6442,8 @@ export class DownloadManager extends EventEmitter { }); return; } - this.runItemIds.clear(); - this.runPackageIds.clear(); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runItemIds.clear(); + this.runPackageIds.clear(); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6211,9 +6472,8 @@ export class DownloadManager extends EventEmitter { this.emitState(true); return; } - this.runItemIds = new Set(runItems.map((item) => item.id)); - this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6230,9 +6490,10 @@ export class DownloadManager extends EventEmitter { } } - this.session.running = true; - this.session.paused = false; - this.session.runStartedAt = nowMs(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -6243,11 +6504,10 @@ export class DownloadManager extends EventEmitter { this.speedEvents = []; this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; - this.lastGlobalProgressBytes = 0; - this.lastGlobalProgressAt = nowMs(); - this.globalSpeedLimitQueue = Promise.resolve(); - this.globalSpeedLimitNextAt = 0; + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.resetGlobalSpeedLimitState(); this.summary = null; this.nonResumableActive = 0; this.persistSoon(); @@ -6261,12 +6521,18 @@ export class DownloadManager extends EventEmitter { }); } - public stop(options?: { parkForRestart?: boolean }): void { - const parkForRestart = options?.parkForRestart === true; - const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; - const keepExtraction = this.settings.autoExtractWhenStopped; - const wasRunning = this.session.running; - this.schedulerGeneration += 1; + public stop(options?: { parkForRestart?: boolean }): void { + const parkForRestart = options?.parkForRestart === true; + this.healthManualStop = !parkForRestart; + this.healthShuttingDown = parkForRestart; + const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; + const keepExtraction = this.settings.autoExtractWhenStopped; + const wasRunning = this.session.running; + const stoppedRunContext = wasRunning + ? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt) + : null; + this.suppressStandalonePackageResults(); + this.schedulerGeneration += 1; this.session.running = false; this.session.paused = false; this.session.reconnectUntil = 0; @@ -6310,25 +6576,33 @@ export class DownloadManager extends EventEmitter { pkg.status = "queued"; pkg.updatedAt = nowMs(); } - } - // A manual stop ends the run without ever reaching finishRun (the scheduler - // loop exits at its while condition), so the run summary would be lost. - // Suppressed for the restart/shutdown path: the process is about to die. - if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { - const outcomes = Array.from(this.runOutcomes.values()); - const success = outcomes.filter((s) => s === "completed").length; - const failed = outcomes.filter((s) => s === "failed").length; - void sendNotification(this.settings.notifyUrl, { - title: "⏹️ Durchlauf gestoppt", - message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`, - mention: this.settings.notifyMention - }); - } + } + if (stoppedRunContext && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { + const packageResults = [...stoppedRunContext.packageGenerations] + .flatMap(([packageId, generation]) => { + const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); + return result ? [result] : []; + }); + this.flushPackageSuccessDigest(); + this.queueNotificationEvent(buildRunNotificationEvent(buildRunResult({ + id: stoppedRunContext.id, + stopped: true, + startedAt: stoppedRunContext.startedAt, + completedAt: nowMs(), + packages: packageResults, + totalPackages: stoppedRunContext.packageGenerations.size + }))); + } + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); this.persistSoon(); this.emitState(true); } public prepareForShutdown(): void { + this.healthShuttingDown = true; logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); this.updateStatisticsActivity(nowMs()); this.rotationListenerActive = false; @@ -6772,8 +7046,9 @@ export class DownloadManager extends EventEmitter { saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); } - private emitState(force = false): void { - const now = nowMs(); + private emitState(force = false): void { + this.evaluateRemainingNotification(); + const now = nowMs(); const MIN_FORCE_GAP_MS = 120; if (force) { const sinceLastEmit = now - this.lastStateEmitAt; @@ -6829,9 +7104,14 @@ export class DownloadManager extends EventEmitter { private lastSpeedPruneAt = 0; - private recordSpeed(bytes: number, packageId: string = ""): void { - const now = nowMs(); - if (bytes > 0 && this.consecutiveReconnects > 0) { + private recordSpeed(bytes: number, packageId: string = ""): void { + const now = nowMs(); + if (!Number.isFinite(bytes) || bytes <= 0) { + return; + } + this.downloadProgressSequence += 1; + this.lastPositiveByteAt = now; + if (bytes > 0 && this.consecutiveReconnects > 0) { this.consecutiveReconnects = 0; } const bucket = now - (now % 120); @@ -6862,8 +7142,9 @@ export class DownloadManager extends EventEmitter { this.statisticsDirty = true; this.statisticsUrgent = true; } - if (status === "completed" && previous !== "completed") { - this.sessionCompletedFiles += 1; + if (status === "completed" && previous !== "completed") { + this.itemCompletionSequence += 1; + this.sessionCompletedFiles += 1; this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1; this.invalidateStatsCache(); } @@ -7928,22 +8209,33 @@ export class DownloadManager extends EventEmitter { next.resolve(); } - private runPackagePostProcessing(packageId: string): Promise { - const existing = this.packagePostProcessTasks.get(packageId); + private runPackagePostProcessing(packageId: string): Promise { + this.trackPackagePostProcessResult(packageId); + const existing = this.packagePostProcessTasks.get(packageId); if (existing) { this.hybridExtractRequeue.add(packageId); return existing; } - const abortController = new AbortController(); - this.packagePostProcessAbortControllers.set(packageId, abortController); + const abortController = new AbortController(); + this.packagePostProcessAbortControllers.set(packageId, abortController); + const queuedPackage = this.session.packages[packageId]; + if (queuedPackage) { + queuedPackage.postProcessQueuedAt = queuedPackage.postProcessQueuedAt || nowMs(); + queuedPackage.updatedAt = nowMs(); + } // Holder so the task's own finally can identity-check itself (the task Promise // cannot reference its own const inside its initializer). Assigned right after. const handle: { task?: Promise } = {}; const task = (async () => { - const slotWaitStart = nowMs(); - await this.acquirePostProcessSlot(packageId); + const slotWaitStart = nowMs(); + await this.acquirePostProcessSlot(packageId); + const startedPackage = this.session.packages[packageId]; + if (startedPackage) { + startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs(); + startedPackage.updatedAt = nowMs(); + } const slotWaitMs = nowMs() - slotWaitStart; if (slotWaitMs > 100) { logger.info(`Post-Process Slot erhalten nach ${(slotWaitMs / 1000).toFixed(1)}s Wartezeit: pkg=${packageId.slice(0, 8)}`); @@ -7999,11 +8291,13 @@ export class DownloadManager extends EventEmitter { } this.persistSoon(); this.emitState(); - if (this.hybridExtractRequeue.delete(packageId)) { - void this.runPackagePostProcessing(packageId).catch((err) => - logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`) - ); - } + if (this.hybridExtractRequeue.delete(packageId)) { + void this.runPackagePostProcessing(packageId).catch((err) => + logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`) + ); + } else { + this.tryFinalizePackageResult(packageId); + } } })(); @@ -8179,9 +8473,10 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Entpacken - Ausstehend"; item.updatedAt = nowMs(); } - } - logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPending): ${compactErrorText(err)}`)); + } + logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); + this.trackPackagePostProcessResult(packageId); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPending): ${compactErrorText(err)}`)); } continue; } @@ -8198,9 +8493,10 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Entpacken - Ausstehend"; item.updatedAt = nowMs(); } - } - logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPendingHybrid): ${compactErrorText(err)}`)); + } + logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); + this.trackPackagePostProcessResult(packageId); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPendingHybrid): ${compactErrorText(err)}`)); } } } @@ -8227,12 +8523,9 @@ export class DownloadManager extends EventEmitter { this.logPackageForPackage(pkg, "INFO", "Extraktion manuell wiederholt", { completedItems: completedItems.length, targetedItems: targetItems.length - }); - // Fresh outcome must notify again (the corrective checkmark after a notified - // extraction failure); unconditional run-membership so the notify guard also - // passes when the retry happens after the run already ended. - this.notifiedPackages.delete(packageId); - this.runPackageIds.add(packageId); + }); + this.beginPackageResultGeneration(packageId, false, true); + this.reactivateStandalonePackageResult(packageId); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); @@ -8260,9 +8553,9 @@ export class DownloadManager extends EventEmitter { this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { completedItems: completedItems.length, targetedItems: targetItems.length - }); - this.notifiedPackages.delete(packageId); - this.runPackageIds.add(packageId); + }); + this.beginPackageResultGeneration(packageId, false, true); + this.reactivateStandalonePackageResult(packageId); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); @@ -8274,9 +8567,15 @@ export class DownloadManager extends EventEmitter { } } - private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void { - this.notePackageDownloadStarted(pkg, completedAt); - pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt); + private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void { + this.notePackageDownloadStarted(pkg, completedAt); + pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt); + if (pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + return !item || isFinishedStatus(item.status); + })) { + pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, completedAt); + } } private getPackageHistoryDurationSeconds(pkg: PackageEntry): number { @@ -8360,10 +8659,9 @@ export class DownloadManager extends EventEmitter { }; this.onHistoryEntryCallback(entry); } - } - this.historyRecordedPackages.delete(packageId); - this.notifiedPackages.delete(packageId); - this.abortPackagePostProcessing(packageId, "package_removed"); + } + this.historyRecordedPackages.delete(packageId); + this.abortPackagePostProcessing(packageId, "package_removed"); for (const itemId of itemIds) { this.retryAfterByItem.delete(itemId); this.retryStateByItem.delete(itemId); @@ -8372,9 +8670,9 @@ export class DownloadManager extends EventEmitter { delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); } - delete this.session.packages[packageId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); - this.runCompletedPackages.delete(packageId); + delete this.session.packages[packageId]; + this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.runCompletedPackages.delete(packageId); this.resetSessionTotalsIfQueueEmpty(); } @@ -8539,9 +8837,12 @@ export class DownloadManager extends EventEmitter { if (effectiveProvider === "bestdebrid") { return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); } - if (effectiveProvider === "alldebrid") { - return Boolean(this.settings.allDebridUseWebLogin || this.settings.allDebridToken.trim()); - } + if (effectiveProvider === "alldebrid") { + return Boolean(this.settings.allDebridUseWebLogin || this.settings.allDebridToken.trim()); + } + if (effectiveProvider === "deepbrid") { + return Boolean(this.settings.deepbridApiKey.trim()); + } if (effectiveProvider === "ddownload") { return Boolean(this.settings.ddownloadLogin.trim() && this.settings.ddownloadPassword.trim()); } @@ -8558,6 +8859,12 @@ export class DownloadManager extends EventEmitter { return false; } + public async flushNotificationsForShutdown(): Promise { + this.notificationsShuttingDown = true; + this.flushPackageSuccessDigest(); + await this.notificationEnqueueChain; + } + private updateStatisticsActivity(now: number): void { if (this.statisticsActivityAt > 0 && this.statisticsActivityWasActive && now > this.statisticsActivityAt) { addStatisticsActiveIntervalInPlace(this.statisticsLedger, this.statisticsActivityAt, now); @@ -9021,6 +9328,7 @@ export class DownloadManager extends EventEmitter { try { while (this.session.running && this.schedulerGeneration === myGeneration) { const now = nowMs(); + this.lastSchedulerTickAt = now; this.updateStatisticsActivity(now); if (now - this.lastSchedulerHeartbeatAt >= 60000) { this.lastSchedulerHeartbeatAt = now; @@ -9159,8 +9467,9 @@ export class DownloadManager extends EventEmitter { return; } - logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalledCount} Task(s) neu starten, diskBlocked=${diskBlockedCount}`); - for (const active of this.activeTasks.values()) { + logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalledCount} Task(s) neu starten, diskBlocked=${diskBlockedCount}`); + this.technicalRecoveryCount += 1; + for (const active of this.activeTasks.values()) { if (active.abortController.signal.aborted) { continue; } @@ -9489,10 +9798,14 @@ export class DownloadManager extends EventEmitter { abortController: new AbortController(), abortReason: "none", resumable: true, - nonResumableCounted: false, - blockedOnDiskWrite: false, - blockedOnDiskSince: 0 - }; + nonResumableCounted: false, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0, + blockedOnThrottleUntil: 0, + phase: "validating", + phaseStartedAt: item.updatedAt, + phaseDeadlineAt: item.updatedAt + getUnrestrictTimeoutMs() + 15_000 + }; this.activeTasks.set(itemId, active); this.notePacedStartForItem(item, nowMs()); this.emitState(); @@ -9508,8 +9821,9 @@ export class DownloadManager extends EventEmitter { if (active.nonResumableCounted) { this.nonResumableActive = Math.max(0, this.nonResumableActive - 1); } - this.activeTasks.delete(itemId); - this.persistSoon(); + this.activeTasks.delete(itemId); + this.tryFinalizePackageResult(packageId); + this.persistSoon(); this.emitState(); }); } @@ -9613,6 +9927,9 @@ export class DownloadManager extends EventEmitter { this.settings, item.url ); + active.phase = "validating"; + active.phaseStartedAt = nowMs(); + active.phaseDeadlineAt = active.phaseStartedAt + unrestrictTimeoutMs; const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs); const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); let unrestricted; @@ -9647,12 +9964,13 @@ export class DownloadManager extends EventEmitter { throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); } const errText = compactErrorText(unrestrictError); - if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { - this.recordProviderFailure(cooldownProvider); - if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { - const busyCooldownMs = isTemporaryUnrestrictError(errText) - ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) - : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); + if (isProviderUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { + this.recordProviderFailure(cooldownProvider); + const backoffKind = classifyProviderUnrestrictBackoff(errText); + if (backoffKind) { + const busyCooldownMs = backoffKind === "temporary" + ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) + : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); this.applyProviderBusyBackoff(cooldownProvider, busyCooldownMs); } } @@ -9717,6 +10035,9 @@ export class DownloadManager extends EventEmitter { throw error; } item.status = "downloading"; + active.phase = "downloading"; + active.phaseStartedAt = nowMs(); + active.phaseDeadlineAt = 0; const pLabel = unrestricted.providerLabel; item.fullStatus = "Starte..."; item.updatedAt = nowMs(); @@ -9767,10 +10088,18 @@ export class DownloadManager extends EventEmitter { item.status = "integrity_check"; item.fullStatus = "CRC-Check läuft"; item.updatedAt = nowMs(); - this.emitState(); - - const integrityStartedAt = nowMs(); - const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); + this.emitState(); + + const integrityStartedAt = nowMs(); + const integrityBytes = Math.max(0, Number(item.downloadedBytes || item.totalBytes || 0)); + const integrityBudgetMs = Math.max( + 120_000, + Math.min(30 * 60 * 1000, 60_000 + Math.ceil(integrityBytes / (25 * 1024 * 1024)) * 1000) + ); + active.phase = "integrity_check"; + active.phaseStartedAt = integrityStartedAt; + active.phaseDeadlineAt = integrityStartedAt + integrityBudgetMs; + const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } @@ -9793,9 +10122,12 @@ export class DownloadManager extends EventEmitter { item.progressPercent = 0; item.downloadedBytes = 0; item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize); - this.emitState(); - await sleep(300); - continue; + this.emitState(); + await sleep(300); + active.phase = "downloading"; + active.phaseStartedAt = nowMs(); + active.phaseDeadlineAt = 0; + continue; } throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`); } @@ -10340,7 +10672,7 @@ export class DownloadManager extends EventEmitter { return; } - if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { + if (isProviderUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText); if (debridLinkCooldown) { active.unrestrictRetries += 1; @@ -11095,7 +11427,7 @@ export class DownloadManager extends EventEmitter { } const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); - await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active.abortController.signal); + await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active); if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } @@ -11478,7 +11810,10 @@ export class DownloadManager extends EventEmitter { throw new Error(lastError || "Download fehlgeschlagen"); } - private async recoverRetryableItems(trigger: "startup" | "start"): Promise { + private async recoverRetryableItems( + trigger: "startup" | "start", + reactivationPackageIds?: ReadonlySet + ): Promise { let recovered = 0; let finalized = 0; const touchedPackages = new Set(); @@ -11557,11 +11892,15 @@ export class DownloadManager extends EventEmitter { if (!pkg) { continue; } - // The package gets a fresh chance — its next outcome must notify again - // (a recovery success after a notified failure is the message the user - // most wants). History dedup stays untouched (separate semantics). - this.notifiedPackages.delete(packageId); - this.refreshPackageStatus(pkg); + this.beginPackageResultGeneration(packageId, false, true); + if (!this.runPackageIds.has(packageId)) { + if (trigger === "start" && reactivationPackageIds?.has(packageId)) { + this.reactivateStandalonePackageResult(packageId); + } else if (trigger === "startup") { + this.trackStandalonePackageResult(packageId); + } + } + this.refreshPackageStatus(pkg); } logger.warn( `Auto-Retry-Recovery (${trigger}): ${recovered} Item(s) wieder in Queue gesetzt, ` + @@ -11625,39 +11964,446 @@ export class DownloadManager extends EventEmitter { return /\b0\s*B\b/i.test(item.fullStatus || ""); } - // Once per package and run; trailing post-processing after run-end still - // notifies (runPackageIds keeps the id), startup recovery does not (set empty). - private notifyPackageOutcome(pkg: PackageEntry, kind: "completed" | "failed", detail: string): void { - const url = String(this.settings.notifyUrl || "").trim(); - if (!url) { - return; - } - if (kind === "completed" && !this.settings.notifyOnPackageCompleted) { - return; - } - if (kind === "failed" && !this.settings.notifyOnPackageFailed) { - return; - } - if (!this.session.running && !this.runPackageIds.has(pkg.id)) { - return; - } - if (this.notifiedPackages.has(pkg.id)) { - return; - } - this.notifiedPackages.add(pkg.id); - // Release the dedup marker if the delivery ultimately failed (after the - // sender's own retries), so a later manual re-run can notify again instead - // of a transient outage permanently consuming the once-per-package slot. - void sendNotification(url, { - title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen", - message: `${pkg.name}\n${detail}`, - mention: this.settings.notifyMention - }).then((ok) => { - if (!ok) { - this.notifiedPackages.delete(pkg.id); - } - }); - } + private packageResultKey(packageId: string, generation: number): string { + return `${packageId}:${generation}`; + } + + private getPackageResultGeneration(packageId: string): number { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return 1; + } + const generation = Number.isFinite(pkg.resultGeneration) + ? Math.max(1, Math.floor(pkg.resultGeneration || 1)) + : 1; + pkg.resultGeneration = generation; + return generation; + } + + private beginPackageResultGeneration(packageId: string, resetDownloadTelemetry = false, forceReset = false): number { + const current = this.getPackageResultGeneration(packageId); + const currentKey = this.packageResultKey(packageId, current); + const pkg = this.session.packages[packageId]; + const wasFinalized = this.finalizedPackageResults.has(currentKey) || (pkg?.terminalAt || 0) > 0; + const next = wasFinalized ? current + 1 : current; + if (pkg) { + pkg.resultGeneration = next; + } + if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) { + if (resetDownloadTelemetry) { + pkg.downloadStartedAt = 0; + pkg.downloadCompletedAt = 0; + pkg.downloadEndedAt = 0; + } + pkg.postProcessQueuedAt = 0; + pkg.postProcessStartedAt = 0; + pkg.postProcessCompletedAt = 0; + pkg.terminalAt = 0; + pkg.archiveOperations = []; + pkg.remuxOperations = []; + pkg.outputCount = 0; + pkg.cleanupErrorCategory = ""; + } + this.pruneFinalizedPackageResults(); + return next; + } + + private pruneFinalizedPackageResults(): void { + const retained = new Set(this.standalonePackageResults); + for (const context of this.runContexts.values()) { + for (const [packageId, generation] of context.packageGenerations) { + retained.add(this.packageResultKey(packageId, generation)); + } + } + for (const pkg of Object.values(this.session.packages)) { + retained.add(this.packageResultKey(pkg.id, this.getPackageResultGeneration(pkg.id))); + } + for (const key of this.finalizedPackageResults.keys()) { + if (!retained.has(key)) { + this.finalizedPackageResults.delete(key); + } + } + } + + private createRunContext(packageIds: Iterable, startedAt: number, downloadsFinished: boolean): RunLifecycleContext { + const packageGenerations = new Map(); + for (const packageId of packageIds) { + packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + } + const context: RunLifecycleContext = { + id: uuidv4(), + startedAt, + packageGenerations, + downloadsFinished, + remainingNotification: { snapshot: null, crossings: 0 } + }; + this.runContexts.set(context.id, context); + return context; + } + + private beginActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const context = this.createRunContext(packageIds, startedAt, false); + for (const [packageId, generation] of context.packageGenerations) { + this.suppressedPackageResults.delete(this.packageResultKey(packageId, generation)); + } + this.activeRunContextId = context.id; + return context; + } + + private trackActiveRunPackage(packageId: string): void { + let context = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined; + if (!context && this.session.running && this.runItemIds.size > 0) { + const startedAt = this.session.runStartedAt || nowMs(); + this.session.runStartedAt = startedAt; + context = this.beginActiveRunContext(this.runPackageIds, startedAt); + } + if (context) { + const generation = this.getPackageResultGeneration(packageId); + context.packageGenerations.set(packageId, generation); + this.suppressedPackageResults.delete(this.packageResultKey(packageId, generation)); + } + } + + private untrackActiveRunPackage(packageId: string): void { + if (!this.activeRunContextId) { + return; + } + this.runContexts.get(this.activeRunContextId)?.packageGenerations.delete(packageId); + } + + private finishActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const active = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined; + const context = active || this.createRunContext(packageIds, startedAt, false); + for (const packageId of packageIds) { + if (!context.packageGenerations.has(packageId)) { + context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + } + } + context.downloadsFinished = true; + if (this.activeRunContextId === context.id) { + this.activeRunContextId = null; + } + return context; + } + + private stopActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const active = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined; + const context = active || this.createRunContext(packageIds, startedAt, false); + for (const packageId of packageIds) { + if (!context.packageGenerations.has(packageId)) { + context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + } + } + for (const [packageId, generation] of context.packageGenerations) { + const key = this.packageResultKey(packageId, generation); + this.standalonePackageResults.delete(key); + this.suppressedPackageResults.add(key); + } + this.runContexts.delete(context.id); + if (this.activeRunContextId === context.id) { + this.activeRunContextId = null; + } + return context; + } + + private isPackageResultTracked(packageId: string, generation: number): boolean { + const key = this.packageResultKey(packageId, generation); + if (this.standalonePackageResults.has(key)) { + return true; + } + for (const context of this.runContexts.values()) { + if (context.packageGenerations.get(packageId) === generation) { + return true; + } + } + return false; + } + + private trackStandalonePackageResult(packageId: string): void { + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); + if (this.suppressedPackageResults.has(key) || this.isPackageResultTracked(packageId, generation)) { + return; + } + this.standalonePackageResults.add(key); + } + + private reactivateStandalonePackageResult(packageId: string): void { + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); + this.suppressedPackageResults.delete(key); + if (!this.isPackageResultTracked(packageId, generation)) { + this.standalonePackageResults.add(key); + } + } + + private suppressStandalonePackageResults(): void { + for (const key of this.standalonePackageResults) { + this.suppressedPackageResults.add(key); + } + this.standalonePackageResults.clear(); + } + + private trackPackagePostProcessResult(packageId: string): void { + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); + for (const context of this.runContexts.values()) { + if (context.packageGenerations.get(packageId) === generation) { + return; + } + } + if (this.suppressedPackageResults.has(key)) { + return; + } + if (this.activeRunContextId) { + this.trackActiveRunPackage(packageId); + } else if (!this.isPackageResultTracked(packageId, generation)) { + this.standalonePackageResults.add(key); + } + } + + private queueNotificationEvent(notification: NotificationEvent): void { + if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) { + return; + } + this.notificationEnqueueChain = this.notificationEnqueueChain + .then(() => this.enqueueNotificationCallback?.(notification)) + .then(() => undefined) + .catch((error) => { + logger.warn(`Notification konnte nicht eingereiht werden: ${compactErrorText(error)}`); + }); + } + + private buildRunRemainingSnapshot(): RunRemainingSnapshot { + const openPackages = new Set(); + let remainingBytes = 0; + let openItems = 0; + let unknownCount = 0; + let finalizingItems = 0; + let speedBps = 0; + for (const itemId of this.runItemIds) { + const item = this.session.items[itemId]; + if (!item || isFinishedStatus(item.status)) { + continue; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + if ((item.status === "downloading" || item.status === "integrity_check") + && item.totalBytes !== null + && item.totalBytes > 0 + && item.downloadedBytes >= item.totalBytes) { + finalizingItems += 1; + continue; + } + openItems += 1; + openPackages.add(pkg.id); + speedBps += Math.max(0, Math.floor(item.speedBps)); + if (item.totalBytes === null) { + unknownCount += 1; + } else { + remainingBytes += Math.max(0, item.totalBytes - item.downloadedBytes); + } + } + if (!this.session.running || this.session.paused) { + speedBps = 0; + } + const etaSeconds = unknownCount === 0 && speedBps > 0 + ? Math.ceil(remainingBytes / speedBps) + : remainingBytes === 0 && unknownCount === 0 + ? 0 + : -1; + return { + remainingBytes, + openItems, + openPackages: openPackages.size, + unknownCount, + finalizingItems, + speedBps, + etaSeconds + }; + } + + private evaluateRemainingNotification(): void { + const context = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined; + if (!context || context.downloadsFinished) { + return; + } + const current = this.buildRunRemainingSnapshot(); + if (current.finalizingItems > 0) { + return; + } + const previous = context.remainingNotification.snapshot; + context.remainingNotification.snapshot = current; + if (!this.settings.notifyOnRemainingBelow) { + return; + } + const thresholdBytes = this.settings.notifyRemainingThresholdGb * 1024 ** 3; + const decision = evaluateRemainingThreshold(previous, current, thresholdBytes); + if (!decision.emit) { + return; + } + context.remainingNotification.crossings += 1; + this.queueNotificationEvent(buildRemainingThresholdNotificationEvent( + context.id, + context.remainingNotification.crossings, + current, + thresholdBytes, + nowMs() + )); + } + + private queueSuccessfulPackageResult(envelope: PackageResultEnvelope): void { + const key = this.packageResultKey(envelope.result.packageId, envelope.generation); + this.successDigestResults.set(key, envelope); + if (this.notificationsShuttingDown) { + this.flushPackageSuccessDigest(); + return; + } + if (this.successDigestTimer) { + return; + } + this.successDigestTimer = setTimeout(() => { + this.successDigestTimer = null; + this.flushPackageSuccessDigest(); + }, 2 * 60 * 1000); + this.successDigestTimer.unref?.(); + } + + private flushPackageSuccessDigest(createdAt = nowMs()): void { + if (this.successDigestTimer) { + clearTimeout(this.successDigestTimer); + this.successDigestTimer = null; + } + if (this.successDigestResults.size === 0) { + return; + } + const envelopes = [...this.successDigestResults.values()]; + this.successDigestResults.clear(); + for (const notification of buildPackageDigestEvents(envelopes, createdAt)) { + this.queueNotificationEvent(notification); + } + } + + private getPackageHistoryProvider(pkg: PackageEntry, items: DownloadItem[]): DebridProvider | null { + const providers = [...new Set([ + ...(pkg.cleanedProviders || []), + ...items.map((item) => item.provider).filter(Boolean) as DebridProvider[] + ])]; + return providers.length === 1 ? providers[0] : null; + } + + private hasPackageLifecycleWork(packageId: string): boolean { + if ([...this.activeTasks.values()].some((task) => task.packageId === packageId)) { + return true; + } + return this.packagePostProcessTasks.has(packageId) + || this.hasDeferredPostProcessPending(packageId) + || (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0 + || this.packageFileOpChain.has(packageId); + } + + private tryFinalizePackageResult(packageId: string): PackageResult | null { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return null; + } + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); + if (this.suppressedPackageResults.has(key)) { + return null; + } + if (!this.runPackageIds.has(packageId) && !this.isPackageResultTracked(packageId, generation)) { + return null; + } + const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + if (items.some((item) => !isFinishedStatus(item.status)) || this.hasPackageLifecycleWork(packageId)) { + return null; + } + const existing = this.finalizedPackageResults.get(key); + if (existing) { + return existing; + } + const completedAt = nowMs(); + if (!(pkg.downloadEndedAt || 0)) { + pkg.downloadEndedAt = completedAt; + } + pkg.postProcessCompletedAt = completedAt; + pkg.terminalAt = completedAt; + const result = finalizePackageResult({ + package: pkg, + items, + archiveOperations: pkg.archiveOperations, + remuxOperations: pkg.remuxOperations, + outputCount: pkg.outputCount, + cleanupErrorCategory: pkg.cleanupErrorCategory + }); + this.finalizedPackageResults.set(key, result); + pkg.status = result.status === "partial" ? "failed" : result.status; + pkg.updatedAt = completedAt; + if (this.onHistoryEntryCallback) { + this.historyRecordedPackages.add(packageId); + this.onHistoryEntryCallback(buildHistoryEntry(result, { + generation, + outputDir: pkg.outputDir, + urls: [...new Set([...(pkg.cleanedUrls || []), ...items.map((item) => item.url).filter(Boolean)])], + provider: this.getPackageHistoryProvider(pkg, items) + })); + } + const envelope = { generation, result }; + if (result.status === "completed") { + if (this.settings.notifyOnPackageCompleted) { + if (this.settings.notifyPackageSuccessMode === "individual") { + this.queueNotificationEvent(buildPackageNotificationEvent(envelope, completedAt)); + } else { + this.queueSuccessfulPackageResult(envelope); + } + } + } else if (this.settings.notifyOnPackageFailed) { + this.queueNotificationEvent(buildPackageNotificationEvent(envelope, completedAt)); + } + this.persistSoon(); + this.emitState(); + this.standalonePackageResults.delete(key); + this.tryFinalizeRunResults(); + return result; + } + + private tryFinalizeRunResults(): void { + for (const context of [...this.runContexts.values()]) { + if (!context.downloadsFinished) { + continue; + } + const packageResults: PackageResult[] = []; + let complete = true; + for (const [packageId, generation] of context.packageGenerations) { + const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); + if (!result) { + complete = false; + break; + } + packageResults.push(result); + } + if (!complete) { + continue; + } + const result = buildRunResult({ + id: context.id, + stopped: false, + startedAt: context.startedAt, + completedAt: nowMs(), + packages: packageResults, + totalPackages: context.packageGenerations.size + }); + this.flushPackageSuccessDigest(result.completedAt); + if (this.settings.notifyOnRunFinished) { + this.queueNotificationEvent(buildRunNotificationEvent(result)); + } + this.runContexts.delete(context.id); + } + this.pruneFinalizedPackageResults(); + } private refreshPackageStatus(pkg: PackageEntry): void { let pending = 0; @@ -11690,42 +12436,47 @@ export class DownloadManager extends EventEmitter { return; } - if (pending > 0) { - pkg.status = pkg.enabled ? "queued" : "paused"; - pkg.updatedAt = nowMs(); - return; - } - - const prevStatus = pkg.status; + if (pending > 0) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } + + if (!(pkg.downloadEndedAt || 0)) { + pkg.downloadEndedAt = nowMs(); + } if (failed > 0 || extractFailed > 0) { pkg.status = "failed"; } else if (cancelled > 0) { pkg.status = success > 0 ? "completed" : "cancelled"; } else if (success > 0) { pkg.status = "completed"; - } - pkg.updatedAt = nowMs(); - // A package whose LAST terminal event is a failure never (re-)enters - // post-processing (its trigger sits only on completion paths), so the - // post-process notify hook can't fire — cover every failed-transition here. - // That includes mixed packages (success > 0): the dedup set prevents a - // double-fire if post-processing does run later. - if (pkg.status === "failed" && prevStatus !== "failed") { - this.notifyPackageOutcome(pkg, "failed", `${failed + extractFailed} von ${total} Datei(en) fehlgeschlagen`); - if (success > 0) { - const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - this.recordPackageHistory(pkg.id, pkg, items); - } - } - } + } + pkg.updatedAt = nowMs(); + this.tryFinalizePackageResult(pkg.id); + } private cachedSpeedLimitKbps = 0; private cachedSpeedLimitAt = 0; - private globalSpeedLimitQueue: Promise = Promise.resolve(); - - private globalSpeedLimitNextAt = 0; + private globalSpeedLimitQueue: Promise = Promise.resolve(); + + private globalSpeedLimitNextAt = 0; + + private globalSpeedLimitHealthNextAt = 0; + + private globalSpeedLimitPending = 0; + + private globalSpeedLimitGeneration = 0; + + private resetGlobalSpeedLimitState(): void { + this.globalSpeedLimitGeneration += 1; + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; + this.globalSpeedLimitHealthNextAt = 0; + this.globalSpeedLimitPending = 0; + } private getEffectiveSpeedLimitKbps(): number { const now = nowMs(); @@ -11769,58 +12520,84 @@ export class DownloadManager extends EventEmitter { return 0; } - private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number, signal?: AbortSignal): Promise { - const task = this.globalSpeedLimitQueue - .catch(() => undefined) - .then(async () => { - if (signal?.aborted) { - throw new Error("aborted:speed_limit"); - } - const now = nowMs(); - const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now); - if (waitMs > 0) { - await new Promise((resolve, reject) => { - let timer: NodeJS.Timeout | null = setTimeout(() => { - timer = null; - if (signal) { - signal.removeEventListener("abort", onAbort); - } - resolve(); - }, waitMs); + private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number, active?: ActiveTask): Promise { + const signal = active?.abortController.signal; + const generation = this.globalSpeedLimitGeneration; + const queuedAt = nowMs(); + const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000)); + const healthReadyAt = Math.max(queuedAt, this.globalSpeedLimitNextAt, this.globalSpeedLimitHealthNextAt); + this.globalSpeedLimitHealthNextAt = healthReadyAt + durationMs; + this.globalSpeedLimitPending += 1; + if (active && healthReadyAt > queuedAt) { + active.blockedOnThrottleUntil = Math.max(active.blockedOnThrottleUntil || 0, healthReadyAt); + } + const task = this.globalSpeedLimitQueue + .catch(() => undefined) + .then(async () => { + if (generation !== this.globalSpeedLimitGeneration) { + throw new Error("aborted:speed_limit_generation"); + } + if (signal?.aborted) { + throw new Error("aborted:speed_limit"); + } + const now = nowMs(); + const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now); + if (waitMs > 0) { + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal?.removeEventListener("abort", onAbort); + resolve(); + }, waitMs); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted:speed_limit")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + } + + if (generation !== this.globalSpeedLimitGeneration) { + throw new Error("aborted:speed_limit_generation"); + } + if (signal?.aborted) { + throw new Error("aborted:speed_limit"); + } + + const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); + this.globalSpeedLimitNextAt = startAt + durationMs; + }); + + this.globalSpeedLimitQueue = task; + try { + await task; + } finally { + if (active && active.blockedOnThrottleUntil === healthReadyAt) { + active.blockedOnThrottleUntil = 0; + } + if (generation === this.globalSpeedLimitGeneration) { + this.globalSpeedLimitPending = Math.max(0, this.globalSpeedLimitPending - 1); + if (this.globalSpeedLimitPending === 0) { + this.globalSpeedLimitHealthNextAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); + } + } + } + } - const onAbort = (): void => { - if (timer) { - clearTimeout(timer); - timer = null; - } - signal?.removeEventListener("abort", onAbort); - reject(new Error("aborted:speed_limit")); - }; - - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener("abort", onAbort, { once: true }); - } - }); - } - - if (signal?.aborted) { - throw new Error("aborted:speed_limit"); - } - - const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); - const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000)); - this.globalSpeedLimitNextAt = startAt + durationMs; - }); - - this.globalSpeedLimitQueue = task; - await task; - } - - private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number, signal?: AbortSignal): Promise { + private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number, active?: ActiveTask): Promise { + const signal = active?.abortController.signal; const limitKbps = this.getEffectiveSpeedLimitKbps(); if (limitKbps <= 0) { return; @@ -11832,40 +12609,48 @@ export class DownloadManager extends EventEmitter { const projected = localWindowBytes + chunkBytes; const allowed = bytesPerSecond * elapsed; if (projected > allowed) { - const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000); - if (sleepMs > 0) { - await new Promise((resolve, reject) => { - let timer: NodeJS.Timeout | null = setTimeout(() => { - timer = null; - if (signal) { - signal.removeEventListener("abort", onAbort); - } - resolve(); - }, Math.min(300, sleepMs)); - - const onAbort = (): void => { - if (timer) { - clearTimeout(timer); - timer = null; - } - signal?.removeEventListener("abort", onAbort); - reject(new Error("aborted:speed_limit")); - }; - - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener("abort", onAbort, { once: true }); - } - }); + const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000); + if (sleepMs > 0) { + const boundedSleepMs = Math.min(300, sleepMs); + if (active) { + active.blockedOnThrottleUntil = nowMs() + boundedSleepMs; + } + try { + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal?.removeEventListener("abort", onAbort); + resolve(); + }, boundedSleepMs); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted:speed_limit")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + } finally { + if (active) { + active.blockedOnThrottleUntil = 0; + } + } } } return; } - await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, signal); + await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, active); } private async findReadyArchiveSets(pkg: PackageEntry): Promise> { @@ -11998,7 +12783,46 @@ export class DownloadManager extends EventEmitter { return false; } - private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise { + private recordArchiveOperation( + pkg: PackageEntry, + progress: ExtractProgressUpdate, + items: DownloadItem[], + errorCategory = "" + ): void { + if (!progress.archiveName || progress.archiveDone !== true) { + return; + } + const completedAt = nowMs(); + const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0)); + const itemIds = [...new Set(items.map((item) => item.id))]; + const itemProvenance = items + .map((item) => String(item.targetPath || item.id).replace(/\\/g, "/").toLocaleLowerCase("de-DE")) + .sort(); + const archiveIdentity = itemProvenance.length > 0 + ? itemProvenance.join("|") + : `${progress.archiveName.toLocaleLowerCase("de-DE")}:${Math.max(0, Math.floor(progress.current))}`; + const operation: ArchiveOperationMetric = { + id: `${pkg.id}:${createHash("sha256").update(archiveIdentity).digest("hex").slice(0, 24)}`, + name: progress.archiveName, + itemIds, + partCount: itemIds.length, + startedAt: Math.max(0, completedAt - durationMs), + completedAt, + durationMs, + status: progress.archiveSuccess === false ? "failed" : "completed", + errorCategory: progress.archiveSuccess === false ? projectPackageFailureCategory("extract", errorCategory) : "" + }; + const operations = [...(pkg.archiveOperations || [])]; + const existingIndex = operations.findIndex((entry) => entry.id === operation.id); + if (existingIndex >= 0) { + operations[existingIndex] = operation; + } else { + operations.push(operation); + } + pkg.archiveOperations = operations; + } + + private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise { const completedForDeobfuscation = items.filter((item) => item.status === "completed"); await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal); if (signal?.aborted) return 0; @@ -12133,8 +12957,9 @@ export class DownloadManager extends EventEmitter { readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey)); } - const autoRecoveredArchives = new Set(); - const failedArchiveErrors = new Map(); + const autoRecoveredArchives = new Set(); + const failedArchiveErrors = new Map(); + const failedArchiveCategories = new Map(); const hybridResolvedItems = new Map(); const hybridStartTimes = new Map(); let hybridLastEmitAt = 0; @@ -12185,8 +13010,9 @@ export class DownloadManager extends EventEmitter { maxParallel: this.settings.maxParallelExtract || 2, extractCpuPriority: "high", onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message), - onArchiveFailure: (failure) => { - const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); + onArchiveFailure: (failure) => { + failedArchiveCategories.set(String(failure.archiveName || "").toLowerCase(), failure.category); + const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); if (failedArchiveKey) { failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"); } @@ -12242,16 +13068,22 @@ export class DownloadManager extends EventEmitter { } const archItems = hybridResolvedItems.get(progress.archiveName) || []; - if (archiveFinished) { - const doneAt = nowMs(); + if (archiveFinished) { + const doneAt = nowMs(); const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; const doneLabel = progress.archiveSuccess === false ? "Entpacken - Error" : formatExtractDone(doneAt - startedAt); const archiveKey = readyArchiveKeyByName.get(progress.archiveName.toLowerCase()); - if (archiveKey && progress.archiveSuccess !== false) { - this.clearHybridArchiveState(packageId, archiveKey); - } + if (archiveKey && progress.archiveSuccess !== false) { + this.clearHybridArchiveState(packageId, archiveKey); + } + this.recordArchiveOperation( + pkg, + progress, + archItems, + failedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" + ); for (const entry of archItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; entry.fullStatus = doneLabel; @@ -12320,9 +13152,10 @@ export class DownloadManager extends EventEmitter { this.emitState(); } } - }); - - logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); + }); + pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + + logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { extracted: result.extracted, failed: result.failed @@ -12350,9 +13183,10 @@ export class DownloadManager extends EventEmitter { updatedAt: failedAt }); } - } - if (result.extracted > 0) { - const hybridController = new AbortController(); + } + if (result.extracted > 0) { + this.trackPackagePostProcessResult(packageId); + const hybridController = new AbortController(); let hybridSet = this.packageHybridPostProcessControllers.get(packageId); if (!hybridSet) { hybridSet = new Set(); @@ -12383,6 +13217,7 @@ export class DownloadManager extends EventEmitter { if (tasks?.size === 0) { this.packageHybridPostProcessTasks.delete(packageId); } + this.tryFinalizePackageResult(packageId); } })(); hybridHandle.task = hybridTask; @@ -12664,8 +13499,9 @@ export class DownloadManager extends EventEmitter { } }, extractTimeoutMs); try { - const autoRecoveredArchives = new Set(); - const fullFailedArchiveErrors = new Map(); + const autoRecoveredArchives = new Set(); + const fullFailedArchiveErrors = new Map(); + const fullFailedArchiveCategories = new Map(); const fullResolvedItems = new Map(); const fullStartTimes = new Map(); let fullLastProgressCurrent: number | null = null; @@ -12747,15 +13583,17 @@ export class DownloadManager extends EventEmitter { maxParallel: this.settings.maxParallelExtract || 2, extractCpuPriority: "high", onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message), - onArchiveFailure: (failure) => { - if (autoRecoveredArchives.has(failure.archiveName)) { + onArchiveFailure: (failure) => { + fullFailedArchiveCategories.set(failure.archiveName.toLowerCase(), failure.category); + if (autoRecoveredArchives.has(failure.archiveName)) { return; } const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); if (changed > 0) { - autoRecoveredArchives.add(failure.archiveName); - fullFailedArchiveErrors.delete(failure.archiveName); - return; + autoRecoveredArchives.add(failure.archiveName); + fullFailedArchiveErrors.delete(failure.archiveName); + fullFailedArchiveCategories.delete(failure.archiveName.toLowerCase()); + return; } fullFailedArchiveErrors.set( failure.archiveName, @@ -12802,12 +13640,18 @@ export class DownloadManager extends EventEmitter { } const archiveItems = fullResolvedItems.get(progress.archiveName) || []; - if (archiveFinished) { + if (archiveFinished) { const doneAt = nowMs(); const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; - const doneLabel = progress.archiveSuccess === false - ? "Entpacken - Error" - : formatExtractDone(doneAt - startedAt); + const doneLabel = progress.archiveSuccess === false + ? "Entpacken - Error" + : formatExtractDone(doneAt - startedAt); + this.recordArchiveOperation( + pkg, + progress, + archiveItems, + fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" + ); for (const entry of archiveItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; entry.fullStatus = doneLabel; @@ -12865,8 +13709,9 @@ export class DownloadManager extends EventEmitter { } emitExtractStatus(overallLabel); } - }); - logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); + }); + pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { extracted: result.extracted, failed: result.failed, @@ -12987,17 +13832,7 @@ export class DownloadManager extends EventEmitter { pkg.postProcessLabel = undefined; pkg.updatedAt = nowMs(); - if (pkg.status === "completed") { - this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`); - } else if (pkg.status === "failed") { - this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`); - } - - if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) { - this.recordPackageHistory(packageId, pkg, items); - } - - if (this.runPackageIds.has(packageId)) { + if (this.runPackageIds.has(packageId)) { if (pkg.status === "completed" || pkg.status === "failed") { this.runCompletedPackages.add(packageId); } else { @@ -13025,6 +13860,7 @@ export class DownloadManager extends EventEmitter { alreadyMarkedExtracted: boolean, extractedCount: number ): Promise { + this.trackPackagePostProcessResult(packageId); const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount) .finally(() => { const tasks = this.packageDeferredPostProcessTasks.get(packageId); @@ -13032,6 +13868,8 @@ export class DownloadManager extends EventEmitter { if (tasks?.size === 0) { this.packageDeferredPostProcessTasks.delete(packageId); } + this.tryFinalizePackageResult(packageId); + this.applyPackageDoneCleanup(packageId); }); const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set>(); tasks.add(task); @@ -13070,12 +13908,14 @@ export class DownloadManager extends EventEmitter { if (nestedCandidates.length > 0) { pkg.postProcessLabel = "Nested Entpacken..."; this.emitState(); - logger.info(`Deferred Nested-Extraction: ${nestedCandidates.length} Archive in ${pkg.extractDir}`); + logger.info(`Deferred Nested-Extraction: ${nestedCandidates.length} Archive in ${pkg.extractDir}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction gestartet", { nestedCandidates: nestedCandidates.length, extractDir: pkg.extractDir }); - const nestedResult = await extractPackageArchives({ + const nestedFailureCategories = new Map(); + const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const nestedResult = await extractPackageArchives({ packageDir: pkg.extractDir, targetDir: pkg.extractDir, cleanupMode: this.settings.cleanupMode, @@ -13087,11 +13927,23 @@ export class DownloadManager extends EventEmitter { packageId, onlyArchives: new Set(nestedCandidates.map((p) => process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p))), maxParallel: this.settings.maxParallelExtract || 2, - extractCpuPriority: this.settings.extractCpuPriority, - onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`), - }); - throwIfAborted(); - extractedCount += nestedResult.extracted; + extractCpuPriority: this.settings.extractCpuPriority, + onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`), + onArchiveFailure: (failure) => { + nestedFailureCategories.set(failure.archiveName.toLowerCase(), failure.category); + }, + onProgress: (progress) => { + this.recordArchiveOperation( + pkg, + progress, + resolveArchiveItemsFromList(progress.archiveName, nestedItems), + nestedFailureCategories.get(progress.archiveName.toLowerCase()) || "" + ); + } + }); + throwIfAborted(); + pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + extractedCount += nestedResult.extracted; logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { extracted: nestedResult.extracted, @@ -13189,8 +14041,7 @@ export class DownloadManager extends EventEmitter { this.persistSoon(); this.emitState(); - this.applyPackageDoneCleanup(packageId); - } catch (error) { + } catch (error) { const reason = compactErrorText(error); if (reason.includes("aborted:deferred") || reason.includes("deferred_replaced") @@ -13201,9 +14052,10 @@ export class DownloadManager extends EventEmitter { || reason === "skip" || reason === "package_toggle") { logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`); - } else { - logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`); - } + } else { + pkg.cleanupErrorCategory = projectPackageFailureCategory("cleanup", reason); + logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`); + } } finally { if (this.packageDeferredPostProcessAbortControllers.get(packageId) === deferredController) { this.packageDeferredPostProcessAbortControllers.delete(packageId); @@ -13342,18 +14194,20 @@ export class DownloadManager extends EventEmitter { } } - private finishRun(): void { - const runStartedAt = this.session.runStartedAt; + private finishRun(): void { + const runStartedAt = this.session.runStartedAt; + const completedAt = nowMs(); this.session.running = false; this.session.paused = false; this.session.runStartedAt = 0; const total = this.runItemIds.size; const outcomes = Array.from(this.runOutcomes.values()); - const success = outcomes.filter((status) => status === "completed").length; - const failed = outcomes.filter((status) => status === "failed").length; - const cancelled = outcomes.filter((status) => status === "cancelled").length; + const success = outcomes.filter((status) => status === "completed").length; + const failed = outcomes.filter((status) => status === "failed").length; + const cancelled = outcomes.filter((status) => status === "cancelled").length; + this.healthTerminalFailure = failed > 0; const extracted = this.runCompletedPackages.size; - const duration = runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - runStartedAt) / 1000)) : 1; + const duration = runStartedAt > 0 ? Math.max(1, Math.floor((completedAt - runStartedAt) / 1000)) : 1; const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration); this.summary = { total, @@ -13363,26 +14217,12 @@ export class DownloadManager extends EventEmitter { extracted, durationSeconds: duration, averageSpeedBps: avgSpeed - }; - this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; - if (this.settings.notifyOnRunFinished && total > 0) { - // With autoExtractWhenStopped (default) the run ends as soon as downloads - // are done while extraction may still be running — say so instead of - // claiming the whole run is finished. - const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending(); - const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet"; - void sendNotification(this.settings.notifyUrl, { - title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`, - message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`, - mention: this.settings.notifyMention - }); - } - this.runItemIds.clear(); - this.runOutcomes.clear(); - if (this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending()) { - this.runPackageIds.clear(); - this.runCompletedPackages.clear(); - } + }; + this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; + const runContext = total > 0 ? this.finishActiveRunContext(this.runPackageIds, runStartedAt) : null; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); @@ -13391,18 +14231,21 @@ export class DownloadManager extends EventEmitter { this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); this.speedEvents = []; - this.speedEventsHead = 0; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.globalSpeedLimitQueue = Promise.resolve(); - this.globalSpeedLimitNextAt = 0; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.resetGlobalSpeedLimitState(); this.nonResumableActive = 0; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.lastSettingsPersistAt = 0; - this.persistNow(); - this.emitState(); - } + this.persistNow(); + this.emitState(); + for (const packageId of runContext?.packageGenerations.keys() || []) { + this.tryFinalizePackageResult(packageId); + } + this.tryFinalizeRunResults(); + } public getSessionStats(): import("../shared/types").SessionStats { const now = nowMs(); diff --git a/src/main/main.ts b/src/main/main.ts index eb1641a..0ffa9da 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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); @@ -48,9 +49,10 @@ const RESETTABLE_PROVIDER_KEYS = new Set([ "realdebrid", "megadebrid-api", "megadebrid-web", - "bestdebrid", - "alldebrid", - "ddownload", + "bestdebrid", + "alldebrid", + "deepbrid", + "ddownload", "onefichier", "debridlink", "linksnappy" @@ -89,12 +91,74 @@ process.on("warning", (warning) => { let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let clipboardTimer: ReturnType | null = null; -let updateQuitTimer: ReturnType | null = null; -let scheduledStartTimer: ReturnType | null = null; +let updateQuitTimer: ReturnType | null = null; +let scheduledStartTimer: ReturnType | null = null; +let dailyStartScheduler: DailyStartScheduler | null = null; let lastClipboardText = ""; let controller: AppController; let pendingBackupImport: Buffer | null = null; -const CLIPBOARD_MAX_TEXT_CHARS = 50_000; +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 | null, + legacyTimer: ReturnType | null +): void { + scheduler?.end(); + if (legacyTimer !== null) { + clearTimeout(legacyTimer); + } + powerMonitor.removeListener("suspend", handlePowerSuspend); + powerMonitor.removeListener("resume", handlePowerResume); +} + +export interface BeforeQuitHandlerOptions { + cleanup: () => void; + shutdown: () => Promise; + 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; + 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); + 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); 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(); @@ -1002,7 +1081,12 @@ app.whenReady().then(() => { // A scheduled start persists in the settings but its timer lived only in this // process — without re-arming it here, any restart (auto-update, reboot, // crash) silently swallowed the planned run. - armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false }); + 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", () => { - if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } - stopClipboardWatcher(); - destroyTray(); - shutdownDaemon(); - if (controller) { - try { - controller.shutdown(); - } catch (error) { - logger.error(`Fehler beim Shutdown: ${String(error)}`); +app.on("before-quit", createBeforeQuitHandler({ + cleanup: () => { + if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } + cleanupSchedulerLifecycle(dailyStartScheduler, scheduledStartTimer); + scheduledStartTimer = null; + stopClipboardWatcher(); + destroyTray(); + shutdownDaemon(); + }, + shutdown: async () => { + if (controller) { + await controller.shutdown(); } + }, + continueQuit: () => app.quit(), + onError: (error) => { + logger.error(`Fehler beim Shutdown: ${String(error)}`); } -}); +})); diff --git a/src/main/notification-events.ts b/src/main/notification-events.ts new file mode 100644 index 0000000..58b33a2 --- /dev/null +++ b/src/main/notification-events.ts @@ -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 })) + }; +} diff --git a/src/main/notification-outbox.ts b/src/main/notification-outbox.ts new file mode 100644 index 0000000..58ef579 --- /dev/null +++ b/src/main/notification-outbox.ts @@ -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; + onDelivered?: (event: NotificationEvent, deliveredAt: number) => void | Promise; + now?: () => number; + autoDrain?: boolean; +} + +interface PersistedNotificationOutbox { + version: 1; + events: NotificationEvent[]; + lastSuccessAt: number; + lastFailureAt: number; +} + +const EVENT_TYPES = new Set([ + "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([ + "package_partial", + "package_failed" +]); +const PACKAGE_FAILURE_PHASES = new Map([ + ["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; + 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 = Promise.resolve(); + private readonly filePath: string; + private readonly sendEvent: (event: NotificationEvent) => Promise; + private readonly onDelivered: ((event: NotificationEvent, deliveredAt: number) => void | Promise) | null; + private readonly clock: () => number; + private readonly autoDrain: boolean; + private retryTimer: NodeJS.Timeout | null = null; + private shutdownRequested = false; + private drainOperation: Promise | 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 { + 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 { + 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 { + this.shutdownRequested = true; + this.clearRetryTimer(); + let timer: NodeJS.Timeout | null = null; + const timeout = new Promise((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(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation); + this.operationChain = result.then(() => undefined, () => undefined); + return result; + } + + private async performDrain(now?: number): Promise { + 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 { + 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; + 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 { + 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; + } + } +} diff --git a/src/main/notify.ts b/src/main/notify.ts index 9f8c0b4..5e84ec5 100644 --- a/src/main/notify.ts +++ b/src/main/notify.ts @@ -1,17 +1,45 @@ import { logger } from "./logger"; -export interface NotifyPayload { - title: string; - message: string; - mention?: string; -} +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 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()); @@ -32,7 +60,7 @@ export function normalizeDiscordMention(raw: string): string { // Discord counts the limit itself; slicing UTF-16 units can split a surrogate // pair at the boundary, which Discord rejects as invalid content. -export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string { +export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string { if (content.length <= maxChars) { return content; } @@ -41,21 +69,73 @@ export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): if (last >= 0xd800 && last <= 0xdbff) { cut = cut.slice(0, -1); } - return cut; -} - -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 }) - } - }; -} + 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 || ""); + return { + url: String(url || "").trim(), + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: WEBHOOK_USERNAME, + content: truncateContent(mention), + embeds: [buildDiscordEmbed(payload)] + }) + } + }; +} function delayMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/main/package-telemetry.ts b/src/main/package-telemetry.ts new file mode 100644 index 0000000..610cd05 --- /dev/null +++ b/src/main/package-telemetry.ts @@ -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([ + ["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 + }; +} diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts index ac219b5..7c7374d 100644 --- a/src/main/renderer-settings.ts +++ b/src/main/renderer-settings.ts @@ -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; diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 2c3ce8c..1d6ec8f 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -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; } diff --git a/src/main/settings-live-overlay.ts b/src/main/settings-live-overlay.ts index d584fe6..22497dd 100644 --- a/src/main/settings-live-overlay.ts +++ b/src/main/settings-live-overlay.ts @@ -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); diff --git a/src/main/statistics-ledger.ts b/src/main/statistics-ledger.ts index 1478fc2..7338e4a 100644 --- a/src/main/statistics-ledger.ts +++ b/src/main/statistics-ledger.ts @@ -21,6 +21,7 @@ const providers = new Set([ "megadebrid-web", "bestdebrid", "alldebrid", + "deepbrid", "ddownload", "onefichier", "debridlink", @@ -41,6 +42,7 @@ const providerLabels: Record = { "megadebrid-web": "Mega-Debrid Web", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", + deepbrid: "Deepbrid", ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link", diff --git a/src/main/storage.ts b/src/main/storage.ts index 8b89cdf..c6f965a 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -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,9 +42,12 @@ const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); const VALID_DOWNLOAD_STATUSES = new Set([ "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" ]); -const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); -const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); -const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; +const VALID_ITEM_PROVIDERS = new Set(["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 { return String(value ?? "").trim(); @@ -287,9 +292,10 @@ function normalizeDebridAccountStatuses( megaIds: string[], debridLinkIds: string[], realDebridIds: string[], - legacyRealDebridTargetId: string | null + legacyRealDebridTargetId: string | null, + deepbridConfigured: boolean ): Record { - const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]); + const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds, ...(deepbridConfigured ? ["svc-deepbrid"] : [])]); const result: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) { for (const [storedKey, raw] of Object.entries(value as Record)) { @@ -304,11 +310,13 @@ function normalizeDebridAccountStatuses( if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") { continue; } - const provider = entry.provider === "debridlink" - ? "debridlink" - : entry.provider === "realdebrid" - ? "realdebrid" - : "megadebrid"; + const provider = key === "svc-deepbrid" + ? "deepbrid" + : entry.provider === "debridlink" + ? "debridlink" + : entry.provider === "realdebrid" + ? "realdebrid" + : "megadebrid"; let username = typeof entry.username === "string" ? entry.username : undefined; let email = typeof entry.email === "string" ? entry.email : undefined; if (provider === "debridlink" && !username && email && !email.includes("@")) { @@ -413,10 +421,14 @@ function migrateUpdateRepo(raw: string, fallback: string): string { return trimmed; } -export function normalizeSettings(settings: AppSettings): AppSettings { - const defaults = defaultSettings(); - const directorySettings = migrateLegacyDefaultDirectories(settings, defaults); - const currentUsageDay = getProviderUsageDayKey(); +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); let legacyMegaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); @@ -487,7 +499,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw) ? providerDailyUsageDayRaw : currentUsageDay; - const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? "")); + const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? "")); + const deepbridApiKey = asText(settings.deepbridApiKey); const providerDailyUsageBytes = normalizeProviderByteMap( settings.providerDailyUsageBytes, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, @@ -510,8 +523,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings { settings.debridLinkApiKeyTotalUsageBytes, debridLinkApiKeyIds ); - const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); - const normalized: AppSettings = { + const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); + const validDailyStartOutcomes = new Set(["", "started", "already_active", "empty_queue", "missing_account", "start_failed", "missed"]); + const normalized: AppSettings = { language: settings.language === "de" ? "de" : "en", token: asText(settings.token), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), @@ -533,9 +547,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaDebridPreferApi, bestToken: asText(settings.bestToken), bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin), - allDebridToken: asText(settings.allDebridToken), - allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), - ddownloadLogin: asText(settings.ddownloadLogin), + allDebridToken: asText(settings.allDebridToken), + allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), + deepbridApiKey, + ddownloadLogin: asText(settings.ddownloadLogin), ddownloadPassword: asText(settings.ddownloadPassword), oneFichierApiKey: asText(settings.oneFichierApiKey), debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(), @@ -604,9 +619,16 @@ export function normalizeSettings(settings: AppSettings): AppSettings { backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics, notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, notifyMention: asText(settings.notifyMention) || defaults.notifyMention, - 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, + 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,11 +663,18 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaDebridAccountIds, debridLinkApiKeyIds, realDebridAccountIds, - legacyRealDebridTargetId + legacyRealDebridTargetId, + Boolean(deepbridApiKey) ), - providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, - scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) - }; + 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) + }; if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { normalized.providerPrimary = defaults.providerPrimary; @@ -682,22 +711,26 @@ export function normalizeSettings(settings: AppSettings): AppSettings { } export interface StoragePaths { - baseDir: string; - configFile: string; - sessionFile: string; + baseDir: string; + configFile: string; + sessionFile: string; historyFile: string; statisticsFile: string; -} + notificationOutboxFile: string; + notificationHealthFile: string; +} export function createStoragePaths(baseDir: string): StoragePaths { return { baseDir, - configFile: path.join(baseDir, "rd_downloader_config.json"), - sessionFile: path.join(baseDir, "rd_session_state.json"), + 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") + }; +} function ensureBaseDir(baseDir: string): void { try { @@ -725,7 +758,7 @@ function asRecord(value: unknown): Record | null { return value as Record; } -function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined { +function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined { const parsed = asRecord(raw); if (!parsed) { return undefined; @@ -753,9 +786,76 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000), failed: clampNumber(parsed.failed, 0, 0, 1_000_000), files - }; -} - + }; +} + +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, 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).realDebridWebAccountIds; } + if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) { + delete (mergedInput as Partial).notifyPackageSuccessMode; + } const merged = normalizeSettings(mergedInput); return { settings: merged, needsCredentialRewrite }; } catch (error) { @@ -907,8 +1010,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState { ? [...new Set(pkg.cleanedProviders.map((value) => asText(value) as DebridProvider).filter((value) => VALID_ITEM_PROVIDERS.has(value)))] : [], downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), - downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER), - createdAt: clampNumber(pkg.createdAt, now, 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) { @@ -1144,13 +1262,15 @@ function readSessionFile(filePath: string): SessionState | null { if (raw === null) { return null; } - try { - const parsed = JSON.parse(raw) as unknown; - const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); + try { + const parsed = JSON.parse(raw) as unknown; + 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; @@ -1264,10 +1384,11 @@ export type SessionLoadStatus = | "empty-fresh" | "empty-unreadable"; -export interface SessionLoadResult { - session: SessionState; - status: SessionLoadStatus; -} +export interface SessionLoadResult { + session: SessionState; + status: SessionLoadStatus; + wasRunning: boolean; +} export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult { ensureBaseDir(paths.baseDir); @@ -1281,69 +1402,69 @@ 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"); } - const primary = primaryExists ? readSessionFile(paths.sessionFile) : null; - - if (primary) { - const primaryPkgCount = Object.keys(primary.packages).length; + const primary = primaryExists ? readSessionFile(paths.sessionFile) : null; + + if (primary) { + 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 backup = readSessionFile(backupFile); + if (backup) { + 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)`); - try { - const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer); + const tmpSession = readSessionFile(tmpPath); + 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.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 { return loadSessionWithStatus(paths).session; @@ -1467,9 +1588,27 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry const entry = asRecord(raw); if (!entry) return null; - const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`; - const name = asText(entry.name) || "Unbenannt"; - const providerRaw = asText(entry.provider); + 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,11 +1619,17 @@ 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", - outputDir: asText(entry.outputDir), - urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined - }; -} + 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, + ...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) } : {}) + }; +} export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] { ensureBaseDir(paths.baseDir); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 4180792..37b260a 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -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"; @@ -99,9 +99,10 @@ export function getSupportBundleDefaultFileName(): string { type HostDiagnosticsMode = "full" | "cached" | "none"; -interface BuildSupportBundleOptions { - hostDiagnosticsMode?: HostDiagnosticsMode; -} +interface BuildSupportBundleOptions { + hostDiagnosticsMode?: HostDiagnosticsMode; + notificationStatus?: NotificationSupportPayload; +} function createDeferredHostDiagnostics(reason: string): unknown { return { @@ -144,7 +145,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri const snapshot = manager.getSnapshot(); const packageIds = Object.keys(snapshot.session.packages); const itemIds = Object.keys(snapshot.session.items); - const debugSetup = getDebugSetupCheck(baseDir); + const debugSetup = getDebugSetupCheck(baseDir); + const notificationStatus = normalizeNotificationSupportPayload(options.notificationStatus); addJson(zip, "overview/meta.json", { appVersion: APP_VERSION, @@ -156,14 +158,15 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri addJson(zip, "overview/status.json", snapshot.session); addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings)); addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); - addJson(zip, "overview/stats.json", { + addJson(zip, "overview/stats.json", { ...buildStatsPayload(snapshot), allTime: { totalDownloadedAllTime: settings.totalDownloadedAllTime, totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, 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", { diff --git a/src/main/support-data.ts b/src/main/support-data.ts index 2678cac..855efcb 100644 --- a/src/main/support-data.ts +++ b/src/main/support-data.ts @@ -1,17 +1,69 @@ 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 { 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; -} +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 | 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, + health: Pick, + 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 { 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: { @@ -39,11 +91,27 @@ export function buildAccountSummary(settings: AppSettings): Record entry.kind === kind); @@ -589,9 +600,10 @@ function getAccountPickerFunctionLabel(option: AccountOption): string { return "Login:Passwort (Web)"; case "bestdebrid-web": return "Cookies.txt-Import"; - case "alldebrid-api": - case "onefichier-api": - return "API-Key"; + case "alldebrid-api": + case "deepbrid-api": + case "onefichier-api": + return "API-Key"; case "ddownload-login": return "Login + Passwort"; default: @@ -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"; @@ -637,7 +650,7 @@ function normalizeProviderOrderForSettings(settings: RendererSettings): DebridPr return buildConfiguredProviderOrder(settings.providerOrder || [], configured); } -function normalizeProviderSelectionForSettings( +function normalizeProviderSelectionForSettings( settings: RendererSettings ): Pick { const providerOrder = normalizeProviderOrderForSettings(settings); @@ -646,9 +659,15 @@ function normalizeProviderSelectionForSettings( providerPrimary: providerOrder[0] ?? settings.providerPrimary, providerSecondary: (providerOrder[1] ?? "none") as DebridFallbackProvider, providerTertiary: (providerOrder[2] ?? "none") as DebridFallbackProvider - }; -} - + }; +} + +export function buildAccountCreateProviderOrderUpdate( + settings: RendererSettings +): Pick { + 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"], @@ -854,10 +877,17 @@ const emptySnapshot = (): UiSnapshot => ({ megaDebridWebDisabledAccountIds: [], megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, - megaDebridAccountTotalUsageBytes: {}, - debridAccountStatuses: {}, - providerDailyUsageDay: getProviderUsageDayKey(), - scheduledStartEpochMs: 0 + megaDebridAccountTotalUsageBytes: {}, + debridAccountStatuses: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + 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; + getSnapshot: () => Promise; + 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, + 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 +): RendererSettingsUpdate { + return settings.dailyStartEnabled + ? { dailyStartEnabled: false } + : { scheduledStartEpochMs: 0 }; +} + +export async function activateDailyScheduleSettings( + time: string, + startDay: DailyScheduleStartDay, + persist: (update: RendererSettingsUpdate) => Promise, + showError: (message: string) => void, + now = new Date() +): Promise { + 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 { + 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(emptySnapshot); const [appVersion, setAppVersion] = useState(""); @@ -1546,9 +1670,10 @@ export function App(): ReactElement { const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); const [settingsDirty, setSettingsDirty] = useState(false); const [settingsSaveState, setSettingsSaveState] = useState("clean"); - const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); - const [scheduleTimeInput, setScheduleTimeInput] = useState(""); - const [scheduleCountdown, setScheduleCountdown] = useState(""); + const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); + const [scheduleTimeInput, setScheduleTimeInput] = useState(""); + const [scheduleStartDay, setScheduleStartDay] = useState("today"); + const [scheduleCountdown, setScheduleCountdown] = useState(""); const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); const updateCheckGenerationRef = useRef(0); const dismissedUpdateTagRef = useRef(""); @@ -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 => ( + 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(() => {}); - setSchedulePickerOpen(false); - }, [scheduleTimeInput]); + void activateDailyScheduleSettings( + scheduleTimeInput, + scheduleStartDay, + (update) => persistDownloadSchedule(update, "activate"), + (message) => showToast(message, 2800) + ).then((persisted) => { + if (persisted) { + setSchedulePickerOpen(false); + } + }); + }, [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> = { maxParallel: [1, 50, 1], retryLimit: [0, 99, 0], diff --git a/src/renderer/account-edit.ts b/src/renderer/account-edit.ts index 351e399..6a7320f 100644 --- a/src/renderer/account-edit.ts +++ b/src/renderer/account-edit.ts @@ -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; diff --git a/src/renderer/account-service-icons.ts b/src/renderer/account-service-icons.ts index ef23773..1347f51 100644 --- a/src/renderer/account-service-icons.ts +++ b/src/renderer/account-service-icons.ts @@ -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", diff --git a/src/renderer/download-format.ts b/src/renderer/download-format.ts index 250def9..eab7726 100644 --- a/src/renderer/download-format.ts +++ b/src/renderer/download-format.ts @@ -9,6 +9,7 @@ export const providerLabels: Record = { "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 }; } diff --git a/src/renderer/hoster-icons.ts b/src/renderer/hoster-icons.ts index bd80f7e..bd79dd0 100644 --- a/src/renderer/hoster-icons.ts +++ b/src/renderer/hoster-icons.ts @@ -1,4 +1,5 @@ export const hosterIconSources: Readonly> = { 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" }; diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index d07aff7..5e3a4bb 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -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 { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index c573afa..391724a 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -11,8 +11,10 @@ import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody"; import "./downloads.css"; const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }); - -export interface DownloadsStatusModel { + +export type DailyScheduleStartDay = "today" | "tomorrow"; + +export interface DownloadsStatusModel { packages: number; links: number; session: string; @@ -37,10 +39,12 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { reconnectSeconds: number; reconnectReason: string; clipboardWatcher: boolean; - scheduleActive: boolean; - scheduleOpen: boolean; - scheduleTime: string; - scheduleLabel: string; + scheduleActive: boolean; + scheduleOpen: boolean; + scheduleTime: string; + scheduleTimeValid: boolean; + scheduleStartDay: DailyScheduleStartDay; + scheduleLabel: string; packageSpeedBps: Record; editingPackageId: string | null; editingName: string; @@ -63,9 +67,10 @@ export interface DownloadsViewActions extends DownloadsTableActions { onStartDownloads: () => void; onPauseDownloads: () => void; onStopDownloads: () => void; - onToggleSchedule: () => void; - onScheduleTimeChange: (value: string) => void; - onActivateSchedule: () => void; + onToggleSchedule: () => void; + onScheduleTimeChange: (value: string) => void; + onScheduleStartDayChange: (value: DailyScheduleStartDay) => void; + onActivateSchedule: () => void; onCancelSchedule: () => void; onMoveSelectionUp: () => void; onMoveSelectionDown: () => void; @@ -121,18 +126,25 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }) })}
Geschwindigkeit{speed}
ETA{eta}
; } -export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { - const hasSelection = model.actionableSelectedIds.length > 0; - const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0; - const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1; - return ( +export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { + 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 (
- {model.scheduleActive - ? Geplant: {model.scheduleLabel} - : <>{model.scheduleOpen ? actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /> : null}} + {!model.scheduleActive ? : null} + + + {model.scheduleActive + ? <>Geplant: {model.scheduleLabel} + : <> actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} />} + + diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index e5eee17..5fbffbd 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -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; } diff --git a/src/renderer/views/history/HistoryView.tsx b/src/renderer/views/history/HistoryView.tsx index 7e1e077..48c7641 100644 --- a/src/renderer/views/history/HistoryView.tsx +++ b/src/renderer/views/history/HistoryView.tsx @@ -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({
Provider
{row.providerLabel}
Dateien
{row.fileCount}
-
Dauer
{row.durationLabel}
+ {row.hasStructuredLifecycle ? ( + <> +
Download gestartet
{row.startedLabel}
+
Download beendet
{row.downloadEndedLabel}
+
Nachbearbeitung gestartet
{row.postProcessStartedLabel}
+
Abgeschlossen
{row.completedLabel}
+
Downloaddauer
{row.downloadDurationLabel}
+
Entpackdauer
{row.extractionDurationLabel}
+
Remuxdauer
{row.remuxDurationLabel}
+
Nachbearbeitungsdauer
{row.postProcessDurationLabel}
+
Gesamtdauer
{row.totalDurationLabel}
+
Status
{row.statusLabel}
+
Erfolgreich / Fehlgeschlagen / Abgebrochen
{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}
+
Archive / Parts / Ausgaben
{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}
+
Fehlerphase
{row.failurePhaseLabel}
+ + ) : ( +
Downloaddauer (Altbestand)
{row.durationLabel}
+ )}
Durchschnitt
{row.averageSpeedLabel}
Zielordner
{row.outputDir || "—"}
URLs
{row.urls?.length ? row.urls.join("\n") : "—"}
+ {row.hasStructuredLifecycle ? ( +
+
+

Archivvorgänge

+ {row.archiveOperations?.length ? ( +
    + {row.archiveOperations.map((operation) => ( +
  • + {operation.name} + {operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""} +
  • + ))} +
+ ) :

Keine Archivvorgänge

} +
+
+

Remuxvorgänge

+ {row.remuxOperations?.length ? ( +
    + {row.remuxOperations.map((operation) => ( +
  • + {operation.fileName} + {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""} +
  • + ))} +
+ ) :

Keine Remuxvorgänge

} +
+
+ ) : null}
diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index fc31d0f..2d96725 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -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 = { "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 = { const statusLabels: Record = { 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): number { +export function deriveHistoryStartAt(entry: Pick): 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) }; } diff --git a/src/renderer/views/history/history.css b/src/renderer/views/history/history.css index f06166b..c306e8a 100644 --- a/src/renderer/views/history/history.css +++ b/src/renderer/views/history/history.css @@ -339,11 +339,23 @@ color: var(--ui-danger-text); } -.history-status-failed { - background: color-mix(in srgb, var(--ui-danger) 15%, transparent); - border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border)); +.history-status-failed { + background: color-mix(in srgb, var(--ui-danger) 15%, transparent); + border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border)); 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, @@ -422,9 +434,55 @@ min-width: 0; } -.history-details-grid .history-detail-wide { - grid-column: span 2; -} +.history-details-grid .history-detail-wide { + 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; @@ -494,9 +552,13 @@ overflow: hidden; } - .history-action { - padding: 0 9px; - } + .history-action { + padding: 0 9px; + } + + .history-operation-groups { + grid-template-columns: 1fr; + } } diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 79381fe..73d2d18 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -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), diff --git a/src/renderer/views/statistics/statistics-model.ts b/src/renderer/views/statistics/statistics-model.ts index 07ade4e..b5eb85f 100644 --- a/src/renderer/views/statistics/statistics-model.ts +++ b/src/renderer/views/statistics/statistics-model.ts @@ -50,6 +50,7 @@ const providerLabels: Record = { "megadebrid-web": "Mega-Debrid Web", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", + deepbrid: "Deepbrid", ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link", diff --git a/src/shared/types.ts b/src/shared/types.ts index 2310ab2..52e8be1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -19,9 +19,10 @@ export type DebridProvider = | "megadebrid" | "megadebrid-api" | "megadebrid-web" - | "bestdebrid" - | "alldebrid" - | "ddownload" + | "bestdebrid" + | "alldebrid" + | "deepbrid" + | "ddownload" | "onefichier" | "debridlink" | "linksnappy"; @@ -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; @@ -140,9 +154,10 @@ export interface AppSettings { megaDebridPreferApi: boolean; bestToken: string; bestDebridUseWebLogin: boolean; - allDebridToken: string; - allDebridUseWebLogin: boolean; - ddownloadLogin: string; + allDebridToken: string; + allDebridUseWebLogin: boolean; + deepbridApiKey: string; + ddownloadLogin: string; ddownloadPassword: string; oneFichierApiKey: string; debridLinkApiKeys: string; @@ -200,11 +215,18 @@ export interface AppSettings { confirmDeleteSelection: boolean; backupIncludeDownloads: boolean; backupIncludeRemoteDiagnostics: boolean; - notifyUrl: string; - notifyMention: string; - notifyOnPackageCompleted: boolean; - notifyOnPackageFailed: boolean; - notifyOnRunFinished: boolean; + notifyUrl: string; + notifyMention: string; + 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; providerDailyUsageDay: string; scheduledStartEpochMs: number; + nextDailyStartEpochMs: number; } -export type RendererSettingsUpdate = Partial & { +export type RendererSettingsUpdate = Partial> & { 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; @@ -455,7 +491,7 @@ export interface AudioStripFileResult { languages?: string; } -export interface AudioStripSummary { +export interface AudioStripSummary { at: number; candidates: number; remuxed: number; @@ -463,10 +499,77 @@ export interface AudioStripSummary { skippedNoGerman: number; skippedNoTool: number; failed: number; - files: AudioStripFileResult[]; -} - -export interface PackageEntry { + 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; outputDir: string; @@ -485,8 +588,18 @@ export interface PackageEntry { cleanedUrls?: string[]; cleanedProviders?: DebridProvider[]; downloadStartedAt?: number; - downloadCompletedAt?: number; - createdAt: 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,10 +927,27 @@ export interface HistoryEntry { provider: DebridProvider | null; completedAt: number; durationSeconds: number; - status: "completed" | "deleted"; - outputDir: string; - urls?: string[]; -} + 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 { entries: HistoryEntry[]; diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts index b492e36..d18fc0d 100644 --- a/tests/account-check.test.ts +++ b/tests/account-check.test.ts @@ -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"; @@ -15,14 +15,23 @@ function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry { return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" }; } -function mockFetchOnce(status: number, body: unknown): void { +function mockFetchOnce(status: number, body: unknown): void { const text = typeof body === "string" ? body : JSON.stringify(body); vi.stubGlobal("fetch", vi.fn(async () => ({ ok: status >= 200 && status < 300, status, text: async () => text })) as unknown as typeof fetch); -} +} + +function mockDeepbridResponse(status: number, body: unknown, contentType = "application/json"): ReturnType { + 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; @@ -179,8 +188,129 @@ 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, "gateway", "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 }; diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts index 7ddbb7c..745f657 100644 --- a/tests/account-commands.test.ts +++ b/tests/account-commands.test.ts @@ -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", diff --git a/tests/account-edit.test.ts b/tests/account-edit.test.ts index ebc9782..79e04d3 100644 --- a/tests/account-edit.test.ts +++ b/tests/account-edit.test.ts @@ -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"); + }); }); diff --git a/tests/account-service-icons.test.ts b/tests/account-service-icons.test.ts index 91d7bee..a9263a3 100644 --- a/tests/account-service-icons.test.ts +++ b/tests/account-service-icons.test.ts @@ -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"); + }); }); diff --git a/tests/app-controller.test.ts b/tests/app-controller.test.ts new file mode 100644 index 0000000..b985526 --- /dev/null +++ b/tests/app-controller.test.ts @@ -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("../src/main/debug-server"); + return { + ...actual, + startDebugServer: vi.fn(), + stopDebugServer: vi.fn() + }; +}); + +vi.mock("../src/main/storage", async () => { + const actual = await vi.importActual("../src/main/storage"); + return { + ...actual, + loadSettings: (...args: Parameters) => bootStorage.settings + ? bootStorage.settings as AppSettings + : actual.loadSettings(...args), + loadSessionWithStatus: (...args: Parameters) => bootStorage.loadResult + ? bootStorage.loadResult as ReturnType + : 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); + }); +}); diff --git a/tests/backup-payload.test.ts b/tests/backup-payload.test.ts index d78ef52..2f8b7f1 100644 --- a/tests/backup-payload.test.ts +++ b/tests/backup-payload.test.ts @@ -1,11 +1,12 @@ 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 { - return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings; -} +function settings(overrides: Partial = {}): AppSettings { + return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl", ...overrides } as unknown as AppSettings; +} const session: SessionState = { version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never }, @@ -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")); diff --git a/tests/credential-protection.test.ts b/tests/credential-protection.test.ts index c3afc96..4c6779b 100644 --- a/tests/credential-protection.test.ts +++ b/tests/credential-protection.test.ts @@ -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(), diff --git a/tests/daily-start-scheduler.test.ts b/tests/daily-start-scheduler.test.ts new file mode 100644 index 0000000..3452e20 --- /dev/null +++ b/tests/daily-start-scheduler.test.ts @@ -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 { + 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 = { queued: { status: "queued", packageId: "package" } }; + public packages: Record = { + 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 { + 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((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(); + } + }); +}); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 63c24d5..b6a34c7 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -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> = {}) { + 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('', { + 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")) diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index a4e4e16..fbda6b3 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -53,7 +53,8 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; 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 { 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,15 +327,25 @@ async function createFixture() { getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null } as unknown as DownloadManager; - startDebugServer(manager, baseDir); + const notificationStatus: NotificationSupportPayload & Record = { + 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)); - return { - baseUrl, - token, - baseDir + return { + baseUrl, + token, + 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; + + 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; + 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; + 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); @@ -584,7 +625,8 @@ describe("debug-server", () => { expect(entries).toContain("overview/accounts.json"); 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/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"); @@ -592,8 +634,16 @@ describe("debug-server", () => { expect(entries).toContain("overview/support-manifest.json"); expect(entries).not.toContain(`runtime/${legacyManifestFile}`); expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join("")); - expect(entries).not.toContain("runtime/debug_token.txt"); - }); + 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 () => { const fixture = await createFixture(); diff --git a/tests/deepbrid.test.ts b/tests/deepbrid.test.ts new file mode 100644 index 0000000..b1ac8aa --- /dev/null +++ b/tests/deepbrid.test.ts @@ -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 = {}): 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?.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("challenge secret", { 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((_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((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((_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(); + }); +}); diff --git a/tests/download-health-monitor.test.ts b/tests/download-health-monitor.test.ts new file mode 100644 index 0000000..9ce6bf7 --- /dev/null +++ b/tests/download-health-monitor.test.ts @@ -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 { + 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[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((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 + })); + }); +}); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 2ffbef5..302d37a 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -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; + 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); }); }); @@ -4112,9 +4203,10 @@ describe("download manager", () => { return originalFetch(input, init); }; - let releaseBlockedPostProcess: ((value?: void | PromiseLike) => void) | undefined; - try { - const manager = new DownloadManager( + let releaseBlockedPostProcess: ((value?: void | PromiseLike) => void) | undefined; + try { + const history: HistoryEntry[] = []; + const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", @@ -4123,14 +4215,15 @@ describe("download manager", () => { autoExtract: false, maxParallel: 1 }, - emptySession(), - createStoragePaths(path.join(root, "state")) + emptySession(), + createStoragePaths(path.join(root, "state")), + { onHistoryEntry: (entry) => history.push(entry) } ); - const blocker = new Promise((resolve) => { - releaseBlockedPostProcess = resolve; - }); - (manager as unknown as { packagePostProcessQueue: Promise }).packagePostProcessQueue = blocker; + const blocker = new Promise((resolve) => { + releaseBlockedPostProcess = resolve; + }); + (manager as any).handlePackagePostProcessing = vi.fn(async () => blocker); manager.addPackages([ { name: "first", links: ["https://dummy/first"] }, @@ -4146,19 +4239,31 @@ describe("download manager", () => { manager.start(); await waitFor(() => manager.getSnapshot().session.items[firstItem]?.status === "completed", 12000); - await waitFor(() => { - const state = manager.getSnapshot().session.items[secondItem]?.status; - return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed"; - }, 6000); + await waitFor(() => { + const state = manager.getSnapshot().session.items[secondItem]?.status; + 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); - - const done = manager.getSnapshot(); - expect(done.session.items[firstItem]?.status).toBe("completed"); - expect(done.session.items[secondItem]?.status).toBe("completed"); + } + 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(); @@ -10706,10 +10811,20 @@ describe("download manager", () => { manager.getSnapshot().session.packages[packageId]?.status === "completed", 25000 ); - const snapshot = manager.getSnapshot(); - expect(snapshot.session.packages[packageId]?.status).toBe("completed"); - expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); - }, 30000); + 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 () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -14187,7 +14302,7 @@ describe("mega-debrid api/web resolution overlap gate", () => { }); }); -describe("package priority ordering", () => { +describe("package priority ordering", () => { function buildPriorityManager(priorities: Array<[string, "high" | "normal" | "low"]>): { manager: DownloadManager; session: ReturnType; @@ -14257,7 +14372,7 @@ describe("package priority ordering", () => { expect(session.packageOrder).toEqual(["high-a", "normal-a", "target", "low-a"]); }); - it("keeps an unchanged priority in place", () => { + it("keeps an unchanged priority in place", () => { const { manager, session } = buildPriorityManager([ ["high-a", "high"], ["high-b", "high"], @@ -14266,6 +14381,494 @@ describe("package priority ordering", () => { manager.setPackagePriority("high-a", "high"); - expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]); - }); -}); + 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, 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); + }); +}); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index e586dd9..89f7cfe 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -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 = {}): 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 = {}) { +function withRuntime(input: DownloadsModelInput, overrides: Partial = {}): DownloadsViewModel { return { ...buildDownloadsViewModel(input), running: true, @@ -795,6 +797,8 @@ function withRuntime(input: DownloadsModelInput, overrides: Record { 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(); + const open = renderToStaticMarkup(); + const motionDisabled = renderToStaticMarkup(); + 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(); const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8"); diff --git a/tests/history-reveal.test.ts b/tests/history-reveal.test.ts index a81a39f..4f0aa29 100644 --- a/tests/history-reveal.test.ts +++ b/tests/history-reveal.test.ts @@ -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(); diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index bedacb5..0a3e2dc 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -133,10 +133,19 @@ describe("history model", () => { for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) { expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids); - } - }); - - it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => { + } + }); + + 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(); const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime(); @@ -176,7 +185,7 @@ describe("history model", () => { expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]); }); - it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => { + it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => { expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com"); expect(deriveHistoryHoster([])).toBe("—"); expect(deriveHistoryHoster(undefined)).toBe("—"); @@ -185,8 +194,22 @@ describe("history model", () => { const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0]; expect(row.hoster).toBe("—"); - expect(row.providerLabel).toBe("Real-Debrid"); - }); + 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"]); @@ -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( + + ); + + 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( + + ); + + 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); diff --git a/tests/hoster-metadata.test.ts b/tests/hoster-metadata.test.ts index 44cc7a4..e09af04 100644 --- a/tests/hoster-metadata.test.ts +++ b/tests/hoster-metadata.test.ts @@ -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 () => { diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index e9a1196..866683a 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -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."); diff --git a/tests/main-shutdown-lifecycle.test.ts b/tests/main-shutdown-lifecycle.test.ts new file mode 100644 index 0000000..3ca1cee --- /dev/null +++ b/tests/main-shutdown-lifecycle.test.ts @@ -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 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(() => {})), + 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; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((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((resolve) => { markSendStarted = resolve; }); + const blockedSend = new Promise((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((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 }); + } + }); +}); diff --git a/tests/notification-outbox.test.ts b/tests/notification-outbox.test.ts new file mode 100644 index 0000000..39d8c50 --- /dev/null +++ b/tests/notification-outbox.test.ts @@ -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 { + 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 => 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((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((resolve) => { markSendStarted = resolve; }); + const sendResult = new Promise((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((resolve) => { markSecondAttemptStarted = resolve; }); + const secondCleanupCompleted = new Promise((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((resolve) => { markSendStarted = resolve; }); + const sendResult = new Promise((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((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((resolve) => { markSendStarted = resolve; }); + const blockedSend = new Promise(() => {}); + 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((resolve) => { markSendStarted = resolve; }); + const sendResult = new Promise((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((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((resolve) => { markSendStarted = resolve; }); + const sendResult = new Promise((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((resolve) => { markSendStarted = resolve; }); + const sendResult = new Promise((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(() => {}), 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((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((resolve) => { markSendStarted = resolve; }); + const blockedSend = new Promise(() => {}); + 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(); + } + }); +}); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 582422f..9aecbc7 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -1,153 +1,890 @@ -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(); - return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) }; -}); - -import { DownloadManager } from "../src/main/download-manager"; -import { defaultSettings } from "../src/main/constants"; -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"; - -const mockedSend = sendNotification as unknown as ReturnType; -const tempDirs: string[] = []; - -afterEach(() => { - mockedSend.mockClear(); - shutdownItemLogs(); - shutdownPackageLogs(); - shutdownRenameLog(); - for (const dir of tempDirs.splice(0)) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } - } -}); - -function setup(): { manager: DownloadManager; session: ReturnType } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-")); - tempDirs.push(root); - const session = emptySession(); - const manager = new DownloadManager( - { - ...defaultSettings(), - token: "rd-token", - outputDir: path.join(root, "out"), - extractDir: path.join(root, "extract"), - notifyUrl: "https://discord.com/api/webhooks/123/abc", - notifyOnPackageCompleted: true, - notifyOnPackageFailed: true - }, - session, - createStoragePaths(path.join(root, "state")) - ); - return { manager, session }; -} - -function addPackage(session: ReturnType, itemStatuses: string[]): any { - const pkgId = "pkg-1"; - const pkg: any = { - id: pkgId, - name: "Test.Show.S01", - outputDir: "C:/out", - extractDir: "C:/extract", - status: "queued", - itemIds: itemStatuses.map((_s, i) => `it-${i}`), - cancelled: false, - enabled: true, - priority: "normal", - createdAt: 1, - updatedAt: 1 - }; - 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, - status, - retries: 0, - speedBps: 0, - downloadedBytes: 0, - totalBytes: null, - progressPercent: 0, - fileName: `f${i}.rar`, - targetPath: "", - resumable: true, - attempts: 1, - lastError: "", - fullStatus: "", - createdAt: 1, - updatedAt: 1 - } as any; - }); - 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(); - const pkg = addPackage(session, ["completed", "failed"]); - session.running = true; - - (manager as any).refreshPackageStatus(pkg); - - 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"); - }); - - it("notifies an all-failed package and dedups repeat refreshes", () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed", "failed"]); - session.running = true; - - (manager as any).refreshPackageStatus(pkg); - (manager as any).refreshPackageStatus(pkg); - - expect(pkg.status).toBe("failed"); - expect(mockedSend).toHaveBeenCalledTimes(1); - }); - - it("stays silent outside a run (startup recovery must not spam)", () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed"]); - session.running = false; - - (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; - - (manager as any).refreshPackageStatus(pkg); - - expect(pkg.status).toBe("queued"); - expect(mockedSend).not.toHaveBeenCalled(); - }); - - it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed", "failed"]); - session.running = true; - mockedSend.mockResolvedValueOnce(false); - - (manager as any).refreshPackageStatus(pkg); - await new Promise((r) => setTimeout(r, 0)); - - expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false); - }); -}); +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +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 type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types"; + +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 setup(settings: Partial = {}): { + manager: DownloadManager; + session: ReturnType; + 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(), + token: "rd-token", + outputDir: path.join(root, "out"), + extractDir: path.join(root, "extract"), + notifyUrl: "https://discord.com/api/webhooks/123/abc", + notifyOnPackageCompleted: true, + notifyOnPackageFailed: true, + notifyOnRunFinished: true, + notifyPackageSuccessMode: "individual", + autoExtract: false, + ...settings + }, + session, + createStoragePaths(path.join(root, "state")), + { + enqueueNotification: async (event: NotificationEvent) => { + events.push(event); + }, + onHistoryEntry: (entry) => history.push(entry) + } + ); + return { manager, session, events, history }; +} + +function addPackage( + session: ReturnType, + 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: statuses.map((_status, index) => `${packageId}-item-${index}`), + cancelled: false, + enabled: true, + priority: "normal", + downloadStartedAt: startedAt, + downloadCompletedAt: startedAt + 10_000, + downloadEndedAt: startedAt + 10_000, + createdAt: startedAt, + updatedAt: startedAt + 10_000 + }; + 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: 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: status === "failed" ? "offline" : "", + fullStatus: status === "completed" ? "Fertig" : status === "failed" ? "Offline" : "Wartet", + createdAt: startedAt, + updatedAt: startedAt + 10_000 + }; + }); + return pkg; +} + +function internal(manager: DownloadManager): any { + return manager as any; +} + +async function flushNotifications(): Promise { + await new Promise((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); + + internal(manager).refreshPackageStatus(pkg); + await flushNotifications(); + + expect(pkg.status).toBe("failed"); + expect(events.map((event) => event.type)).toEqual(["package_partial"]); + expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 }); + }); + + 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.stop(); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["run_stopped"]); + expect(events.some((event) => event.type === "run_completed")).toBe(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; + + 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.stop(); + await flushNotifications(); + + 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("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; + 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" + }]; + + state.finishRun(); + 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", "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((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((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((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((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((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((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((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()); + }); +}); diff --git a/tests/notify.test.ts b/tests/notify.test.ts index e398714..a084e0b 100644 --- a/tests/notify.test.ts +++ b/tests/notify.test.ts @@ -52,26 +52,66 @@ 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)" }); - 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"); - }); - 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"); - }); - 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"); - }); +describe("buildNotifyRequest", () => { + 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).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>"); + 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(""); + }); + 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); + }); }); describe("sendNotification", () => { @@ -104,20 +144,20 @@ describe("sendNotification", () => { await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); expect(fetchFn).toHaveBeenCalledTimes(1); }); - 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); - return new Response(null, { status: 204 }); - }); + 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)).embeds[0].title); + return new Response(null, { status: 204 }); + }); const sends = [ sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep), sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep), 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"]); - }); + ]; + await expect(Promise.all(sends)).resolves.toEqual([true, true, true]); + expect(order).toEqual(["1", "2", "3"]); + }); it("does not call fetch for an invalid URL", async () => { const fetchFn = vi.fn(); await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); diff --git a/tests/online-backup.test.ts b/tests/online-backup.test.ts index f96710b..cdab8f5 100644 --- a/tests/online-backup.test.ts +++ b/tests/online-backup.test.ts @@ -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"]); diff --git a/tests/package-telemetry.test.ts b/tests/package-telemetry.test.ts new file mode 100644 index 0000000..9cbd4b0 --- /dev/null +++ b/tests/package-telemetry.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/tests/provider-daily-limits.test.ts b/tests/provider-daily-limits.test.ts index 21f0e07..b425dbb 100644 --- a/tests/provider-daily-limits.test.ts +++ b/tests/provider-daily-limits.test.ts @@ -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 }); + }); +}); diff --git a/tests/provider-error-classification.test.ts b/tests/provider-error-classification.test.ts index 05c5aba..72be0ef 100644 --- a/tests/provider-error-classification.test.ts +++ b/tests/provider-error-classification.test.ts @@ -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); + }); }); diff --git a/tests/remaining-notification.test.ts b/tests/remaining-notification.test.ts new file mode 100644 index 0000000..686b92c --- /dev/null +++ b/tests/remaining-notification.test.ts @@ -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 { + return { + remainingBytes: 51 * GIB, + openItems: 2, + openPackages: 1, + unknownCount: 0, + finalizingItems: 0, + speedBps: 1024 ** 2, + etaSeconds: 51 * 1024, + ...overrides + }; +} + +function setupManager(settings: Partial = {}, session = emptySession()): { + manager: DownloadManager; + session: ReturnType; + 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, + 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 { + await new Promise((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((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); + }); +}); diff --git a/tests/renderer-settings-form.test.tsx b/tests/renderer-settings-form.test.tsx new file mode 100644 index 0000000..26ce0c8 --- /dev/null +++ b/tests/renderer-settings-form.test.tsx @@ -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"]); + }); +}); diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts index d669258..6dbdd35 100644 --- a/tests/renderer-settings.test.ts +++ b/tests/renderer-settings.test.ts @@ -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"); + }); }); diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts index 23d9aae..9eab3e7 100644 --- a/tests/renderer-state.test.ts +++ b/tests/renderer-state.test.ts @@ -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"; diff --git a/tests/session-restart-loss.test.ts b/tests/session-restart-loss.test.ts index 39bb814..fbefcd9 100644 Binary files a/tests/session-restart-loss.test.ts and b/tests/session-restart-loss.test.ts differ diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 2fe472c..43db7ad 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -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"); diff --git a/tests/statistics-ledger.test.ts b/tests/statistics-ledger.test.ts index 9e01e85..8772b5d 100644 --- a/tests/statistics-ledger.test.ts +++ b/tests/statistics-ledger.test.ts @@ -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 = { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 96ce2da..ff93e6f 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -6,10 +6,10 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; -import { AppSettings } from "../src/shared/types"; +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): 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; delete legacy.animatePackageDisclosure; @@ -928,7 +1155,7 @@ describe("settings storage", () => { expect(normalizedDisabled.allDebridUseWebLogin).toBe(false); }); - it("defaults history retention to permanent and normalizes invalid values", () => { + it("defaults history retention to permanent and normalizes invalid values", () => { expect(defaultSettings().historyRetentionMode).toBe("permanent"); const normalized = normalizeSettings({ @@ -936,9 +1163,269 @@ describe("settings storage", () => { historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"] }); - expect(normalized.historyRetentionMode).toBe("permanent"); - }); - + 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); diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts index bc3cf31..148d5ca 100644 --- a/tests/support-bundle.test.ts +++ b/tests/support-bundle.test.ts @@ -2,9 +2,12 @@ import fs from "node:fs"; import os from "node:os"; 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 type { DownloadManager } from "../src/main/download-manager"; +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 { - const snapshot = { - stats: {}, - session: { packages: {}, items: {}, packageOrder: [] }, +function fakeManager(session: SessionState = emptySession()): DownloadManager { + const snapshot = { + stats: {}, + session, speedText: "", etaText: "", canStart: false, @@ -60,7 +63,7 @@ describe("buildSupportBundle (async, non-blocking)", () => { expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); }); - it("does not block the event loop while building (a concurrent timer still fires)", async () => { + it("does not block the event loop while building (a concurrent timer still fires)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); tempDirs.push(root); @@ -69,6 +72,99 @@ describe("buildSupportBundle (async, non-blocking)", () => { await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); clearTimeout(timer); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(timerFired).toBe(true); - }); -}); + 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 = { + 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/); + }); +}); diff --git a/tests/support-data.test.ts b/tests/support-data.test.ts index bd580ae..dca1806 100644 --- a/tests/support-data.test.ts +++ b/tests/support-data.test.ts @@ -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 }; + downloadHealthMonitor: { getState: () => Record }; + }; + 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[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[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 }); + } + }); }); diff --git a/tests/visual-fixtures.test.ts b/tests/visual-fixtures.test.ts index 3342624..2a72b96 100644 --- a/tests/visual-fixtures.test.ts +++ b/tests/visual-fixtures.test.ts @@ -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); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index afbcd5c..94e1fd6 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -74,9 +74,10 @@ function createSettings(): AppSettings { megaDebridPreferApi: true, bestToken: "visual-best-debrid-token", bestDebridUseWebLogin: false, - allDebridToken: "visual-all-debrid-token", - allDebridUseWebLogin: false, - ddownloadLogin: "visual-ddownload", + allDebridToken: "visual-all-debrid-token", + allDebridUseWebLogin: false, + deepbridApiKey: "visual-deepbrid-key", + ddownloadLogin: "visual-ddownload", ddownloadPassword: "visual-password", oneFichierApiKey: "visual-onefichier-key", debridLinkApiKeys, @@ -136,9 +137,16 @@ function createSettings(): AppSettings { backupIncludeRemoteDiagnostics: false, notifyUrl: "https://example.test/visual-webhook", notifyMention: "@visual", - notifyOnPackageCompleted: true, - notifyOnPackageFailed: true, - notifyOnRunFinished: true, + 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, @@ -232,8 +240,14 @@ function createSettings(): AppSettings { checkedAt: 1786312800000 } }, - providerDailyUsageDay: "2026-08-10", - scheduledStartEpochMs: 0 + providerDailyUsageDay: "2026-08-10", + dailyStartEnabled: false, + dailyStartMinuteOfDay: 0, + dailyStartFirstLocalDate: "", + dailyStartLastHandledLocalDate: "", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "", + scheduledStartEpochMs: 0 }; }