diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 65bab73..db57975 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4363,12 +4363,12 @@ export class DownloadManager extends EventEmitter { return false; } - private async countPackageOutputFiles(rootDir: string): Promise { + private async snapshotPackageOutputFiles(rootDir: string): Promise> { + const snapshot = new Map(); if (!rootDir) { - return 0; + return snapshot; } const stack = [rootDir]; - let count = 0; while (stack.length > 0) { const current = stack.pop() as string; let entries: fs.Dirent[] = []; @@ -4385,11 +4385,29 @@ export class DownloadManager extends EventEmitter { if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) { - count += 1; + try { + const stat = await fs.promises.stat(fullPath); + const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/"); + const key = process.platform === "win32" ? relativePath.toLowerCase() : relativePath; + snapshot.set(key, `${stat.size}:${stat.mtimeMs}`); + } catch { + } } } } - return count; + return snapshot; + } + + private async recordPackageOutputFiles(pkg: PackageEntry, before: ReadonlyMap): Promise { + const after = await this.snapshotPackageOutputFiles(pkg.extractDir); + const provenance = new Set(pkg.outputProvenance || []); + for (const [relativePath, signature] of after) { + if (before.get(relativePath) !== signature) { + provenance.add(createHash("sha256").update(relativePath).digest("hex")); + } + } + pkg.outputProvenance = [...provenance]; + pkg.outputCount = Math.max(pkg.outputCount || 0, provenance.size); } private async removeEmptyDirectoryTree(rootDir: string): Promise { @@ -12244,6 +12262,7 @@ export class DownloadManager extends EventEmitter { pkg.archiveOperations = []; pkg.remuxOperations = []; pkg.outputCount = 0; + pkg.outputProvenance = []; pkg.cleanupErrorCategory = ""; } return next; @@ -13221,11 +13240,12 @@ export class DownloadManager extends EventEmitter { try { await this.waitForCompletedArchiveFilesToSettle(pkg, hybridItems, signal, "hybrid"); - if (signal?.aborted) { - return 0; - } - - const result = await extractPackageArchives({ + if (signal?.aborted) { + return 0; + } + + const packageOutputBefore = await this.snapshotPackageOutputFiles(pkg.extractDir); + const result = await extractPackageArchives({ packageDir: pkg.outputDir, targetDir: pkg.extractDir, cleanupMode: this.settings.cleanupMode, @@ -13384,7 +13404,7 @@ export class DownloadManager extends EventEmitter { } } }); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.recordPackageOutputFiles(pkg, packageOutputBefore); logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { @@ -13799,9 +13819,10 @@ export class DownloadManager extends EventEmitter { preExtractStatuses.set(entry.id, String(entry.fullStatus || "").trim()); entry.fullStatus = "Entpacken - Ausstehend"; entry.updatedAt = pendingAt; - } - this.emitState(); - const result = await extractPackageArchives({ + } + this.emitState(); + const packageOutputBefore = await this.snapshotPackageOutputFiles(pkg.extractDir); + const result = await extractPackageArchives({ packageDir: pkg.outputDir, targetDir: pkg.extractDir, cleanupMode: this.settings.cleanupMode, @@ -13943,7 +13964,7 @@ export class DownloadManager extends EventEmitter { emitExtractStatus(overallLabel); } }); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.recordPackageOutputFiles(pkg, packageOutputBefore); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { extracted: result.extracted, @@ -14150,6 +14171,7 @@ export class DownloadManager extends EventEmitter { }); const nestedFailureCategories = new Map(); const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const packageOutputBefore = await this.snapshotPackageOutputFiles(pkg.extractDir); const nestedResult = await extractPackageArchives({ packageDir: pkg.extractDir, targetDir: pkg.extractDir, @@ -14177,7 +14199,7 @@ export class DownloadManager extends EventEmitter { } }); throwIfAborted(); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.recordPackageOutputFiles(pkg, packageOutputBefore); extractedCount += nestedResult.extracted; logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { diff --git a/src/main/storage.ts b/src/main/storage.ts index 5984c95..33d0e80 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1007,6 +1007,9 @@ export function normalizeLoadedSession(raw: unknown): SessionState { archiveOperations: normalizeArchiveOperations(pkg.archiveOperations), remuxOperations: normalizeRemuxOperations(pkg.remuxOperations), outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000), + outputProvenance: Array.isArray(pkg.outputProvenance) + ? [...new Set(pkg.outputProvenance.map((value) => asText(value).toLowerCase()).filter((value) => /^[a-f0-9]{64}$/.test(value)))].slice(0, 1_000_000) + : [], cleanupErrorCategory: asText(pkg.cleanupErrorCategory), resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), diff --git a/src/shared/types.ts b/src/shared/types.ts index 8f4506a..028ce74 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -588,6 +588,7 @@ export interface PackageEntry { archiveOperations?: ArchiveOperationMetric[]; remuxOperations?: RemuxOperationMetric[]; outputCount?: number; + outputProvenance?: string[]; cleanupErrorCategory?: string; resultGeneration?: number; createdAt: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 2fece3c..d6fed80 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -11466,8 +11466,10 @@ describe("download manager", () => { tempDirs.push(root); const outputDir = path.join(root, "downloads", "recovery"); - const extractDir = path.join(root, "extract", "recovery"); - fs.mkdirSync(outputDir, { recursive: true }); + const extractDir = path.join(root, "extract"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "foreign-package.txt"), "foreign"); const zip = new AdmZip(); zip.addFile("episode.txt", Buffer.from("ok")); @@ -11518,7 +11520,7 @@ describe("download manager", () => { token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), - createExtractSubfolder: true, + createExtractSubfolder: false, autoExtract: true, enableIntegrityCheck: false, cleanupMode: "none" diff --git a/tests/storage.test.ts b/tests/storage.test.ts index c64a58e..0db7385 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1200,6 +1200,7 @@ describe("settings storage", () => { }], remuxOperations: [], outputCount: 1, + outputProvenance: ["a".repeat(64), "invalid", "a".repeat(64)], cleanupErrorCategory: "", createdAt: 1_000, updatedAt: 21_000 @@ -1226,6 +1227,7 @@ describe("settings storage", () => { archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })], remuxOperations: [], outputCount: 1, + outputProvenance: ["a".repeat(64)], cleanupErrorCategory: "" })); });