fix: scope extraction follow-up work
Remove .rd-output staging and merge handling, parse validated native extractor output, and drive nested extraction, rename, audio, cleanup, residual handling, and library collection from package-owned output records. Preserve provenance migration, package pruning, and run-owned waiter isolation while allowing one bounded legacy adoption scan only for provably exclusive package roots.
This commit is contained in:
+130
-21
@@ -2,11 +2,58 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, MAX_LINK_ARTIFACT_BYTES, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||
|
||||
async function yieldToLoop(): Promise<void> {
|
||||
async function yieldToLoop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function isDownloadLinkArtifact(filePath: string): Promise<boolean> {
|
||||
const fileName = path.basename(filePath);
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const name = fileName.toLowerCase();
|
||||
if (LINK_ARTIFACT_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
if (![".txt", ".html", ".htm", ".nfo"].includes(ext)
|
||||
|| !/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.lstat(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_LINK_ARTIFACT_BYTES) {
|
||||
return false;
|
||||
}
|
||||
const text = await fs.promises.readFile(filePath, "utf8");
|
||||
return /https?:\/\//i.test(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmptyParentChains(rootDir: string, parents: ReadonlySet<string>): Promise<number> {
|
||||
const rootPath = path.resolve(rootDir);
|
||||
const candidates = new Set<string>();
|
||||
for (const parent of parents) {
|
||||
let current = path.resolve(parent);
|
||||
while (current !== rootPath && current.startsWith(`${rootPath}${path.sep}`)) {
|
||||
candidates.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
let removed = 0;
|
||||
for (const directory of [...candidates].sort((left, right) => right.length - left.length)) {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(directory);
|
||||
if (entries.length === 0) {
|
||||
await fs.promises.rmdir(directory);
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function isArchiveOrTempFile(filePath: string): boolean {
|
||||
const lowerName = path.basename(filePath).toLowerCase();
|
||||
@@ -126,22 +173,7 @@ export async function removeDownloadLinkArtifacts(
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const name = entry.name.toLowerCase();
|
||||
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
||||
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(full);
|
||||
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
||||
const text = await fs.promises.readFile(full, "utf8");
|
||||
shouldDelete = /https?:\/\//i.test(text);
|
||||
}
|
||||
} catch {
|
||||
shouldDelete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldDelete = await isDownloadLinkArtifact(full);
|
||||
|
||||
if (shouldDelete) {
|
||||
try {
|
||||
@@ -153,9 +185,35 @@ export async function removeDownloadLinkArtifacts(
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDownloadLinkArtifactsFromScope(
|
||||
outputFiles: readonly string[],
|
||||
options: { shouldAbort?: () => boolean; rootDir?: string } = {}
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
const parents = new Set<string>();
|
||||
for (const outputFile of outputFiles) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
if (!await isDownloadLinkArtifact(outputFile)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rm(outputFile, { force: true });
|
||||
parents.add(path.dirname(outputFile));
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (options.rootDir) {
|
||||
await removeEmptyParentChains(options.rootDir, parents);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeSampleArtifacts(
|
||||
export async function removeSampleArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<{ files: number; dirs: number }> {
|
||||
@@ -263,4 +321,55 @@ export async function removeSampleArtifacts(
|
||||
}
|
||||
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeSampleArtifactsFromScope(
|
||||
outputFiles: readonly string[],
|
||||
options: { shouldAbort?: () => boolean; rootDir?: string } = {}
|
||||
): Promise<{ files: number; dirs: number }> {
|
||||
let removedFiles = 0;
|
||||
const candidateParents = new Set<string>();
|
||||
for (const outputFile of outputFiles) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: 0 };
|
||||
}
|
||||
const fileName = path.basename(outputFile);
|
||||
const stem = path.parse(fileName).name.toLowerCase();
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const parentDir = path.dirname(outputFile);
|
||||
const inSampleDir = SAMPLE_DIR_NAMES.has(path.basename(parentDir).toLowerCase());
|
||||
if (!inSampleDir && !(SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.lstat(outputFile);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.rm(outputFile, { force: true });
|
||||
removedFiles += 1;
|
||||
if (inSampleDir) {
|
||||
candidateParents.add(parentDir);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
let removedDirs = 0;
|
||||
for (const parentDir of candidateParents) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
try {
|
||||
const entries = await fs.promises.readdir(parentDir);
|
||||
if (entries.length === 0) {
|
||||
await fs.promises.rmdir(parentDir);
|
||||
removedDirs += 1;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (options.rootDir) {
|
||||
removedDirs += await removeEmptyParentChains(options.rootDir, candidateParents);
|
||||
}
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
|
||||
+343
-368
File diff suppressed because it is too large
Load Diff
+151
-58
@@ -5,7 +5,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import AdmZip from "adm-zip";
|
||||
import { CleanupMode, ConflictMode } from "../shared/types";
|
||||
import { logger } from "./logger";
|
||||
import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||
import { removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } from "./cleanup";
|
||||
import { PackageOutputScope, type ExtractOutputEvent } from "./package-output-scope";
|
||||
|
||||
export type { ExtractOutputEvent } from "./package-output-scope";
|
||||
@@ -170,7 +170,7 @@ type ExtractResumeMember = {
|
||||
type ExtractResumeOutput = {
|
||||
entryPath: string;
|
||||
path: string;
|
||||
disposition: ExtractOutputEvent["disposition"];
|
||||
disposition: Exclude<ExtractOutputEvent["disposition"], "skipped">;
|
||||
};
|
||||
|
||||
type ExtractResumeArchive = {
|
||||
@@ -369,7 +369,7 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (!/^\.rd-(?:output|replace)-/i.test(entry.name)) {
|
||||
if (!/^\.rd-(?:output|replace)-/i.test(entry.name) && !/^\.rd-trash$/i.test(entry.name)) {
|
||||
stack.push(fullPath);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
@@ -2132,7 +2132,7 @@ async function runJvmExtractCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildExternalExtractArgs(
|
||||
export function buildExternalExtractArgs(
|
||||
command: string,
|
||||
archivePath: string,
|
||||
targetDir: string,
|
||||
@@ -2155,10 +2155,101 @@ export function buildExternalExtractArgs(
|
||||
|
||||
const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos";
|
||||
const pass = password ? `-p${password}` : "-p";
|
||||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||
}
|
||||
|
||||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return ["x", "-y", "-bb1", "-sccUTF-8", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||
}
|
||||
|
||||
export function parseNativeExtractOutput(
|
||||
command: string,
|
||||
line: string,
|
||||
archivePath: string,
|
||||
targetDir: string,
|
||||
conflictMode: ConflictMode
|
||||
): ExtractOutputEvent[] {
|
||||
const trimmed = String(line || "").trim();
|
||||
let reportedPath = "";
|
||||
if (extractorCommandKind(command) === "seven_zip") {
|
||||
const match = trimmed.match(/^[-+]\s+(.+)$/);
|
||||
reportedPath = match?.[1]?.trim() || "";
|
||||
} else if (isRarNativeCommand(command)) {
|
||||
const match = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i);
|
||||
reportedPath = match?.[1]?.trim() || "";
|
||||
}
|
||||
if (!reportedPath) {
|
||||
return [];
|
||||
}
|
||||
const targetRoot = path.resolve(targetDir);
|
||||
const rawPath = reportedPath.replace(/^"|"$/g, "");
|
||||
const outputPath = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(targetRoot, rawPath);
|
||||
const relativePath = path.relative(targetRoot, outputPath);
|
||||
if (!relativePath
|
||||
|| relativePath === ".."
|
||||
|| relativePath.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(relativePath)) {
|
||||
return [];
|
||||
}
|
||||
const entryPath = relativePath.replace(/\\/g, "/");
|
||||
if (entryPath.split("/").some((segment) => !segment || segment === "..")) {
|
||||
return [];
|
||||
}
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
if (mode === "rename" && !/ \(\d+\)(?=\.[^./]+$|$)/.test(path.basename(outputPath))) {
|
||||
return [];
|
||||
}
|
||||
const event: ExtractOutputEvent = {
|
||||
version: 1,
|
||||
archivePath: path.resolve(archivePath),
|
||||
entryPath,
|
||||
outputPath,
|
||||
state: "complete",
|
||||
disposition: mode === "rename" ? "renamed" : mode === "overwrite" ? "overwritten" : "written"
|
||||
};
|
||||
try {
|
||||
const scope = new PackageOutputScope([targetRoot]);
|
||||
scope.add(event);
|
||||
return [event];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createNativeOutputCollector(
|
||||
command: string,
|
||||
archivePath: string,
|
||||
targetDir: string,
|
||||
conflictMode: ConflictMode,
|
||||
onOutput?: (event: ExtractOutputEvent) => void
|
||||
): { push: (chunk: string) => void; finish: (state: ExtractOutputEvent["state"]) => void } {
|
||||
let buffer = "";
|
||||
const lines = new Set<string>();
|
||||
const collectLine = (value: string): void => {
|
||||
const trimmed = value.trim();
|
||||
if ((extractorCommandKind(command) === "seven_zip" && /^[-+]\s+/.test(trimmed))
|
||||
|| (isRarNativeCommand(command) && /^Extracting\s+/i.test(trimmed))) {
|
||||
lines.add(trimmed);
|
||||
}
|
||||
};
|
||||
return {
|
||||
push: (chunk) => {
|
||||
buffer += chunk;
|
||||
const parts = buffer.split(/[\r\n]+/);
|
||||
buffer = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
collectLine(part);
|
||||
}
|
||||
},
|
||||
finish: (state) => {
|
||||
collectLine(buffer);
|
||||
buffer = "";
|
||||
for (const outputLine of lines) {
|
||||
for (const event of parseNativeExtractOutput(command, outputLine, archivePath, targetDir, conflictMode)) {
|
||||
onOutput?.({ ...event, state });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function runExternalExtractInner(
|
||||
command: string,
|
||||
@@ -2188,9 +2279,26 @@ async function runExternalExtractInner(
|
||||
let bestPercent = 0;
|
||||
let passwordAttempt = 0;
|
||||
let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags();
|
||||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
||||
let createErrorText = "";
|
||||
let createErrorPassword = "";
|
||||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
||||
let createErrorText = "";
|
||||
let createErrorPassword = "";
|
||||
const runNativeAttempt = async (args: string[]): Promise<ExtractSpawnResult> => {
|
||||
const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput);
|
||||
const result = await runExtractCommand(command, args, (chunk) => {
|
||||
outputs.push(chunk);
|
||||
const parsed = parseProgressPercent(chunk);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
const next = nextArchivePercent(bestPercent, parsed);
|
||||
if (next !== bestPercent) {
|
||||
bestPercent = next;
|
||||
onArchiveProgress?.(bestPercent);
|
||||
}
|
||||
}, signal, timeoutMs);
|
||||
outputs.finish(result.ok ? "complete" : "partial");
|
||||
return result;
|
||||
};
|
||||
|
||||
if (forceFlatMode) {
|
||||
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||
@@ -2201,12 +2309,7 @@ async function runExternalExtractInner(
|
||||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||
const result = await runExtractCommand(command, args, (chunk) => {
|
||||
const parsed = parseProgressPercent(chunk);
|
||||
if (parsed === null) return;
|
||||
const next = nextArchivePercent(bestPercent, parsed);
|
||||
if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); }
|
||||
}, signal, timeoutMs);
|
||||
const result = await runNativeAttempt(args);
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||
@@ -2233,17 +2336,7 @@ async function runExternalExtractInner(
|
||||
onPasswordAttempt?.(passwordAttempt, passwords.length);
|
||||
}
|
||||
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
|
||||
let result = await runExtractCommand(command, args, (chunk) => {
|
||||
const parsed = parseProgressPercent(chunk);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
const next = nextArchivePercent(bestPercent, parsed);
|
||||
if (next !== bestPercent) {
|
||||
bestPercent = next;
|
||||
onArchiveProgress?.(bestPercent);
|
||||
}
|
||||
}, signal, timeoutMs);
|
||||
let result = await runNativeAttempt(args);
|
||||
|
||||
if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) {
|
||||
usePerformanceFlags = false;
|
||||
@@ -2251,17 +2344,7 @@ async function runExternalExtractInner(
|
||||
onLog?.("WARN", `Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`);
|
||||
logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`);
|
||||
args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode);
|
||||
result = await runExtractCommand(command, args, (chunk) => {
|
||||
const parsed = parseProgressPercent(chunk);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
const next = nextArchivePercent(bestPercent, parsed);
|
||||
if (next !== bestPercent) {
|
||||
bestPercent = next;
|
||||
onArchiveProgress?.(bestPercent);
|
||||
}
|
||||
}, signal, timeoutMs);
|
||||
result = await runNativeAttempt(args);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -2324,12 +2407,7 @@ async function runExternalExtractInner(
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||
const result = await runExtractCommand(command, args, (chunk) => {
|
||||
const parsed = parseProgressPercent(chunk);
|
||||
if (parsed === null) return;
|
||||
const next = nextArchivePercent(bestPercent, parsed);
|
||||
if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); }
|
||||
}, signal, timeoutMs);
|
||||
const result = await runNativeAttempt(args);
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||
@@ -2436,9 +2514,15 @@ async function runExternalExtract(
|
||||
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
||||
if (subst) {
|
||||
onLog?.("INFO", `Legacy-Zielpfad verkuerzt via subst: archive=${archiveName}, originalTargetDir=${targetDir}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||
} else {
|
||||
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||
}
|
||||
} else {
|
||||
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||
}
|
||||
const legacyOnOutput = subst && onOutput
|
||||
? (event: ExtractOutputEvent): void => onOutput({
|
||||
...event,
|
||||
outputPath: path.resolve(targetDir, ...event.entryPath.split("/"))
|
||||
})
|
||||
: onOutput;
|
||||
|
||||
const command = await resolveExtractorCommand(archivePath);
|
||||
const legacyStartedAt = Date.now();
|
||||
@@ -2449,7 +2533,7 @@ async function runExternalExtract(
|
||||
password = await runExternalExtractInner(
|
||||
command, archivePath, effectiveTargetDir, conflictMode, passwordCandidates,
|
||||
onArchiveProgress, signal, timeoutMs, hybridMode, onPasswordAttempt,
|
||||
forceFlatMode, flatModeResult, onLog
|
||||
forceFlatMode, flatModeResult, onLog, legacyOnOutput
|
||||
);
|
||||
} catch (primaryError) {
|
||||
const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName);
|
||||
@@ -2465,7 +2549,7 @@ async function runExternalExtract(
|
||||
password = await runExternalExtractInner(
|
||||
alt, archivePath, effectiveTargetDir, conflictMode, passwordCandidates,
|
||||
onArchiveProgress, signal, timeoutMs, hybridMode, onPasswordAttempt,
|
||||
forceFlatMode, flatModeResult, onLog
|
||||
forceFlatMode, flatModeResult, onLog, legacyOnOutput
|
||||
);
|
||||
} else {
|
||||
throw primaryError;
|
||||
@@ -2507,7 +2591,8 @@ async function runExternalExtract(
|
||||
onPasswordAttempt,
|
||||
forceFlatMode,
|
||||
flatModeResult,
|
||||
onLog
|
||||
onLog,
|
||||
legacyOnOutput
|
||||
);
|
||||
logger.info(`Legacy-Retry erfolgreich: ${archiveName}`);
|
||||
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
||||
@@ -3660,7 +3745,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
}
|
||||
|
||||
if (extracted > 0) {
|
||||
const hasOutputAfter = await hasAnyFilesRecursive(options.targetDir);
|
||||
const hasOutputAfter = outputScope.completeFiles().length > 0;
|
||||
const hadResumeProgress = resumeCompletedAtStart > 0;
|
||||
if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) {
|
||||
lastError = "Keine entpackten Dateien erkannt";
|
||||
@@ -3681,12 +3766,20 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
if (options.cleanupMode !== "none") {
|
||||
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
|
||||
}
|
||||
if (options.removeLinks) {
|
||||
const removedLinks = await removeDownloadLinkArtifacts(options.targetDir);
|
||||
logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`);
|
||||
}
|
||||
if (options.removeSamples) {
|
||||
const removedSamples = await removeSampleArtifacts(options.targetDir);
|
||||
if (options.removeLinks) {
|
||||
const removedLinks = await removeDownloadLinkArtifactsFromScope(outputScope.completeFiles(), {
|
||||
shouldAbort: () => options.signal?.aborted === true,
|
||||
rootDir: options.targetDir
|
||||
});
|
||||
outputScope.pruneMissing();
|
||||
logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`);
|
||||
}
|
||||
if (options.removeSamples) {
|
||||
const removedSamples = await removeSampleArtifactsFromScope(outputScope.completeFiles(), {
|
||||
shouldAbort: () => options.signal?.aborted === true,
|
||||
rootDir: options.targetDir
|
||||
});
|
||||
outputScope.pruneMissing();
|
||||
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class PackageOutputScope {
|
||||
}
|
||||
|
||||
public archiveFiles(): string[] {
|
||||
return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|001)$/i.test(filePath));
|
||||
return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|tgz|tbz2|txz|001)$/i.test(filePath));
|
||||
}
|
||||
|
||||
public replacePath(sourcePath: string, targetPath: string, state?: ExtractOutputState): boolean {
|
||||
|
||||
+54
-3
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PACKAGE_OUTPUT_PROVENANCE_VERSION, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
|
||||
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PACKAGE_OUTPUT_PROVENANCE_VERSION, PackageEntry, PackageOutputRecord, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
||||
import { defaultSettings } from "./constants";
|
||||
@@ -838,6 +838,44 @@ function normalizeRemuxOperations(raw: unknown): RemuxOperationMetric[] {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePackageOutputRecords(raw: unknown): PackageOutputRecord[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const records = new Map<string, PackageOutputRecord>();
|
||||
for (const value of raw.slice(0, 1_000_000)) {
|
||||
const record = asRecord(value);
|
||||
if (!record || Number(record.version) !== 1) {
|
||||
continue;
|
||||
}
|
||||
const archivePath = asText(record.archivePath);
|
||||
const outputPath = asText(record.outputPath);
|
||||
const entryPath = asText(record.entryPath).replace(/\\/g, "/");
|
||||
const state = asText(record.state);
|
||||
const disposition = asText(record.disposition);
|
||||
if (!path.isAbsolute(archivePath)
|
||||
|| !path.isAbsolute(outputPath)
|
||||
|| !entryPath
|
||||
|| entryPath.startsWith("/")
|
||||
|| /^[a-zA-Z]:/.test(entryPath)
|
||||
|| entryPath.split("/").some((segment) => !segment || segment === "..")
|
||||
|| (state !== "complete" && state !== "partial")
|
||||
|| !["written", "overwritten", "renamed", "skipped"].includes(disposition)) {
|
||||
continue;
|
||||
}
|
||||
const key = path.resolve(outputPath).toLocaleLowerCase("en-US");
|
||||
records.set(key, {
|
||||
version: 1,
|
||||
archivePath: path.resolve(archivePath),
|
||||
entryPath,
|
||||
outputPath: path.resolve(outputPath),
|
||||
state,
|
||||
disposition: disposition as PackageOutputRecord["disposition"]
|
||||
});
|
||||
}
|
||||
return [...records.values()];
|
||||
}
|
||||
|
||||
function optionalClampedNumber(record: Record<string, unknown>, key: string, max = Number.MAX_SAFE_INTEGER): number | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(record, key)
|
||||
? clampNumber(record[key], 0, 0, max)
|
||||
@@ -974,9 +1012,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
const statusRaw = asText(pkg.status) as DownloadStatus;
|
||||
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
||||
const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : [];
|
||||
const outputProvenance = Array.isArray(pkg.outputProvenance)
|
||||
const normalizedOutputProvenance = 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)
|
||||
: [];
|
||||
const hasOutputProvenanceVersion = pkg.outputProvenanceVersion !== undefined && pkg.outputProvenanceVersion !== null;
|
||||
const rawOutputProvenanceVersion = Number(pkg.outputProvenanceVersion);
|
||||
const unknownOutputProvenanceVersion = hasOutputProvenanceVersion
|
||||
&& rawOutputProvenanceVersion !== PACKAGE_OUTPUT_PROVENANCE_VERSION;
|
||||
const outputProvenance = unknownOutputProvenanceVersion ? [] : normalizedOutputProvenance;
|
||||
const outputRecords = unknownOutputProvenanceVersion
|
||||
|| (!hasOutputProvenanceVersion && outputProvenance.length === 0)
|
||||
? []
|
||||
: normalizePackageOutputRecords(pkg.outputRecords);
|
||||
packagesById[id] = {
|
||||
id,
|
||||
name: asText(pkg.name) || "Paket",
|
||||
@@ -1010,8 +1057,12 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
|
||||
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
|
||||
outputCount: outputProvenance.length,
|
||||
outputProvenanceVersion: PACKAGE_OUTPUT_PROVENANCE_VERSION,
|
||||
outputProvenanceVersion: unknownOutputProvenanceVersion
|
||||
? rawOutputProvenanceVersion
|
||||
: PACKAGE_OUTPUT_PROVENANCE_VERSION,
|
||||
outputProvenance,
|
||||
outputRecords,
|
||||
outputScopeAdopted: Boolean(pkg.outputScopeAdopted),
|
||||
cleanupErrorCategory: asText(pkg.cleanupErrorCategory),
|
||||
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||
|
||||
@@ -497,6 +497,15 @@ export type PackageResultStatus = "completed" | "partial" | "failed" | "cancelle
|
||||
export type FailurePhase = "download" | "extract" | "remux" | "cleanup" | null;
|
||||
export const PACKAGE_OUTPUT_PROVENANCE_VERSION = 1;
|
||||
|
||||
export interface PackageOutputRecord {
|
||||
version: 1;
|
||||
archivePath: string;
|
||||
entryPath: string;
|
||||
outputPath: string;
|
||||
state: "complete" | "partial";
|
||||
disposition: "written" | "overwritten" | "renamed" | "skipped";
|
||||
}
|
||||
|
||||
export interface ArchiveOperationMetric {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -591,6 +600,8 @@ export interface PackageEntry {
|
||||
outputCount?: number;
|
||||
outputProvenanceVersion?: number;
|
||||
outputProvenance?: string[];
|
||||
outputRecords?: PackageOutputRecord[];
|
||||
outputScopeAdopted?: boolean;
|
||||
cleanupErrorCategory?: string;
|
||||
resultGeneration?: number;
|
||||
createdAt: number;
|
||||
|
||||
Reference in New Issue
Block a user