docs: document notification center v2
This commit is contained in:
@@ -4,6 +4,16 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Notification Center
|
||||||
|
|
||||||
|
- Added rich Discord embeds for final package results, run summaries, remaining-volume alerts, and confirmed download stalls and recoveries.
|
||||||
|
- Wait for downloads, extraction, remuxing, cleanup, and deferred post-processing before reporting a package or run as finished.
|
||||||
|
- Persist pending notifications across restarts with ordered delivery, bounded retries, rate-limit handling, expiration, and a controlled shutdown drain.
|
||||||
|
- Support immediate package notifications or two-minute success digests while always delivering enabled failure notifications immediately.
|
||||||
|
- Added configurable run-scoped remaining-volume, download-stall, cooldown, and recovery alerts that ignore expected waits and short self-healing interruptions.
|
||||||
|
- Expanded History with separate download, extraction, remux, post-processing, and total durations plus final file, archive, part, output, and failure-phase details.
|
||||||
|
- Keep webhook details, local paths, account data, and credentials out of persisted notification events and failure messages.
|
||||||
|
|
||||||
## [2.0.55] - 2026-08-22
|
## [2.0.55] - 2026-08-22
|
||||||
|
|
||||||
### 1Fichier imports
|
### 1Fichier imports
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox";
|
import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox";
|
||||||
import { buildPackageNotificationEvent } from "../src/main/notification-events";
|
import { buildPackageNotificationEvent } from "../src/main/notification-events";
|
||||||
import { buildNotifyRequest, sendNotification } from "../src/main/notify";
|
import { buildNotifyRequest, sendNotification } from "../src/main/notify";
|
||||||
import type { PackageResult } from "../src/shared/types";
|
import { finalizePackageResult } from "../src/main/package-telemetry";
|
||||||
|
import type { DownloadItem, PackageEntry, PackageResult, PackageTelemetry, RemuxOperationMetric } from "../src/shared/types";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@@ -45,6 +46,73 @@ function persisted(filePath: string): { events: NotificationEvent[]; lastSuccess
|
|||||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as { 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", () => {
|
describe("NotificationOutbox", () => {
|
||||||
it("sends due events serially in stable enqueue order and removes each success", async () => {
|
it("sends due events serially in stable enqueue order and removes each success", async () => {
|
||||||
const filePath = createOutboxFile();
|
const filePath = createOutboxFile();
|
||||||
@@ -338,6 +406,36 @@ describe("NotificationOutbox", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("projects private failure details from an existing outbox before persisting again", async () => {
|
||||||
const filePath = createOutboxFile();
|
const filePath = createOutboxFile();
|
||||||
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
|
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
|
||||||
@@ -366,6 +464,54 @@ describe("NotificationOutbox", () => {
|
|||||||
expect(persistedText).not.toContain("token=SUPERSECRET");
|
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("returns after the default three-second shutdown budget when sending hangs", async () => {
|
it("returns after the default three-second shutdown budget when sending hangs", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const filePath = createOutboxFile();
|
const filePath = createOutboxFile();
|
||||||
|
|||||||
Reference in New Issue
Block a user