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:
@@ -1034,14 +1034,14 @@ export class AppController {
|
||||
return paused;
|
||||
}
|
||||
|
||||
public retryExtraction(packageId: string): void {
|
||||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
||||
this.manager.retryExtraction(packageId);
|
||||
}
|
||||
public async retryExtraction(packageId: string): Promise<void> {
|
||||
this.audit("INFO", "Extraktion manuell wiederholt", { 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.manager.extractNow(request);
|
||||
await this.manager.extractNow(request);
|
||||
}
|
||||
|
||||
public resetPackage(packageId: string): void {
|
||||
|
||||
+225
-178
@@ -380,13 +380,50 @@ function getGlobalStallWatchdogTimeoutMs(): number {
|
||||
return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function getPostExtractTimeoutMs(): number {
|
||||
function getPostExtractTimeoutMs(): number {
|
||||
const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN);
|
||||
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) {
|
||||
return Math.floor(fromEnv);
|
||||
}
|
||||
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 {
|
||||
const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN);
|
||||
@@ -3024,8 +3061,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise<void>[] {
|
||||
const tasks: Promise<void>[] = [];
|
||||
void this.extractionCoordinator.cancelPackage(packageId, reason);
|
||||
const tasks: Promise<void>[] = [this.extractionCoordinator.cancelPackage(packageId, reason)];
|
||||
if (invalidateDeferred) {
|
||||
this.bumpPackagePostProcessVersion(packageId);
|
||||
}
|
||||
@@ -6849,8 +6885,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerPendingExtractions();
|
||||
const runItems = Object.values(this.session.items)
|
||||
const runItems = Object.values(this.session.items)
|
||||
.filter((item) => {
|
||||
if (!targetSet.has(item.packageId)) return false;
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") return false;
|
||||
@@ -6858,6 +6893,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||
});
|
||||
if (runItems.length === 0) {
|
||||
this.triggerPendingExtractions();
|
||||
this.lifecyclePhase = "idle";
|
||||
this.lifecycleReason = "Bereit";
|
||||
this.persistSoon();
|
||||
@@ -6881,6 +6917,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.lifecycleReason = "Downloads laufen";
|
||||
this.session.runStartedAt = nowMs();
|
||||
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
|
||||
this.triggerPendingExtractions();
|
||||
this.session.totalDownloadedBytes = 0;
|
||||
this.sessionCompletedFiles = 0;
|
||||
this.session.summaryText = "";
|
||||
@@ -6963,8 +7000,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerPendingExtractions();
|
||||
const runItems = [...targetSet]
|
||||
const runItems = [...targetSet]
|
||||
.map((id) => this.session.items[id])
|
||||
.filter((item) => {
|
||||
if (!item) return false;
|
||||
@@ -6973,6 +7009,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||
});
|
||||
if (runItems.length === 0) {
|
||||
this.triggerPendingExtractions();
|
||||
this.lifecyclePhase = "idle";
|
||||
this.lifecycleReason = "Bereit";
|
||||
this.persistSoon();
|
||||
@@ -6996,6 +7033,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.lifecycleReason = "Downloads laufen";
|
||||
this.session.runStartedAt = nowMs();
|
||||
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
|
||||
this.triggerPendingExtractions();
|
||||
this.session.totalDownloadedBytes = 0;
|
||||
this.sessionCompletedFiles = 0;
|
||||
this.session.summaryText = "";
|
||||
@@ -7189,6 +7227,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public stop(options?: { parkForRestart?: boolean }): void {
|
||||
const parkForRestart = options?.parkForRestart === true;
|
||||
const previousLifecyclePhase = this.lifecyclePhase;
|
||||
const wasStopping = this.lifecyclePhase === "stopping";
|
||||
this.lifecycleGeneration += 1;
|
||||
this.lifecyclePhase = "stopping";
|
||||
@@ -7200,40 +7239,72 @@ export class DownloadManager extends EventEmitter {
|
||||
this.healthShuttingDown = parkForRestart;
|
||||
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
||||
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
|
||||
? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt)
|
||||
: null;
|
||||
this.suppressStandalonePackageResults();
|
||||
if (!stoppedRunContext || stopsStandalonePostProcessing) {
|
||||
this.suppressStandalonePackageResults();
|
||||
}
|
||||
this.schedulerGeneration += 1;
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.session.reconnectUntil = 0;
|
||||
this.session.reconnectReason = "";
|
||||
this.retryAfterByItem.clear();
|
||||
this.providerStartReservations.clear();
|
||||
this.pacedStartReservationByItem.clear();
|
||||
this.retryStateByItem.clear();
|
||||
if (hasScopedRun) {
|
||||
const paceKeys = new Set<string>();
|
||||
for (const itemId of stoppedItemIds) {
|
||||
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.lastGlobalProgressAt = nowMs();
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.abortPostProcessing("stop", stoppedRunContext?.id);
|
||||
for (const active of this.activeTasks.values()) {
|
||||
active.abortReason = abortReason;
|
||||
active.abortController.abort(abortReason);
|
||||
}
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (!isFinishedStatus(item.status)) {
|
||||
this.abortPostProcessing("stop", stopsStandalonePostProcessing ? undefined : stoppedRunContext?.id);
|
||||
for (const active of this.activeTasks.values()) {
|
||||
if (hasScopedRun && !stoppedItemIds.has(active.itemId)) {
|
||||
continue;
|
||||
}
|
||||
active.abortReason = abortReason;
|
||||
active.abortController.abort(abortReason);
|
||||
}
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (hasScopedRun && !stoppedItemIds.has(item.id)) {
|
||||
continue;
|
||||
}
|
||||
if (!isFinishedStatus(item.status)) {
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
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"
|
||||
|| pkg.status === "extracting" || pkg.status === "integrity_check"
|
||||
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
|
||||
@@ -8417,8 +8488,16 @@ export class DownloadManager extends EventEmitter {
|
||||
changed += 1;
|
||||
}
|
||||
|
||||
if (changed > 0) {
|
||||
this.clearHybridArchiveState(pkg.id);
|
||||
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);
|
||||
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued";
|
||||
pkg.updatedAt = queuedAt;
|
||||
const evidence = corruptArchiveItems
|
||||
@@ -8823,10 +8902,10 @@ export class DownloadManager extends EventEmitter {
|
||||
if (pkg.status === "extracting" || pkg.status === "integrity_check") {
|
||||
pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused";
|
||||
pkg.updatedAt = nowMs();
|
||||
}
|
||||
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
}
|
||||
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || item.status !== "completed") {
|
||||
continue;
|
||||
}
|
||||
@@ -9153,56 +9232,52 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
public retryExtraction(packageId: string): void {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg) return;
|
||||
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(
|
||||
public async retryExtraction(packageId: string): Promise<void> {
|
||||
if (!(await this.armExtractNowPackage(packageId))) {
|
||||
throw new Error("Kein entpackbarer Archivsatz ausgewählt");
|
||||
}
|
||||
}
|
||||
|
||||
private async armExtractNowPackage(
|
||||
packageId: string,
|
||||
selectedItemIds?: ReadonlySet<string>,
|
||||
archiveFilter?: ReadonlySet<string>
|
||||
): boolean {
|
||||
const pkg = this.session.packages[packageId];
|
||||
): Promise<boolean> {
|
||||
let pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled) return false;
|
||||
if (this.packagePostProcessTasks.has(packageId)) return false;
|
||||
this.clearHybridArchiveState(packageId);
|
||||
if (!pkg.enabled) {
|
||||
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)));
|
||||
let items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||
let completedItems = items.filter((item) => item.status === "completed");
|
||||
let targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id)));
|
||||
if (targetItems.length === 0) {
|
||||
this.manualExtractArchiveFilters.delete(packageId);
|
||||
this.manualExtractPackages.delete(packageId);
|
||||
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));
|
||||
else this.manualExtractArchiveFilters.delete(packageId);
|
||||
this.manualExtractPackages.add(packageId);
|
||||
@@ -9225,8 +9300,9 @@ export class DownloadManager extends EventEmitter {
|
||||
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>>();
|
||||
let armed = 0;
|
||||
for (const itemId of itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || excludedPackageIds.has(item.packageId)) {
|
||||
@@ -9238,7 +9314,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
for (const [packageId, selectedItemIds] of selectedByPackage) {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(packageId)) {
|
||||
if (!pkg || pkg.cancelled) {
|
||||
continue;
|
||||
}
|
||||
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}`);
|
||||
continue;
|
||||
}
|
||||
this.armExtractNowPackage(
|
||||
if (await this.armExtractNowPackage(
|
||||
packageId,
|
||||
selection.itemIds,
|
||||
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") {
|
||||
this.armExtractNowPackage(target);
|
||||
if (!(await this.armExtractNowPackage(target))) {
|
||||
throw new Error("Kein entpackbarer Archivsatz ausgewählt");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const packageIds = [...new Set(target.packageIds)];
|
||||
const packageSet = new Set(packageIds);
|
||||
let armed = 0;
|
||||
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 {
|
||||
@@ -10253,8 +10338,9 @@ export class DownloadManager extends EventEmitter {
|
||||
if (normalCandidate && pkgPrio === "low") continue;
|
||||
if (normalCandidate && pkgPrio === "normal") continue;
|
||||
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
for (const itemId of pkg.itemIds) {
|
||||
if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) continue;
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
|
||||
if (retryAfter > now) continue;
|
||||
@@ -10292,8 +10378,11 @@ export class DownloadManager extends EventEmitter {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled || !pkg.enabled) continue;
|
||||
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue;
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
for (const itemId of pkg.itemIds) {
|
||||
if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) {
|
||||
continue;
|
||||
}
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
|
||||
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
|
||||
@@ -10325,9 +10414,12 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) {
|
||||
continue;
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) {
|
||||
continue;
|
||||
}
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
@@ -13585,13 +13677,22 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
const completedItems = items.filter((item) => item.status === "completed");
|
||||
|
||||
const alreadyTried = this.hybridExtractedPaths.get(packageId);
|
||||
if (alreadyTried) {
|
||||
for (const key of [...readyArchives]) {
|
||||
if (alreadyTried.has(key)) {
|
||||
readyArchives.delete(key);
|
||||
}
|
||||
}
|
||||
const alreadyTried = this.hybridExtractedPaths.get(packageId);
|
||||
if (alreadyTried) {
|
||||
for (const key of [...readyArchives]) {
|
||||
if (!alreadyTried.has(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);
|
||||
}
|
||||
}
|
||||
|
||||
const failedArchiveStates = this.hybridFailedArchives.get(packageId);
|
||||
@@ -13668,6 +13769,7 @@ export class DownloadManager extends EventEmitter {
|
||||
const autoRecoveredArchives = new Set<string>();
|
||||
const failedArchiveErrors = new Map<string, string>();
|
||||
const failedArchiveCategories = new Map<string, string>();
|
||||
const successfulArchiveKeys = new Set<string>();
|
||||
const hybridResolvedItems = new Map<string, DownloadItem[]>();
|
||||
const hybridStartTimes = new Map<string, number>();
|
||||
let hybridLastEmitAt = 0;
|
||||
@@ -13783,6 +13885,7 @@ export class DownloadManager extends EventEmitter {
|
||||
: formatExtractDone(doneAt - startedAt);
|
||||
const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined;
|
||||
if (archiveKey && progress.archiveSuccess !== false) {
|
||||
successfulArchiveKeys.add(archiveKey);
|
||||
this.clearHybridArchiveState(packageId, archiveKey);
|
||||
}
|
||||
this.recordArchiveOperation(
|
||||
@@ -13803,25 +13906,9 @@ export class DownloadManager extends EventEmitter {
|
||||
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`;
|
||||
this.emitState();
|
||||
}
|
||||
} else {
|
||||
const archiveLabel = ` · ${progress.archiveName}`;
|
||||
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();
|
||||
} else {
|
||||
const label = formatExtractionProgressLabels(progress).itemLabel;
|
||||
const updatedAt = nowMs();
|
||||
for (const entry of archItems) {
|
||||
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue;
|
||||
entry.fullStatus = label;
|
||||
@@ -13830,22 +13917,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
|
||||
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})`;
|
||||
}
|
||||
pkg.postProcessLabel = formatExtractionProgressLabels(progress).packageLabel;
|
||||
|
||||
const now = nowMs();
|
||||
if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
|
||||
@@ -13861,11 +13933,12 @@ export class DownloadManager extends EventEmitter {
|
||||
extracted: result.extracted,
|
||||
failed: result.failed
|
||||
});
|
||||
{
|
||||
let tried = this.hybridExtractedPaths.get(packageId);
|
||||
if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); }
|
||||
for (const key of readyArchives) { tried.add(key); }
|
||||
}
|
||||
{
|
||||
let tried = this.hybridExtractedPaths.get(packageId);
|
||||
if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); }
|
||||
for (const key of successfulArchiveKeys) { tried.add(key); }
|
||||
if (tried.size === 0) this.hybridExtractedPaths.delete(packageId);
|
||||
}
|
||||
if (failedArchiveErrors.size > 0) {
|
||||
let failed = this.hybridFailedArchives.get(packageId);
|
||||
if (!failed) {
|
||||
@@ -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...";
|
||||
this.emitState();
|
||||
const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal);
|
||||
@@ -14147,7 +14220,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allDone) {
|
||||
if (!manualExtraction && !allDone) {
|
||||
pkg.postProcessLabel = undefined;
|
||||
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued";
|
||||
logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`);
|
||||
@@ -14173,14 +14246,14 @@ export class DownloadManager extends EventEmitter {
|
||||
resolveArchiveItemsFromList(archiveName, completedItems, archivePath);
|
||||
|
||||
let lastExtractEmitAt = 0;
|
||||
const emitExtractStatus = (text: string, force = false): void => {
|
||||
const emitExtractStatus = (text: string, force = false): void => {
|
||||
const now = nowMs();
|
||||
if (!force && now - lastExtractEmitAt < EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastExtractEmitAt = now;
|
||||
pkg.postProcessLabel = text || "Entpacken...";
|
||||
this.emitState();
|
||||
lastExtractEmitAt = now;
|
||||
pkg.postProcessLabel = text || "Entpacken...";
|
||||
this.emitState(force);
|
||||
};
|
||||
|
||||
const extractTimeoutMs = getPostExtractTimeoutMs();
|
||||
@@ -14361,25 +14434,9 @@ export class DownloadManager extends EventEmitter {
|
||||
if (done < progress.total) {
|
||||
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true);
|
||||
}
|
||||
} else {
|
||||
const archiveTag = progress.archiveName ? ` · ${progress.archiveName}` : "";
|
||||
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();
|
||||
} else {
|
||||
const label = formatExtractionProgressLabels(progress).itemLabel;
|
||||
const updatedAt = nowMs();
|
||||
for (const entry of archiveItems) {
|
||||
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue;
|
||||
entry.fullStatus = label;
|
||||
@@ -14388,24 +14445,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
|
||||
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);
|
||||
emitExtractStatus(formatExtractionProgressLabels(progress).packageLabel);
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -14490,7 +14530,14 @@ export class DownloadManager extends EventEmitter {
|
||||
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 hasRemainingExtractWork = completedItems.some((entry) => !isExtractedLabel(entry.fullStatus || "") && /^Entpack/i.test(entry.fullStatus || ""));
|
||||
pkg.status = hasRemainingExtractError ? "failed" : hasRemainingExtractWork ? "queued" : "completed";
|
||||
|
||||
+25
-11
@@ -1452,11 +1452,23 @@ function parseProgressPercent(chunk: string): number | null {
|
||||
return latest;
|
||||
}
|
||||
|
||||
function nextArchivePercent(previous: number, incoming: number): number {
|
||||
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)));
|
||||
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(
|
||||
command: string,
|
||||
@@ -4088,12 +4100,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
if (lastArchiveFinishedAt !== null) {
|
||||
logger.info(`Extract-Trace Gap: before=${archiveName}, prevDoneToStartMs=${archiveStartedAt - lastArchiveFinishedAt}, progress=${startedCurrent}/${candidates.length}`);
|
||||
}
|
||||
let archivePercent = 0;
|
||||
let reached99At: number | null = null;
|
||||
let archiveOutcome: "success" | "failed" | "skipped" = "failed";
|
||||
let archivePercent = 0;
|
||||
let reached99At: number | null = null;
|
||||
let archiveOutcome: "success" | "failed" | "skipped" = "failed";
|
||||
let activePasswordProgress: ExtractPasswordProgress | undefined;
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0, undefined, undefined, archivePath);
|
||||
const pulseTimer = setInterval(() => {
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, undefined, archivePath);
|
||||
const pulseTimer = setInterval(() => {
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath);
|
||||
}, 1100);
|
||||
const hybrid = Boolean(options.hybridMode);
|
||||
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
||||
@@ -4110,7 +4123,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
reached99At = Date.now();
|
||||
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);
|
||||
@@ -4139,8 +4152,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }, undefined, archivePath);
|
||||
}
|
||||
const onPwAttempt = hasManyPasswords
|
||||
? (attempt: number, total: number) => {
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }, undefined, archivePath);
|
||||
? (attempt: number, total: number) => {
|
||||
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>`);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -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."],
|
||||
["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"],
|
||||
["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"],
|
||||
["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"],
|
||||
@@ -227,6 +228,8 @@ function translatePackageStatusParts(value: string, language: AppLanguage): stri
|
||||
if (parts.length < 2) return null;
|
||||
const translated = parts.map((part): string | null => {
|
||||
if (language === "en") {
|
||||
const exact = deToEn.get(part);
|
||||
if (exact) return exact;
|
||||
const extractionError = part.match(/^(\d+) Entpackfehler$/);
|
||||
if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`;
|
||||
const retry = part.match(/^(\d+) Wiederholung(?:en)?$/);
|
||||
@@ -237,6 +240,8 @@ function translatePackageStatusParts(value: string, language: AppLanguage): stri
|
||||
if (cancelled) return `${cancelled[1]} cancelled`;
|
||||
return null;
|
||||
}
|
||||
const exact = enToDe.get(part);
|
||||
if (exact) return exact;
|
||||
const extractionError = part.match(/^(\d+) extraction errors?$/);
|
||||
if (extractionError) return `${extractionError[1]} Entpackfehler`;
|
||||
const retry = part.match(/^(\d+) retr(?:y|ies)$/);
|
||||
|
||||
@@ -175,6 +175,8 @@ export function compactDownloadStatus(value: string): string {
|
||||
if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`;
|
||||
const finalizing = status.match(/^(Finalisieren|Finalizing)\b/i);
|
||||
if (finalizing) {
|
||||
const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/);
|
||||
if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`;
|
||||
const fraction = status.match(/\(([^)]*)\)/);
|
||||
if (fraction) {
|
||||
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)}%`;
|
||||
return finalizing[1];
|
||||
}
|
||||
const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/);
|
||||
if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`;
|
||||
return finalizing[1];
|
||||
}
|
||||
return status;
|
||||
@@ -205,7 +205,7 @@ function DownloadMeter({ value, text }: { value: number; text: string }): ReactE
|
||||
|
||||
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
|
||||
const visibleStatus = compactDownloadStatus(status);
|
||||
const statusTitle = /^(Finalisieren|Finalizing)\b/i.test(status) ? visibleStatus : title || status;
|
||||
const statusTitle = title || status;
|
||||
return (
|
||||
<span aria-label={visibleStatus} className="downloads-cell downloads-status-cell" title={statusTitle}>
|
||||
<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)
|
||||
? "Entpacken - Ausstehend"
|
||||
: 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
|
||||
&& presentation.retryCount === 0
|
||||
&& postProcessLabel
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface PackagePresentation {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function isExtractFailure(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 {
|
||||
@@ -42,6 +42,10 @@ function isRetrying(item: DownloadItem): boolean {
|
||||
|| (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 {
|
||||
if (item.status === "completed") {
|
||||
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 || "")));
|
||||
let extracting = 0;
|
||||
let retrying = 0;
|
||||
let linkConversionRetrying = 0;
|
||||
let waitsForDisk = 0;
|
||||
const extractFailures: DownloadItem[] = [];
|
||||
|
||||
@@ -79,7 +84,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
extractionLifecycle = true;
|
||||
} else {
|
||||
const progress = extractionPercent(fullStatus);
|
||||
if (progress > 0 || /^Entpacken\b/i.test(fullStatus)) {
|
||||
if (progress > 0 || /^(?:Entpacken|Finalisieren)\b/i.test(fullStatus)) {
|
||||
extracting += 1;
|
||||
extractionUnits += progress;
|
||||
}
|
||||
@@ -90,7 +95,10 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -101,8 +109,11 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
const value = allExtracted ? 100 : Math.min(extractionLifecycle ? 99 : 100, downloadValue + extractionValue);
|
||||
|
||||
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 (retrying > 0) parts.push(`${retrying} Wiederholung${retrying === 1 ? "" : "en"}`);
|
||||
if (retrying > 0) parts.push(retryLabel);
|
||||
if (failed > 0) parts.push(`${failed} Fehler`);
|
||||
if (cancelled > 0) parts.push(`${cancelled} abgebrochen`);
|
||||
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;
|
||||
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) {
|
||||
status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`;
|
||||
} else if (waitsForDisk > 0) {
|
||||
@@ -122,7 +133,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
} else if (extracting > 0 || row.package.status === "extracting") {
|
||||
status = packageExtractLabel || "Entpacken";
|
||||
} else if (retrying > 0) {
|
||||
status = `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`;
|
||||
status = retryLabel;
|
||||
} else if (downloading) {
|
||||
status = "Download läuft";
|
||||
}
|
||||
|
||||
+299
-14
@@ -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", () => {
|
||||
it.each([69, 661, 2_470])("emits a running queue snapshot no sooner than 750 ms for %i items", async (itemCount) => {
|
||||
vi.useFakeTimers();
|
||||
@@ -2311,7 +2400,7 @@ describe("download manager", () => {
|
||||
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-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -2377,13 +2466,21 @@ describe("download manager", () => {
|
||||
autoExtract: true,
|
||||
hybridExtract: true
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.extractNow(packageId);
|
||||
|
||||
expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)");
|
||||
session,
|
||||
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";
|
||||
|
||||
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-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.packages[packageId].status).toBe("queued");
|
||||
@@ -2543,6 +2640,111 @@ describe("download manager", () => {
|
||||
expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false);
|
||||
}, 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", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-"));
|
||||
tempDirs.push(root);
|
||||
@@ -7010,7 +7212,14 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const changed = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
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(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]!),
|
||||
{
|
||||
@@ -7033,10 +7242,14 @@ describe("download manager", () => {
|
||||
expect(item.attempts).toBe(0);
|
||||
expect(item.fullStatus).toContain("Auto-Recovery");
|
||||
}
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false);
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
||||
expect(session.packages[packageId]?.status).toBe("queued");
|
||||
});
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false);
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
||||
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)", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
@@ -7393,7 +7606,79 @@ describe("download manager", () => {
|
||||
expect(Array.from(ready)).toEqual([part1Path.toLowerCase()]);
|
||||
});
|
||||
|
||||
it("skips unchanged hybrid archives after a previous extraction failure", async () => {
|
||||
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 () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
|
||||
@@ -1691,7 +1691,7 @@ describe("download table row contracts", () => {
|
||||
["package", "Finalizing - 50% * release.part1.rar", "Finalizing - 50%"],
|
||||
["item", "Finalizing (3/4) · release.part1.rar", "Finalizing - 75%"],
|
||||
["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"
|
||||
? renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
@@ -1720,10 +1720,11 @@ describe("download table row contracts", () => {
|
||||
expect(html).toContain(`aria-label="${expectedStatus}"`);
|
||||
expect(html.match(new RegExp(`>${expectedStatus}</span>`, "g"))).toHaveLength(2);
|
||||
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([
|
||||
["Finalisieren - 99% (0/1) · release.part1.rar", "Finalisieren - 99%"],
|
||||
["Finalisieren (3/2) · release.part1.rar", "Finalisieren - 100%"],
|
||||
["Finalizing (-1/2) · release.part1.rar", "Finalizing - 0%"],
|
||||
["Finalisieren (/2) · release.part1.rar", "Finalisieren"],
|
||||
@@ -2210,7 +2211,7 @@ describe("download table row contracts", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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)"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
|
||||
["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"],
|
||||
["Jetzt entpacken (2)", "Extract now (2)"],
|
||||
["1 Entpackfehler", "1 extraction error"],
|
||||
|
||||
@@ -85,6 +85,15 @@ describe("download package presentation", () => {
|
||||
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", () => {
|
||||
const items = [
|
||||
...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" }));
|
||||
|
||||
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("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", () => {
|
||||
|
||||
Reference in New Issue
Block a user