diff --git a/CHANGELOG.md b/CHANGELOG.md index be4c10c..74ff6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.65] - 2026-08-23 + +### Extract now behavior + +- Extract only archive sets that are already complete on disk without starting or re-queueing downloads. +- Skip incomplete multipart sets while leaving every queued, failed, or missing part unchanged. +- Start every currently extractable package in a multi-selection even when other selected packages are not ready. +- Extract complete sets from a package immediately while its unrelated incomplete sets continue waiting for a later download. +- Keep the existing error response when none of the selected packages contains a complete extractable archive set. + +### Compatibility correction + +- Remove the automatic missing-archive download repair introduced in 2.0.64 so `Extract now` remains a strictly local post-processing action. + ## [2.0.64] - 2026-08-23 ### Manual extraction repair diff --git a/package-lock.json b/package-lock.json index f06301e..f69c443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.64", + "version": "2.0.65", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.64", + "version": "2.0.65", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 3d8d916..de54088 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.64", + "version": "2.0.65", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 1e4d73c..e6e869e 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -197,12 +197,6 @@ type ManualExtractionPlan = { archiveFilter?: Set; itemFiles: Map; }; - -type ManualExtractionRepairPlan = { - packageId: string; - generation: number; - itemStates: Map; -}; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; @@ -315,15 +309,15 @@ function resolvePreallocResumeMismatchThreshold(pathHint: string): number { : PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES; } -function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { - if (item.targetPath) { - return item.targetPath; - } - if (item.fileName && pkg.outputDir) { - return path.join(pkg.outputDir, item.fileName); - } - return null; -} +function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { + if (item.targetPath) { + return item.targetPath; + } + if (item.fileName && pkg.outputDir) { + return path.join(pkg.outputDir, item.fileName); + } + return null; +} function inspectPackageItemDiskState(pkg: PackageEntry, item: DownloadItem): PackageItemDiskState { const minBytes = itemExpectedMinBytes(item); @@ -3007,9 +3001,9 @@ export class DownloadManager extends EventEmitter { return `${item.updatedAt}|${item.status}|${item.progressPercent}|${item.speedBps}|${item.downloadedBytes}|${item.totalBytes}|${item.retries}|${item.fullStatus || ""}|${item.fileName}|${item.providerLabel || ""}|${item.provider || ""}|${item.onlineStatus || ""}|${item.lastError || ""}`; } - private buildPackageHash(pkg: PackageEntry): string { - return `${pkg.updatedAt}|${pkg.status}|${pkg.name}|${pkg.enabled ? 1 : 0}|${pkg.cancelled ? 1 : 0}|${pkg.priority || ""}|${pkg.itemIds.length}|${pkg.postProcessLabel || ""}|${pkg.manualExtractionPending ? 1 : 0}|${(pkg.manualExtractionRepairItemIds || []).join(",")}|${pkg.audioStripSummary?.at || 0}`; - } + private buildPackageHash(pkg: PackageEntry): string { + return `${pkg.updatedAt}|${pkg.status}|${pkg.name}|${pkg.enabled ? 1 : 0}|${pkg.cancelled ? 1 : 0}|${pkg.priority || ""}|${pkg.itemIds.length}|${pkg.postProcessLabel || ""}|${pkg.audioStripSummary?.at || 0}`; + } public getSnapshotForEmit(forceFull = false): UiSnapshot { const base = this.getSnapshot(); @@ -3465,13 +3459,9 @@ export class DownloadManager extends EventEmitter { } const pkg = this.session.packages[item.packageId]; let removedByPackageCleanup = false; - if (pkg) { - pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); - pkg.manualExtractionRepairItemIds = (pkg.manualExtractionRepairItemIds || []).filter((id) => id !== itemId); - if (pkg.manualExtractionRepairItemIds.length === 0) { - pkg.manualExtractionPending = false; - } - if (pkg.itemIds.length === 0) { + if (pkg) { + pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); + if (pkg.itemIds.length === 0) { this.removePackageFromSession(item.packageId, [itemId]); removedByPackageCleanup = true; } else { @@ -6919,8 +6909,6 @@ export class DownloadManager extends EventEmitter { pkg.cancelled = false; pkg.enabled = true; pkg.postProcessLabel = undefined; - pkg.manualExtractionPending = false; - pkg.manualExtractionRepairItemIds = []; pkg.audioStripSummary = undefined; pkg.cleanedCompletedItemCount = 0; pkg.cleanedExtractedItemCount = 0; @@ -7011,8 +6999,6 @@ export class DownloadManager extends EventEmitter { if (pkg) { pkg.cancelled = false; pkg.postProcessLabel = undefined; - pkg.manualExtractionPending = false; - pkg.manualExtractionRepairItemIds = []; pkg.audioStripSummary = undefined; pkg.downloadCompletedAt = 0; this.beginPackageResultGeneration(pkgId, false, true); @@ -9534,47 +9520,18 @@ export class DownloadManager extends EventEmitter { } const success = items.filter((item) => item.status === "completed").length; - const failed = items.filter((item) => item.status === "failed").length; - const cancelled = items.filter((item) => item.status === "cancelled").length; - const allDone = this.areAllPackageItemRefsFinished(pkg); - if (!allDone && success + failed + cancelled >= items.length) { + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { logger.warn( `Post-Processing wartet trotz gefiltert fertiger Items: ` + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + `success=${success}, failed=${failed}, cancelled=${cancelled}` - ); - } - - if (pkg.manualExtractionPending === true) { - const repairItems = (pkg.manualExtractionRepairItemIds || []) - .filter((itemId) => pkg.itemIds.includes(itemId) && Boolean(this.session.items[itemId])) - .map((itemId) => this.session.items[itemId]); - const repairReady = repairItems.length > 0 - && repairItems.every((item) => item.status === "completed" && inspectPackageItemDiskState(pkg, item).reason === "ok"); - if (repairReady) { - pkg.status = "queued"; - pkg.updatedAt = nowMs(); - for (const item of items) { - if (item.status === "completed" && !isExtractedLabel(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); - } - } - changed = true; - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverManualExtraction): ${compactErrorText(err)}`)); - } else { - const hasRepairFailure = repairItems.some((item) => item.status === "failed" || item.status === "cancelled"); - const nextStatus = hasRepairFailure ? "failed" : "queued"; - if (pkg.status !== nextStatus) { - pkg.status = nextStatus; - pkg.updatedAt = nowMs(); - changed = true; - } - } - continue; - } - - if (!allDone && this.settings.autoExtract && this.settings.hybridExtract && success > 0 && failed === 0) { + ); + } + + if (!allDone && this.settings.autoExtract && this.settings.hybridExtract && success > 0 && failed === 0) { const needsExtraction = items.some((item) => item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)); if (needsExtraction) { pkg.status = "queued"; @@ -9649,9 +9606,8 @@ export class DownloadManager extends EventEmitter { } private triggerPendingExtractions(packageFilter?: ReadonlySet): void { - if (!this.settings.autoExtract - && !Object.values(this.session.packages).some((pkg) => pkg.manualExtractionPending === true)) { - return; + if (!this.settings.autoExtract) { + return; } for (const packageId of this.session.packageOrder) { if (packageFilter && !packageFilter.has(packageId)) { @@ -9661,12 +9617,9 @@ export class DownloadManager extends EventEmitter { continue; } const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; - } - if (!this.settings.autoExtract && pkg.manualExtractionPending !== true) { - continue; - } + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } if (this.packagePostProcessTasks.has(packageId)) { continue; } @@ -9674,21 +9627,11 @@ export class DownloadManager extends EventEmitter { if (items.length === 0) { continue; } - const success = items.filter((item) => item.status === "completed").length; - const failed = items.filter((item) => item.status === "failed").length; - const cancelled = items.filter((item) => item.status === "cancelled").length; - const allDone = this.areAllPackageItemRefsFinished(pkg); - if (pkg.manualExtractionPending === true && !this.manualExtractPackages.has(packageId)) { - const repairItems = (pkg.manualExtractionRepairItemIds || []) - .filter((itemId) => pkg.itemIds.includes(itemId) && Boolean(this.session.items[itemId])) - .map((itemId) => this.session.items[itemId]); - const repairReady = repairItems.length > 0 - && repairItems.every((item) => item.status === "completed" && inspectPackageItemDiskState(pkg, item).reason === "ok"); - if (!repairReady) { - continue; - } - } - if (!allDone && success + failed + cancelled >= items.length) { + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { logger.warn( `Post-Processing wartet trotz gefiltert fertiger Items: ` + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + @@ -9813,147 +9756,6 @@ export class DownloadManager extends EventEmitter { }; } - private resolveManualExtractionRepairPlan( - packageId: string, - selectedItemIds?: ReadonlySet - ): ManualExtractionRepairPlan | null { - const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) { - return null; - } - const unextractedItems = pkg.itemIds - .map((itemId) => this.session.items[itemId]) - .filter((item): item is DownloadItem => Boolean( - item - && !isExtractedLabel(item.fullStatus) - )); - const initiallySelectedItems = selectedItemIds - ? unextractedItems.filter((item) => selectedItemIds.has(item.id) && ( - item.status === "completed" - || pkg.manualExtractionPending === true && (pkg.manualExtractionRepairItemIds || []).includes(item.id) - )) - : unextractedItems; - const targetItems = selectedItemIds - ? [...new Map(initiallySelectedItems.flatMap((item) => - resolveArchiveItemsFromList(path.basename(item.targetPath || item.fileName || ""), unextractedItems) - ).map((item) => [item.id, item])).values()] - : unextractedItems; - if (targetItems.length === 0 - || (pkg.manualExtractionPending !== true && !unextractedItems.some((item) => isExtractErrorLabel(item.fullStatus)))) { - return null; - } - const itemStates = new Map(); - for (const item of targetItems) { - if (!isArchiveLikePath(item.fileName || item.targetPath || "")) { - continue; - } - const diskState = inspectPackageItemDiskState(pkg, item); - if (item.status === "completed" && diskState.reason === "ok") { - continue; - } - if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") { - return null; - } - if ((diskState.reason !== "missing_file" && diskState.reason !== "missing_path") - || !["completed", "queued", "reconnect_wait", "failed", "cancelled"].includes(item.status)) { - return null; - } - itemStates.set(item.id, { - status: item.status, - targetPath: String(item.targetPath || ""), - fullStatus: item.fullStatus, - updatedAt: item.updatedAt - }); - } - if (itemStates.size === 0) { - return null; - } - return { - packageId, - generation: this.getPackageResultGeneration(packageId), - itemStates - }; - } - - private isManualExtractionRepairPlanCurrent(plan: ManualExtractionRepairPlan): boolean { - const pkg = this.session.packages[plan.packageId]; - if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) { - return false; - } - return [...plan.itemStates].every(([itemId, expected]) => { - const item = this.session.items[itemId]; - if (!item - || item.packageId !== plan.packageId - || item.status !== expected.status - || item.updatedAt !== expected.updatedAt - || String(item.targetPath || "") !== expected.targetPath - || item.fullStatus !== expected.fullStatus) { - return false; - } - const reason = inspectPackageItemDiskState(pkg, item).reason; - return reason === "missing_file" || reason === "missing_path"; - }); - } - - private async prepareManualExtractionRepairPlan(plan: ManualExtractionRepairPlan): Promise { - if (!this.isManualExtractionRepairPlanCurrent(plan)) { - return false; - } - if (this.packagePostProcessTasks.has(plan.packageId) || this.hasDeferredPostProcessPending(plan.packageId)) { - await Promise.allSettled(this.abortPackagePostProcessing(plan.packageId, "manual_extract_repair")); - } - return this.isManualExtractionRepairPlanCurrent(plan); - } - - private commitManualExtractionRepairPlan(plan: ManualExtractionRepairPlan): string[] { - const pkg = this.session.packages[plan.packageId] as PackageEntry; - const repairItemIds = [...plan.itemStates.keys()]; - this.clearPackageDiskRetry(plan.packageId); - this.clearHybridArchiveState(plan.packageId); - pkg.manualExtractionPending = true; - pkg.manualExtractionRepairItemIds = repairItemIds; - pkg.enabled = true; - pkg.cancelled = false; - pkg.status = "queued"; - pkg.postProcessLabel = undefined; - pkg.updatedAt = nowMs(); - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - if (!item || isExtractedLabel(item.fullStatus)) { - continue; - } - if (!plan.itemStates.has(itemId)) { - if (isArchiveLikePath(item.fileName || item.targetPath || "")) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); - } - continue; - } - this.releaseTargetPath(itemId); - this.dropItemContribution(itemId); - this.runOutcomes.delete(itemId); - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); - item.status = "queued"; - item.downloadedBytes = 0; - item.progressPercent = 0; - item.speedBps = 0; - item.attempts = 0; - item.retries = 0; - item.lastError = ""; - item.resumable = true; - item.targetPath = ""; - item.provider = null; - item.fullStatus = "Wartet auf erneuten Download"; - item.updatedAt = nowMs(); - } - this.runCompletedPackages.delete(plan.packageId); - this.historyRecordedPackages.delete(plan.packageId); - this.beginPackageResultGeneration(plan.packageId, false, true); - this.reactivateStandalonePackageResult(plan.packageId); - return repairItemIds; - } - private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean { const pkg = this.session.packages[plan.packageId]; if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) { @@ -10016,45 +9818,20 @@ export class DownloadManager extends EventEmitter { this.reactivateStandalonePackageResult(plan.packageId); } - private async executeManualExtractionPlans( - plans: ManualExtractionPlan[], - repairPlans: ManualExtractionRepairPlan[] - ): Promise { - if (repairPlans.length > 0) { - if (this.lifecyclePhase === "starting" || this.lifecyclePhase === "stopping") { - throw new Error("Downloadsteuerung ist beschäftigt, Entpacken bitte erneut starten"); - } - this.ensureUsableDownloadAccount(); - } + private async executeManualExtractionPlans(plans: ManualExtractionPlan[]): Promise { for (const plan of plans) { if (!await this.prepareManualExtractionPlan(plan)) { throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); } } - for (const plan of repairPlans) { - if (!await this.prepareManualExtractionRepairPlan(plan)) { - throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); - } - } - if (!plans.every((plan) => this.isManualExtractionPlanCurrent(plan)) - || !repairPlans.every((plan) => this.isManualExtractionRepairPlanCurrent(plan))) { + if (!plans.every((plan) => this.isManualExtractionPlanCurrent(plan))) { throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); } - if (repairPlans.length > 0) { - if (this.lifecyclePhase === "starting" || this.lifecyclePhase === "stopping") { - throw new Error("Downloadsteuerung ist beschäftigt, Entpacken bitte erneut starten"); - } - this.ensureUsableDownloadAccount(); - } - const repairItemIds = repairPlans.flatMap((plan) => this.commitManualExtractionRepairPlan(plan)); for (const plan of plans) { this.commitManualExtractionPlan(plan); } this.persistSoon(); this.emitState(true); - if (repairItemIds.length > 0) { - await this.startItems(repairItemIds); - } for (const plan of plans) { const pkg = this.session.packages[plan.packageId]; logger.info(`Jetzt entpacken: pkg=${pkg?.name || plan.packageId}, targeted=${plan.targetItemIds.size}`); @@ -10063,15 +9840,6 @@ export class DownloadManager extends EventEmitter { } void this.runPackagePostProcessing(plan.packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); } - for (const plan of repairPlans) { - const pkg = this.session.packages[plan.packageId]; - logger.info(`Jetzt entpacken repariert fehlende Archive: pkg=${pkg?.name || plan.packageId}, redownload=${plan.itemStates.size}`); - if (pkg) { - this.logPackageForPackage(pkg, "INFO", "Fehlende Archive werden erneut geladen", { - redownloadItems: plan.itemStates.size - }); - } - } } public async extractNow(target: string | ExtractNowRequest): Promise { @@ -10095,28 +9863,23 @@ export class DownloadManager extends EventEmitter { } } const plans: ManualExtractionPlan[] = []; - const repairPlans: ManualExtractionRepairPlan[] = []; for (const packageId of packageIds) { const plan = await this.resolveManualExtractionPlan(packageId); - const repairPlan = this.resolveManualExtractionRepairPlan(packageId); if (plan) plans.push(plan); - if (repairPlan) repairPlans.push(repairPlan); - if (!plan && !repairPlan) rejected += 1; + else rejected += 1; } for (const [packageId, itemIds] of itemIdsByPackage) { const plan = await this.resolveManualExtractionPlan(packageId, itemIds); - const repairPlan = this.resolveManualExtractionRepairPlan(packageId, itemIds); if (plan) plans.push(plan); - if (repairPlan) repairPlans.push(repairPlan); - if (!plan && !repairPlan) rejected += 1; + else rejected += 1; } - if (plans.length === 0 && repairPlans.length === 0) { + if (plans.length === 0) { throw new Error("Kein entpackbarer Archivsatz ausgewählt"); } if (rejected > 0) { - throw new Error(`${plans.length + repairPlans.length} Entpackvorgang bereit, ${rejected} nicht gestartet`); + logger.info(`Jetzt entpacken: ${plans.length} Entpackvorgang/Vorgänge bereit, ${rejected} Auswahl(en) ohne vollständigen Archivsatz übersprungen`); } - await this.executeManualExtractionPlans(plans, repairPlans); + await this.executeManualExtractionPlans(plans); } private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { @@ -13894,18 +13657,7 @@ export class DownloadManager extends EventEmitter { if ([...this.activeTasks.values()].some((task) => task.packageId === packageId)) { return true; } - const pkg = this.session.packages[packageId]; - const repairItems = pkg?.manualExtractionPending === true - ? (pkg.manualExtractionRepairItemIds || []) - .filter((itemId) => pkg.itemIds.includes(itemId) && Boolean(this.session.items[itemId])) - .map((itemId) => this.session.items[itemId]) - : []; - const repairReachedTerminalFailure = repairItems.length > 0 - && repairItems.every((item) => isFinishedStatus(item.status)) - && repairItems.some((item) => item.status === "failed" || item.status === "cancelled"); - const repairLifecycleActive = repairItems.length > 0 && !repairReachedTerminalFailure; return this.packagePostProcessTasks.has(packageId) - || repairLifecycleActive || this.packageDiskRetryPlans.has(packageId) || this.hasDeferredPostProcessPending(packageId) || (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0 @@ -14968,26 +14720,8 @@ export class DownloadManager extends EventEmitter { setupMs, recoveryMs }); - + const allDone = this.areAllPackageItemRefsFinished(pkg); - const repairItemIds = (pkg.manualExtractionRepairItemIds || []) - .filter((itemId) => pkg.itemIds.includes(itemId) && Boolean(this.session.items[itemId])); - const manualRepairPending = pkg.manualExtractionPending === true && repairItemIds.length > 0; - const repairItems = repairItemIds.map((itemId) => this.session.items[itemId]); - const repairDownloadsComplete = manualRepairPending - && repairItems.every((item) => item.status === "completed" && inspectPackageItemDiskState(pkg, item).reason === "ok"); - if (manualRepairPending && !repairDownloadsComplete && !this.manualExtractPackages.has(packageId)) { - pkg.postProcessLabel = undefined; - pkg.status = repairItems.some((item) => item.status === "failed" || item.status === "cancelled") - ? "failed" - : (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; - pkg.updatedAt = nowMs(); - return; - } - if (repairDownloadsComplete) { - this.manualExtractPackages.add(packageId); - pkg.updatedAt = nowMs(); - } const manualExtraction = this.manualExtractPackages.has(packageId); const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); const shouldExtract = this.settings.autoExtract || manualExtraction; @@ -15034,13 +14768,11 @@ export class DownloadManager extends EventEmitter { return; } - const completedItems = items.filter((item) => item.status === "completed"); - const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); - let extractedCount = 0; - let manualRepairExtractionAttempted = false; - + const completedItems = items.filter((item) => item.status === "completed"); + const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); + let extractedCount = 0; + if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { - manualRepairExtractionAttempted = manualRepairPending && repairDownloadsComplete; pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.status = "extracting"; this.emitState(); @@ -15421,15 +15153,10 @@ export class DownloadManager extends EventEmitter { pkg.status = "failed"; } else if (cancelled > 0) { pkg.status = success > 0 ? "completed" : "cancelled"; - } else { - pkg.status = "completed"; - } - - if (manualRepairExtractionAttempted) { - pkg.manualExtractionPending = false; - pkg.manualExtractionRepairItemIds = []; - } - + } else { + pkg.status = "completed"; + } + pkg.postProcessLabel = undefined; pkg.updatedAt = nowMs(); diff --git a/src/main/storage.ts b/src/main/storage.ts index 6b84942..f50084e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1034,12 +1034,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState { .map((value) => normalizeSessionId(value)) .filter((value) => value.length > 0), cancelled: Boolean(pkg.cancelled), - enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), + enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", - manualExtractionPending: pkg.manualExtractionPending === true, - manualExtractionRepairItemIds: Array.isArray(pkg.manualExtractionRepairItemIds) - ? [...new Set(pkg.manualExtractionRepairItemIds.map((value) => normalizeSessionId(value)).filter((value) => value.length > 0))].slice(0, 1_000_000) - : [], audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary), cleanedCompletedItemCount: clampNumber(pkg.cleanedCompletedItemCount, 0, 0, 1_000_000), cleanedExtractedItemCount: clampNumber(pkg.cleanedExtractedItemCount, 0, 0, 1_000_000), @@ -1103,17 +1099,12 @@ export function normalizeLoadedSession(raw: unknown): SessionState { logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`); } - for (const pkg of Object.values(packagesById)) { - pkg.itemIds = pkg.itemIds.filter((itemId) => { - const item = itemsById[itemId]; - return Boolean(item) && item.packageId === pkg.id; - }); - const itemIdSet = new Set(pkg.itemIds); - pkg.manualExtractionRepairItemIds = (pkg.manualExtractionRepairItemIds || []) - .filter((itemId) => itemIdSet.has(itemId)); - pkg.manualExtractionPending = pkg.manualExtractionPending === true - && pkg.manualExtractionRepairItemIds.length > 0; - } + for (const pkg of Object.values(packagesById)) { + pkg.itemIds = pkg.itemIds.filter((itemId) => { + const item = itemsById[itemId]; + return Boolean(item) && item.packageId === pkg.id; + }); + } const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; const seenOrder = new Set(); diff --git a/src/renderer/views/downloads/extract-action.ts b/src/renderer/views/downloads/extract-action.ts index 971e463..45e3e24 100644 --- a/src/renderer/views/downloads/extract-action.ts +++ b/src/renderer/views/downloads/extract-action.ts @@ -15,19 +15,14 @@ export interface ExtractNowContextInput { items: Record; } -function canExtractItem(item: DownloadItem | undefined, pkg: PackageEntry | undefined): item is DownloadItem { - return Boolean(item && ( - item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "") - || pkg?.manualExtractionPending === true - && (pkg.manualExtractionRepairItemIds || []).includes(item.id) - && ["queued", "reconnect_wait", "failed", "cancelled"].includes(item.status) - )); +function canExtractItem(item: DownloadItem | undefined): item is DownloadItem { + return Boolean(item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "")); } export function buildExtractNowContextAction(input: ExtractNowContextInput): ExtractNowContextAction | null { const packageIds = [...new Set(input.selectedPackageIds)].filter((packageId) => { const entry = input.packages[packageId]; - return Boolean(entry && !entry.cancelled && entry.itemIds.some((itemId) => canExtractItem(input.items[itemId], entry))); + return Boolean(entry && !entry.cancelled && entry.itemIds.some((itemId) => canExtractItem(input.items[itemId]))); }); const packageSet = new Set(packageIds); const selectedItemIds = input.selectedItemIds.length > 0 @@ -37,7 +32,7 @@ export function buildExtractNowContextAction(input: ExtractNowContextInput): Ext : []; const itemIds = [...new Set(selectedItemIds)].filter((itemId) => { const item = input.items[itemId]; - return canExtractItem(item, item ? input.packages[item.packageId] : undefined) && !packageSet.has(item.packageId); + return canExtractItem(item) && !packageSet.has(item.packageId); }); const targetCount = packageIds.length + itemIds.length; if (targetCount === 0) { diff --git a/src/shared/types.ts b/src/shared/types.ts index 29f8914..fff9288 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -581,8 +581,6 @@ export interface PackageEntry { enabled: boolean; priority?: PackagePriority; postProcessLabel?: string; - manualExtractionPending?: boolean; - manualExtractionRepairItemIds?: string[]; audioStripSummary?: AudioStripSummary; cleanedCompletedItemCount?: number; cleanedExtractedItemCount?: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 6a66f15..1828bcf 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -17434,263 +17434,6 @@ describe("post-processing lifecycle audit", () => { expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler"); }); - it("repairs selected failed packages by downloading missing archive files before extraction", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-missing-batch-")); - tempDirs.push(root); - const zipA = new AdmZip(); - zipA.addFile("Episode.A.mkv", Buffer.from("episode-a")); - const zipB = new AdmZip(); - zipB.addFile("Episode.B.mkv", Buffer.from("episode-b")); - const { manager, session } = createCompletedFileManager(root, [ - { packageId: "repair-a", itemId: "repair-a-item", fileName: "Episode.A.zip", content: zipA.toBuffer() }, - { packageId: "repair-b", itemId: "repair-b-item", fileName: "Episode.B.zip", content: zipB.toBuffer() } - ]); - const internal = manager as any; - internal.ensureScheduler = vi.fn(async () => {}); - for (const item of Object.values(session.items)) { - fs.rmSync(item.targetPath, { force: true }); - item.fullStatus = "Entpack-Fehler: Quelldatei fehlt"; - item.lastError = "Quelldatei fehlt"; - } - - await manager.extractNow({ packageIds: ["repair-a", "repair-b"], itemIds: [] }); - - expect(session.running).toBe(true); - expect((internal.runScopeKind as string)).toBe("selected"); - expect([...internal.runItemIds].sort()).toEqual(["repair-a-item", "repair-b-item"]); - expect((session.packages["repair-a"] as any).manualExtractionPending).toBe(true); - expect((session.packages["repair-b"] as any).manualExtractionPending).toBe(true); - expect((session.packages["repair-a"] as any).manualExtractionRepairItemIds).toEqual(["repair-a-item"]); - expect((session.packages["repair-b"] as any).manualExtractionRepairItemIds).toEqual(["repair-b-item"]); - for (const item of Object.values(session.items)) { - expect(item).toEqual(expect.objectContaining({ - status: "queued", - downloadedBytes: 0, - progressPercent: 0, - targetPath: "", - fullStatus: "Wartet auf erneuten Download" - })); - } - - for (const [itemId, content] of [["repair-a-item", zipA.toBuffer()], ["repair-b-item", zipB.toBuffer()]] as const) { - const item = session.items[itemId]; - const pkg = session.packages[item.packageId]; - const targetPath = path.join(pkg.outputDir, item.fileName); - fs.writeFileSync(targetPath, content); - item.status = "completed"; - item.targetPath = targetPath; - item.downloadedBytes = content.length; - item.totalBytes = content.length; - item.progressPercent = 100; - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = Date.now(); - } - await Promise.all([ - internal.runPackagePostProcessing("repair-a"), - internal.runPackagePostProcessing("repair-b") - ]); - await waitFor(() => fs.existsSync(path.join(session.packages["repair-a"].extractDir, "Episode.A.mkv")), 10_000); - await waitFor(() => fs.existsSync(path.join(session.packages["repair-b"].extractDir, "Episode.B.mkv")), 10_000); - expect((session.packages["repair-a"] as any).manualExtractionPending).toBe(false); - expect((session.packages["repair-b"] as any).manualExtractionPending).toBe(false); - expect(session.items["repair-a-item"].fullStatus).toMatch(/^Entpackt/); - expect(session.items["repair-b-item"].fullStatus).toMatch(/^Entpackt/); - }); - - it("keeps present archive files while repairing only missing siblings", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-partial-")); - tempDirs.push(root); - const { manager, session } = createCompletedFileManager(root, [ - { packageId: "repair-partial", itemId: "repair-present", fileName: "show.part1.rar" }, - { packageId: "repair-partial", itemId: "repair-missing", fileName: "show.part2.rar" }, - { packageId: "repair-partial", itemId: "repair-video", fileName: "bonus.mkv" } - ]); - const internal = manager as any; - internal.ensureScheduler = vi.fn(async () => {}); - const presentPath = session.items["repair-present"].targetPath; - fs.rmSync(session.items["repair-missing"].targetPath, { force: true }); - for (const item of Object.values(session.items)) { - item.fullStatus = "Entpack-Fehler: Quelldatei fehlt"; - item.lastError = "Quelldatei fehlt"; - } - session.items["repair-video"].fullStatus = "Fertig (256 B)"; - session.items["repair-video"].lastError = ""; - - await manager.extractNow({ packageIds: [], itemIds: ["repair-present"] }); - - expect(fs.existsSync(presentPath)).toBe(true); - expect(session.items["repair-present"]).toEqual(expect.objectContaining({ - status: "completed", - targetPath: presentPath, - fullStatus: "Entpacken - Ausstehend" - })); - expect(session.items["repair-missing"]).toEqual(expect.objectContaining({ - status: "queued", - targetPath: "", - fullStatus: "Wartet auf erneuten Download" - })); - expect(session.items["repair-video"].fullStatus).toBe("Fertig (256 B)"); - expect([...internal.runItemIds]).toEqual(["repair-missing"]); - expect((session.packages["repair-partial"] as any).manualExtractionRepairItemIds).toEqual(["repair-missing"]); - }); - - it("repairs missing archive sets even when another set in the same package is already ready", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-ready-and-repair-")); - tempDirs.push(root); - const readyZip = new AdmZip(); - readyZip.addFile("Ready.mkv", Buffer.from("ready")); - const missingZip = new AdmZip(); - missingZip.addFile("Missing.mkv", Buffer.from("missing")); - const { manager, session } = createCompletedFileManager(root, [ - { packageId: "ready-repair", itemId: "ready-item", fileName: "Ready.zip", content: readyZip.toBuffer() }, - { packageId: "ready-repair", itemId: "missing-item", fileName: "Missing.zip" } - ]); - const internal = manager as any; - internal.ensureScheduler = vi.fn(async () => {}); - fs.rmSync(session.items["missing-item"].targetPath, { force: true }); - for (const item of Object.values(session.items)) { - item.fullStatus = "Entpack-Fehler: vorheriger Fehler"; - item.lastError = "vorheriger Fehler"; - } - - await manager.extractNow("ready-repair"); - - await waitFor(() => fs.existsSync(path.join(session.packages["ready-repair"].extractDir, "Ready.mkv")), 10_000); - expect(session.items["ready-item"].fullStatus).toMatch(/^Entpackt/); - expect(session.items["missing-item"].status).toBe("queued"); - expect((session.packages["ready-repair"] as any).manualExtractionPending).toBe(true); - expect((session.packages["ready-repair"] as any).manualExtractionRepairItemIds).toEqual(["missing-item"]); - const missingItem = session.items["missing-item"]; - const missingPath = path.join(session.packages["ready-repair"].outputDir, missingItem.fileName); - const missingContent = missingZip.toBuffer(); - fs.writeFileSync(missingPath, missingContent); - missingItem.status = "completed"; - missingItem.targetPath = missingPath; - missingItem.downloadedBytes = missingContent.length; - missingItem.totalBytes = missingContent.length; - missingItem.progressPercent = 100; - missingItem.fullStatus = "Entpacken - Ausstehend"; - missingItem.updatedAt = Date.now(); - await internal.runPackagePostProcessing("ready-repair"); - - await waitFor(() => fs.existsSync(path.join(session.packages["ready-repair"].extractDir, "Missing.mkv")), 10_000); - expect(session.items["missing-item"].fullStatus).toMatch(/^Entpackt/); - expect(session.packages["ready-repair"].manualExtractionPending).toBe(false); - expect(session.packages["ready-repair"].manualExtractionRepairItemIds).toEqual([]); - }, 15_000); - - it("includes queued and failed missing parts in the same repair run", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-noncompleted-")); - tempDirs.push(root); - const { manager, session } = createCompletedFileManager(root, [ - { packageId: "repair-noncompleted", itemId: "selected-part", fileName: "show.part1.rar" }, - { packageId: "repair-noncompleted", itemId: "queued-part", fileName: "show.part2.rar" }, - { packageId: "repair-noncompleted", itemId: "failed-part", fileName: "show.part3.rar" } - ]); - const internal = manager as any; - internal.ensureScheduler = vi.fn(async () => {}); - for (const item of Object.values(session.items)) { - fs.rmSync(item.targetPath, { force: true }); - item.fullStatus = "Entpack-Fehler: Quelldatei fehlt"; - item.lastError = "Quelldatei fehlt"; - } - session.items["queued-part"].status = "queued"; - session.items["failed-part"].status = "failed"; - - await manager.extractNow({ packageIds: [], itemIds: ["selected-part"] }); - - expect([...internal.runItemIds].sort()).toEqual(["failed-part", "queued-part", "selected-part"]); - expect((session.packages["repair-noncompleted"] as any).manualExtractionRepairItemIds.sort()).toEqual(["failed-part", "queued-part", "selected-part"]); - expect(session.items["failed-part"].status).toBe("queued"); - }); - - it("keeps the persisted extraction request when a repaired download fails", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-failed-download-")); - tempDirs.push(root); - const { manager, session } = createCompletedFileManager(root, [ - { packageId: "repair-failure", itemId: "repair-failure-item", fileName: "Episode.zip" } - ]); - const internal = manager as any; - internal.ensureScheduler = vi.fn(async () => {}); - fs.rmSync(session.items["repair-failure-item"].targetPath, { force: true }); - session.items["repair-failure-item"].fullStatus = "Entpack-Fehler: Quelldatei fehlt"; - - await manager.extractNow({ packageIds: [], itemIds: ["repair-failure-item"] }); - session.items["repair-failure-item"].status = "failed"; - session.items["repair-failure-item"].fullStatus = "Download fehlgeschlagen"; - session.items["repair-failure-item"].lastError = "Download fehlgeschlagen"; - session.items["repair-failure-item"].updatedAt = Date.now(); - await internal.runPackagePostProcessing("repair-failure"); - - expect(session.packages["repair-failure"].status).toBe("failed"); - expect(session.packages["repair-failure"].manualExtractionPending).toBe(true); - expect(session.packages["repair-failure"].manualExtractionRepairItemIds).toEqual(["repair-failure-item"]); - expect((internal.finalizedPackageResults as Map).size).toBe(1); - const failedGeneration = session.packages["repair-failure"].resultGeneration || 0; - internal.finishRun(); - expect((internal.runContexts as Map).size).toBe(0); - - await manager.extractNow({ packageIds: [], itemIds: ["repair-failure-item"] }); - - expect(session.items["repair-failure-item"].status).toBe("queued"); - expect(session.packages["repair-failure"].manualExtractionPending).toBe(true); - expect(session.packages["repair-failure"].resultGeneration).toBeGreaterThan(failedGeneration); - }); - - it("resumes a persisted manual extraction after restart when repaired downloads are complete", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-restart-")); - tempDirs.push(root); - const zip = new AdmZip(); - zip.addFile("Restarted.mkv", Buffer.from("restarted")); - const { session } = createCompletedFileManager(root, [ - { packageId: "repair-restart", itemId: "repair-restart-item", fileName: "Restarted.zip", content: zip.toBuffer() } - ]); - session.packages["repair-restart"].manualExtractionPending = true; - session.packages["repair-restart"].manualExtractionRepairItemIds = ["repair-restart-item"]; - session.packages["repair-restart"].status = "queued"; - session.items["repair-restart-item"].fullStatus = "Wartet auf erneuten Download"; - const restarted = new DownloadManager( - { - ...defaultSettings(), - token: "rd-token", - outputDir: path.join(root, "downloads"), - extractDir: path.join(root, "extract"), - autoExtract: false, - cleanupMode: "none", - autoRename4sf4sj: false, - collectMkvToLibrary: false, - enableIntegrityCheck: false - }, - session, - createStoragePaths(path.join(root, "restart-state")) - ); - - await waitFor(() => fs.existsSync(path.join(session.packages["repair-restart"].extractDir, "Restarted.mkv")), 10_000); - await waitFor(() => !(restarted as any).packagePostProcessTasks.has("repair-restart"), 10_000); - expect(session.items["repair-restart-item"].fullStatus).toMatch(/^Entpackt/); - expect(session.packages["repair-restart"].manualExtractionPending).toBe(false); - expect(session.packages["repair-restart"].manualExtractionRepairItemIds).toEqual([]); - }, 15_000); - - it("emits a package delta when only the persisted manual extraction request changes", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-delta-")); - tempDirs.push(root); - const { manager, session } = createCompletedFileManager(root, [ - { packageId: "repair-delta", itemId: "repair-delta-item", fileName: "Episode.zip" } - ]); - manager.getSnapshotForEmit(true); - session.packages["repair-delta"].manualExtractionPending = true; - session.packages["repair-delta"].manualExtractionRepairItemIds = ["repair-delta-item"]; - - const snapshot = manager.getSnapshotForEmit(); - - expect(snapshot.payloadKind).toBe("delta"); - expect(snapshot.session.packages["repair-delta"]).toEqual(expect.objectContaining({ - manualExtractionPending: true, - manualExtractionRepairItemIds: ["repair-delta-item"] - })); - }); - it("recognizes and arms an opaque archive file by signature", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-")); tempDirs.push(root); @@ -17752,7 +17495,7 @@ describe("post-processing lifecycle audit", () => { expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); }); - it("rejects a mixed extraction batch before starting any package", async () => { + it("starts every extractable package in a mixed batch and skips the rest", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-")); tempDirs.push(root); const zip = new AdmZip(); @@ -17764,12 +17507,12 @@ describe("post-processing lifecycle audit", () => { const internal = manager as any; internal.runPackagePostProcessing = vi.fn(async () => {}); - await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i); - expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package"); + await manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] }); + expect(internal.runPackagePostProcessing).toHaveBeenCalledWith("valid-package"); expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-package"); }); - it("keeps opaque archive files untouched when batch preflight rejects another target", async () => { + it("starts an opaque archive when another target in the batch is not extractable", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-")); tempDirs.push(root); const zip = new AdmZip(); @@ -17782,13 +17525,52 @@ describe("post-processing lifecycle audit", () => { internal.runPackagePostProcessing = vi.fn(async () => {}); const opaquePath = session.items["atomic-opaque-item"].targetPath; - await expect(manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] })).rejects.toThrow(/nicht gestartet/i); + await manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] }); expect(session.items["atomic-opaque-item"].fileName).toBe("download.bin"); expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath); expect(fs.existsSync(opaquePath)).toBe(true); expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false); - expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); + expect(internal.runPackagePostProcessing).toHaveBeenCalledWith("atomic-opaque"); + expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("atomic-invalid"); + }); + + it("extracts only complete sets from one package without starting its incomplete downloads", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-complete-only-")); + tempDirs.push(root); + const zip = new AdmZip(); + zip.addFile("complete.mkv", Buffer.from("complete")); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "complete-only", itemId: "complete-item", fileName: "complete.zip", content: zip.toBuffer() } + ]); + const internal = manager as any; + const incompleteId = "incomplete-part"; + session.packages["complete-only"].itemIds.push(incompleteId); + session.items[incompleteId] = { + ...session.items["complete-item"], + id: incompleteId, + status: "queued", + fileName: "later.part1.rar", + targetPath: "", + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fullStatus: "Wartet" + }; + internal.runPackagePostProcessing = vi.fn(async () => {}); + internal.ensureScheduler = vi.fn(async () => {}); + + await manager.extractNow("complete-only"); + + expect(internal.runPackagePostProcessing).toHaveBeenCalledWith("complete-only"); + expect(session.items["complete-item"].fullStatus).toBe("Entpacken - Ausstehend"); + expect(session.items[incompleteId]).toEqual(expect.objectContaining({ + status: "queued", + targetPath: "", + fullStatus: "Wartet" + })); + expect(session.running).toBe(false); + expect(internal.ensureScheduler).not.toHaveBeenCalled(); }); it("exposes and drains stop for standalone manual extraction without starting a download run", async () => { diff --git a/tests/extract-action.test.ts b/tests/extract-action.test.ts index e56f520..c76d53a 100644 --- a/tests/extract-action.test.ts +++ b/tests/extract-action.test.ts @@ -98,25 +98,4 @@ describe("extract now context action", () => { items })).toBeNull(); }); - - it("keeps retrying a failed child that belongs to a persisted manual extraction repair", () => { - const failed = item("failed", "pkg-1", "failed", "Download fehlgeschlagen"); - const packageEntry = { - ...pkg("pkg-1", ["failed"]), - manualExtractionPending: true, - manualExtractionRepairItemIds: ["failed"] - }; - - expect(buildExtractNowContextAction({ - contextItemId: "failed", - selectedPackageIds: [], - selectedItemIds: ["failed"], - packages: { "pkg-1": packageEntry }, - items: { failed } - })).toEqual({ - label: "Jetzt entpacken", - request: { packageIds: [], itemIds: ["failed"] }, - targetCount: 1 - }); - }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 097fd0c..321c205 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1498,10 +1498,8 @@ describe("settings storage", () => { status: "downloading", itemIds: ["item1", "item2", "item3", "item4"], cancelled: false, - enabled: true, - manualExtractionPending: true, - manualExtractionRepairItemIds: ["item1", "missing-item", "item1"], - downloadStartedAt: 0, + enabled: true, + downloadStartedAt: 0, downloadCompletedAt: 0, createdAt: Date.now(), updatedAt: Date.now() @@ -1597,10 +1595,8 @@ describe("settings storage", () => { expect(loaded.items["item3"].status).toBe("completed"); expect(loaded.items["item4"].status).toBe("queued"); expect(loaded.items["item1"].downloadedBytes).toBe(5000); - expect(loaded.packages["pkg1"].name).toBe("Test Package"); - expect(loaded.packages["pkg1"].manualExtractionPending).toBe(true); - expect(loaded.packages["pkg1"].manualExtractionRepairItemIds).toEqual(["item1"]); - }); + expect(loaded.packages["pkg1"].name).toBe("Test Package"); + }); it("preserves cleaned package progress aggregates while normalizing a session", () => { const session = emptySession();