feat(notifications): alert on remaining volume

This commit is contained in:
Sucukdeluxe
2026-08-22 06:42:13 +02:00
parent f4c4fb2fac
commit 2142ae8bf0
4 changed files with 541 additions and 4 deletions
+87 -4
View File
@@ -97,9 +97,13 @@ import {
buildHistoryEntry,
buildPackageDigestEvents,
buildPackageNotificationEvent,
buildRemainingThresholdNotificationEvent,
buildRunNotificationEvent,
buildRunResult,
type PackageResultEnvelope
evaluateRemainingThreshold,
type PackageResultEnvelope,
type RemainingThresholdState,
type RunRemainingSnapshot
} from "./notification-events";
type ActiveTask = {
@@ -471,6 +475,7 @@ type RunLifecycleContext = {
startedAt: number;
packageGenerations: Map<string, number>;
downloadsFinished: boolean;
remainingNotification: RemainingThresholdState;
};
function generateHistoryId(): string {
@@ -6859,8 +6864,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;
@@ -11757,7 +11763,13 @@ export class DownloadManager extends EventEmitter {
for (const packageId of packageIds) {
packageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations, downloadsFinished };
const context: RunLifecycleContext = {
id: uuidv4(),
startedAt,
packageGenerations,
downloadsFinished,
remainingNotification: { snapshot: null, crossings: 0 }
};
this.runContexts.set(context.id, context);
return context;
}
@@ -11866,6 +11878,11 @@ export class DownloadManager extends EventEmitter {
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;
}
@@ -11888,6 +11905,72 @@ export class DownloadManager extends EventEmitter {
});
}
private buildRunRemainingSnapshot(): RunRemainingSnapshot {
const openPackages = new Set<string>();
let remainingBytes = 0;
let openItems = 0;
let unknownCount = 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;
}
openItems += 1;
openPackages.add(pkg.id);
if (item.totalBytes === null) {
unknownCount += 1;
} else {
remainingBytes += Math.max(0, item.totalBytes - item.downloadedBytes);
}
}
const speedBps = !this.session.running || this.session.paused
? 0
: Math.max(0, Math.floor(this.speedBytesLastWindow / SPEED_WINDOW_SECONDS));
const etaSeconds = unknownCount === 0 && speedBps > 0
? Math.ceil(remainingBytes / speedBps)
: remainingBytes === 0 && unknownCount === 0
? 0
: -1;
return {
remainingBytes,
openItems,
openPackages: openPackages.size,
unknownCount,
speedBps,
etaSeconds
};
}
private evaluateRemainingNotification(): void {
const context = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined;
if (!context || context.downloadsFinished) {
return;
}
const current = this.buildRunRemainingSnapshot();
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);
+63
View File
@@ -62,6 +62,25 @@ export interface HistoryEntryContext {
provider: DebridProvider | null;
}
export interface RunRemainingSnapshot {
remainingBytes: number;
openItems: number;
openPackages: number;
unknownCount: 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;
@@ -73,6 +92,25 @@ function finiteNonNegative(value: unknown): number {
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.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"];
@@ -285,6 +323,31 @@ export function buildRunNotificationEvent(result: RunResult): NotificationEvent
);
}
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