feat(notifications): finalize package and run results

This commit is contained in:
Sucukdeluxe
2026-08-22 05:08:48 +02:00
parent c7e48891bb
commit 03da04b629
9 changed files with 1614 additions and 416 deletions
+5
View File
@@ -184,6 +184,7 @@ export class AppController {
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
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)}`);
});
+419 -96
View File
@@ -14,8 +14,10 @@ import {
DownloadStatus,
DuplicatePolicy,
HistoryEntry,
ArchiveOperationMetric,
PackageEntry,
PackagePriority,
PackageResult,
ParsedPackageInput,
SessionState,
StatisticsLedger,
@@ -59,11 +61,10 @@ function releaseTlsSkip(): void {
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor";
import { sendNotification } from "./notify";
import { logger } from "./logger";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import { createAccountRuntimeEntries } from "./account-runtime-snapshot";
@@ -90,6 +91,16 @@ import {
saveStatisticsLedger,
seedStatisticsDayProviderBytes
} from "./statistics-ledger";
import { finalizePackageResult } from "./package-telemetry";
import type { NotificationEvent } from "./notification-outbox";
import {
buildHistoryEntry,
buildPackageDigestEvents,
buildPackageNotificationEvent,
buildRunNotificationEvent,
buildRunResult,
type PackageResultEnvelope
} from "./notification-events";
type ActiveTask = {
itemId: string;
@@ -451,9 +462,16 @@ type DownloadManagerOptions = {
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void;
onHistoryEntry?: HistoryEntryCallback;
enqueueNotification?: (event: NotificationEvent) => Promise<void>;
protectEmptyClobber?: boolean;
};
type PendingRunResult = {
id: string;
startedAt: number;
packageGenerations: Map<string, number>;
};
function generateHistoryId(): string {
return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
@@ -1918,7 +1936,19 @@ export class DownloadManager extends EventEmitter {
private historyRecordedPackages = new Set<string>();
private notifiedPackages = new Set<string>();
private packageResultGenerations = new Map<string, number>();
private finalizedPackageResults = new Map<string, PackageResult>();
private runPackageGenerations = new Map<string, number>();
private pendingRunResult: PendingRunResult | null = null;
private successDigestResults = new Map<string, PackageResultEnvelope>();
private successDigestTimer: NodeJS.Timeout | null = null;
private notificationEnqueueChain: Promise<void> = Promise.resolve();
private itemCount = 0;
@@ -1963,6 +1993,8 @@ export class DownloadManager extends EventEmitter {
private onHistoryEntryCallback?: HistoryEntryCallback;
private enqueueNotificationCallback?: (event: NotificationEvent) => Promise<void>;
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
super();
this.settings = settings;
@@ -1991,6 +2023,7 @@ export class DownloadManager extends EventEmitter {
});
this.invalidateMegaSessionFn = options.invalidateMegaSession;
this.onHistoryEntryCallback = options.onHistoryEntry;
this.enqueueNotificationCallback = options.enqueueNotification;
logger.info(`DownloadManager Init: ${Object.keys(this.session.packages).length} Pakete, ${this.itemCount} Items, cleanupPolicy=${this.settings.completedCleanupPolicy}`);
for (const pkg of Object.values(this.session.packages)) {
this.ensurePackageLogForPackage(pkg);
@@ -3089,7 +3122,15 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.historyRecordedPackages.clear();
this.notifiedPackages.clear();
this.packageResultGenerations.clear();
this.finalizedPackageResults.clear();
this.runPackageGenerations.clear();
this.pendingRunResult = null;
this.successDigestResults.clear();
if (this.successDigestTimer) {
clearTimeout(this.successDigestTimer);
this.successDigestTimer = null;
}
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
@@ -3199,6 +3240,7 @@ export class DownloadManager extends EventEmitter {
if (this.session.running) {
this.runItemIds.add(itemId);
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, this.beginPackageResultGeneration(packageId));
}
if (looksLikeOpaqueFilename(fileName)) {
const existing = unresolvedByLink.get(link) ?? [];
@@ -4400,6 +4442,7 @@ export class DownloadManager extends EventEmitter {
if (this.packageFileOpChain.get(pkgId) === result) {
this.packageFileOpChain.delete(pkgId);
}
this.tryFinalizePackageResult(pkgId);
});
}
@@ -4517,6 +4560,7 @@ export class DownloadManager extends EventEmitter {
return processed;
}
const sourceName = path.basename(sourcePath);
const remuxStartedAt = nowMs();
let result: VideoProcessResult | null = null;
let remuxLease: DiskReservationLease | null = null;
try {
@@ -4551,6 +4595,18 @@ export class DownloadManager extends EventEmitter {
result = { action: "error", reason: "exception", error: "Unbekannter Remux-Status" };
}
if (result.action === "aborted") {
if (pkg) {
const completedAt = nowMs();
pkg.remuxOperations = [...(pkg.remuxOperations || []), {
id: uuidv4(),
fileName: sourceName,
startedAt: remuxStartedAt,
completedAt,
durationMs: Math.max(0, completedAt - remuxStartedAt),
status: "cancelled",
errorCategory: "aborted"
}];
}
return processed;
}
const langs = (result.audioLanguages || []).join(",");
@@ -4595,6 +4651,24 @@ export class DownloadManager extends EventEmitter {
if (result.action === "remuxed" || result.action === "kept-single") {
await this.stripDualLangFromFileName(sourcePath, pkg);
}
if (pkg) {
const completedAt = nowMs();
const failedOperation = result.action === "error" || result.action === "skipped-no-space";
const errorCategory = result.action === "skipped-no-space"
? "disk_full"
: failedOperation
? compactErrorText(result.error || result.reason || "remux").slice(0, 256)
: "";
pkg.remuxOperations = [...(pkg.remuxOperations || []), {
id: uuidv4(),
fileName: result.action === "remuxed" || result.action === "kept-single" ? stripDualLangMarker(sourceName) : sourceName,
startedAt: remuxStartedAt,
completedAt,
durationMs: Math.max(0, completedAt - remuxStartedAt),
status: failedOperation ? "failed" : "completed",
errorCategory
}];
}
}
writeSummary();
logger.info(`Tonspur-Bereinigung fertig: ${processed} verarbeitet, ${failed} Fehler von ${targets.length} Kandidaten in ${extractDir}`);
@@ -5701,17 +5775,16 @@ export class DownloadManager extends EventEmitter {
pkg.cleanedTotalBytes = 0;
pkg.cleanedUrls = [];
pkg.cleanedProviders = [];
pkg.downloadStartedAt = 0;
pkg.downloadCompletedAt = 0;
this.beginPackageResultGeneration(packageId, true);
pkg.updatedAt = nowMs();
this.historyRecordedPackages.delete(packageId);
this.notifiedPackages.delete(packageId);
if (this.session.running) {
for (const itemId of itemIds) {
this.runItemIds.add(itemId);
}
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
await Promise.allSettled(postProcessTasks);
@@ -5781,7 +5854,6 @@ export class DownloadManager extends EventEmitter {
for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task);
this.runCompletedPackages.delete(pkgId);
this.historyRecordedPackages.delete(pkgId);
this.notifiedPackages.delete(pkgId);
const pkg = this.session.packages[pkgId];
if (pkg) {
@@ -5789,11 +5861,13 @@ export class DownloadManager extends EventEmitter {
pkg.postProcessLabel = undefined;
pkg.audioStripSummary = undefined;
pkg.downloadCompletedAt = 0;
this.beginPackageResultGeneration(pkgId, false, true);
this.refreshPackageStatus(pkg);
pkg.updatedAt = nowMs();
}
if (this.session.running) {
this.runPackageIds.add(pkgId);
this.runPackageGenerations.set(pkgId, this.getPackageResultGeneration(pkgId));
}
}
@@ -5916,6 +5990,7 @@ export class DownloadManager extends EventEmitter {
if (item.status === "queued" || item.status === "reconnect_wait") {
this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId);
this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId));
}
}
this.persistSoon();
@@ -5939,6 +6014,8 @@ export class DownloadManager extends EventEmitter {
this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6021,6 +6098,7 @@ export class DownloadManager extends EventEmitter {
if (item.status === "queued" || item.status === "reconnect_wait") {
this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId);
this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId));
}
}
this.persistSoon();
@@ -6045,6 +6123,8 @@ export class DownloadManager extends EventEmitter {
this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6139,6 +6219,8 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.clear();
this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.session.running = true;
@@ -6158,6 +6240,8 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.clear();
this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6189,6 +6273,8 @@ export class DownloadManager extends EventEmitter {
this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6286,18 +6372,30 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs();
}
}
// A manual stop ends the run without ever reaching finishRun (the scheduler
// loop exits at its while condition), so the run summary would be lost.
// Suppressed for the restart/shutdown path: the process is about to die.
if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) {
const outcomes = Array.from(this.runOutcomes.values());
const success = outcomes.filter((s) => s === "completed").length;
const failed = outcomes.filter((s) => s === "failed").length;
void sendNotification(this.settings.notifyUrl, {
title: "⏹️ Durchlauf gestoppt",
message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`,
mention: this.settings.notifyMention
});
const cancelled = outcomes.filter((s) => s === "cancelled").length;
this.ensureRunPackageGenerations(this.runPackageIds);
const packageResults = [...this.runPackageGenerations]
.flatMap(([packageId, generation]) => {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation));
return result ? [result] : [];
});
this.flushPackageSuccessDigest();
this.queueNotificationEvent(buildRunNotificationEvent(buildRunResult({
id: uuidv4(),
stopped: true,
startedAt: this.session.runStartedAt,
completedAt: nowMs(),
packages: packageResults,
totalPackages: this.runPackageIds.size,
successfulFiles: success,
failedFiles: failed,
cancelledFiles: cancelled
})));
this.pendingRunResult = null;
}
this.persistSoon();
this.emitState(true);
@@ -7907,6 +8005,11 @@ export class DownloadManager extends EventEmitter {
const abortController = new AbortController();
this.packagePostProcessAbortControllers.set(packageId, abortController);
const queuedPackage = this.session.packages[packageId];
if (queuedPackage) {
queuedPackage.postProcessQueuedAt = queuedPackage.postProcessQueuedAt || nowMs();
queuedPackage.updatedAt = nowMs();
}
// Holder so the task's own finally can identity-check itself (the task Promise
// cannot reference its own const inside its initializer). Assigned right after.
@@ -7914,6 +8017,11 @@ export class DownloadManager extends EventEmitter {
const task = (async () => {
const slotWaitStart = nowMs();
await this.acquirePostProcessSlot(packageId);
const startedPackage = this.session.packages[packageId];
if (startedPackage) {
startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs();
startedPackage.updatedAt = nowMs();
}
const slotWaitMs = nowMs() - slotWaitStart;
if (slotWaitMs > 100) {
logger.info(`Post-Process Slot erhalten nach ${(slotWaitMs / 1000).toFixed(1)}s Wartezeit: pkg=${packageId.slice(0, 8)}`);
@@ -7973,6 +8081,8 @@ export class DownloadManager extends EventEmitter {
void this.runPackagePostProcessing(packageId).catch((err) =>
logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`)
);
} else {
this.tryFinalizePackageResult(packageId);
}
}
})();
@@ -8198,11 +8308,9 @@ export class DownloadManager extends EventEmitter {
completedItems: completedItems.length,
targetedItems: targetItems.length
});
// Fresh outcome must notify again (the corrective checkmark after a notified
// extraction failure); unconditional run-membership so the notify guard also
// passes when the retry happens after the run already ended.
this.notifiedPackages.delete(packageId);
const generation = this.beginPackageResultGeneration(packageId, false, true);
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, generation);
this.persistSoon();
this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`));
@@ -8231,8 +8339,9 @@ export class DownloadManager extends EventEmitter {
completedItems: completedItems.length,
targetedItems: targetItems.length
});
this.notifiedPackages.delete(packageId);
const generation = this.beginPackageResultGeneration(packageId, false, true);
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, generation);
this.persistSoon();
this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
@@ -8332,7 +8441,6 @@ export class DownloadManager extends EventEmitter {
}
}
this.historyRecordedPackages.delete(packageId);
this.notifiedPackages.delete(packageId);
this.abortPackagePostProcessing(packageId, "package_removed");
for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId);
@@ -8528,6 +8636,11 @@ export class DownloadManager extends EventEmitter {
return false;
}
public async flushNotificationsForShutdown(): Promise<void> {
this.flushPackageSuccessDigest();
await this.notificationEnqueueChain;
}
private updateStatisticsActivity(now: number): void {
if (this.statisticsActivityAt > 0 && this.statisticsActivityWasActive && now > this.statisticsActivityAt) {
addStatisticsActiveIntervalInPlace(this.statisticsLedger, this.statisticsActivityAt, now);
@@ -9451,6 +9564,7 @@ export class DownloadManager extends EventEmitter {
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
}
this.activeTasks.delete(itemId);
this.tryFinalizePackageResult(packageId);
this.persistSoon();
this.emitState();
});
@@ -11494,10 +11608,10 @@ export class DownloadManager extends EventEmitter {
if (!pkg) {
continue;
}
// The package gets a fresh chance — its next outcome must notify again
// (a recovery success after a notified failure is the message the user
// most wants). History dedup stays untouched (separate semantics).
this.notifiedPackages.delete(packageId);
const generation = this.beginPackageResultGeneration(packageId, false, true);
if (this.runPackageIds.has(packageId)) {
this.runPackageGenerations.set(packageId, generation);
}
this.refreshPackageStatus(pkg);
}
logger.warn(
@@ -11562,38 +11676,208 @@ export class DownloadManager extends EventEmitter {
return /\b0\s*B\b/i.test(item.fullStatus || "");
}
// Once per package and run; trailing post-processing after run-end still
// notifies (runPackageIds keeps the id), startup recovery does not (set empty).
private notifyPackageOutcome(pkg: PackageEntry, kind: "completed" | "failed", detail: string): void {
const url = String(this.settings.notifyUrl || "").trim();
if (!url) {
return;
private packageResultKey(packageId: string, generation: number): string {
return `${packageId}:${generation}`;
}
private getPackageResultGeneration(packageId: string): number {
const existing = this.packageResultGenerations.get(packageId);
if (existing && existing > 0) {
return existing;
}
if (kind === "completed" && !this.settings.notifyOnPackageCompleted) {
return;
}
if (kind === "failed" && !this.settings.notifyOnPackageFailed) {
return;
}
if (!this.session.running && !this.runPackageIds.has(pkg.id)) {
return;
}
if (this.notifiedPackages.has(pkg.id)) {
return;
}
this.notifiedPackages.add(pkg.id);
// Release the dedup marker if the delivery ultimately failed (after the
// sender's own retries), so a later manual re-run can notify again instead
// of a transient outage permanently consuming the once-per-package slot.
void sendNotification(url, {
title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen",
message: `${pkg.name}\n${detail}`,
mention: this.settings.notifyMention
}).then((ok) => {
if (!ok) {
this.notifiedPackages.delete(pkg.id);
this.packageResultGenerations.set(packageId, 1);
return 1;
}
private beginPackageResultGeneration(packageId: string, resetDownloadTelemetry = false, forceReset = false): number {
const current = this.getPackageResultGeneration(packageId);
const currentKey = this.packageResultKey(packageId, current);
const wasFinalized = this.finalizedPackageResults.has(currentKey);
const next = wasFinalized ? current + 1 : current;
this.packageResultGenerations.set(packageId, next);
const pkg = this.session.packages[packageId];
if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) {
if (resetDownloadTelemetry) {
pkg.downloadStartedAt = 0;
pkg.downloadCompletedAt = 0;
pkg.downloadEndedAt = 0;
}
pkg.postProcessQueuedAt = 0;
pkg.postProcessStartedAt = 0;
pkg.postProcessCompletedAt = 0;
pkg.terminalAt = 0;
pkg.archiveOperations = [];
pkg.remuxOperations = [];
pkg.outputCount = 0;
pkg.cleanupErrorCategory = "";
}
return next;
}
private ensureRunPackageGenerations(packageIds: Iterable<string>): void {
for (const packageId of packageIds) {
if (this.runPackageGenerations.has(packageId)) {
continue;
}
const generation = this.getPackageResultGeneration(packageId);
this.runPackageGenerations.set(packageId, generation);
}
}
private queueNotificationEvent(notification: NotificationEvent): void {
if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) {
return;
}
this.notificationEnqueueChain = this.notificationEnqueueChain
.then(() => this.enqueueNotificationCallback?.(notification))
.then(() => undefined)
.catch((error) => {
logger.warn(`Notification konnte nicht eingereiht werden: ${compactErrorText(error)}`);
});
}
private queueSuccessfulPackageResult(envelope: PackageResultEnvelope): void {
const key = this.packageResultKey(envelope.result.packageId, envelope.generation);
this.successDigestResults.set(key, envelope);
if (this.successDigestTimer) {
return;
}
this.successDigestTimer = setTimeout(() => {
this.successDigestTimer = null;
this.flushPackageSuccessDigest();
}, 2 * 60 * 1000);
this.successDigestTimer.unref?.();
}
private flushPackageSuccessDigest(createdAt = nowMs()): void {
if (this.successDigestTimer) {
clearTimeout(this.successDigestTimer);
this.successDigestTimer = null;
}
if (this.successDigestResults.size === 0) {
return;
}
const envelopes = [...this.successDigestResults.values()];
this.successDigestResults.clear();
for (const notification of buildPackageDigestEvents(envelopes, createdAt)) {
this.queueNotificationEvent(notification);
}
}
private getPackageHistoryProvider(pkg: PackageEntry, items: DownloadItem[]): DebridProvider | null {
const providers = [...new Set([
...(pkg.cleanedProviders || []),
...items.map((item) => item.provider).filter(Boolean) as DebridProvider[]
])];
return providers.length === 1 ? providers[0] : null;
}
private hasPackageLifecycleWork(packageId: string): boolean {
if ([...this.activeTasks.values()].some((task) => task.packageId === packageId)) {
return true;
}
return this.packagePostProcessTasks.has(packageId)
|| this.hasDeferredPostProcessPending(packageId)
|| (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0
|| this.packageFileOpChain.has(packageId);
}
private tryFinalizePackageResult(packageId: string): PackageResult | null {
const pkg = this.session.packages[packageId];
if (!pkg || (!this.runPackageIds.has(packageId) && !this.runPackageGenerations.has(packageId))) {
return null;
}
const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
if (items.some((item) => !isFinishedStatus(item.status)) || this.hasPackageLifecycleWork(packageId)) {
return null;
}
const generation = this.runPackageGenerations.get(packageId) || this.getPackageResultGeneration(packageId);
const key = this.packageResultKey(packageId, generation);
const existing = this.finalizedPackageResults.get(key);
if (existing) {
return existing;
}
const completedAt = nowMs();
if (!(pkg.downloadEndedAt || 0)) {
pkg.downloadEndedAt = completedAt;
}
if (!(pkg.postProcessStartedAt || 0) && (pkg.postProcessQueuedAt || 0) > 0) {
pkg.postProcessStartedAt = pkg.postProcessQueuedAt;
}
pkg.postProcessCompletedAt = completedAt;
pkg.terminalAt = completedAt;
const result = finalizePackageResult({
package: pkg,
items,
archiveOperations: pkg.archiveOperations,
remuxOperations: pkg.remuxOperations,
outputCount: pkg.outputCount,
cleanupErrorCategory: pkg.cleanupErrorCategory
});
this.finalizedPackageResults.set(key, result);
pkg.status = result.status === "partial" ? "failed" : result.status;
pkg.updatedAt = completedAt;
if (this.onHistoryEntryCallback) {
this.onHistoryEntryCallback(buildHistoryEntry(result, {
generation,
outputDir: pkg.outputDir,
urls: [...new Set([...(pkg.cleanedUrls || []), ...items.map((item) => item.url).filter(Boolean)])],
provider: this.getPackageHistoryProvider(pkg, items)
}));
}
const envelope = { generation, result };
if (result.status === "completed") {
if (this.settings.notifyOnPackageCompleted) {
if (this.settings.notifyPackageSuccessMode === "individual") {
this.queueNotificationEvent(buildPackageNotificationEvent(envelope, completedAt));
} else {
this.queueSuccessfulPackageResult(envelope);
}
}
} else if (this.settings.notifyOnPackageFailed) {
this.queueNotificationEvent(buildPackageNotificationEvent(envelope, completedAt));
}
this.persistSoon();
this.emitState();
this.tryFinalizeRunResult();
return result;
}
private tryFinalizeRunResult(): void {
const pending = this.pendingRunResult;
if (!pending) {
return;
}
const packageResults: PackageResult[] = [];
for (const [packageId, generation] of pending.packageGenerations) {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation));
if (!result) {
return;
}
packageResults.push(result);
}
const result = buildRunResult({
id: pending.id,
stopped: false,
startedAt: pending.startedAt,
completedAt: nowMs(),
packages: packageResults,
totalPackages: pending.packageGenerations.size
});
this.flushPackageSuccessDigest(result.completedAt);
if (this.settings.notifyOnRunFinished) {
this.queueNotificationEvent(buildRunNotificationEvent(result));
}
this.pendingRunResult = null;
for (const [packageId, generation] of pending.packageGenerations) {
if (!this.session.packages[packageId]) {
this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation));
this.packageResultGenerations.delete(packageId);
}
}
this.runPackageIds.clear();
this.runPackageGenerations.clear();
this.runCompletedPackages.clear();
}
private refreshPackageStatus(pkg: PackageEntry): void {
@@ -11633,7 +11917,7 @@ export class DownloadManager extends EventEmitter {
return;
}
const prevStatus = pkg.status;
pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, nowMs());
if (failed > 0 || extractFailed > 0) {
pkg.status = "failed";
} else if (cancelled > 0) {
@@ -11642,18 +11926,7 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed";
}
pkg.updatedAt = nowMs();
// A package whose LAST terminal event is a failure never (re-)enters
// post-processing (its trigger sits only on completion paths), so the
// post-process notify hook can't fire — cover every failed-transition here.
// That includes mixed packages (success > 0): the dedup set prevents a
// double-fire if post-processing does run later.
if (pkg.status === "failed" && prevStatus !== "failed") {
this.notifyPackageOutcome(pkg, "failed", `${failed + extractFailed} von ${total} Datei(en) fehlgeschlagen`);
if (success > 0) {
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
this.recordPackageHistory(pkg.id, pkg, items);
}
}
this.tryFinalizePackageResult(pkg.id);
}
private cachedSpeedLimitKbps = 0;
@@ -11935,6 +12208,38 @@ export class DownloadManager extends EventEmitter {
return false;
}
private recordArchiveOperation(
pkg: PackageEntry,
progress: ExtractProgressUpdate,
items: DownloadItem[],
errorCategory = ""
): void {
if (!progress.archiveName || progress.archiveDone !== true) {
return;
}
const completedAt = nowMs();
const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0));
const operation: ArchiveOperationMetric = {
id: `${pkg.id}:${progress.archiveName.toLocaleLowerCase("de-DE")}`,
name: progress.archiveName,
itemIds: [...new Set(items.map((item) => item.id))],
partCount: Math.max(1, items.length),
startedAt: Math.max(0, completedAt - durationMs),
completedAt,
durationMs,
status: progress.archiveSuccess === false ? "failed" : "completed",
errorCategory: progress.archiveSuccess === false ? (errorCategory || "extract") : ""
};
const operations = [...(pkg.archiveOperations || [])];
const existingIndex = operations.findIndex((entry) => entry.id === operation.id);
if (existingIndex >= 0) {
operations[existingIndex] = operation;
} else {
operations.push(operation);
}
pkg.archiveOperations = operations;
}
private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise<number> {
const completedForDeobfuscation = items.filter((item) => item.status === "completed");
await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal);
@@ -12072,6 +12377,7 @@ export class DownloadManager extends EventEmitter {
const autoRecoveredArchives = new Set<string>();
const failedArchiveErrors = new Map<string, string>();
const failedArchiveCategories = new Map<string, string>();
const hybridResolvedItems = new Map<string, DownloadItem[]>();
const hybridStartTimes = new Map<string, number>();
let hybridLastEmitAt = 0;
@@ -12123,6 +12429,7 @@ export class DownloadManager extends EventEmitter {
extractCpuPriority: "high",
onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message),
onArchiveFailure: (failure) => {
failedArchiveCategories.set(String(failure.archiveName || "").toLowerCase(), failure.category);
const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase());
if (failedArchiveKey) {
failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen");
@@ -12189,6 +12496,12 @@ export class DownloadManager extends EventEmitter {
if (archiveKey && progress.archiveSuccess !== false) {
this.clearHybridArchiveState(packageId, archiveKey);
}
this.recordArchiveOperation(
pkg,
progress,
archItems,
failedArchiveCategories.get(progress.archiveName.toLowerCase()) || ""
);
for (const entry of archItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
entry.fullStatus = doneLabel;
@@ -12320,6 +12633,7 @@ export class DownloadManager extends EventEmitter {
if (tasks?.size === 0) {
this.packageHybridPostProcessTasks.delete(packageId);
}
this.tryFinalizePackageResult(packageId);
}
})();
hybridHandle.task = hybridTask;
@@ -12603,6 +12917,7 @@ export class DownloadManager extends EventEmitter {
try {
const autoRecoveredArchives = new Set<string>();
const fullFailedArchiveErrors = new Map<string, string>();
const fullFailedArchiveCategories = new Map<string, string>();
const fullResolvedItems = new Map<string, DownloadItem[]>();
const fullStartTimes = new Map<string, number>();
let fullLastProgressCurrent: number | null = null;
@@ -12685,6 +13000,7 @@ export class DownloadManager extends EventEmitter {
extractCpuPriority: "high",
onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message),
onArchiveFailure: (failure) => {
fullFailedArchiveCategories.set(failure.archiveName.toLowerCase(), failure.category);
if (autoRecoveredArchives.has(failure.archiveName)) {
return;
}
@@ -12692,6 +13008,7 @@ export class DownloadManager extends EventEmitter {
if (changed > 0) {
autoRecoveredArchives.add(failure.archiveName);
fullFailedArchiveErrors.delete(failure.archiveName);
fullFailedArchiveCategories.delete(failure.archiveName.toLowerCase());
return;
}
fullFailedArchiveErrors.set(
@@ -12745,6 +13062,12 @@ export class DownloadManager extends EventEmitter {
const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt);
this.recordArchiveOperation(
pkg,
progress,
archiveItems,
fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || ""
);
for (const entry of archiveItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
entry.fullStatus = doneLabel;
@@ -12924,16 +13247,6 @@ export class DownloadManager extends EventEmitter {
pkg.postProcessLabel = undefined;
pkg.updatedAt = nowMs();
if (pkg.status === "completed") {
this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`);
} else if (pkg.status === "failed") {
this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`);
}
if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) {
this.recordPackageHistory(packageId, pkg, items);
}
if (this.runPackageIds.has(packageId)) {
if (pkg.status === "completed" || pkg.status === "failed") {
this.runCompletedPackages.add(packageId);
@@ -12969,6 +13282,8 @@ export class DownloadManager extends EventEmitter {
if (tasks?.size === 0) {
this.packageDeferredPostProcessTasks.delete(packageId);
}
this.tryFinalizePackageResult(packageId);
this.applyPackageDoneCleanup(packageId);
});
const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set<Promise<void>>();
tasks.add(task);
@@ -13012,6 +13327,8 @@ export class DownloadManager extends EventEmitter {
nestedCandidates: nestedCandidates.length,
extractDir: pkg.extractDir
});
const nestedFailureCategories = new Map<string, string>();
const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
const nestedResult = await extractPackageArchives({
packageDir: pkg.extractDir,
targetDir: pkg.extractDir,
@@ -13026,6 +13343,17 @@ export class DownloadManager extends EventEmitter {
maxParallel: this.settings.maxParallelExtract || 2,
extractCpuPriority: this.settings.extractCpuPriority,
onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`),
onArchiveFailure: (failure) => {
nestedFailureCategories.set(failure.archiveName.toLowerCase(), failure.category);
},
onProgress: (progress) => {
this.recordArchiveOperation(
pkg,
progress,
resolveArchiveItemsFromList(progress.archiveName, nestedItems),
nestedFailureCategories.get(progress.archiveName.toLowerCase()) || ""
);
}
});
throwIfAborted();
extractedCount += nestedResult.extracted;
@@ -13126,7 +13454,6 @@ export class DownloadManager extends EventEmitter {
this.persistSoon();
this.emitState();
this.applyPackageDoneCleanup(packageId);
} catch (error) {
const reason = compactErrorText(error);
if (reason.includes("aborted:deferred")
@@ -13139,6 +13466,7 @@ export class DownloadManager extends EventEmitter {
|| reason === "package_toggle") {
logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`);
} else {
pkg.cleanupErrorCategory = reason.slice(0, 256) || "cleanup";
logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`);
}
} finally {
@@ -13281,6 +13609,7 @@ export class DownloadManager extends EventEmitter {
private finishRun(): void {
const runStartedAt = this.session.runStartedAt;
const completedAt = nowMs();
this.session.running = false;
this.session.paused = false;
this.session.runStartedAt = 0;
@@ -13290,7 +13619,7 @@ export class DownloadManager extends EventEmitter {
const failed = outcomes.filter((status) => status === "failed").length;
const cancelled = outcomes.filter((status) => status === "cancelled").length;
const extracted = this.runCompletedPackages.size;
const duration = runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - runStartedAt) / 1000)) : 1;
const duration = runStartedAt > 0 ? Math.max(1, Math.floor((completedAt - runStartedAt) / 1000)) : 1;
const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration);
this.summary = {
total,
@@ -13302,24 +13631,14 @@ export class DownloadManager extends EventEmitter {
averageSpeedBps: avgSpeed
};
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
if (this.settings.notifyOnRunFinished && total > 0) {
// With autoExtractWhenStopped (default) the run ends as soon as downloads
// are done while extraction may still be running — say so instead of
// claiming the whole run is finished.
const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending();
const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet";
void sendNotification(this.settings.notifyUrl, {
title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`,
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`,
mention: this.settings.notifyMention
});
}
this.ensureRunPackageGenerations(this.runPackageIds);
this.pendingRunResult = total > 0 ? {
id: uuidv4(),
startedAt: runStartedAt,
packageGenerations: new Map(this.runPackageGenerations)
} : null;
this.runItemIds.clear();
this.runOutcomes.clear();
if (this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending()) {
this.runPackageIds.clear();
this.runCompletedPackages.clear();
}
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
@@ -13339,6 +13658,10 @@ export class DownloadManager extends EventEmitter {
this.lastSettingsPersistAt = 0;
this.persistNow();
this.emitState();
for (const packageId of this.runPackageIds) {
this.tryFinalizePackageResult(packageId);
}
this.tryFinalizeRunResult();
}
public getSessionStats(): import("../shared/types").SessionStats {
+322
View File
@@ -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 }))
};
}
+56 -1
View File
@@ -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>
+48 -9
View File
@@ -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)
};
}
+62
View File
@@ -345,6 +345,18 @@
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,
.history-row-time {
@@ -426,6 +438,52 @@
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;
user-select: text;
@@ -498,6 +556,10 @@
padding: 0 9px;
}
.history-operation-groups {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
+83
View File
@@ -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);
});
});
@@ -11038,6 +11046,15 @@ describe("download manager", () => {
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 () => {
@@ -14598,3 +14615,69 @@ describe("package priority ordering", () => {
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);
});
});
+102
View File
@@ -197,6 +197,20 @@ describe("history model", () => {
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"]);
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
@@ -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);
+285 -78
View File
@@ -2,37 +2,41 @@ 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 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 { sendNotification } from "../src/main/notify";
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
const mockedSend = sendNotification as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedSend.mockClear();
vi.restoreAllMocks();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
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(),
@@ -41,113 +45,316 @@ function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySe
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
notifyPackageSuccessMode: "individual",
autoExtract: false,
...settings
},
session,
createStoragePaths(path.join(root, "state"))
createStoragePaths(path.join(root, "state")),
{
enqueueNotification: async (event: NotificationEvent) => {
events.push(event);
},
onHistoryEntry: (entry) => history.push(entry)
}
);
return { manager, session };
return { manager, session, events, history };
}
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",
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: itemStatuses.map((_s, i) => `it-${i}`),
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
downloadStartedAt: startedAt,
downloadCompletedAt: startedAt + 10_000,
downloadEndedAt: startedAt + 10_000,
createdAt: startedAt,
updatedAt: startedAt + 10_000
};
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,
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: 0,
totalBytes: null,
progressPercent: 0,
fileName: `f${i}.rar`,
targetPath: "",
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: "",
fullStatus: "",
createdAt: 1,
updatedAt: 1
} as any;
lastError: status === "failed" ? "offline" : "",
fullStatus: status === "completed" ? "Fertig" : status === "failed" ? "Offline" : "Wartet",
createdAt: startedAt,
updatedAt: startedAt + 10_000
};
});
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();
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);
(manager as any).refreshPackageStatus(pkg);
internal(manager).refreshPackageStatus(pkg);
await flushNotifications();
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");
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 });
});
it("notifies an all-failed package and dedups repeat refreshes", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
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;
(manager as any).refreshPackageStatus(pkg);
(manager as any).refreshPackageStatus(pkg);
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(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
expect(events).toHaveLength(2);
expect(events[1].id).not.toBe(firstId);
expect(events[1].type).toBe("package_completed");
expect(history).toHaveLength(2);
});
it("stays silent outside a run (startup recovery must not spam)", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed"]);
session.running = false;
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);
(manager as any).refreshPackageStatus(pkg);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
expect(pkg.status).toBe("failed");
expect(mockedSend).not.toHaveBeenCalled();
});
await state.flushNotificationsForShutdown?.();
await flushNotifications();
it("does not notify while items are still pending", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "queued"]);
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"]]);
(manager as any).refreshPackageStatus(pkg);
state.finishRun();
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(pkg.status).toBe("queued");
expect(mockedSend).not.toHaveBeenCalled();
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
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;
mockedSend.mockResolvedValueOnce(false);
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"]]);
(manager as any).refreshPackageStatus(pkg);
await new Promise((r) => setTimeout(r, 0));
state.tryFinalizePackageResult(pkg.id);
state.applyPackageDoneCleanup(pkg.id);
expect(session.packages[pkg.id]).toBeUndefined();
state.finishRun();
await flushNotifications();
expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false);
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
});