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:
Sucukdeluxe
2026-08-22 13:54:25 +02:00
parent 9049489e87
commit 58237bed0c
10 changed files with 952 additions and 533 deletions
+130 -21
View File
@@ -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 };
}
File diff suppressed because it is too large Load Diff
+151 -58
View File
@@ -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`);
}
}
+1 -1
View File
@@ -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
View File
@@ -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),
+11
View File
@@ -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;
+37 -5
View File
@@ -2,7 +2,13 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
import {
cleanupCancelledPackageArtifacts,
removeDownloadLinkArtifacts,
removeDownloadLinkArtifactsFromScope,
removeSampleArtifacts,
removeSampleArtifactsFromScope
} from "../src/main/cleanup";
const tempDirs: string[] = [];
@@ -81,7 +87,7 @@ describe("cleanup", () => {
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
});
it("does not recurse into sample symlink or junction targets", async () => {
it("does not recurse into sample symlink or junction targets", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
tempDirs.push(dir, external);
@@ -95,6 +101,32 @@ describe("cleanup", () => {
const result = await removeSampleArtifacts(dir);
expect(result.files).toBe(0);
expect(fs.existsSync(outsideFile)).toBe(true);
});
});
expect(fs.existsSync(outsideFile)).toBe(true);
});
it("removes only scoped link and sample outputs from a shared root", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-scope-"));
tempDirs.push(dir);
const sampleDir = path.join(dir, "Samples");
fs.mkdirSync(sampleDir, { recursive: true });
const ownedLink = path.join(dir, "owned.url");
const foreignLink = path.join(dir, "foreign.url");
const ownedSample = path.join(sampleDir, "owned-sample.mkv");
const foreignSample = path.join(sampleDir, "foreign-sample.mkv");
fs.writeFileSync(ownedLink, "owned");
fs.writeFileSync(foreignLink, "foreign");
fs.writeFileSync(ownedSample, "owned");
fs.writeFileSync(foreignSample, "foreign");
const removedLinks = await removeDownloadLinkArtifactsFromScope([ownedLink]);
const removedSamples = await removeSampleArtifactsFromScope([ownedSample]);
expect(removedLinks).toBe(1);
expect(removedSamples).toEqual({ files: 1, dirs: 0 });
expect(fs.existsSync(ownedLink)).toBe(false);
expect(fs.existsSync(ownedSample)).toBe(false);
expect(fs.existsSync(foreignLink)).toBe(true);
expect(fs.existsSync(foreignSample)).toBe(true);
expect(fs.existsSync(sampleDir)).toBe(true);
});
});
+102 -77
View File
@@ -587,7 +587,16 @@ describe("disk write recovery", () => {
})
});
const processed = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
const outputScope = (manager as any).getPackageOutputScope(pkg);
outputScope.add({
version: 1,
archivePath: path.join(pkg.outputDir, "archive.rar"),
entryPath: path.basename(sourcePath),
outputPath: sourcePath,
state: "complete",
disposition: "written"
});
const processed = await (manager as any).keepGermanAudioOnlyImpl(extractDir, outputScope, pkg);
expect(processed).toBe(0);
expect(fs.existsSync(sourcePath)).toBe(true);
@@ -12758,7 +12767,7 @@ describe("download manager", () => {
void manager;
}, 20000);
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
it("does not collect unscoped raw files outside the package extract directory", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -12808,10 +12817,9 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
expect(fs.existsSync(path.join(outputDir, rawName))).toBe(true);
expect(fs.existsSync(path.join(outputDir, rawSrt))).toBe(true);
expect(fs.existsSync(mkvLibraryDir) ? fs.readdirSync(mkvLibraryDir) : []).toEqual([]);
void manager;
}, 20000);
@@ -13246,7 +13254,7 @@ describe("download manager", () => {
void manager;
}, 20000);
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -13318,8 +13326,53 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
}
void manager;
}, 20000);
void manager;
}, 20000);
it("does not adopt unscoped files from an extraction root shared by packages", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shared-adoption-"));
tempDirs.push(root);
const sharedExtractDir = path.join(root, "shared");
const libraryDir = path.join(root, "library");
fs.mkdirSync(sharedExtractDir, { recursive: true });
const foreignPath = path.join(sharedExtractDir, "foreign.mkv");
fs.writeFileSync(foreignPath, "foreign");
const session = emptySession();
for (const id of ["package-a", "package-b"]) {
session.packages[id] = {
id,
name: id,
outputDir: path.join(root, "downloads", id),
extractDir: sharedExtractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
outputProvenanceVersion: 1,
outputRecords: [],
createdAt: 1_000,
updatedAt: 1_000
};
}
session.packageOrder = ["package-a", "package-b"];
const manager = new DownloadManager(
{
...defaultSettings(),
autoExtract: true,
createExtractSubfolder: true,
collectMkvToLibrary: true,
mkvLibraryDir: libraryDir
},
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).collectMkvFilesToLibrary("package-a", session.packages["package-a"]);
expect(fs.existsSync(foreignPath)).toBe(true);
expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false);
expect(session.packages["package-a"].outputRecords).toEqual([]);
});
it("does NOT move bonus files from Extras subdirectory to flat library", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -15114,7 +15167,7 @@ describe("package priority ordering", () => {
});
describe("package lifecycle telemetry boundaries", () => {
it("captures shared-root provenance from package staging without scanning unrelated files", async () => {
it("captures direct package output scopes concurrently without scanning a shared root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-provenance-lock-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
@@ -15122,17 +15175,10 @@ describe("package lifecycle telemetry boundaries", () => {
for (let index = 0; index < 2_000; index += 1) {
fs.writeFileSync(path.join(extractDir, `foreign-${index}.txt`), "foreign");
}
const traversedDirectories: string[] = [];
const manager = new DownloadManager(
{ ...defaultSettings(), extractConflictMode: "overwrite" },
emptySession(),
createStoragePaths(path.join(root, "state")),
{
readOutputDirectory: async (directory: string) => {
traversedDirectories.push(path.resolve(directory));
return fs.promises.readdir(directory, { withFileTypes: true });
}
} as any
createStoragePaths(path.join(root, "state"))
);
const createPackage = (id: string): PackageEntry => ({
id,
@@ -15155,79 +15201,51 @@ describe("package lifecycle telemetry boundaries", () => {
let enteredB = false;
const state = manager as any;
const first = state.runWithPackageOutputProvenance(packageA, async (operationTarget = extractDir) => {
fs.writeFileSync(path.join(operationTarget, "package-a.mkv"), "a");
const first = state.runWithPackageOutputProvenance(packageA, async (operationTarget: string, scope: any) => {
const outputPath = path.join(operationTarget, "package-a.mkv");
fs.writeFileSync(outputPath, "a");
scope.add({
version: 1,
archivePath: path.join(packageA.outputDir, "archive.rar"),
entryPath: "package-a.mkv",
outputPath,
state: "complete",
disposition: "written"
});
await gateA;
});
await vi.waitFor(() => expect(enteredB).toBe(false));
const second = state.runWithPackageOutputProvenance(packageB, async (operationTarget = extractDir) => {
const second = state.runWithPackageOutputProvenance(packageB, async (operationTarget: string, scope: any) => {
enteredB = true;
fs.writeFileSync(path.join(operationTarget, "package-b.mkv"), "b");
const outputPath = path.join(operationTarget, "package-b.mkv");
fs.writeFileSync(outputPath, "b");
scope.add({
version: 1,
archivePath: path.join(packageB.outputDir, "archive.rar"),
entryPath: "package-b.mkv",
outputPath,
state: "complete",
disposition: "written"
});
});
await vi.waitFor(() => expect(enteredB).toBe(true));
releaseA();
await Promise.all([first, second]);
expect(packageA.outputCount).toBe(1);
expect(packageB.outputCount).toBe(1);
expect(traversedDirectories.length).toBeGreaterThan(0);
expect(traversedDirectories).not.toContain(path.resolve(extractDir));
expect(traversedDirectories.length).toBeLessThanOrEqual(4);
expect(packageA.outputRecords).toEqual([expect.objectContaining({ outputPath: path.join(extractDir, "package-a.mkv") })]);
expect(packageB.outputRecords).toEqual([expect.objectContaining({ outputPath: path.join(extractDir, "package-b.mkv") })]);
});
it.each([
["overwrite", "package", ["episode.mkv"]],
["skip", "foreign", ["episode.mkv"]],
["rename", "foreign", ["episode (1).mkv", "episode.mkv"]]
] as const)("preserves %s conflicts while merging staged package outputs", async (conflictMode, expectedOriginal, expectedFiles) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-output-${conflictMode}-`));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "foreign");
const manager = new DownloadManager(
{ ...defaultSettings(), extractConflictMode: conflictMode },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const pkg: PackageEntry = {
id: `conflict-${conflictMode}`,
name: `conflict-${conflictMode}`,
outputDir: path.join(root, "downloads"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const state = manager as any;
await state.runWithPackageOutputProvenance(pkg, async (operationTarget = extractDir) => {
fs.writeFileSync(path.join(operationTarget, "episode.mkv"), "package");
});
expect(fs.readFileSync(path.join(extractDir, "episode.mkv"), "utf8")).toBe(expectedOriginal);
expect(fs.readdirSync(extractDir).filter((name) => name.endsWith(".mkv")).sort()).toEqual([...expectedFiles]);
expect(pkg.outputCount).toBe(conflictMode === "skip" ? 0 : 1);
});
it("retains partial staged outputs deterministically when extraction aborts", async () => {
it("retains directly reported partial outputs when extraction aborts", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-abort-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
fs.mkdirSync(extractDir, { recursive: true });
const traversedDirectories: string[] = [];
const manager = new DownloadManager(
defaultSettings(),
emptySession(),
createStoragePaths(path.join(root, "state")),
{
readOutputDirectory: async (directory: string) => {
traversedDirectories.push(path.resolve(directory));
return fs.promises.readdir(directory, { withFileTypes: true });
}
} as any
createStoragePaths(path.join(root, "state"))
);
const pkg: PackageEntry = {
id: "aborted-output",
@@ -15243,16 +15261,23 @@ describe("package lifecycle telemetry boundaries", () => {
};
const state = manager as any;
await expect(state.runWithPackageOutputProvenance(pkg, async (operationTarget = extractDir) => {
fs.writeFileSync(path.join(operationTarget, "partial.mkv"), "partial");
await expect(state.runWithPackageOutputProvenance(pkg, async (operationTarget: string, scope: any) => {
const outputPath = path.join(operationTarget, "partial.mkv");
fs.writeFileSync(outputPath, "partial");
scope.add({
version: 1,
archivePath: path.join(pkg.outputDir, "archive.rar"),
entryPath: "partial.mkv",
outputPath,
state: "partial",
disposition: "written"
});
throw new Error("aborted:extract");
})).rejects.toThrow("aborted:extract");
expect(fs.readFileSync(path.join(extractDir, "partial.mkv"), "utf8")).toBe("partial");
expect(pkg.outputCount).toBe(1);
expect(traversedDirectories.length).toBeGreaterThan(0);
expect(traversedDirectories).not.toContain(path.resolve(extractDir));
expect(fs.readdirSync(extractDir).filter((name) => name.startsWith(".rd-output-"))).toEqual([]);
expect(pkg.outputRecords).toEqual([expect.objectContaining({ state: "partial", outputPath: path.join(extractDir, "partial.mkv") })]);
});
it("uses normalized nested item paths for archive identity and leaves empty item provenance at zero", () => {
+28
View File
@@ -17,6 +17,7 @@ import {
shouldSerialRetryParallelFailures,
findArchiveCandidates,
orderExtractorCandidatesForArchive,
parseNativeExtractOutput,
resolveExtractorBackendModeForArchive,
resolveExtractorBackendMode,
shouldFallbackLegacyRarToJvm,
@@ -1467,5 +1468,32 @@ describe("extractor", () => {
expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false);
});
it("strictly parses native output paths and fails closed for ambiguous rename output", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-output-"));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const archivePath = path.join(root, "archive.7z");
const exactPath = path.join(targetDir, "folder", "episode.mkv");
fs.mkdirSync(path.dirname(exactPath), { recursive: true });
fs.writeFileSync(exactPath, "video");
expect(parseNativeExtractOutput("7z.exe", "- folder\\episode.mkv", archivePath, targetDir, "overwrite")).toEqual([
expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath: exactPath, disposition: "overwritten" })
]);
expect(parseNativeExtractOutput("UnRAR.exe", `Extracting ${exactPath} OK`, archivePath, targetDir, "overwrite")).toEqual([
expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath: exactPath })
]);
expect(parseNativeExtractOutput("7z.exe", "- ..\\foreign.mkv", archivePath, targetDir, "overwrite")).toEqual([]);
const renamedPath = path.join(targetDir, "episode (1).mkv");
fs.writeFileSync(path.join(targetDir, "episode.mkv"), "foreign");
fs.writeFileSync(renamedPath, "owned");
expect(parseNativeExtractOutput("7z.exe", "- episode.mkv", archivePath, targetDir, "rename")).toEqual([]);
expect(parseNativeExtractOutput("7z.exe", "- episode (1).mkv", archivePath, targetDir, "rename")).toEqual([
expect.objectContaining({ outputPath: renamedPath, disposition: "renamed" })
]);
});
});
});
+95
View File
@@ -1271,6 +1271,101 @@ describe("settings storage", () => {
}));
});
it("fails closed for unknown package output provenance versions without relabeling", () => {
const normalized = normalizeLoadedSession({
version: 2,
packageOrder: ["future-output"],
packages: {
"future-output": {
id: "future-output",
name: "Future output",
outputDir: "C:\\Downloads\\Future",
extractDir: "C:\\Downloads\\Shared",
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
outputCount: 2,
outputProvenanceVersion: 99,
outputProvenance: ["a".repeat(64), "b".repeat(64)],
outputRecords: [{
version: 1,
archivePath: "C:\\Downloads\\Future\\archive.rar",
entryPath: "episode.mkv",
outputPath: "C:\\Downloads\\Shared\\episode.mkv",
state: "complete",
disposition: "written"
}],
createdAt: 1_000,
updatedAt: 2_000
}
},
items: {},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 2_000
});
expect(normalized.packages["future-output"]).toEqual(expect.objectContaining({
outputCount: 0,
outputProvenanceVersion: 99,
outputProvenance: [],
outputRecords: []
}));
});
it("migrates unversioned valid provenance hashes and concrete output records to v1", () => {
const normalized = normalizeLoadedSession({
version: 2,
packageOrder: ["legacy-hashes"],
packages: {
"legacy-hashes": {
id: "legacy-hashes",
name: "Legacy hashes",
outputDir: "C:\\Downloads\\Legacy",
extractDir: "C:\\Downloads\\Shared",
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
outputCount: 40_000,
outputProvenance: ["a".repeat(64)],
outputRecords: [{
version: 1,
archivePath: "C:\\Downloads\\Legacy\\archive.rar",
entryPath: "episode.mkv",
outputPath: "C:\\Downloads\\Shared\\episode.mkv",
state: "complete",
disposition: "written"
}],
createdAt: 1_000,
updatedAt: 2_000
}
},
items: {},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 2_000
});
expect(normalized.packages["legacy-hashes"]).toEqual(expect.objectContaining({
outputCount: 1,
outputProvenanceVersion: 1,
outputProvenance: ["a".repeat(64)],
outputRecords: [expect.objectContaining({ entryPath: "episode.mkv", state: "complete" })]
}));
});
it("skips adding persisted history entries when history retention is never", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);