feat(history): complete package lifecycle telemetry

Persist separate download, extraction, remux, post-processing, and total durations together with accurate file, byte, archive, part, output, and phase failure results. Keep download item counts independent from extraction and remux outcomes, distinguish partial and cancelled packages, and expose additive failure counters and fixed categories in history and Discord notifications. Track archive operations across parallel work, aborts, restarts, nested multipart sets, and skipped split files while deriving generation outputs from privacy-safe signatures and merging overlapping extraction intervals.
This commit is contained in:
Sucukdeluxe
2026-08-24 20:59:44 +02:00
parent 7fe438ba5d
commit e8046f3fa3
16 changed files with 1139 additions and 150 deletions
+407 -3
View File
@@ -14,7 +14,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
import { loadStatisticsLedger } from "../src/main/statistics-ledger";
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
@@ -23,6 +23,7 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/
import { UnrestrictedLink } from "../src/main/realdebrid";
import { resetVideoToolingCache } from "../src/main/video-processor";
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
import { finalizePackageResult } from "../src/main/package-telemetry";
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
const tempDirs: string[] = [];
@@ -11991,7 +11992,7 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false);
}, 20000);
it("moves extracted MKV files into a flat library folder per completed package", async () => {
it("moves extracted MKV files into a flat library folder per completed package", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -12024,7 +12025,72 @@ describe("download manager", () => {
expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
expect(fs.existsSync(flattenedPath)).toBe(true);
expect(fs.existsSync(originalExtractedPath)).toBe(false);
}, 20000);
}, 20000);
it("records a real library move failure as post-processing telemetry", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-library-move-failure-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "package");
const libraryDir = path.join(root, "library");
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, "episode.mkv"), "video", "utf8");
const session = emptySession();
const packageId = "library-move-failure";
const itemId = "library-move-failure-item";
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Library move failure",
outputDir,
extractDir: path.join(root, "extract"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: 1_000,
downloadEndedAt: 2_000,
postProcessStartedAt: 2_000,
postProcessCompletedAt: 3_000,
terminalAt: 3_000,
createdAt: 1_000,
updatedAt: 3_000
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://example.test/episode.mkv",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 5,
totalBytes: 5,
progressPercent: 100,
fileName: "episode.mkv",
targetPath: path.join(outputDir, "episode.mkv"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt: 1_000,
updatedAt: 2_000
};
const manager = new DownloadManager({
...defaultSettings(),
autoExtract: false,
collectMkvToLibrary: true,
mkvLibraryDir: libraryDir
}, session, createStoragePaths(path.join(root, "state")));
const state = manager as any;
vi.spyOn(state, "moveFileWithExdevFallback").mockRejectedValue(new Error("move denied"));
const pkg = state.session.packages[packageId] as PackageEntry;
await state.collectMkvFilesToLibrary(packageId, pkg);
const result = finalizePackageResult({ package: pkg, items: [state.session.items[itemId]] });
expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung");
expect(result).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 });
});
it("moves extracted AVI files into a flat library folder per completed package", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -14592,6 +14658,344 @@ describe("package lifecycle telemetry boundaries", () => {
expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]);
});
it("terminalizes a started archive as cancelled with the validated part count", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-cancelled-metric-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "archive-cancelled-package",
name: "Archive cancelled",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const state = manager as unknown as {
recordArchiveOperation: (entry: PackageEntry, update: Record<string, unknown>, items: DownloadItem[], errorCategory: string, partCount: number) => void;
finalizeActiveArchiveOperations: (entry: PackageEntry) => void;
};
state.recordArchiveOperation(pkg, {
current: 0,
total: 1,
percent: 0,
archiveName: "show.part01.rar",
archivePercent: 0,
elapsedMs: 0,
archiveDone: false
}, [], "", 16);
state.finalizeActiveArchiveOperations(pkg);
expect(pkg.archiveOperations).toEqual([
expect.objectContaining({
name: "show.part01.rar",
partCount: 16,
status: "cancelled"
})
]);
});
it("replaces an unresolved archive start with its terminal completion", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-terminal-metric-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "archive-terminal-package",
name: "Archive terminal",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const state = manager as unknown as {
recordArchiveOperation: (entry: PackageEntry, update: Record<string, unknown>, items: DownloadItem[], errorCategory: string, partCount: number) => void;
finalizeActiveArchiveOperations: (entry: PackageEntry) => void;
};
const start = {
current: 0,
total: 1,
percent: 0,
archiveName: "nested.rar",
archivePercent: 0,
elapsedMs: 0,
archiveDone: false
};
state.recordArchiveOperation(pkg, start, [], "", 1);
state.recordArchiveOperation(pkg, { ...start, current: 1, percent: 100, archivePercent: 100, elapsedMs: 1_000, archiveDone: true, archiveSuccess: true }, [], "", 1);
state.finalizeActiveArchiveOperations(pkg);
expect(pkg.archiveOperations).toEqual([
expect.objectContaining({ name: "nested.rar", partCount: 1, status: "completed" })
]);
});
it("keeps parallel unresolved archives stable by archive path when completions arrive in reverse order", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-parallel-metric-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "archive-parallel-package",
name: "Archive parallel",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const state = manager as unknown as {
recordArchiveOperation: (entry: PackageEntry, update: Record<string, unknown>, items: DownloadItem[], errorCategory: string, partCount: number) => void;
finalizeActiveArchiveOperations: (entry: PackageEntry) => void;
};
const progress = (archivePath: string, current: number, archiveDone: boolean) => ({
current,
total: 2,
percent: archiveDone ? 100 : 0,
archiveName: "same.rar",
archivePath,
archivePercent: archiveDone ? 100 : 0,
elapsedMs: archiveDone ? 1_000 : 0,
archiveDone,
archiveSuccess: archiveDone
});
state.recordArchiveOperation(pkg, progress(path.join(root, "a", "same.rar"), 0, false), [], "", 2);
state.recordArchiveOperation(pkg, progress(path.join(root, "b", "same.rar"), 0, false), [], "", 3);
expect(pkg.archiveOperations).toHaveLength(2);
expect(new Set(pkg.archiveOperations?.map((operation) => operation.id))).toHaveLength(2);
state.recordArchiveOperation(pkg, progress(path.join(root, "b", "same.rar"), 1, true), [], "", 3);
state.recordArchiveOperation(pkg, progress(path.join(root, "a", "same.rar"), 2, true), [], "", 2);
state.finalizeActiveArchiveOperations(pkg);
expect(pkg.archiveOperations).toHaveLength(2);
expect(pkg.archiveOperations?.map((operation) => operation.status)).toEqual(["completed", "completed"]);
expect(pkg.archiveOperations?.map((operation) => operation.partCount).sort((a, b) => a - b)).toEqual([2, 3]);
});
it("persists an archive start as a cancelled fallback before completion", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-restart-metric-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const session = emptySession();
const manager = new DownloadManager(defaultSettings(), session, paths);
const pkg: PackageEntry = {
id: "archive-restart-package",
name: "Archive restart",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
session.packageOrder = [pkg.id];
session.packages[pkg.id] = pkg;
const state = manager as unknown as {
recordArchiveOperation: (entry: PackageEntry, update: Record<string, unknown>, items: DownloadItem[], errorCategory: string, partCount: number) => void;
};
state.recordArchiveOperation(pkg, {
current: 0,
total: 1,
percent: 0,
archiveName: "restart.rar",
archivePath: path.join(root, "restart.rar"),
archivePercent: 0,
elapsedMs: 0,
archiveDone: false
}, [], "", 4);
saveSession(paths, session);
expect(loadSession(paths).packages[pkg.id].archiveOperations).toEqual([
expect.objectContaining({ name: "restart.rar", partCount: 4, status: "cancelled" })
]);
});
it("restarts a persisted cancelled archive fallback with fresh timing before a second abort", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-second-abort-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const session = emptySession();
const packageId = "archive-second-abort";
const archivePath = path.join(root, "restart.rar");
session.packageOrder = [packageId];
session.packages[packageId] = { id: packageId, name: "Second abort", outputDir: root, extractDir: root, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 };
const firstManager = new DownloadManager(defaultSettings(), session, paths);
const firstState = firstManager as any;
const firstPackage = firstState.session.packages[packageId] as PackageEntry;
const progress = { current: 0, total: 1, percent: 0, archiveName: "restart.rar", archivePath, archivePercent: 0, elapsedMs: 0, archiveDone: false };
firstState.recordArchiveOperation(firstPackage, progress, [], "", 2);
saveSession(paths, firstState.session);
const firstStartedAt = loadSession(paths).packages[packageId].archiveOperations?.[0].startedAt || 0;
await new Promise((resolve) => setTimeout(resolve, 5));
const secondManager = new DownloadManager(defaultSettings(), loadSession(paths), paths);
const secondState = secondManager as any;
const secondPackage = secondState.session.packages[packageId] as PackageEntry;
secondState.recordArchiveOperation(secondPackage, progress, [], "", 2);
const secondStartedAt = secondPackage.archiveOperations?.[0].startedAt || 0;
await new Promise((resolve) => setTimeout(resolve, 5));
secondState.finalizeActiveArchiveOperations(secondPackage);
expect(secondStartedAt).toBeGreaterThan(firstStartedAt);
expect(secondPackage.archiveOperations).toEqual([
expect.objectContaining({ status: "cancelled", startedAt: secondStartedAt })
]);
expect(secondPackage.archiveOperations?.[0].completedAt).toBeGreaterThan(secondStartedAt);
expect(secondPackage.archiveOperations?.[0].durationMs).toBeGreaterThan(0);
});
it("removes a cancelled archive fallback when the extractor skips a non-archive generic split", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-skipped-metric-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = { id: "skipped", name: "Skipped", outputDir: root, extractDir: root, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 };
const state = manager as any;
const archivePath = path.join(root, "data.001");
const start = { current: 0, total: 1, percent: 0, archiveName: "data.001", archivePath, archivePercent: 0, elapsedMs: 0, archiveDone: false };
state.recordArchiveOperation(pkg, start, [], "", 3);
state.recordArchiveOperation(pkg, { ...start, current: 1, percent: 100, archiveDone: true, archiveSkipped: true }, [], "", 3);
state.finalizeActiveArchiveOperations(pkg);
const result = finalizePackageResult({ package: { ...pkg, terminalAt: 2, downloadStartedAt: 1, downloadEndedAt: 2 }, items: [] });
expect(pkg.archiveOperations).toEqual([]);
expect(result).toMatchObject({ status: "completed", archiveCount: 0, partCount: 0, extractionFailures: 0 });
});
it("counts only outputs created after the package generation baseline", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-generation-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "old-output.mkv"), "old", "utf8");
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "output-generation-package",
name: "Output generation",
outputDir: path.join(root, "downloads"),
extractDir,
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const state = manager as unknown as {
capturePackageOutputBaseline: (entry: PackageEntry) => Promise<void>;
refreshPackageOutputCount: (entry: PackageEntry) => Promise<void>;
};
await state.capturePackageOutputBaseline(pkg);
fs.writeFileSync(path.join(extractDir, "new-output.mkv"), "new", "utf8");
await state.refreshPackageOutputCount(pkg);
expect(pkg.outputBaselineSignatures).toHaveLength(1);
expect(pkg.outputBaselineSignatures?.[0]).toMatch(/^[a-f0-9]{64}$/);
expect(JSON.stringify(pkg.outputBaselineSignatures)).not.toContain("old-output.mkv");
expect(pkg.outputCount).toBe(1);
});
it("counts an overwritten baseline path as a generation output", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-overwrite-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const outputPath = path.join(extractDir, "episode.mkv");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(outputPath, "old", "utf8");
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = { id: "overwrite", name: "Overwrite", outputDir: root, extractDir, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 };
const state = manager as any;
await state.capturePackageOutputBaseline(pkg);
fs.writeFileSync(outputPath, "new-content", "utf8");
await state.refreshPackageOutputCount(pkg);
expect(pkg.outputCount).toBe(1);
});
it("counts a new output when a baseline file is deleted in parallel", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-replaced-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const oldPath = path.join(extractDir, "old.mkv");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(oldPath, "old", "utf8");
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = { id: "replaced", name: "Replaced", outputDir: root, extractDir, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 };
const state = manager as any;
await state.capturePackageOutputBaseline(pkg);
fs.rmSync(oldPath);
fs.writeFileSync(path.join(extractDir, "new.mkv"), "new", "utf8");
await state.refreshPackageOutputCount(pkg);
expect(pkg.outputCount).toBe(1);
});
it("counts nested multipart files from the candidate directory only", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-parts-"));
tempDirs.push(root);
const nestedDir = path.join(root, "season");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(root, "show.001"), "root", "utf8");
fs.writeFileSync(path.join(root, "show.002"), "root", "utf8");
fs.writeFileSync(path.join(nestedDir, "show.001"), "nested", "utf8");
fs.writeFileSync(path.join(nestedDir, "show.002"), "nested", "utf8");
fs.writeFileSync(path.join(nestedDir, "show.003"), "nested", "utf8");
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const state = manager as unknown as { resolveArchivePartCount: (archivePath: string) => Promise<number> };
expect(await state.resolveArchivePartCount(path.join(nestedDir, "show.001"))).toBe(3);
});
it("keeps cleanup and generic post-processing failures in separate phases", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-phase-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const makePackage = (id: string): PackageEntry => ({
id,
name: id,
outputDir: path.join(root, "downloads", id),
extractDir: path.join(root, "extract", id),
status: "failed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
});
const cleanupPackage = makePackage("cleanup-package");
const postProcessPackage = makePackage("postprocess-package");
const state = manager as unknown as {
recordPostProcessFailure: (entry: PackageEntry, phase: "cleanup" | "postprocess", error: unknown) => void;
};
state.recordPostProcessFailure(cleanupPackage, "cleanup", new Error("ENOSPC C:\\Private\\archive.rar"));
state.recordPostProcessFailure(postProcessPackage, "postprocess", new Error("rename C:\\Private\\episode.mkv"));
expect(cleanupPackage.cleanupErrorCategory).toBe("Speicherplatz");
expect(cleanupPackage.postProcessErrorCategory).toBeUndefined();
expect(postProcessPackage.cleanupErrorCategory).toBeUndefined();
expect(postProcessPackage.postProcessErrorCategory).toBe("Nachbearbeitung");
});
it("projects archive failure details before storing operation telemetry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-failure-category-"));
tempDirs.push(root);
+69 -7
View File
@@ -152,7 +152,7 @@ describe("extractor", () => {
expect(targets.has(other)).toBe(false);
});
it("extracts archives in natural episode order", async () => {
it("extracts archives in natural episode order", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
@@ -194,8 +194,38 @@ describe("extractor", () => {
"Show.S01E01.zip",
"Show.S01E02.zip",
"Show.S01E10.zip"
]);
});
]);
});
it("includes the normalized archive path in archive progress updates", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-progress-path-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const archivePath = path.join(packageDir, "episode.zip");
const zip = new AdmZip();
zip.addFile("episode.txt", Buffer.from("episode"));
zip.writeZip(archivePath);
const progressPaths: string[] = [];
await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
if (update.archiveName === "episode.zip") {
progressPaths.push(String(update.archivePath || ""));
}
}
});
expect(progressPaths.length).toBeGreaterThan(0);
expect(new Set(progressPaths)).toEqual(new Set([path.resolve(archivePath)]));
});
it("deletes split zip companion parts when cleanup is enabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
@@ -849,7 +879,7 @@ describe("extractor", () => {
expect(targets.has(other)).toBe(false);
});
it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => {
it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
@@ -880,9 +910,41 @@ describe("extractor", () => {
expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true);
expect(fs.existsSync(d001)).toBe(true);
expect(fs.existsSync(d002)).toBe(true);
expect(fs.existsSync(d003)).toBe(true);
});
});
expect(fs.existsSync(d003)).toBe(true);
});
it("emits terminal skipped progress for a generic split without an archive signature", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-skip-progress-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const archivePath = path.join(packageDir, "data.001");
fs.writeFileSync(archivePath, "plain data without archive signature", "utf8");
const progress: Array<{ archiveDone?: boolean; archiveSkipped?: boolean; archivePath?: string }> = [];
await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onlyArchives: new Set([process.platform === "win32" ? path.resolve(archivePath).toLowerCase() : path.resolve(archivePath)]),
onProgress: (update) => {
if (update.archiveName === "data.001") {
progress.push(update);
}
}
});
expect(progress.at(-1)).toMatchObject({
archiveDone: true,
archiveSkipped: true,
archivePath: path.resolve(archivePath)
});
});
});
describe("detectArchiveSignature", () => {
it("detects RAR signature", async () => {
+39 -4
View File
@@ -125,9 +125,11 @@ describe("history model", () => {
all: ["today", "week-edge", "week", "older"],
today: ["today"],
week: ["week-edge", "week"],
older: ["older"],
completed: ["today", "week-edge"],
deleted: ["week"],
older: ["older"],
completed: ["today", "week-edge"],
partial: [],
cancelled: [],
deleted: ["week"],
failed: ["older"]
};
@@ -145,6 +147,28 @@ describe("history model", () => {
expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]);
});
it("does not invent zero failure counts for legacy history", () => {
const legacy = entry({
id: "legacy-structured",
name: "Legacy structured",
startedAt: todayStart,
totalDurationSeconds: 60
});
expect(filterHistoryRows([legacy], "all", "", now)[0].failureCountsLabel).toBe("—");
});
it("filters partial and cancelled package results independently", () => {
const values = [
entry({ id: "partial", name: "Teilweise", status: "partial" }),
entry({ id: "cancelled", name: "Abgebrochen", status: "cancelled" }),
entry({ id: "failed", name: "Fehlgeschlagen", status: "failed" })
];
expect(filterHistoryRows(values, "partial", "", now).map((row) => row.id)).toEqual(["partial"]);
expect(filterHistoryRows(values, "cancelled", "", now).map((row) => row.id)).toEqual(["cancelled"]);
});
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime();
@@ -378,7 +402,7 @@ describe("HistoryView", () => {
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(7);
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(9);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
@@ -632,6 +656,13 @@ describe("HistoryView", () => {
partCount: 16,
outputCount: 10,
failurePhase: "remux",
errorCategory: "Berechtigung",
downloadFailures: 1,
offlineFailures: 1,
extractionFailures: 2,
remuxFailures: 3,
cleanupFailures: 4,
postProcessFailures: 5,
archiveOperations: [{
id: "archive-1",
name: "show.part01.rar",
@@ -673,6 +704,8 @@ describe("HistoryView", () => {
"Erfolgreich / Fehlgeschlagen / Abgebrochen",
"Archive / Parts / Ausgaben",
"Fehlerphase",
"Fehlerkategorie",
"Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung",
"Archivvorgänge",
"Remuxvorgänge"
]) {
@@ -682,6 +715,8 @@ describe("HistoryView", () => {
expect(html).toContain("16 Parts");
expect(html).toContain("episode.mkv");
expect(html).toContain("ffmpeg");
expect(html).toContain("Berechtigung");
expect(html).toContain("1 / 1 / 2 / 3 / 4 / 5");
expect(html).not.toContain("Downloaddauer (Altbestand)");
});
+72
View File
@@ -586,6 +586,7 @@ describe("NotificationOutbox", () => {
extractionFailures: 0,
remuxFailures: 0,
cleanupFailures: 0,
postProcessFailures: 0,
archiveCount: 0,
partCount: 0,
outputCount: 0,
@@ -623,6 +624,77 @@ describe("NotificationOutbox", () => {
}
});
it("keeps the fixed post-processing phase while sanitizing a persisted package failure", async () => {
const filePath = createOutboxFile();
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: [event("postprocess-private", {
payload: {
title: "Paket teilweise fertig",
fields: [{ name: "Fehler", value: `Nachbearbeitung · ${privateFailureDetails}`, inline: false }]
}
})],
lastSuccessAt: 0,
lastFailureAt: 0
}), "utf8");
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
await outbox.enqueue(event("safe"));
expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Nachbearbeitung · Nachbearbeitung");
expect(fs.readFileSync(filePath, "utf8")).not.toContain(privateFailureDetails);
});
it("includes post-processing duration in package notifications", () => {
const result = finalizePackageResult({
...privateFailureTelemetry("cleanup"),
cleanupErrorCategory: "",
postProcessErrorCategory: "rename failed",
package: {
...privateFailureTelemetry("cleanup").package,
postProcessStartedAt: 3_000,
postProcessCompletedAt: 10_000,
terminalAt: 10_000
}
});
const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 10_000);
expect(notificationEvent.payload.fields.find((field) => field.name === "Zeiten")?.value).toContain("Nachbearbeitung 0:07");
});
it("persists cancelled package events without coercing them to failures", async () => {
const filePath = createOutboxFile();
const result = finalizePackageResult({
...privateFailureTelemetry("fullStatus"),
package: { ...privateFailureTelemetry("fullStatus").package, cancelled: true },
items: [{ ...privateFailureTelemetry("fullStatus").items[0], status: "cancelled", lastError: "", fullStatus: "Abgebrochen" }]
});
const cancelledEvent = buildPackageNotificationEvent({ generation: 1, result }, 3_000);
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1_000 });
await outbox.enqueue(cancelledEvent);
expect(cancelledEvent.type).toBe("package_cancelled");
expect(persisted(filePath).events[0].type).toBe("package_cancelled");
});
it("sanitizes unexpected failure details on persisted cancelled package events", async () => {
const filePath = createOutboxFile();
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1_000 });
await outbox.enqueue(event("cancelled-private", {
type: "package_cancelled",
payload: {
title: "Paket abgebrochen",
fields: [{ name: "Fehler", value: `Nachbearbeitung · ${privateFailureDetails}`, inline: false }]
}
}));
expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Nachbearbeitung · Nachbearbeitung");
expect(fs.readFileSync(filePath, "utf8")).not.toContain(privateFailureDetails);
});
it.each([
["fullStatus fallback", "fullStatus", "Download · Download"],
["remux operation", "remux", "Remux · Remux"],
+73 -5
View File
@@ -208,9 +208,17 @@ describe("authoritative package completion", () => {
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_failed"]);
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
expect(events[0].priority).toBe("error");
expect(history[0]).toMatchObject({ status: "failed", failurePhase: "remux", failedFiles: 1 });
expect(history[0]).toMatchObject({
status: "partial",
failurePhase: "remux",
errorCategory: "Remux",
successfulFiles: 1,
failedFiles: 0,
remuxFailures: 1,
postProcessFailures: 0
});
});
it("emits a partial package result when the terminal downloads are mixed", async () => {
@@ -224,7 +232,67 @@ describe("authoritative package completion", () => {
expect(pkg.status).toBe("failed");
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 });
expect(history[0]).toMatchObject({
status: "partial",
successfulFiles: 1,
failedFiles: 1,
downloadFailures: 1,
extractionFailures: 0,
remuxFailures: 0,
cleanupFailures: 0,
postProcessFailures: 0
});
});
it("turns a hybrid post-processing exception into a partial package result", async () => {
const { manager, session, events, history } = setup({ autoExtract: true });
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.handlePackagePostProcessing = async () => {
throw new Error("hybrid failed");
};
await state.runPackagePostProcessing(pkg.id);
await flushNotifications();
expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung");
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
expect(history[0]).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 });
});
it("turns a rename failure into a post-processing package result", async () => {
const { manager, session, history } = setup({ autoExtract: true });
const pkg = addPackage(session);
pkg.status = "completed";
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.autoRenameExtractedVideoFiles = async () => {
throw new Error("rename failed");
};
await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, false, 1);
await flushNotifications();
expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung");
expect(history[0]).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 });
});
it("turns a library move failure into a post-processing package result", async () => {
const { manager, session, history } = setup();
const pkg = addPackage(session);
pkg.status = "completed";
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.collectMkvFilesToLibrary = async () => {
throw new Error("library move failed");
};
await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, false, 0);
await flushNotifications();
expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung");
expect(history[0]).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 });
});
it("creates a new result generation when extraction is retried", async () => {
@@ -463,10 +531,10 @@ describe("authoritative run completion", () => {
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_failed", "run_completed"]);
expect(events.map((event) => event.type)).toEqual(["package_partial", "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);
expect(runEvent.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
});
it("flushes successful package digests before run_completed", async () => {
+50 -5
View File
@@ -131,7 +131,35 @@ describe("package lifecycle telemetry", () => {
}));
});
it("counts a failed 16-part archive as one failed file and produces a partial result", () => {
it("uses the union of parallel extraction intervals", () => {
const items = [downloadItem("item-1"), downloadItem("item-2")];
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [
archiveOperation({ id: "archive-a", startedAt: 130_000, completedAt: 160_000, durationMs: 30_000 }),
archiveOperation({ id: "archive-b", startedAt: 130_000, completedAt: 160_000, durationMs: 30_000 })
]
}));
expect(result.extractionDurationSeconds).toBe(30);
});
it("adds non-overlapping extraction intervals", () => {
const items = [downloadItem("item-1"), downloadItem("item-2")];
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [
archiveOperation({ id: "archive-a", startedAt: 100_000, completedAt: 130_000, durationMs: 30_000 }),
archiveOperation({ id: "archive-b", startedAt: 130_000, completedAt: 160_000, durationMs: 30_000 })
]
}));
expect(result.extractionDurationSeconds).toBe(60);
});
it("keeps download file counts independent from a failed multipart archive", () => {
const items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`));
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
@@ -149,15 +177,32 @@ describe("package lifecycle telemetry", () => {
downloadDurationSeconds: 120,
extractionDurationSeconds: 30,
totalDurationSeconds: 165,
successfulFiles: 15,
failedFiles: 1,
successfulFiles: 16,
failedFiles: 0,
partCount: 16,
archiveCount: 1,
extractionFailures: 1,
failurePhase: "extract",
errorCategory: "Entpacken"
}));
});
it("classifies a generic post-processing failure separately from cleanup", () => {
const result = finalizePackageResult(telemetry({
postProcessErrorCategory: "rename failed"
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
successfulFiles: 1,
failedFiles: 0,
cleanupFailures: 0,
postProcessFailures: 1,
failurePhase: "postprocess",
errorCategory: "Nachbearbeitung"
}));
});
it("classifies a package with no successful files and a download failure as failed", () => {
const item = downloadItem("item-1", "failed");
const result = finalizePackageResult(telemetry({
@@ -396,8 +441,8 @@ describe("package lifecycle telemetry", () => {
expect(result).toEqual(expect.objectContaining({
status: "partial",
successfulFiles: 1,
failedFiles: 1,
successfulFiles: 2,
failedFiles: 0,
failurePhase: "remux"
}));
});
+23 -1
View File
@@ -1235,6 +1235,13 @@ describe("settings storage", () => {
partCount: 16,
outputCount: 15,
failurePhase: "extract",
errorCategory: "checksum",
downloadFailures: 2,
offlineFailures: 1,
extractionFailures: 3,
remuxFailures: 4,
cleanupFailures: 5,
postProcessFailures: 6,
archiveOperations: [{
id: "archive-1",
name: "Paket.part01.rar",
@@ -1274,6 +1281,13 @@ describe("settings storage", () => {
partCount: 16,
outputCount: 15,
failurePhase: "extract",
errorCategory: "Entpacken",
downloadFailures: 2,
offlineFailures: 1,
extractionFailures: 3,
remuxFailures: 4,
cleanupFailures: 5,
postProcessFailures: 6,
archiveOperations: [{
id: "archive-1",
name: "Paket.part01.rar",
@@ -1332,7 +1346,13 @@ describe("settings storage", () => {
}],
remuxOperations: [],
outputCount: 1,
outputBaselineSignatures: [
"a".repeat(64),
"invalid",
"b".repeat(64)
],
cleanupErrorCategory: "",
postProcessErrorCategory: "rename failed",
createdAt: 1_000,
updatedAt: 21_000
}
@@ -1358,7 +1378,9 @@ describe("settings storage", () => {
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
remuxOperations: [],
outputCount: 1,
cleanupErrorCategory: ""
outputBaselineSignatures: ["a".repeat(64), "b".repeat(64)],
cleanupErrorCategory: "",
postProcessErrorCategory: "Nachbearbeitung"
}));
});