feat(notifications): finalize package and run results
This commit is contained in:
@@ -182,8 +182,9 @@ 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);
|
||||
}
|
||||
@@ -1295,6 +1296,10 @@ export class AppController {
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
const notificationFlush = this.manager.flushNotificationsForShutdown?.();
|
||||
if (notificationFlush) {
|
||||
await notificationFlush;
|
||||
}
|
||||
await this.notificationOutbox.drainForShutdown(3000).catch((error) => {
|
||||
logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`);
|
||||
});
|
||||
|
||||
+553
-230
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 }))
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
<dl className="history-details-grid">
|
||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
|
||||
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
|
||||
{row.hasStructuredLifecycle ? (
|
||||
<>
|
||||
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
|
||||
<div><dt>Download beendet</dt><dd>{row.downloadEndedLabel}</dd></div>
|
||||
<div><dt>Nachbearbeitung gestartet</dt><dd>{row.postProcessStartedLabel}</dd></div>
|
||||
<div><dt>Abgeschlossen</dt><dd>{row.completedLabel}</dd></div>
|
||||
<div><dt>Downloaddauer</dt><dd>{row.downloadDurationLabel}</dd></div>
|
||||
<div><dt>Entpackdauer</dt><dd>{row.extractionDurationLabel}</dd></div>
|
||||
<div><dt>Remuxdauer</dt><dd>{row.remuxDurationLabel}</dd></div>
|
||||
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
|
||||
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
|
||||
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
|
||||
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
|
||||
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
|
||||
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
|
||||
</>
|
||||
) : (
|
||||
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
|
||||
)}
|
||||
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
|
||||
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
|
||||
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
|
||||
</dl>
|
||||
{row.hasStructuredLifecycle ? (
|
||||
<div className="history-operation-groups">
|
||||
<section className="history-operation-group">
|
||||
<h3>Archivvorgänge</h3>
|
||||
{row.archiveOperations?.length ? (
|
||||
<ul>
|
||||
{row.archiveOperations.map((operation) => (
|
||||
<li key={operation.id}>
|
||||
<strong>{operation.name}</strong>
|
||||
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p>Keine Archivvorgänge</p>}
|
||||
</section>
|
||||
<section className="history-operation-group">
|
||||
<h3>Remuxvorgänge</h3>
|
||||
{row.remuxOperations?.length ? (
|
||||
<ul>
|
||||
{row.remuxOperations.map((operation) => (
|
||||
<li key={operation.id}>
|
||||
<strong>{operation.fileName}</strong>
|
||||
<span>{formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p>Keine Remuxvorgänge</p>}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
@@ -147,7 +156,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);
|
||||
@@ -158,6 +167,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));
|
||||
@@ -234,7 +256,11 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
|
||||
return hostnames.length > 0 ? hostnames.join(", ") : "—";
|
||||
}
|
||||
|
||||
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
|
||||
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds" | "startedAt">): 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);
|
||||
@@ -244,19 +270,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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -611,6 +611,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: "disk_full"
|
||||
});
|
||||
expect(pkg.remuxOperations?.[0].completedAt).toBeGreaterThanOrEqual(pkg.remuxOperations?.[0].startedAt || 0);
|
||||
expect(pkg.remuxOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11035,10 +11043,19 @@ 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);
|
||||
}, 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-"));
|
||||
@@ -14516,7 +14533,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<typeof emptySession>;
|
||||
@@ -14586,7 +14603,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"],
|
||||
@@ -14595,6 +14612,72 @@ 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("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);
|
||||
});
|
||||
});
|
||||
|
||||
+105
-3
@@ -185,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("—");
|
||||
@@ -194,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"]);
|
||||
@@ -597,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(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel([structured], "all", "", [], [structured.id], false, "", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel([legacy], "all", "", [], [legacy.id], false, "", now)}
|
||||
/>
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
+360
-153
@@ -1,153 +1,360 @@
|
||||
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<typeof import("../src/main/notify")>();
|
||||
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<typeof vi.fn>;
|
||||
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<typeof emptySession> } {
|
||||
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<typeof emptySession>, 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 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<AppSettings> = {}): {
|
||||
manager: DownloadManager;
|
||||
session: ReturnType<typeof emptySession>;
|
||||
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<typeof emptySession>,
|
||||
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<void> {
|
||||
await new Promise<void>((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("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("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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authoritative run completion", () => {
|
||||
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("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.packageResultGenerations = new Map([[pkg.id, 1]]);
|
||||
state.runPackageGenerations = new Map([[pkg.id, 1]]);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user