From 1b7caba2eb4e378f1fe11b9f7933bd621cfb4bb1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 24 Aug 2026 07:37:55 +0200 Subject: [PATCH] feat: add Deepbrid, daily scheduling, and notification center Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery. --- CHANGELOG.md | 25 + README.md | 6 +- THIRD_PARTY_NOTICES.md | 3 +- assets/provider-icons/deepbrid.png | Bin 0 -> 2105 bytes package-lock.json | 4 +- package.json | 2 +- scripts/verify_public_release.mjs | 2 +- src/main/account-check.ts | 63 +- src/main/account-commands.ts | 15 +- src/main/account-status-sanitizer.ts | 1 + src/main/app-controller.ts | 179 +- src/main/constants.ts | 36 +- src/main/credential-protection.ts | 1 + src/main/daily-start-scheduler.ts | 263 +++ src/main/debrid.ts | 34 +- src/main/debug-server.ts | 48 +- src/main/deepbrid.ts | 394 ++++ src/main/download-health-monitor.ts | 532 +++++ src/main/download-manager.ts | 1805 ++++++++++++----- src/main/main.ts | 152 +- src/main/notification-events.ts | 393 ++++ src/main/notification-outbox.ts | 444 ++++ src/main/notify.ts | 126 +- src/main/package-telemetry.ts | 201 ++ src/main/renderer-settings.ts | 20 +- src/main/renderer-state.ts | 24 +- src/main/settings-live-overlay.ts | 5 +- src/main/statistics-ledger.ts | 2 + src/main/storage.ts | 313 ++- src/main/support-bundle.ts | 17 +- src/main/support-data.ts | 88 +- src/renderer/App.tsx | 268 ++- src/renderer/account-edit.ts | 2 +- src/renderer/account-service-icons.ts | 1 + src/renderer/download-format.ts | 3 +- src/renderer/hoster-icons.ts | 1 + src/renderer/i18n.ts | 7 +- .../views/downloads/DownloadsView.tsx | 46 +- src/renderer/views/downloads/downloads.css | 34 +- src/renderer/views/history/HistoryView.tsx | 57 +- src/renderer/views/history/history-model.ts | 60 +- src/renderer/views/history/history.css | 82 +- src/renderer/views/settings/settings-model.ts | 47 +- .../views/statistics/statistics-model.ts | 1 + src/shared/types.ts | 182 +- tests/account-check.test.ts | 136 +- tests/account-commands.test.ts | 132 ++ tests/account-edit.test.ts | 19 + tests/account-service-icons.test.ts | 5 + tests/app-controller.test.ts | 258 +++ tests/backup-payload.test.ts | 20 +- tests/credential-protection.test.ts | 26 + tests/daily-start-scheduler.test.ts | 416 ++++ tests/debrid.test.ts | 205 ++ tests/debug-server.test.ts | 68 +- tests/deepbrid.test.ts | 516 +++++ tests/download-health-monitor.test.ts | 536 +++++ tests/download-manager.test.ts | 661 +++++- tests/downloads-view.test.tsx | 79 +- tests/history-reveal.test.ts | 7 + tests/history-view.test.tsx | 125 +- tests/hoster-metadata.test.ts | 2 +- tests/i18n.test.ts | 34 + tests/main-shutdown-lifecycle.test.ts | 381 ++++ tests/notification-outbox.test.ts | 973 +++++++++ tests/notify-hooks.test.ts | 1043 ++++++++-- tests/notify.test.ts | 100 +- tests/online-backup.test.ts | 2 + tests/package-telemetry.test.ts | 412 ++++ tests/provider-daily-limits.test.ts | 15 + tests/provider-error-classification.test.ts | 13 +- tests/remaining-notification.test.ts | 503 +++++ tests/renderer-settings-form.test.tsx | 127 ++ tests/renderer-settings.test.ts | 78 +- tests/renderer-state.test.ts | 12 + tests/session-restart-loss.test.ts | Bin 12682 -> 13958 bytes tests/settings-view.test.tsx | 151 +- tests/statistics-ledger.test.ts | 15 + tests/storage.test.ts | 499 ++++- tests/support-bundle.test.ts | 118 +- tests/support-data.test.ts | 134 +- tests/visual-fixtures.test.ts | 27 + tests/visual/fixtures.ts | 30 +- 83 files changed, 12769 insertions(+), 1098 deletions(-) create mode 100644 assets/provider-icons/deepbrid.png create mode 100644 src/main/daily-start-scheduler.ts create mode 100644 src/main/deepbrid.ts create mode 100644 src/main/download-health-monitor.ts create mode 100644 src/main/notification-events.ts create mode 100644 src/main/notification-outbox.ts create mode 100644 src/main/package-telemetry.ts create mode 100644 tests/app-controller.test.ts create mode 100644 tests/daily-start-scheduler.test.ts create mode 100644 tests/deepbrid.test.ts create mode 100644 tests/download-health-monitor.test.ts create mode 100644 tests/main-shutdown-lifecycle.test.ts create mode 100644 tests/notification-outbox.test.ts create mode 100644 tests/package-telemetry.test.ts create mode 100644 tests/remaining-notification.test.ts create mode 100644 tests/renderer-settings-form.test.tsx 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 0000000000000000000000000000000000000000..6f05149794299b0809ce6cfac30682048f911813 GIT binary patch literal 2105 zcmc&zc{r3?9RAIWC1aO0B3)Yxjd2M{)Qn7+GH4NnED^E{F~UsZT1r&uyXq;Nr`9)#ISS^4hZ@}ygfB<%KplBTS z3LsSgdj#Mo03QKFwm=LQ=r=x?LHvB|AH98^{+9{j>UUA3@UL}aTmD%3jQJmuPp*)o z`5yJ}gnjv`Z&UuXEngD9C*S$W|KEVWvGk$Xoy}jA{2nY22o!6KGLbv{m`Wi#y}!)= zu3?0PMTEp=84%q<6EcsSK3`T*elBk(MO*yJ?WYs(rU%ZrNO`=Oykv!9KK92*)V~Tw z>8)R9qR~6D%Hr-@FSC~oirICp0j#LDsfW)^HA(jR=pP+ysg>IpS<+W)bj;>?N<=<* z!G9EnT`<%WQHYW(;>cwrZn|D_p*d}4i?uKMB!Mj}Gkb1`u28CqJnxiR^nv^*fiu+O zSB@!EHPX<>mG9rW<9+Zf`Wd|)mGf)o<-;~8Q6*LNb@QrTB88&7>{b$n8}BF@AQMq4 z$$=<3jFFKMkysQp(FuTYBN42fZ5v{<{Wi|DmQP%_dM&j+HMMFY812O}z|If~OqMLw zmSyb{u21q17RTPQsu!6*KQ&oWb$(=BPt4u;eiQt~JO>VcaK%W~XwS{u+{>QI?k=aX z7;#6Dey6>mn$9=QR`LcPJnODVdYu>^?l{_3BA_#>6GWs{%mZI1XT%lSOp8XP>!9xlnPmo9I@hjSIRFLy5({nm!*qn z15#-|Y%{t?T$r46z}t!1iZO=GNs_V<_JT3>D*HAlW)&!m&2KDstuhKS z{^5E@pW>J~xYl;`J-pUi+wEj@!A&ywT~Ka0gJIHSJ$iWS6O}6^A+EvIYtUn@Xftwe zu!iMHwAA*<{K?~+s~>sS`5na$K#pY+F}JKkv!q&n3TP|Taqj7j`iqvnG#-6gkrTKv zvV}LgtTldoTKFyPWCq@Nf7~OrX@14)%`eiJc=FWZw!BaS&mgRBP1cmD<@7RzLSeKx z&9NK#^|YzP^;B<9s*m}WFdrlU9)~y4#~JAxnm8MpnB$Ocv;c=Q$Khtk*h_vz2-@!L v?;HKkglLDcQiP!QS%K;w { + 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 39bb814601160d26298c32d9183ee94429479450..fbefcd91dd923a35342833774832b8bce24c5d2c 100644 GIT binary patch delta 1202 zcmb_b!EVz)5EZ!~1tGzq5*MVAB_u~AArS}_M=9!|<<_S3SQWI`<75?Q*WDdAiK56i z4u}i#XK+F4l~3Rw_yph&*tH$kDRAQwM{maC_ujnu;J=@JIca3C8!xhtjh9cFd=^VZ zu!18=JmfwjF2%tm3Tkgb#|gcYi|psEogGo@?8qE<23Ox2KW>kj0GwP$p)W5{X<#A% zjUq7rAT|KkgaOnC01F8fDAdFhIc;EiDSU8SFx-Px_UrEM!&wk?>aTRl=8n^94YK?2 zAUnDDI5nRJ@TJfM7YQa9utv2BIbs(xt)2xl>A52|i~ z3)(N>n}cGuj3hJES3sO1%W=fgDH4P@iN>h?Nn`{GV`j)P3e4EEb6Dr9)`MovsE{j7 zBPD&*+Snp+zGYXjb9dubX!MU5O+1LrukO}&a-8NlI5aIAy>{>3);dWgRaMBjS8mxl zQIKm5@+X@!Zp75cby4I?b!)3N%{3`$fDt#DL-Wm}T0rxa8~H=yK{36^NSPrF{*U}5 zwde6q`DhQSh;+``X0;;RjT1d_iE8x8l(1l03LbSmd77EIuzsmfe5M93!0)qeQ!{SJx9(L&P+lCcaHrmE#;d fH0P8kv|z8~ZWUx-n{Uk3f6Aou^y>Sa&yRis9$$cI delta 55 zcmZq6?MmJd$i&DyIgrT`NOm#VGxBa$Vt&s)SwTW<@;QFb$(DLNljlntOx6+&pPZv^ KwE4EcYE=Nx`w?XT 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 }; }