import { createHash } from "node:crypto"; import type { DebridProvider, HistoryEntry, PackageResult, PackageResultStatus } from "../shared/types"; import type { NotificationEvent, NotificationEventType, NotificationPriority } from "./notification-outbox"; 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; } 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 + (priority === "success" ? SUCCESS_TTL_MS : IMPORTANT_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) { fields.push({ name: "Fehler", value: `${failurePhaseLabel(result)}${result.errorCategory ? ` · ${result.errorCategory.slice(0, 256)}` : ""}`, 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: packages.reduce((total, result) => total + result.archiveOperations.filter((operation) => operation.status === "failed").length, 0), remuxFailures: packages.reduce((total, result) => total + result.remuxOperations.filter((operation) => operation.status === "failed").length, 0), downloadFailures: packages.filter((result) => result.failurePhase === "download").reduce((total, result) => total + result.failedFiles, 0), offlineFailures: packages.filter((result) => /offline|not found|nicht gefunden/i.test(result.errorCategory)).reduce((total, result) => total + result.failedFiles, 0) }; } 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 } ] ); } 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 })) }; }