diff --git a/CHANGELOG.md b/CHANGELOG.md index c09874f..be4c10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.64] - 2026-08-23 + +### Manual extraction repair + +- Turn `Extract now` into a recovery action when archive files from an earlier failed extraction are no longer available locally. +- Re-download only missing archive parts while preserving complete sibling parts and unrelated finished files. +- Extract already complete archive sets immediately while missing sets from the same package download in parallel. +- Include queued, failed, cancelled, and reconnecting multipart siblings in the same selected repair run. +- Keep manual extraction recovery pending across download failures, Stop, application restarts, and disabled automatic extraction. +- Resume extraction automatically after every required repair item finishes successfully. +- Allow direct retries from failed child archive rows as well as selected parent packages. + +### Lifecycle integrity + +- Persist manual repair intent and validated item IDs in session state and local full backups. +- Validate persisted repair IDs against their owning package during session loading. +- Keep failed repair runs and later successful retries in separate package generations and run contexts. +- Prevent pending repairs from producing premature successful package or run results. +- Clear repair intent only after a terminal extraction attempt, while preserving it across aborts and disk-capacity retries. +- Revalidate accounts and lifecycle state immediately before committing a repair batch. + ## [2.0.63] - 2026-08-23 ### Archive recovery and password handling diff --git a/package-lock.json b/package-lock.json index e1a9483..f06301e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.63", + "version": "2.0.64", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.63", + "version": "2.0.64", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 6280c81..3d8d916 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.63", + "version": "2.0.64", "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 a3f9992..1e4d73c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -197,6 +197,12 @@ type ManualExtractionPlan = { archiveFilter?: Set; itemFiles: Map; }; + +type ManualExtractionRepairPlan = { + packageId: string; + generation: number; + itemStates: Map; +}; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; @@ -309,15 +315,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); @@ -3001,9 +3007,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.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.manualExtractionPending ? 1 : 0}|${(pkg.manualExtractionRepairItemIds || []).join(",")}|${pkg.audioStripSummary?.at || 0}`; + } public getSnapshotForEmit(forceFull = false): UiSnapshot { const base = this.getSnapshot(); @@ -3459,9 +3465,13 @@ 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); - if (pkg.itemIds.length === 0) { + 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) { this.removePackageFromSession(item.packageId, [itemId]); removedByPackageCleanup = true; } else { @@ -6909,6 +6919,8 @@ 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; @@ -6999,6 +7011,8 @@ 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); @@ -9520,18 +9534,47 @@ 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 (!allDone && this.settings.autoExtract && this.settings.hybridExtract && success > 0 && failed === 0) { + ); + } + + 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) { const needsExtraction = items.some((item) => item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)); if (needsExtraction) { pkg.status = "queued"; @@ -9606,8 +9649,9 @@ export class DownloadManager extends EventEmitter { } private triggerPendingExtractions(packageFilter?: ReadonlySet): void { - if (!this.settings.autoExtract) { - return; + if (!this.settings.autoExtract + && !Object.values(this.session.packages).some((pkg) => pkg.manualExtractionPending === true)) { + return; } for (const packageId of this.session.packageOrder) { if (packageFilter && !packageFilter.has(packageId)) { @@ -9617,9 +9661,12 @@ export class DownloadManager extends EventEmitter { continue; } const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; - } + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + if (!this.settings.autoExtract && pkg.manualExtractionPending !== true) { + continue; + } if (this.packagePostProcessTasks.has(packageId)) { continue; } @@ -9627,11 +9674,21 @@ 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 (!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 (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) { logger.warn( `Post-Processing wartet trotz gefiltert fertiger Items: ` + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + @@ -9756,6 +9813,147 @@ 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) { @@ -9818,20 +10016,45 @@ export class DownloadManager extends EventEmitter { this.reactivateStandalonePackageResult(plan.packageId); } - private async executeManualExtractionPlans(plans: ManualExtractionPlan[]): Promise { + 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(); + } for (const plan of plans) { if (!await this.prepareManualExtractionPlan(plan)) { throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); } } - if (!plans.every((plan) => this.isManualExtractionPlanCurrent(plan))) { + 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))) { 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}`); @@ -9840,6 +10063,15 @@ 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 { @@ -9863,23 +10095,28 @@ 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); - else rejected += 1; + if (repairPlan) repairPlans.push(repairPlan); + if (!plan && !repairPlan) 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); - else rejected += 1; + if (repairPlan) repairPlans.push(repairPlan); + if (!plan && !repairPlan) rejected += 1; } - if (plans.length === 0) { + if (plans.length === 0 && repairPlans.length === 0) { throw new Error("Kein entpackbarer Archivsatz ausgewählt"); } if (rejected > 0) { - throw new Error(`${plans.length} Entpackvorgang bereit, ${rejected} nicht gestartet`); + throw new Error(`${plans.length + repairPlans.length} Entpackvorgang bereit, ${rejected} nicht gestartet`); } - await this.executeManualExtractionPlans(plans); + await this.executeManualExtractionPlans(plans, repairPlans); } private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { @@ -13657,7 +13894,18 @@ 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 @@ -14720,8 +14968,26 @@ 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; @@ -14768,11 +15034,13 @@ 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; - + 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; + if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { + manualRepairExtractionAttempted = manualRepairPending && repairDownloadsComplete; pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.status = "extracting"; this.emitState(); @@ -15153,10 +15421,15 @@ export class DownloadManager extends EventEmitter { pkg.status = "failed"; } else if (cancelled > 0) { pkg.status = success > 0 ? "completed" : "cancelled"; - } else { - pkg.status = "completed"; - } - + } else { + pkg.status = "completed"; + } + + if (manualRepairExtractionAttempted) { + pkg.manualExtractionPending = false; + pkg.manualExtractionRepairItemIds = []; + } + pkg.postProcessLabel = undefined; pkg.updatedAt = nowMs(); diff --git a/src/main/storage.ts b/src/main/storage.ts index f50084e..6b84942 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1034,8 +1034,12 @@ 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), @@ -1099,12 +1103,17 @@ 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; - }); - } + 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; + } 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 45e3e24..971e463 100644 --- a/src/renderer/views/downloads/extract-action.ts +++ b/src/renderer/views/downloads/extract-action.ts @@ -15,14 +15,19 @@ export interface ExtractNowContextInput { items: Record; } -function canExtractItem(item: DownloadItem | undefined): item is DownloadItem { - return Boolean(item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "")); +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) + )); } 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]))); + return Boolean(entry && !entry.cancelled && entry.itemIds.some((itemId) => canExtractItem(input.items[itemId], entry))); }); const packageSet = new Set(packageIds); const selectedItemIds = input.selectedItemIds.length > 0 @@ -32,7 +37,7 @@ export function buildExtractNowContextAction(input: ExtractNowContextInput): Ext : []; const itemIds = [...new Set(selectedItemIds)].filter((itemId) => { const item = input.items[itemId]; - return canExtractItem(item) && !packageSet.has(item.packageId); + return canExtractItem(item, item ? input.packages[item.packageId] : undefined) && !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 fff9288..29f8914 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -581,6 +581,8 @@ 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 2a263d0..6a66f15 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -17434,6 +17434,263 @@ 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); diff --git a/tests/extract-action.test.ts b/tests/extract-action.test.ts index c76d53a..e56f520 100644 --- a/tests/extract-action.test.ts +++ b/tests/extract-action.test.ts @@ -98,4 +98,25 @@ 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 321c205..097fd0c 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1498,8 +1498,10 @@ describe("settings storage", () => { status: "downloading", itemIds: ["item1", "item2", "item3", "item4"], cancelled: false, - enabled: true, - downloadStartedAt: 0, + enabled: true, + manualExtractionPending: true, + manualExtractionRepairItemIds: ["item1", "missing-item", "item1"], + downloadStartedAt: 0, downloadCompletedAt: 0, createdAt: Date.now(), updatedAt: Date.now() @@ -1595,8 +1597,10 @@ 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"].name).toBe("Test Package"); + expect(loaded.packages["pkg1"].manualExtractionPending).toBe(true); + expect(loaded.packages["pkg1"].manualExtractionRepairItemIds).toEqual(["item1"]); + }); it("preserves cleaned package progress aggregates while normalizing a session", () => { const session = emptySession();