fix(extraction): close final scope integration gaps

Ignore late JVM output events after abort or timeout until the real child close reconciles opened files. Restrict deferred archive cleanup to package-owned item paths so shared download roots retain foreign archives while package-local empty directory cleanup still works. Update deferred ownership and output-scope fixtures to the coordinator contract.
This commit is contained in:
Sucukdeluxe
2026-08-22 17:29:03 +02:00
parent 3e18ec57ea
commit 522f53d787
4 changed files with 231 additions and 118 deletions
+86 -90
View File
@@ -64,7 +64,7 @@ function releaseTlsSkip(): void {
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } 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, isProviderDisabledForSelection, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, 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";
@@ -5883,45 +5883,53 @@ export class DownloadManager extends EventEmitter {
return removed;
}
private async cleanupRemainingArchiveArtifacts(packageDir: string, shouldAbort?: () => boolean): Promise<number> {
if (this.settings.cleanupMode === "none") {
return 0;
}
if (shouldAbort?.()) {
return 0;
}
const candidates = await findArchiveCandidates(packageDir);
if (candidates.length === 0) {
return 0;
}
let removed = 0;
const dirFilesCache = new Map<string, string[]>();
const targets = new Set<string>();
for (const sourceFile of candidates) {
if (shouldAbort?.()) {
return removed;
}
const dir = path.dirname(sourceFile);
let filesInDir = dirFilesCache.get(dir);
if (!filesInDir) {
try {
filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
} catch {
filesInDir = [];
}
dirFilesCache.set(dir, filesInDir);
}
for (const target of collectArchiveCleanupTargets(sourceFile, filesInDir)) {
targets.add(target);
}
}
for (const targetPath of targets) {
if (shouldAbort?.()) {
return removed;
private async cleanupRemainingArchiveArtifacts(pkg: PackageEntry, shouldAbort?: () => boolean): Promise<number> {
if (this.settings.cleanupMode === "none") {
return 0;
}
if (shouldAbort?.()) {
return 0;
}
const ownedPaths = new Map<string, string>();
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
const rawPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim();
if (!rawPath || !isArchiveLikePath(rawPath) || !isPathInsideDir(rawPath, pkg.outputDir)) {
continue;
}
const resolved = path.resolve(rawPath);
ownedPaths.set(pathKey(resolved), resolved);
}
if (ownedPaths.size === 0) {
return 0;
}
let removed = 0;
const dirFiles = new Map<string, string[]>();
for (const ownedPath of ownedPaths.values()) {
const directory = path.dirname(ownedPath);
const directoryKey = pathKey(directory);
const files = dirFiles.get(directoryKey) || [];
files.push(path.basename(ownedPath));
dirFiles.set(directoryKey, files);
}
const targets = new Set<string>();
for (const sourceFile of ownedPaths.values()) {
if (shouldAbort?.()) {
return removed;
}
const dir = path.dirname(sourceFile);
for (const target of collectArchiveCleanupTargets(sourceFile, dirFiles.get(pathKey(dir)) || [])) {
const resolved = path.resolve(target);
if (ownedPaths.has(pathKey(resolved))) {
targets.add(resolved);
}
}
}
for (const targetPath of targets) {
if (shouldAbort?.()) {
return removed;
}
try {
if (!await this.existsAsync(targetPath)) {
@@ -5941,20 +5949,20 @@ export class DownloadManager extends EventEmitter {
await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" });
moved = true;
break;
}
if (moved) {
removed += 1;
}
continue;
}
await fs.promises.rm(toWindowsLongPathIfNeeded(targetPath), { force: true });
removed += 1;
} catch {
}
}
return removed;
}
}
if (moved) {
removed += 1;
}
continue;
}
await fs.promises.rm(toWindowsLongPathIfNeeded(targetPath), { force: true });
removed += 1;
} catch {
}
}
return removed;
}
private hasDeferredPostProcessPending(packageId: string): boolean {
if ((this.packageDeferredPostProcessTasks.get(packageId)?.size || 0) > 0) {
@@ -14597,26 +14605,15 @@ export class DownloadManager extends EventEmitter {
if (hasBlockingExtractError) {
logger.info(`Deferred Archive-Cleanup uebersprungen: pkg=${pkg.name}, reason=extract_error`);
} else {
const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase();
if (!sourceAndTargetEqual) {
const candidates = await findArchiveCandidates(pkg.outputDir);
if (candidates.length > 0) {
const removed = await cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort });
if (removed > 0) {
logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`);
}
}
}
}
}
if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none" && !hasBlockingExtractError) {
throwIfAborted();
const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort);
if (removedArchives > 0) {
logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`);
}
}
const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase();
if (!sourceAndTargetEqual) {
const removed = await this.cleanupRemainingArchiveArtifacts(pkg, shouldAbort);
if (removed > 0) {
logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`);
}
}
}
}
if (extractedCount > 0 || alreadyMarkedExtracted) {
throwIfAborted();
@@ -14636,23 +14633,22 @@ export class DownloadManager extends EventEmitter {
}
}
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) {
throwIfAborted();
await clearExtractResumeState(pkg.outputDir, packageId);
await clearExtractResumeState(pkg.outputDir);
}
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) {
throwIfAborted();
await clearExtractResumeState(pkg.outputDir, packageId);
await clearExtractResumeState(pkg.outputDir);
const archiveParents = new Set<string>();
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
const itemPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim();
if (itemPath && isArchiveLikePath(itemPath) && isPathInsideDir(itemPath, pkg.outputDir)) {
archiveParents.add(path.dirname(path.resolve(itemPath)));
}
}
await this.removeEmptyScopedParentChains(pkg.outputDir, archiveParents);
}
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode === "delete") {
throwIfAborted();
if (!(await hasAnyFilesRecursive(pkg.outputDir))) {
const removedDirs = await removeEmptyDirectoryTree(pkg.outputDir);
if (removedDirs > 0) {
logger.info(`Deferred leere Download-Ordner entfernt: pkg=${pkg.name}, dirs=${removedDirs}`);
}
}
}
if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) {
if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) {
throwIfAborted();
pkg.postProcessLabel = "Verschiebe Videos...";
this.emitState();
+10 -4
View File
@@ -1863,6 +1863,9 @@ function handleDaemonLine(line: string): void {
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
if (req.terminationStarted) {
return;
}
parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
@@ -1912,12 +1915,15 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
child.stderr!.on("data", (chunk) => {
const raw = String(chunk || "");
daemonOutput = appendLimited(daemonOutput, raw);
daemonStderrBuffer += raw;
const lines = daemonStderrBuffer.split(/\r?\n/);
daemonStderrBuffer = lines.pop() || "";
for (const line of lines) {
daemonStderrBuffer += raw;
const lines = daemonStderrBuffer.split(/\r?\n/);
daemonStderrBuffer = lines.pop() || "";
for (const line of lines) {
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
if (req.terminationStarted) {
continue;
}
parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}