feat(notifications): alert on remaining volume
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user