fix(extraction): make manual archive workflows deterministic

Scope selected item runs and stop mutations precisely, attach hybrid work to the active run, retain password progress, and report finalization with real percentages.

Drain stale post-processing before manual extraction, allow package and child extraction with open siblings, retry nonterminal hybrid archive attempts, and replace misleading aggregate retry counts with actionable conversion status.
This commit is contained in:
Sucukdeluxe
2026-08-23 01:21:45 +02:00
parent bffefe0130
commit b8a7c24954
12 changed files with 682 additions and 225 deletions
+4 -4
View File
@@ -1034,14 +1034,14 @@ export class AppController {
return paused; return paused;
} }
public retryExtraction(packageId: string): void { public async retryExtraction(packageId: string): Promise<void> {
this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
this.manager.retryExtraction(packageId); await this.manager.retryExtraction(packageId);
} }
public extractNow(request: ExtractNowRequest): void { public async extractNow(request: ExtractNowRequest): Promise<void> {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageIds: request.packageIds, itemIds: request.itemIds }); this.audit("INFO", "Jetzt entpacken ausgelöst", { packageIds: request.packageIds, itemIds: request.itemIds });
this.manager.extractNow(request); await this.manager.extractNow(request);
} }
public resetPackage(packageId: string): void { public resetPackage(packageId: string): void {
+180 -133
View File
@@ -388,6 +388,43 @@ function getPostExtractTimeoutMs(): number {
return DEFAULT_POST_EXTRACT_TIMEOUT_MS; return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
} }
export function formatExtractionProgressLabels(progress: Pick<ExtractProgressUpdate,
"current" | "total" | "percent" | "archiveName" | "archivePercent" | "elapsedMs"
| "passwordAttempt" | "passwordTotal" | "passwordFound" | "archiveDone"
>): { itemLabel: string; packageLabel: string } {
const archivePercent = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
const overallPercent = Math.max(0, Math.min(100, Math.floor(Number(progress.percent ?? 0))));
const total = Math.max(1, Math.floor(Number(progress.total) || 1));
const current = Math.max(0, Math.min(total, Math.floor(Number(progress.current) || 0)));
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: "";
if (progress.passwordFound) {
return {
itemLabel: `Passwort gefunden${archive}`,
packageLabel: "Passwort gefunden"
};
}
if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
const passwordPercent = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
return {
itemLabel: `Passwort knacken: ${passwordPercent}% (${progress.passwordAttempt}/${progress.passwordTotal})${archive}`,
packageLabel: `Passwort knacken: ${passwordPercent}% (${progress.passwordAttempt}/${progress.passwordTotal})`
};
}
if (archivePercent >= 99 && progress.archiveDone !== true) {
return {
itemLabel: `Finalisieren - ${archivePercent}%${archive}${elapsed}`,
packageLabel: `Finalisieren - ${overallPercent}% (${current}/${total})${archive}${elapsed}`
};
}
return {
itemLabel: `Entpacken ${archivePercent}%${archive}${elapsed}`,
packageLabel: `Entpacken ${overallPercent}% (${current}/${total})${archive}${elapsed}`
};
}
function getUnrestrictTimeoutMs(): number { function getUnrestrictTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) { if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) {
@@ -3024,8 +3061,7 @@ export class DownloadManager extends EventEmitter {
} }
private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise<void>[] { private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise<void>[] {
const tasks: Promise<void>[] = []; const tasks: Promise<void>[] = [this.extractionCoordinator.cancelPackage(packageId, reason)];
void this.extractionCoordinator.cancelPackage(packageId, reason);
if (invalidateDeferred) { if (invalidateDeferred) {
this.bumpPackagePostProcessVersion(packageId); this.bumpPackagePostProcessVersion(packageId);
} }
@@ -6849,7 +6885,6 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
this.triggerPendingExtractions();
const runItems = Object.values(this.session.items) const runItems = Object.values(this.session.items)
.filter((item) => { .filter((item) => {
if (!targetSet.has(item.packageId)) return false; if (!targetSet.has(item.packageId)) return false;
@@ -6858,6 +6893,7 @@ export class DownloadManager extends EventEmitter {
return Boolean(pkg && !pkg.cancelled && pkg.enabled); return Boolean(pkg && !pkg.cancelled && pkg.enabled);
}); });
if (runItems.length === 0) { if (runItems.length === 0) {
this.triggerPendingExtractions();
this.lifecyclePhase = "idle"; this.lifecyclePhase = "idle";
this.lifecycleReason = "Bereit"; this.lifecycleReason = "Bereit";
this.persistSoon(); this.persistSoon();
@@ -6881,6 +6917,7 @@ export class DownloadManager extends EventEmitter {
this.lifecycleReason = "Downloads laufen"; this.lifecycleReason = "Downloads laufen";
this.session.runStartedAt = nowMs(); this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.triggerPendingExtractions();
this.session.totalDownloadedBytes = 0; this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0; this.sessionCompletedFiles = 0;
this.session.summaryText = ""; this.session.summaryText = "";
@@ -6963,7 +7000,6 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
this.triggerPendingExtractions();
const runItems = [...targetSet] const runItems = [...targetSet]
.map((id) => this.session.items[id]) .map((id) => this.session.items[id])
.filter((item) => { .filter((item) => {
@@ -6973,6 +7009,7 @@ export class DownloadManager extends EventEmitter {
return Boolean(pkg && !pkg.cancelled && pkg.enabled); return Boolean(pkg && !pkg.cancelled && pkg.enabled);
}); });
if (runItems.length === 0) { if (runItems.length === 0) {
this.triggerPendingExtractions();
this.lifecyclePhase = "idle"; this.lifecyclePhase = "idle";
this.lifecycleReason = "Bereit"; this.lifecycleReason = "Bereit";
this.persistSoon(); this.persistSoon();
@@ -6996,6 +7033,7 @@ export class DownloadManager extends EventEmitter {
this.lifecycleReason = "Downloads laufen"; this.lifecycleReason = "Downloads laufen";
this.session.runStartedAt = nowMs(); this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.triggerPendingExtractions();
this.session.totalDownloadedBytes = 0; this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0; this.sessionCompletedFiles = 0;
this.session.summaryText = ""; this.session.summaryText = "";
@@ -7189,6 +7227,7 @@ export class DownloadManager extends EventEmitter {
public stop(options?: { parkForRestart?: boolean }): void { public stop(options?: { parkForRestart?: boolean }): void {
const parkForRestart = options?.parkForRestart === true; const parkForRestart = options?.parkForRestart === true;
const previousLifecyclePhase = this.lifecyclePhase;
const wasStopping = this.lifecyclePhase === "stopping"; const wasStopping = this.lifecyclePhase === "stopping";
this.lifecycleGeneration += 1; this.lifecycleGeneration += 1;
this.lifecyclePhase = "stopping"; this.lifecyclePhase = "stopping";
@@ -7200,31 +7239,60 @@ export class DownloadManager extends EventEmitter {
this.healthShuttingDown = parkForRestart; this.healthShuttingDown = parkForRestart;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const wasRunning = this.session.running; const wasRunning = this.session.running;
const stoppedItemIds = new Set(this.runItemIds);
const stoppedPackageIds = new Set(this.runPackageIds);
const hasScopedRun = wasRunning && stoppedItemIds.size > 0;
const stopsStandalonePostProcessing = wasRunning && !hasScopedRun && previousLifecyclePhase === "postprocessing";
const stoppedRunContext = wasRunning const stoppedRunContext = wasRunning
? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt) ? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt)
: null; : null;
this.suppressStandalonePackageResults(); if (!stoppedRunContext || stopsStandalonePostProcessing) {
this.suppressStandalonePackageResults();
}
this.schedulerGeneration += 1; this.schedulerGeneration += 1;
this.session.running = false; this.session.running = false;
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.retryAfterByItem.clear(); if (hasScopedRun) {
this.providerStartReservations.clear(); const paceKeys = new Set<string>();
this.pacedStartReservationByItem.clear(); for (const itemId of stoppedItemIds) {
this.retryStateByItem.clear(); const item = this.session.items[itemId];
const paceKey = item ? this.getPacedStartKeyForItem(item) : "";
if (paceKey) paceKeys.add(paceKey);
this.retryAfterByItem.delete(itemId);
this.pacedStartReservationByItem.delete(itemId);
this.retryStateByItem.delete(itemId);
}
for (const paceKey of paceKeys) {
if (this.countFuturePacedStarts(paceKey, nowMs()) <= 0) {
this.providerStartReservations.delete(paceKey);
}
}
} else {
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
}
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.speedEvents = []; this.speedEvents = [];
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear(); this.speedBytesPerPackage.clear();
this.speedEventsHead = 0; this.speedEventsHead = 0;
this.abortPostProcessing("stop", stoppedRunContext?.id); this.abortPostProcessing("stop", stopsStandalonePostProcessing ? undefined : stoppedRunContext?.id);
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
if (hasScopedRun && !stoppedItemIds.has(active.itemId)) {
continue;
}
active.abortReason = abortReason; active.abortReason = abortReason;
active.abortController.abort(abortReason); active.abortController.abort(abortReason);
} }
for (const item of Object.values(this.session.items)) { for (const item of Object.values(this.session.items)) {
if (hasScopedRun && !stoppedItemIds.has(item.id)) {
continue;
}
if (!isFinishedStatus(item.status)) { if (!isFinishedStatus(item.status)) {
item.status = "queued"; item.status = "queued";
item.speedBps = 0; item.speedBps = 0;
@@ -7234,6 +7302,9 @@ export class DownloadManager extends EventEmitter {
} }
} }
for (const pkg of Object.values(this.session.packages)) { for (const pkg of Object.values(this.session.packages)) {
if (hasScopedRun && !stoppedPackageIds.has(pkg.id)) {
continue;
}
if (pkg.status === "downloading" || pkg.status === "validating" if (pkg.status === "downloading" || pkg.status === "validating"
|| pkg.status === "extracting" || pkg.status === "integrity_check" || pkg.status === "extracting" || pkg.status === "integrity_check"
|| pkg.status === "paused" || pkg.status === "reconnect_wait") { || pkg.status === "paused" || pkg.status === "reconnect_wait") {
@@ -8418,6 +8489,14 @@ export class DownloadManager extends EventEmitter {
} }
if (changed > 0) { if (changed > 0) {
if (this.session.running) {
for (const { item } of corruptArchiveItems) {
this.runItemIds.add(item.id);
this.runOutcomes.delete(item.id);
}
this.runPackageIds.add(pkg.id);
this.trackActiveRunPackage(pkg.id);
}
this.clearHybridArchiveState(pkg.id); this.clearHybridArchiveState(pkg.id);
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued";
pkg.updatedAt = queuedAt; pkg.updatedAt = queuedAt;
@@ -9153,56 +9232,52 @@ export class DownloadManager extends EventEmitter {
} }
} }
public retryExtraction(packageId: string): void { public async retryExtraction(packageId: string): Promise<void> {
const pkg = this.session.packages[packageId]; if (!(await this.armExtractNowPackage(packageId))) {
if (!pkg) return; throw new Error("Kein entpackbarer Archivsatz ausgewählt");
if (this.packagePostProcessTasks.has(packageId)) return;
this.clearHybridArchiveState(packageId);
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
const completedItems = items.filter((item) => item.status === "completed");
const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus));
if (targetItems.length === 0) return;
pkg.status = "queued";
pkg.updatedAt = nowMs();
for (const item of targetItems) {
if (!isExtractedLabel(item.fullStatus)) {
item.fullStatus = "Entpacken - Ausstehend";
item.updatedAt = nowMs();
}
} }
logger.info(`Extraktion manuell wiederholt: pkg=${pkg.name}`);
this.logPackageForPackage(pkg, "INFO", "Extraktion manuell wiederholt", {
completedItems: completedItems.length,
targetedItems: targetItems.length
});
this.beginPackageResultGeneration(packageId, false, true);
this.reactivateStandalonePackageResult(packageId);
this.manualExtractPackages.add(packageId);
this.persistSoon();
this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`));
} }
private armExtractNowPackage( private async armExtractNowPackage(
packageId: string, packageId: string,
selectedItemIds?: ReadonlySet<string>, selectedItemIds?: ReadonlySet<string>,
archiveFilter?: ReadonlySet<string> archiveFilter?: ReadonlySet<string>
): boolean { ): Promise<boolean> {
const pkg = this.session.packages[packageId]; let pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) return false; if (!pkg || pkg.cancelled) return false;
if (this.packagePostProcessTasks.has(packageId)) return false; let items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
this.clearHybridArchiveState(packageId); let completedItems = items.filter((item) => item.status === "completed");
if (!pkg.enabled) { let targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id)));
pkg.enabled = true;
}
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
const completedItems = items.filter((item) => item.status === "completed");
const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id)));
if (targetItems.length === 0) { if (targetItems.length === 0) {
this.manualExtractArchiveFilters.delete(packageId); this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.delete(packageId); this.manualExtractPackages.delete(packageId);
return false; return false;
} }
const initialTargetIds = new Set(targetItems.map((item) => item.id));
if (this.packagePostProcessTasks.has(packageId) || this.hasDeferredPostProcessPending(packageId)) {
pkg.postProcessLabel = "Entpacken wird neu gestartet...";
pkg.updatedAt = nowMs();
this.emitState(true);
await Promise.allSettled(this.abortPackagePostProcessing(packageId, "manual_extract_restart"));
pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) return false;
items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
completedItems = items.filter((item) => item.status === "completed");
targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id)));
if (targetItems.length === 0) {
pkg.postProcessLabel = undefined;
pkg.updatedAt = nowMs();
this.emitState(true);
return [...initialTargetIds].every((itemId) => {
const item = this.session.items[itemId];
return Boolean(item && item.status === "completed" && isExtractedLabel(item.fullStatus));
});
}
}
this.clearHybridArchiveState(packageId);
if (!pkg.enabled) {
pkg.enabled = true;
}
if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter)); if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter));
else this.manualExtractArchiveFilters.delete(packageId); else this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.add(packageId); this.manualExtractPackages.add(packageId);
@@ -9225,8 +9300,9 @@ export class DownloadManager extends EventEmitter {
return true; return true;
} }
private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet<string>): Promise<void> { private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet<string>): Promise<number> {
const selectedByPackage = new Map<string, Set<string>>(); const selectedByPackage = new Map<string, Set<string>>();
let armed = 0;
for (const itemId of itemIds) { for (const itemId of itemIds) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item || excludedPackageIds.has(item.packageId)) { if (!item || excludedPackageIds.has(item.packageId)) {
@@ -9238,7 +9314,7 @@ export class DownloadManager extends EventEmitter {
} }
for (const [packageId, selectedItemIds] of selectedByPackage) { for (const [packageId, selectedItemIds] of selectedByPackage) {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(packageId)) { if (!pkg || pkg.cancelled) {
continue; continue;
} }
const completedItems = pkg.itemIds const completedItems = pkg.itemIds
@@ -9250,27 +9326,36 @@ export class DownloadManager extends EventEmitter {
logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`); logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`);
continue; continue;
} }
this.armExtractNowPackage( if (await this.armExtractNowPackage(
packageId, packageId,
selection.itemIds, selection.itemIds,
new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath)))
); )) {
armed += 1;
}
} }
return armed;
} }
public extractNow(target: string | ExtractNowRequest): void { public async extractNow(target: string | ExtractNowRequest): Promise<void> {
if (typeof target === "string") { if (typeof target === "string") {
this.armExtractNowPackage(target); if (!(await this.armExtractNowPackage(target))) {
throw new Error("Kein entpackbarer Archivsatz ausgewählt");
}
return; return;
} }
const packageIds = [...new Set(target.packageIds)]; const packageIds = [...new Set(target.packageIds)];
const packageSet = new Set(packageIds); const packageSet = new Set(packageIds);
let armed = 0;
for (const packageId of packageIds) { for (const packageId of packageIds) {
this.armExtractNowPackage(packageId); if (await this.armExtractNowPackage(packageId)) {
armed += 1;
}
}
armed += await this.extractNowItems(target.itemIds, packageSet);
if (armed === 0) {
throw new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt");
} }
void this.extractNowItems(target.itemIds, packageSet).catch((error) => {
logger.warn(`Jetzt entpacken für Dateiauswahl fehlgeschlagen: ${compactErrorText(error)}`);
});
} }
private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void {
@@ -10254,6 +10339,7 @@ export class DownloadManager extends EventEmitter {
if (normalCandidate && pkgPrio === "normal") continue; if (normalCandidate && pkgPrio === "normal") continue;
for (const itemId of pkg.itemIds) { for (const itemId of pkg.itemIds) {
if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) continue;
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item) continue; if (!item) continue;
const retryAfter = this.retryAfterByItem.get(itemId) || 0; const retryAfter = this.retryAfterByItem.get(itemId) || 0;
@@ -10293,6 +10379,9 @@ export class DownloadManager extends EventEmitter {
if (!pkg || pkg.cancelled || !pkg.enabled) continue; if (!pkg || pkg.cancelled || !pkg.enabled) continue;
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue;
for (const itemId of pkg.itemIds) { for (const itemId of pkg.itemIds) {
if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) {
continue;
}
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item) continue; if (!item) continue;
if (item.status !== "queued" && item.status !== "reconnect_wait") continue; if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
@@ -10327,6 +10416,9 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
for (const itemId of pkg.itemIds) { for (const itemId of pkg.itemIds) {
if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) {
continue;
}
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item) { if (!item) {
continue; continue;
@@ -13588,9 +13680,18 @@ export class DownloadManager extends EventEmitter {
const alreadyTried = this.hybridExtractedPaths.get(packageId); const alreadyTried = this.hybridExtractedPaths.get(packageId);
if (alreadyTried) { if (alreadyTried) {
for (const key of [...readyArchives]) { for (const key of [...readyArchives]) {
if (alreadyTried.has(key)) { if (!alreadyTried.has(key)) {
readyArchives.delete(key); continue;
} }
const archiveItems = resolveArchiveItemsFromList(path.basename(key), completedItems, key);
if (archiveItems.length === 0 || archiveItems.every((item) => isExtractedLabel(item.fullStatus))) {
readyArchives.delete(key);
} else {
alreadyTried.delete(key);
}
}
if (alreadyTried.size === 0) {
this.hybridExtractedPaths.delete(packageId);
} }
} }
@@ -13668,6 +13769,7 @@ export class DownloadManager extends EventEmitter {
const autoRecoveredArchives = new Set<string>(); const autoRecoveredArchives = new Set<string>();
const failedArchiveErrors = new Map<string, string>(); const failedArchiveErrors = new Map<string, string>();
const failedArchiveCategories = new Map<string, string>(); const failedArchiveCategories = new Map<string, string>();
const successfulArchiveKeys = new Set<string>();
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;
@@ -13783,6 +13885,7 @@ export class DownloadManager extends EventEmitter {
: formatExtractDone(doneAt - startedAt); : formatExtractDone(doneAt - startedAt);
const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined; const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined;
if (archiveKey && progress.archiveSuccess !== false) { if (archiveKey && progress.archiveSuccess !== false) {
successfulArchiveKeys.add(archiveKey);
this.clearHybridArchiveState(packageId, archiveKey); this.clearHybridArchiveState(packageId, archiveKey);
} }
this.recordArchiveOperation( this.recordArchiveOperation(
@@ -13804,23 +13907,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
} else { } else {
const archiveLabel = ` · ${progress.archiveName}`; const label = formatExtractionProgressLabels(progress).itemLabel;
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: "";
const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
const isFinalizing = archivePct >= 99;
let label: string;
if (progress.passwordFound) {
label = `Passwort gefunden · ${progress.archiveName}`;
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`;
} else if (isFinalizing) {
label = `Finalisieren${archiveLabel}${elapsed}`;
} else {
label = `Entpacken ${archivePct}%${archiveLabel}${elapsed}`;
}
const updatedAt = nowMs(); const updatedAt = nowMs();
for (const entry of archItems) { for (const entry of archItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue;
@@ -13830,22 +13917,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; pkg.postProcessLabel = formatExtractionProgressLabels(progress).packageLabel;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
if (progress.passwordFound) {
pkg.postProcessLabel = "Passwort gefunden";
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`;
} else if (Number(progress.archivePercent ?? 0) >= 99) {
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: "";
pkg.postProcessLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`;
} else {
pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`;
}
const now = nowMs(); const now = nowMs();
if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
@@ -13864,7 +13936,8 @@ export class DownloadManager extends EventEmitter {
{ {
let tried = this.hybridExtractedPaths.get(packageId); let tried = this.hybridExtractedPaths.get(packageId);
if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); } if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); }
for (const key of readyArchives) { tried.add(key); } for (const key of successfulArchiveKeys) { tried.add(key); }
if (tried.size === 0) this.hybridExtractedPaths.delete(packageId);
} }
if (failedArchiveErrors.size > 0) { if (failedArchiveErrors.size > 0) {
let failed = this.hybridFailedArchives.get(packageId); let failed = this.hybridFailedArchives.get(packageId);
@@ -14119,7 +14192,7 @@ export class DownloadManager extends EventEmitter {
); );
} }
if (!allDone && this.settings.hybridExtract && shouldExtract && failed === 0 && success > 0) { if (!manualExtraction && !allDone && this.settings.hybridExtract && shouldExtract && failed === 0 && success > 0) {
pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.postProcessLabel = "Entpacken vorbereiten...";
this.emitState(); this.emitState();
const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal); const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal);
@@ -14147,7 +14220,7 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
if (!allDone) { if (!manualExtraction && !allDone) {
pkg.postProcessLabel = undefined; pkg.postProcessLabel = undefined;
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued";
logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`); logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`);
@@ -14180,7 +14253,7 @@ export class DownloadManager extends EventEmitter {
} }
lastExtractEmitAt = now; lastExtractEmitAt = now;
pkg.postProcessLabel = text || "Entpacken..."; pkg.postProcessLabel = text || "Entpacken...";
this.emitState(); this.emitState(force);
}; };
const extractTimeoutMs = getPostExtractTimeoutMs(); const extractTimeoutMs = getPostExtractTimeoutMs();
@@ -14362,23 +14435,7 @@ export class DownloadManager extends EventEmitter {
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true); emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true);
} }
} else { } else {
const archiveTag = progress.archiveName ? ` · ${progress.archiveName}` : ""; const label = formatExtractionProgressLabels(progress).itemLabel;
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: "";
const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
const isFinalizing = archivePct >= 99;
let label: string;
if (progress.passwordFound) {
label = `Passwort gefunden · ${progress.archiveName}`;
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`;
} else if (isFinalizing) {
label = `Finalisieren${archiveTag}${elapsed}`;
} else {
label = `Entpacken ${archivePct}%${archiveTag}${elapsed}`;
}
const updatedAt = nowMs(); const updatedAt = nowMs();
for (const entry of archiveItems) { for (const entry of archiveItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue;
@@ -14388,24 +14445,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; emitExtractStatus(formatExtractionProgressLabels(progress).packageLabel);
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: "";
const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
let overallLabel: string;
if (progress.passwordFound) {
overallLabel = `Passwort gefunden · ${progress.archiveName || ""}`;
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`;
} else if (Number(progress.archivePercent ?? 0) >= 99) {
overallLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`;
} else {
overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
}
emitExtractStatus(overallLabel);
} }
})); }));
} catch (error) { } catch (error) {
@@ -14490,7 +14530,14 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = finalAt; entry.updatedAt = finalAt;
} }
} }
if (manualArchiveFilter) { if (manualExtraction && !allDone) {
const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || ""));
pkg.status = hasRemainingExtractError
? "failed"
: this.session.paused
? "paused"
: (pkg.enabled && this.session.running ? "downloading" : "queued");
} else if (manualArchiveFilter) {
const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || "")); const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || ""));
const hasRemainingExtractWork = completedItems.some((entry) => !isExtractedLabel(entry.fullStatus || "") && /^Entpack/i.test(entry.fullStatus || "")); const hasRemainingExtractWork = completedItems.some((entry) => !isExtractedLabel(entry.fullStatus || "") && /^Entpack/i.test(entry.fullStatus || ""));
pkg.status = hasRemainingExtractError ? "failed" : hasRemainingExtractWork ? "queued" : "completed"; pkg.status = hasRemainingExtractError ? "failed" : hasRemainingExtractWork ? "queued" : "completed";
+17 -3
View File
@@ -1458,6 +1458,18 @@ function nextArchivePercent(previous: number, incoming: number): number {
return next >= prev ? next : prev; return next >= prev ? next : prev;
} }
type ExtractPasswordProgress = Pick<ExtractProgressUpdate, "passwordAttempt" | "passwordTotal" | "passwordFound">;
export function mergeExtractPasswordProgress(
current: ExtractPasswordProgress | undefined,
update: ExtractPasswordProgress | undefined
): ExtractPasswordProgress | undefined {
if (update?.passwordFound || (update?.passwordAttempt && update?.passwordTotal)) {
return { ...update };
}
return current;
}
function runExtractCommand( function runExtractCommand(
command: string, command: string,
args: string[], args: string[],
@@ -4091,9 +4103,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
let archivePercent = 0; let archivePercent = 0;
let reached99At: number | null = null; let reached99At: number | null = null;
let archiveOutcome: "success" | "failed" | "skipped" = "failed"; let archiveOutcome: "success" | "failed" | "skipped" = "failed";
let activePasswordProgress: ExtractPasswordProgress | undefined;
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0, undefined, undefined, archivePath); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0, undefined, undefined, archivePath);
const pulseTimer = setInterval(() => { const pulseTimer = setInterval(() => {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, undefined, archivePath); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath);
}, 1100); }, 1100);
const hybrid = Boolean(options.hybridMode); const hybrid = Boolean(options.hybridMode);
const filenamePasswords = archiveFilenamePasswords(archiveName); const filenamePasswords = archiveFilenamePasswords(archiveName);
@@ -4110,7 +4123,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
reached99At = Date.now(); reached99At = Date.now();
logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`); logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`);
} }
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, undefined, archivePath); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath);
}; };
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);
@@ -4140,7 +4153,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
} }
const onPwAttempt = hasManyPasswords const onPwAttempt = hasManyPasswords
? (attempt: number, total: number) => { ? (attempt: number, total: number) => {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }, undefined, archivePath); activePasswordProgress = mergeExtractPasswordProgress(activePasswordProgress, { passwordAttempt: attempt, passwordTotal: total });
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath);
options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`); options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`);
} }
: undefined; : undefined;
+5
View File
@@ -175,6 +175,7 @@ const pairs = [
["Token neu (Code ungueltig machen)", "New token (invalidate code)"], ["Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.", "Contains the access token - treat it like a password. New token = old code becomes invalid immediately."], ["Token neu (Code ungueltig machen)", "New token (invalidate code)"], ["Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.", "Contains the access token - treat it like a password. New token = old code becomes invalid immediately."],
["Möchtest Du wirklich diese Aufräumaktion(en) durchführen?", "Do you really want to perform these cleanup action(s)?"], ["Ausgewählte Links löschen", "Delete selected links"], ["Nicht mehr anzeigen", "Do not show again"], ["Möchtest Du wirklich diese Aufräumaktion(en) durchführen?", "Do you really want to perform these cleanup action(s)?"], ["Ausgewählte Links löschen", "Delete selected links"], ["Nicht mehr anzeigen", "Do not show again"],
["Paket bereits entpackt", "Package already extracted"], ["ist im Ziel bereits vorhanden.", "already exists at the destination."], ["Für alle weiteren Pakete dieselbe Auswahl verwenden", "Use the same selection for all remaining packages"], ["Paket bereits entpackt", "Package already extracted"], ["ist im Ziel bereits vorhanden.", "already exists at the destination."], ["Für alle weiteren Pakete dieselbe Auswahl verwenden", "Use the same selection for all remaining packages"],
["Link-Umwandlung erneut", "Retrying link conversion"],
["Entpacktes überspringen", "Skip extracted content"], ["Links, .dlc oder Export-Dateien hier ablegen", "Drop links, .dlc or export files here"], ["Account prüfen", "Check account"], ["Entpacktes überspringen", "Skip extracted content"], ["Links, .dlc oder Export-Dateien hier ablegen", "Drop links, .dlc or export files here"], ["Account prüfen", "Check account"],
["Account aktivieren", "Enable account"], ["Account deaktivieren", "Disable account"], ["Ausgewählte Downloads starten", "Start selected downloads"], ["Alle Downloads starten", "Start all downloads"], ["Account aktivieren", "Enable account"], ["Account deaktivieren", "Disable account"], ["Ausgewählte Downloads starten", "Start selected downloads"], ["Alle Downloads starten", "Start all downloads"],
["Linkadressen anzeigen", "Show link addresses"], ["Paket exportieren", "Export package"], ["Log öffnen", "Open log"], ["Item-Log öffnen", "Open item log"], ["Jetzt entpacken", "Extract now"], ["Linkadressen anzeigen", "Show link addresses"], ["Paket exportieren", "Export package"], ["Log öffnen", "Open log"], ["Item-Log öffnen", "Open item log"], ["Jetzt entpacken", "Extract now"],
@@ -227,6 +228,8 @@ function translatePackageStatusParts(value: string, language: AppLanguage): stri
if (parts.length < 2) return null; if (parts.length < 2) return null;
const translated = parts.map((part): string | null => { const translated = parts.map((part): string | null => {
if (language === "en") { if (language === "en") {
const exact = deToEn.get(part);
if (exact) return exact;
const extractionError = part.match(/^(\d+) Entpackfehler$/); const extractionError = part.match(/^(\d+) Entpackfehler$/);
if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`; if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`;
const retry = part.match(/^(\d+) Wiederholung(?:en)?$/); const retry = part.match(/^(\d+) Wiederholung(?:en)?$/);
@@ -237,6 +240,8 @@ function translatePackageStatusParts(value: string, language: AppLanguage): stri
if (cancelled) return `${cancelled[1]} cancelled`; if (cancelled) return `${cancelled[1]} cancelled`;
return null; return null;
} }
const exact = enToDe.get(part);
if (exact) return exact;
const extractionError = part.match(/^(\d+) extraction errors?$/); const extractionError = part.match(/^(\d+) extraction errors?$/);
if (extractionError) return `${extractionError[1]} Entpackfehler`; if (extractionError) return `${extractionError[1]} Entpackfehler`;
const retry = part.match(/^(\d+) retr(?:y|ies)$/); const retry = part.match(/^(\d+) retr(?:y|ies)$/);
@@ -175,6 +175,8 @@ export function compactDownloadStatus(value: string): string {
if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`; if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`;
const finalizing = status.match(/^(Finalisieren|Finalizing)\b/i); const finalizing = status.match(/^(Finalisieren|Finalizing)\b/i);
if (finalizing) { if (finalizing) {
const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/);
if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`;
const fraction = status.match(/\(([^)]*)\)/); const fraction = status.match(/\(([^)]*)\)/);
if (fraction) { if (fraction) {
const values = fraction[1].split("/"); const values = fraction[1].split("/");
@@ -184,8 +186,6 @@ export function compactDownloadStatus(value: string): string {
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) return `${finalizing[1]} - ${progress((current / total) * 100)}%`; if (Number.isFinite(current) && Number.isFinite(total) && total > 0) return `${finalizing[1]} - ${progress((current / total) * 100)}%`;
return finalizing[1]; return finalizing[1];
} }
const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/);
if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`;
return finalizing[1]; return finalizing[1];
} }
return status; return status;
@@ -205,7 +205,7 @@ function DownloadMeter({ value, text }: { value: number; text: string }): ReactE
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement { function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
const visibleStatus = compactDownloadStatus(status); const visibleStatus = compactDownloadStatus(status);
const statusTitle = /^(Finalisieren|Finalizing)\b/i.test(status) ? visibleStatus : title || status; const statusTitle = title || status;
return ( return (
<span aria-label={visibleStatus} className="downloads-cell downloads-status-cell" title={statusTitle}> <span aria-label={visibleStatus} className="downloads-cell downloads-status-cell" title={statusTitle}>
<span aria-hidden="true" className="downloads-status-full">{visibleStatus}</span> <span aria-hidden="true" className="downloads-status-full">{visibleStatus}</span>
@@ -392,7 +392,8 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
? "Entpacken - Ausstehend" ? "Entpacken - Ausstehend"
: compactPostProcessLabel; : compactPostProcessLabel;
const details = `${presentation.details}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; const detailPostProcessLabel = rawPostProcessLabel || postProcessLabel;
const details = `${presentation.details}${detailPostProcessLabel ? ` · ${detailPostProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const status = presentation.extractFailureCount === 0 const status = presentation.extractFailureCount === 0
&& presentation.retryCount === 0 && presentation.retryCount === 0
&& postProcessLabel && postProcessLabel
@@ -21,7 +21,7 @@ export interface PackagePresentation {
} }
function extractionPercent(fullStatus: string): number { function extractionPercent(fullStatus: string): number {
const match = fullStatus.match(/^Entpacken\s+(\d+)%/i); const match = fullStatus.match(/^(?:Entpacken\s+|Finalisieren\s*-\s*)(\d+)%/i);
return match ? Math.max(0, Math.min(100, Number(match[1]))) / 100 : 0; return match ? Math.max(0, Math.min(100, Number(match[1]))) / 100 : 0;
} }
@@ -30,7 +30,7 @@ function isExtractFailure(fullStatus: string): boolean {
} }
function isExtractionLifecycle(fullStatus: string): boolean { function isExtractionLifecycle(fullStatus: string): boolean {
return /^(?:Entpack|Passwort)/i.test(fullStatus); return /^(?:Entpack|Passwort|Finalisieren)/i.test(fullStatus);
} }
function isArchiveItem(item: DownloadItem): boolean { function isArchiveItem(item: DownloadItem): boolean {
@@ -42,6 +42,10 @@ function isRetrying(item: DownloadItem): boolean {
|| (item.retries > 0 && (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait")); || (item.retries > 0 && (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait"));
} }
function isLinkConversionRetry(item: DownloadItem): boolean {
return /(?:Link-Umwandlung erneut|Retrying link conversion)/i.test(item.fullStatus || "");
}
function downloadFraction(item: DownloadItem): number { function downloadFraction(item: DownloadItem): number {
if (item.status === "completed") { if (item.status === "completed") {
return 1; return 1;
@@ -65,6 +69,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|| (row.allItems.some(isArchiveItem) && !row.allItems.every((item) => /^Fertig\b/i.test(item.fullStatus || ""))); || (row.allItems.some(isArchiveItem) && !row.allItems.every((item) => /^Fertig\b/i.test(item.fullStatus || "")));
let extracting = 0; let extracting = 0;
let retrying = 0; let retrying = 0;
let linkConversionRetrying = 0;
let waitsForDisk = 0; let waitsForDisk = 0;
const extractFailures: DownloadItem[] = []; const extractFailures: DownloadItem[] = [];
@@ -79,7 +84,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
extractionLifecycle = true; extractionLifecycle = true;
} else { } else {
const progress = extractionPercent(fullStatus); const progress = extractionPercent(fullStatus);
if (progress > 0 || /^Entpacken\b/i.test(fullStatus)) { if (progress > 0 || /^(?:Entpacken|Finalisieren)\b/i.test(fullStatus)) {
extracting += 1; extracting += 1;
extractionUnits += progress; extractionUnits += progress;
} }
@@ -90,7 +95,10 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
extractionLifecycle = true; extractionLifecycle = true;
} }
} }
if (isRetrying(item)) retrying += 1; if (isRetrying(item)) {
retrying += 1;
if (isLinkConversionRetry(item)) linkConversionRetrying += 1;
}
if (/Warte auf Festplatte/i.test(fullStatus)) waitsForDisk += 1; if (/Warte auf Festplatte/i.test(fullStatus)) waitsForDisk += 1;
} }
@@ -101,8 +109,11 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
const value = allExtracted ? 100 : Math.min(extractionLifecycle ? 99 : 100, downloadValue + extractionValue); const value = allExtracted ? 100 : Math.min(extractionLifecycle ? 99 : 100, downloadValue + extractionValue);
const parts: string[] = []; const parts: string[] = [];
const retryLabel = linkConversionRetrying > 0
? "Link-Umwandlung erneut"
: `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`;
if (extractFailures.length > 0) parts.push(`${extractFailures.length} Entpackfehler`); if (extractFailures.length > 0) parts.push(`${extractFailures.length} Entpackfehler`);
if (retrying > 0) parts.push(`${retrying} Wiederholung${retrying === 1 ? "" : "en"}`); if (retrying > 0) parts.push(retryLabel);
if (failed > 0) parts.push(`${failed} Fehler`); if (failed > 0) parts.push(`${failed} Fehler`);
if (cancelled > 0) parts.push(`${cancelled} abgebrochen`); if (cancelled > 0) parts.push(`${cancelled} abgebrochen`);
const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`; const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`;
@@ -114,7 +125,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
let status = allExtracted ? "Entpackt" : details; let status = allExtracted ? "Entpackt" : details;
if (extractFailures.length > 0 && retrying > 0) { if (extractFailures.length > 0 && retrying > 0) {
status = `${extractFailures.length} Entpackfehler · ${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; status = `${extractFailures.length} Entpackfehler · ${retryLabel}`;
} else if (extractFailures.length > 0) { } else if (extractFailures.length > 0) {
status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`; status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`;
} else if (waitsForDisk > 0) { } else if (waitsForDisk > 0) {
@@ -122,7 +133,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
} else if (extracting > 0 || row.package.status === "extracting") { } else if (extracting > 0 || row.package.status === "extracting") {
status = packageExtractLabel || "Entpacken"; status = packageExtractLabel || "Entpacken";
} else if (retrying > 0) { } else if (retrying > 0) {
status = `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; status = retryLabel;
} else if (downloading) { } else if (downloading) {
status = "Download läuft"; status = "Download läuft";
} }
+288 -3
View File
@@ -63,6 +63,95 @@ describe("runWithLimitedConcurrency", () => {
}); });
}); });
describe("selected item run scope", () => {
function createSelectedItemManager(root: string): { manager: DownloadManager; packageId: string; itemIds: string[] } {
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
autoExtract: true,
hybridExtract: true,
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "selected-items", links: ["https://dummy/first", "https://dummy/second"] }]);
const snapshot = manager.getSnapshot().session;
const packageId = snapshot.packageOrder[0];
return { manager, packageId, itemIds: snapshot.packages[packageId].itemIds };
}
it("creates the active run context before triggering pending hybrid extraction", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-owner-"));
tempDirs.push(root);
const { manager, itemIds } = createSelectedItemManager(root);
const internal = manager as any;
const owners: Array<string | null> = [];
internal.ensureScheduler = async () => {};
internal.triggerPendingExtractions = () => owners.push(internal.activeRunContextId);
await internal.startItemsNow([itemIds[1]]);
expect(owners).toHaveLength(1);
expect(owners[0]).toBeTypeOf("string");
expect(owners[0]).toBe(internal.activeRunContextId);
});
it("never schedules an unselected queued sibling from the same package", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-scope-"));
tempDirs.push(root);
const { manager, itemIds } = createSelectedItemManager(root);
const internal = manager as any;
internal.ensureScheduler = async () => {};
internal.triggerPendingExtractions = () => {};
await internal.startItemsNow([itemIds[0]]);
expect(internal.findNextQueuedItem()).toEqual(expect.objectContaining({ itemId: itemIds[0] }));
internal.session.items[itemIds[0]].status = "downloading";
expect(internal.findNextQueuedItem()).toBeNull();
expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false });
});
it("stops only selected run items without erasing sibling wait state", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-stop-"));
tempDirs.push(root);
const { manager, packageId, itemIds } = createSelectedItemManager(root);
const internal = manager as any;
internal.ensureScheduler = async () => {};
internal.triggerPendingExtractions = () => {};
await internal.startItemsNow([itemIds[0]]);
internal.session.items[itemIds[0]].status = "downloading";
internal.session.items[itemIds[0]].fullStatus = "Download läuft";
internal.session.items[itemIds[1]].status = "reconnect_wait";
internal.session.items[itemIds[1]].fullStatus = "Unselektierter Backoff";
internal.session.packages[packageId].status = "downloading";
internal.retryAfterByItem.set(itemIds[0], 100);
internal.retryAfterByItem.set(itemIds[1], 200);
internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false });
internal.retryStateByItem.set(itemIds[1], { freshRetryUsed: false, resumeHardResetUsed: true });
internal.pacedStartReservationByItem.set(itemIds[0], 100);
internal.pacedStartReservationByItem.set(itemIds[1], 200);
internal.standalonePackageResults.add("foreign-package:1");
manager.stop();
expect(internal.session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" }));
expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "Unselektierter Backoff" }));
expect(internal.retryAfterByItem.has(itemIds[0])).toBe(false);
expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200);
expect(internal.retryStateByItem.has(itemIds[0])).toBe(false);
expect(internal.retryStateByItem.has(itemIds[1])).toBe(true);
expect(internal.pacedStartReservationByItem.has(itemIds[0])).toBe(false);
expect(internal.pacedStartReservationByItem.get(itemIds[1])).toBe(200);
expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true);
expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false);
});
});
describe("download live update cadence", () => { describe("download live update cadence", () => {
it.each([69, 661, 2_470])("emits a running queue snapshot no sooner than 750 ms for %i items", async (itemCount) => { it.each([69, 661, 2_470])("emits a running queue snapshot no sooner than 750 ms for %i items", async (itemCount) => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -2311,7 +2400,7 @@ describe("download manager", () => {
expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false); expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false);
}); });
it("extractNow only re-arms completed items that are not already extracted", () => { it("extractNow only re-arms completed items that are not already extracted", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-"));
tempDirs.push(root); tempDirs.push(root);
@@ -2380,9 +2469,17 @@ describe("download manager", () => {
session, session,
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
const staleController = new AbortController();
const restartPostProcessing = vi.fn(() => Promise.resolve());
(manager as any).packagePostProcessTasks.set(packageId, Promise.resolve());
(manager as any).packagePostProcessAbortControllers.set(packageId, staleController);
(manager as any).runPackagePostProcessing = restartPostProcessing;
session.packages[packageId].status = "paused";
manager.extractNow(packageId); await manager.extractNow(packageId);
expect(staleController.signal.aborted).toBe(true);
expect(restartPostProcessing).toHaveBeenCalledTimes(1);
expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)"); expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)");
expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)"); expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)");
expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend"); expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend");
@@ -2543,6 +2640,111 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false); expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false);
}, 15_000); }, 15_000);
it.each([
["package", false],
["package", true],
["item", false],
["item", true]
] as const)("extractNow %s runs with an open sibling while session paused=%s", async (scope, paused) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-extract-open-${scope}-${paused}-`));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "Open sibling");
const extractDir = path.join(root, "extract", "Open sibling");
fs.mkdirSync(outputDir, { recursive: true });
const archivePath = path.join(outputDir, "Episode.E01.zip");
const zip = new AdmZip();
zip.addFile("Episode.E01.mkv", Buffer.from("episode-one"));
zip.writeZip(archivePath);
const archiveSize = fs.statSync(archivePath).size;
const session = emptySession();
const packageId = `open-${scope}-${paused}`;
const archiveItemId = `${packageId}-archive`;
const queuedItemId = `${packageId}-queued`;
const createdAt = Date.now() - 1000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Open sibling",
outputDir,
extractDir,
status: paused ? "paused" : "queued",
itemIds: [archiveItemId, queuedItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[archiveItemId] = {
id: archiveItemId,
packageId,
url: "https://dummy/Episode.E01.zip",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: archiveSize,
totalBytes: archiveSize,
progressPercent: 100,
fileName: "Episode.E01.zip",
targetPath: archivePath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpacken - Ausstehend",
createdAt,
updatedAt: createdAt
};
session.items[queuedItemId] = {
id: queuedItemId,
packageId,
url: "https://dummy/Episode.E02.zip",
provider: "realdebrid",
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Episode.E02.zip",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir,
extractDir,
autoExtract: false,
hybridExtract: false,
cleanupMode: "none",
removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false,
autoRename4sf4sj: false,
keepGermanAudioOnly: false
},
session,
createStoragePaths(path.join(root, "state"))
);
session.running = paused;
session.paused = paused;
manager.extractNow(scope === "package"
? { packageIds: [packageId], itemIds: [] }
: { packageIds: [], itemIds: [archiveItemId] });
await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000);
await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000);
expect(session.items[archiveItemId].fullStatus).toMatch(/^Entpackt/);
expect(session.items[queuedItemId]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" }));
expect(session.packages[packageId].status).toBe(paused ? "paused" : "queued");
}, 15_000);
it("assigns same-named archive failures only to the matching directory", () => { it("assigns same-named archive failures only to the matching directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-"));
tempDirs.push(root); tempDirs.push(root);
@@ -7010,6 +7212,13 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
session.running = true;
(manager as any).runItemIds.add("selected-item");
(manager as any).runPackageIds.add(packageId);
for (const itemId of itemIds) {
(manager as any).runOutcomes.set(itemId, "completed");
}
const changed = (manager as any).autoRecoverArchiveCrcFailure( const changed = (manager as any).autoRecoverArchiveCrcFailure(
session.packages[packageId], session.packages[packageId],
itemIds.map((itemId) => session.items[itemId]!), itemIds.map((itemId) => session.items[itemId]!),
@@ -7035,7 +7244,11 @@ describe("download manager", () => {
} }
expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false); expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false);
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
expect(session.packages[packageId]?.status).toBe("queued"); expect(session.packages[packageId]?.status).toBe("downloading");
for (const itemId of itemIds) {
expect((manager as any).runItemIds.has(itemId)).toBe(true);
expect((manager as any).runOutcomes.has(itemId)).toBe(false);
}
}); });
it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => { it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => {
@@ -7393,6 +7606,78 @@ describe("download manager", () => {
expect(Array.from(ready)).toEqual([part1Path.toLowerCase()]); expect(Array.from(ready)).toEqual([part1Path.toLowerCase()]);
}); });
it("retries a complete archive that was marked attempted without a terminal extraction result", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-hybrid-stale-attempt-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "stale-attempt");
const extractDir = path.join(root, "extract", "stale-attempt");
fs.mkdirSync(outputDir, { recursive: true });
const archivePath = path.join(outputDir, "Episode.E01.zip");
const zip = new AdmZip();
zip.addFile("Episode.E01.mkv", Buffer.from("episode"));
zip.writeZip(archivePath);
const archiveSize = fs.statSync(archivePath).size;
const session = emptySession();
const packageId = "stale-attempt-pkg";
const itemId = "stale-attempt-item";
const createdAt = Date.now() - 1000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "stale-attempt",
outputDir,
extractDir,
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/Episode.E01.zip",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: archiveSize,
totalBytes: archiveSize,
progressPercent: 100,
fileName: "Episode.E01.zip",
targetPath: archivePath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpacken - Warten auf Parts",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir,
extractDir,
autoExtract: true,
hybridExtract: true,
cleanupMode: "none",
autoRename4sf4sj: false,
keepGermanAudioOnly: false
},
session,
createStoragePaths(path.join(root, "state"))
);
(manager as any).hybridExtractedPaths.set(packageId, new Set([archivePath.toLowerCase()]));
const extracted = await (manager as any).runHybridExtraction(packageId, session.packages[packageId], [session.items[itemId]]);
expect(extracted).toBe(1);
expect(fs.existsSync(path.join(extractDir, "Episode.E01.mkv"))).toBe(true);
expect(session.items[itemId].fullStatus).toMatch(/^Entpackt/);
}, 10_000);
it("skips unchanged hybrid archives after a previous extraction failure", async () => { it("skips unchanged hybrid archives after a previous extraction failure", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
+4 -3
View File
@@ -1691,7 +1691,7 @@ describe("download table row contracts", () => {
["package", "Finalizing - 50% * release.part1.rar", "Finalizing - 50%"], ["package", "Finalizing - 50% * release.part1.rar", "Finalizing - 50%"],
["item", "Finalizing (3/4) · release.part1.rar", "Finalizing - 75%"], ["item", "Finalizing (3/4) · release.part1.rar", "Finalizing - 75%"],
["item", "Finalisieren - 50% * release.part1.rar", "Finalisieren - 50%"] ["item", "Finalisieren - 50% * release.part1.rar", "Finalisieren - 50%"]
])("shows %s finalization status without archive details", (target, rawStatus, expectedStatus) => { ])("shows compact %s finalization text while retaining archive details in the tooltip", (target, rawStatus, expectedStatus) => {
const html = target === "package" const html = target === "package"
? renderToStaticMarkup(PackageCardContent({ ? renderToStaticMarkup(PackageCardContent({
actions: createActions(), actions: createActions(),
@@ -1720,10 +1720,11 @@ describe("download table row contracts", () => {
expect(html).toContain(`aria-label="${expectedStatus}"`); expect(html).toContain(`aria-label="${expectedStatus}"`);
expect(html.match(new RegExp(`>${expectedStatus}</span>`, "g"))).toHaveLength(2); expect(html.match(new RegExp(`>${expectedStatus}</span>`, "g"))).toHaveLength(2);
expect(html).not.toContain(">release.part1.rar</span>"); expect(html).not.toContain(">release.part1.rar</span>");
if (target === "item") expect(html).not.toMatch(/title="[^"]*release\.part1\.rar/); expect(html).toMatch(/title="[^"]*release\.part1\.rar/);
}); });
it.each([ it.each([
["Finalisieren - 99% (0/1) · release.part1.rar", "Finalisieren - 99%"],
["Finalisieren (3/2) · release.part1.rar", "Finalisieren - 100%"], ["Finalisieren (3/2) · release.part1.rar", "Finalisieren - 100%"],
["Finalizing (-1/2) · release.part1.rar", "Finalizing - 0%"], ["Finalizing (-1/2) · release.part1.rar", "Finalizing - 0%"],
["Finalisieren (/2) · release.part1.rar", "Finalisieren"], ["Finalisieren (/2) · release.part1.rar", "Finalisieren"],
@@ -2210,7 +2211,7 @@ describe("download table row contracts", () => {
selectedVersion: 0 selectedVersion: 0
})); }));
expect(html).toMatch(/title="0\/1 fertig · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s); expect(html).toMatch(/title="0\/1 fertig · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
}); });
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => { it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import * as downloadManagerModule from "../src/main/download-manager";
describe("extraction progress labels", () => {
it("keeps a finalizing archive at its real 99 percent instead of counting it as complete", () => {
const format = (downloadManagerModule as Record<string, unknown>).formatExtractionProgressLabels as ((progress: Record<string, unknown>) => {
itemLabel: string;
packageLabel: string;
}) | undefined;
expect(format).toBeTypeOf("function");
if (!format) return;
expect(format({
current: 0,
total: 1,
percent: 99,
archiveName: "release.part01.rar",
archivePercent: 99,
elapsedMs: 17_000
})).toEqual({
itemLabel: "Finalisieren - 99% · release.part01.rar · 17s",
packageLabel: "Finalisieren - 99% (0/1) · release.part01.rar · 17s"
});
});
it("keeps password attempts more important than a stale 99 percent archive value", () => {
const format = (downloadManagerModule as Record<string, unknown>).formatExtractionProgressLabels as ((progress: Record<string, unknown>) => {
itemLabel: string;
packageLabel: string;
}) | undefined;
expect(format).toBeTypeOf("function");
if (!format) return;
expect(format({
current: 0,
total: 1,
percent: 99,
archiveName: "release.part01.rar",
archivePercent: 99,
passwordAttempt: 7,
passwordTotal: 7
})).toEqual({
itemLabel: "Passwort knacken: 100% (7/7) · release.part01.rar",
packageLabel: "Passwort knacken: 100% (7/7)"
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import * as extractorModule from "../src/main/extractor";
describe("extractor password progress", () => {
it("retains the active password attempt across pulse and percentage updates", () => {
const merge = (extractorModule as Record<string, unknown>).mergeExtractPasswordProgress as ((
current: Record<string, unknown> | undefined,
update: Record<string, unknown> | undefined
) => Record<string, unknown> | undefined) | undefined;
expect(merge).toBeTypeOf("function");
if (!merge) return;
const secondAttempt = { passwordAttempt: 2, passwordTotal: 7 };
expect(merge(undefined, secondAttempt)).toEqual(secondAttempt);
expect(merge(secondAttempt, undefined)).toEqual(secondAttempt);
expect(merge(secondAttempt, { passwordAttempt: 3, passwordTotal: 7 })).toEqual({
passwordAttempt: 3,
passwordTotal: 7
});
});
});
+2
View File
@@ -172,6 +172,8 @@ describe("renderer localization", () => {
["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"], ["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"],
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"], ["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
["7 Entpackfehler · 1 Wiederholung", "7 extraction errors · 1 retry"], ["7 Entpackfehler · 1 Wiederholung", "7 extraction errors · 1 retry"],
["Link-Umwandlung erneut", "Retrying link conversion"],
["7 Entpackfehler · Link-Umwandlung erneut", "7 extraction errors · Retrying link conversion"],
["Download fertig · 1 Entpackfehler", "Download complete · 1 extraction error"], ["Download fertig · 1 Entpackfehler", "Download complete · 1 extraction error"],
["Jetzt entpacken (2)", "Extract now (2)"], ["Jetzt entpacken (2)", "Extract now (2)"],
["1 Entpackfehler", "1 extraction error"], ["1 Entpackfehler", "1 extraction error"],
+25 -2
View File
@@ -85,6 +85,15 @@ describe("download package presentation", () => {
expect(after.progress.value).toBe(90); expect(after.progress.value).toBe(90);
}); });
it("includes finalization progress in the reserved extraction range", () => {
const presentation = buildPackagePresentation(row([
item("archive", "Finalisieren - 99% · release.part01.rar")
], { status: "extracting", postProcessLabel: "Finalisieren - 99% (0/1) · release.part01.rar" }));
expect(presentation.progress.value).toBe(99);
expect(presentation.status).toBe("Finalisieren - 99% (0/1) · release.part01.rar");
});
it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => { it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => {
const items = [ const items = [
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")), ...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
@@ -97,9 +106,23 @@ describe("download package presentation", () => {
]; ];
const presentation = buildPackagePresentation(row(items, { status: "queued" })); const presentation = buildPackagePresentation(row(items, { status: "queued" }));
expect(presentation.status).toBe("7 Entpackfehler · 1 Wiederholung"); expect(presentation.status).toBe("7 Entpackfehler · Link-Umwandlung erneut");
expect(presentation.details).toContain("7 Entpackfehler"); expect(presentation.details).toContain("7 Entpackfehler");
expect(presentation.details).toContain("1 Wiederholung"); expect(presentation.details).toContain("Link-Umwandlung erneut");
});
it("describes parallel link conversion retries without presenting the item count as attempts", () => {
const retries = Array.from({ length: 20 }, (_, index) => item(`retry-${index}`, "Link-Umwandlung erneut, Versuch 2/...", {
status: "validating",
retries: 2,
downloadedBytes: 0,
progressPercent: 0
}));
const presentation = buildPackagePresentation(row(retries, { status: "queued" }));
expect(presentation.status).toBe("Link-Umwandlung erneut");
expect(presentation.status).not.toContain("20");
}); });
it("keeps a single normal active download compact", () => { it("keeps a single normal active download compact", () => {