Fix extraction completion and password prioritization

This commit is contained in:
Sucukdeluxe 2026-03-05 14:11:30 +01:00
parent 6e00bbab53
commit babcd8edb7
2 changed files with 98 additions and 32 deletions

View File

@ -6396,6 +6396,7 @@ export class DownloadManager extends EventEmitter {
const hybridResolvedItems = new Map<string, DownloadItem[]>(); const hybridResolvedItems = new Map<string, DownloadItem[]>();
const hybridStartTimes = new Map<string, number>(); const hybridStartTimes = new Map<string, number>();
let hybridLastEmitAt = 0; let hybridLastEmitAt = 0;
let hybridLastProgressCurrent: number | null = null;
// Mark items based on whether their archive is actually ready for extraction. // Mark items based on whether their archive is actually ready for extraction.
// Only items whose archive is in readyArchives get "Ausstehend"; others keep // Only items whose archive is in readyArchives get "Ausstehend"; others keep
@ -6443,9 +6444,15 @@ export class DownloadManager extends EventEmitter {
if (progress.phase === "done") { if (progress.phase === "done") {
hybridResolvedItems.clear(); hybridResolvedItems.clear();
hybridStartTimes.clear(); hybridStartTimes.clear();
hybridLastProgressCurrent = null;
return; return;
} }
const currentCount = Math.max(0, Number(progress.current ?? 0));
const archiveFinished = progress.archiveDone === true
|| (hybridLastProgressCurrent !== null && currentCount > hybridLastProgressCurrent);
hybridLastProgressCurrent = currentCount;
if (progress.archiveName) { if (progress.archiveName) {
// Resolve items for this archive if not yet tracked // Resolve items for this archive if not yet tracked
if (!hybridResolvedItems.has(progress.archiveName)) { if (!hybridResolvedItems.has(progress.archiveName)) {
@ -6470,11 +6477,14 @@ export class DownloadManager extends EventEmitter {
} }
const archItems = hybridResolvedItems.get(progress.archiveName) || []; const archItems = hybridResolvedItems.get(progress.archiveName) || [];
// If archive is at 100%, mark its items as done and remove from active // Only mark as finished on explicit archive-done signal (or real current increment),
if (Number(progress.archivePercent ?? 0) >= 100) { // never on raw 100% archivePercent, because password retries can report 100% mid-run.
if (archiveFinished) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt); const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt);
for (const entry of archItems) { for (const entry of archItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = doneLabel; entry.fullStatus = doneLabel;
@ -6484,7 +6494,7 @@ export class DownloadManager extends EventEmitter {
hybridResolvedItems.delete(progress.archiveName); hybridResolvedItems.delete(progress.archiveName);
hybridStartTimes.delete(progress.archiveName); hybridStartTimes.delete(progress.archiveName);
// Show transitional label while next archive initializes // Show transitional label while next archive initializes
const done = progress.current + 1; const done = currentCount;
if (done < progress.total) { if (done < progress.total) {
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Naechstes Archiv...`; pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Naechstes Archiv...`;
this.emitState(); this.emitState();
@ -6516,7 +6526,7 @@ export class DownloadManager extends EventEmitter {
} }
// Update package-level label with overall extraction progress // Update package-level label with overall extraction progress
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
if (progress.passwordFound) { if (progress.passwordFound) {
pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`; pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`;
@ -6777,6 +6787,7 @@ export class DownloadManager extends EventEmitter {
// Track archives for parallel extraction progress // Track archives for parallel extraction progress
const fullResolvedItems = new Map<string, DownloadItem[]>(); const fullResolvedItems = new Map<string, DownloadItem[]>();
const fullStartTimes = new Map<string, number>(); const fullStartTimes = new Map<string, number>();
let fullLastProgressCurrent: number | null = null;
const result = await extractPackageArchives({ const result = await extractPackageArchives({
packageDir: pkg.outputDir, packageDir: pkg.outputDir,
@ -6802,10 +6813,16 @@ export class DownloadManager extends EventEmitter {
if (progress.phase === "done") { if (progress.phase === "done") {
fullResolvedItems.clear(); fullResolvedItems.clear();
fullStartTimes.clear(); fullStartTimes.clear();
fullLastProgressCurrent = null;
emitExtractStatus("Entpacken 100%", true); emitExtractStatus("Entpacken 100%", true);
return; return;
} }
const currentCount = Math.max(0, Number(progress.current ?? 0));
const archiveFinished = progress.archiveDone === true
|| (fullLastProgressCurrent !== null && currentCount > fullLastProgressCurrent);
fullLastProgressCurrent = currentCount;
if (progress.archiveName) { if (progress.archiveName) {
// Resolve items for this archive if not yet tracked // Resolve items for this archive if not yet tracked
if (!fullResolvedItems.has(progress.archiveName)) { if (!fullResolvedItems.has(progress.archiveName)) {
@ -6829,11 +6846,14 @@ export class DownloadManager extends EventEmitter {
} }
const archiveItems = fullResolvedItems.get(progress.archiveName) || []; const archiveItems = fullResolvedItems.get(progress.archiveName) || [];
// If archive is at 100%, mark its items as done and remove from active // Only finalize on explicit archive completion (or real current increment),
if (Number(progress.archivePercent ?? 0) >= 100) { // not on plain 100% archivePercent.
if (archiveFinished) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; const startedAt = fullStartTimes.get(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt); const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt);
for (const entry of archiveItems) { for (const entry of archiveItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = doneLabel; entry.fullStatus = doneLabel;
@ -6843,7 +6863,7 @@ export class DownloadManager extends EventEmitter {
fullResolvedItems.delete(progress.archiveName); fullResolvedItems.delete(progress.archiveName);
fullStartTimes.delete(progress.archiveName); fullStartTimes.delete(progress.archiveName);
// Show transitional label while next archive initializes // Show transitional label while next archive initializes
const done = progress.current + 1; const done = currentCount;
if (done < progress.total) { if (done < progress.total) {
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Naechstes Archiv...`, true); emitExtractStatus(`Entpacken (${done}/${progress.total}) - Naechstes Archiv...`, true);
} }
@ -6878,7 +6898,7 @@ export class DownloadManager extends EventEmitter {
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s` ? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: ""; : "";
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
let overallLabel: string; let overallLabel: string;
if (progress.passwordFound) { if (progress.passwordFound) {

View File

@ -123,6 +123,8 @@ export interface ExtractProgressUpdate {
passwordAttempt?: number; passwordAttempt?: number;
passwordTotal?: number; passwordTotal?: number;
passwordFound?: boolean; passwordFound?: boolean;
archiveDone?: boolean;
archiveSuccess?: boolean;
} }
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
@ -378,6 +380,19 @@ function parseProgressPercent(chunk: string): number | null {
return latest; return latest;
} }
function nextArchivePercent(previous: number, incoming: number): number {
const prev = Math.max(0, Math.min(100, Math.floor(Number(previous) || 0)));
const next = Math.max(0, Math.min(100, Math.floor(Number(incoming) || 0)));
if (next >= prev) {
return next;
}
// Wrong-password retries can emit a fresh 0..100 run for the same archive.
if (prev >= 95 && next <= 5) {
return next;
}
return prev;
}
async function shouldPreferExternalZip(archivePath: string): Promise<boolean> { async function shouldPreferExternalZip(archivePath: string): Promise<boolean> {
if (extractorBackendMode() !== "legacy") { if (extractorBackendMode() !== "legacy") {
return true; return true;
@ -529,9 +544,12 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
return passwords; return passwords;
} }
const index = passwords.findIndex((candidate) => candidate === target); const index = passwords.findIndex((candidate) => candidate === target);
if (index <= 0) { if (index === 0) {
return passwords; return passwords;
} }
if (index < 0) {
return [target, ...passwords.filter((candidate) => candidate !== target)];
}
const next = [...passwords]; const next = [...passwords];
const [value] = next.splice(index, 1); const [value] = next.splice(index, 1);
next.unshift(value); next.unshift(value);
@ -961,9 +979,12 @@ function parseJvmLine(
if (trimmed.startsWith("RD_PROGRESS ")) { if (trimmed.startsWith("RD_PROGRESS ")) {
const parsed = parseProgressPercent(trimmed); const parsed = parseProgressPercent(trimmed);
if (parsed !== null && parsed > state.bestPercent) { if (parsed !== null) {
state.bestPercent = parsed; const next = nextArchivePercent(state.bestPercent, parsed);
onArchiveProgress?.(parsed); if (next !== state.bestPercent) {
state.bestPercent = next;
onArchiveProgress?.(next);
}
} }
return; return;
} }
@ -1742,6 +1763,10 @@ async function runExternalExtractInner(
onArchiveProgress?.(0); onArchiveProgress?.(0);
} }
passwordAttempt += 1; passwordAttempt += 1;
if (passwordAttempt > 1 && bestPercent > 0) {
bestPercent = 0;
onArchiveProgress?.(0);
}
const quotedPw = password === "" ? '""' : `"${password}"`; const quotedPw = password === "" ? '""' : `"${password}"`;
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`);
if (passwords.length > 1) { if (passwords.length > 1) {
@ -1750,11 +1775,14 @@ async function runExternalExtractInner(
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
let result = await runExtractCommand(command, args, (chunk) => { let result = await runExtractCommand(command, args, (chunk) => {
const parsed = parseProgressPercent(chunk); const parsed = parseProgressPercent(chunk);
if (parsed === null || parsed <= bestPercent) { if (parsed === null) {
return; return;
} }
bestPercent = parsed; const next = nextArchivePercent(bestPercent, parsed);
onArchiveProgress?.(bestPercent); if (next !== bestPercent) {
bestPercent = next;
onArchiveProgress?.(bestPercent);
}
}, signal, timeoutMs); }, signal, timeoutMs);
if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) { if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) {
@ -1764,11 +1792,14 @@ async function runExternalExtractInner(
args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode); args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode);
result = await runExtractCommand(command, args, (chunk) => { result = await runExtractCommand(command, args, (chunk) => {
const parsed = parseProgressPercent(chunk); const parsed = parseProgressPercent(chunk);
if (parsed === null || parsed <= bestPercent) { if (parsed === null) {
return; return;
} }
bestPercent = parsed; const next = nextArchivePercent(bestPercent, parsed);
onArchiveProgress?.(bestPercent); if (next !== bestPercent) {
bestPercent = next;
onArchiveProgress?.(bestPercent);
}
}, signal, timeoutMs); }, signal, timeoutMs);
} }
@ -2258,6 +2289,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
let extracted = candidates.length - pendingCandidates.length; let extracted = candidates.length - pendingCandidates.length;
let failed = 0; let failed = 0;
let lastError = ""; let lastError = "";
let learnedPassword = "";
const extractedArchives = new Set<string>(); const extractedArchives = new Set<string>();
for (const archivePath of candidates) { for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
@ -2271,7 +2303,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
phase: "extracting" | "done", phase: "extracting" | "done",
archivePercent?: number, archivePercent?: number,
elapsedMs?: number, elapsedMs?: number,
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean } pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean },
archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean }
): void => { ): void => {
if (!options.onProgress) { if (!options.onProgress) {
return; return;
@ -2292,6 +2325,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
archivePercent, archivePercent,
elapsedMs, elapsedMs,
phase, phase,
...(archiveInfo || {}),
...(pwInfo || {}) ...(pwInfo || {})
}); });
} catch (error) { } catch (error) {
@ -2306,7 +2340,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes. // rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
for (const archivePath of candidates) { for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0); emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
} }
} }
@ -2329,11 +2363,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, 1100); }, 1100);
const hybrid = Boolean(options.hybridMode); const hybrid = Boolean(options.hybridMode);
// Insert archive-filename-derived passwords after "" but before custom passwords // Before the first successful extraction, filename-derived candidates are useful.
// After a known password is learned, try that first to avoid per-archive delays.
const filenamePasswords = archiveFilenamePasswords(archiveName); const filenamePasswords = archiveFilenamePasswords(archiveName);
const archivePasswordCandidates = filenamePasswords.length > 0 const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
? Array.from(new Set(["", ...filenamePasswords, ...passwordCandidates.filter((p) => p !== "")])) const orderedNonEmpty = learnedPassword
: passwordCandidates; ? [learnedPassword, ...nonEmptyBasePasswords.filter((p) => p !== learnedPassword), ...filenamePasswords]
: [...filenamePasswords, ...nonEmptyBasePasswords];
const archivePasswordCandidates = Array.from(new Set(["", ...orderedNonEmpty]));
// Validate generic .001 splits via file signature before attempting extraction // Validate generic .001 splits via file signature before attempting extraction
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName); const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
@ -2368,9 +2405,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (preferExternal) { if (preferExternal) {
try { try {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = nextArchivePercent(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal, hybrid, onPwAttempt); }, options.signal, hybrid, onPwAttempt);
if (usedPassword) {
learnedPassword = usedPassword;
}
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} catch (error) { } catch (error) {
if (isNoExtractorError(String(error))) { if (isNoExtractorError(String(error))) {
@ -2389,9 +2429,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
try { try {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = nextArchivePercent(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal, hybrid, onPwAttempt); }, options.signal, hybrid, onPwAttempt);
if (usedPassword) {
learnedPassword = usedPassword;
}
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} catch (externalError) { } catch (externalError) {
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) { if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
@ -2403,9 +2446,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
} else { } else {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = nextArchivePercent(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal, hybrid, onPwAttempt); }, options.signal, hybrid, onPwAttempt);
if (usedPassword) {
learnedPassword = usedPassword;
}
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} }
extracted += 1; extracted += 1;
@ -2415,9 +2461,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
archivePercent = 100; archivePercent = 100;
if (hasManyPasswords) { if (hasManyPasswords) {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true });
} else { } else {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true });
} }
} catch (error) { } catch (error) {
const errorText = String(error); const errorText = String(error);
@ -2428,7 +2474,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
lastError = errorText; lastError = errorText;
const errorCategory = classifyExtractionError(errorText); const errorCategory = classifyExtractionError(errorText);
logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`); logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false });
if (isNoExtractorError(errorText)) { if (isNoExtractorError(errorText)) {
noExtractorEncountered = true; noExtractorEncountered = true;
} }