fix(extraction): repair missing archives before manual retry
Persist selected manual extraction repair requests, re-download only missing multipart members, and resume extraction after successful repairs across Stop, failures, and application restarts. Allow complete archive sets and missing sets from the same package to progress together, preserve existing parts, and keep retry generations and run contexts isolated after failed repair downloads. Expose direct child retries for persisted repair items and validate repair state through storage normalization and renderer deltas.
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+322
-49
@@ -197,6 +197,12 @@ type ManualExtractionPlan = {
|
||||
archiveFilter?: Set<string>;
|
||||
itemFiles: Map<string, ArchiveCleanupTarget>;
|
||||
};
|
||||
|
||||
type ManualExtractionRepairPlan = {
|
||||
packageId: string;
|
||||
generation: number;
|
||||
itemStates: Map<string, { status: DownloadStatus; targetPath: string; fullStatus: string; updatedAt: number }>;
|
||||
};
|
||||
|
||||
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<string>): 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<string>
|
||||
): 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<string, { status: DownloadStatus; targetPath: string; fullStatus: string; updatedAt: number }>();
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
private async executeManualExtractionPlans(
|
||||
plans: ManualExtractionPlan[],
|
||||
repairPlans: ManualExtractionRepairPlan[]
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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();
|
||||
|
||||
|
||||
+16
-7
@@ -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<string>();
|
||||
|
||||
@@ -15,14 +15,19 @@ export interface ExtractNowContextInput {
|
||||
items: Record<string, DownloadItem>;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -581,6 +581,8 @@ export interface PackageEntry {
|
||||
enabled: boolean;
|
||||
priority?: PackagePriority;
|
||||
postProcessLabel?: string;
|
||||
manualExtractionPending?: boolean;
|
||||
manualExtractionRepairItemIds?: string[];
|
||||
audioStripSummary?: AudioStripSummary;
|
||||
cleanedCompletedItemCount?: number;
|
||||
cleanedExtractedItemCount?: number;
|
||||
|
||||
@@ -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<string, unknown>).size).toBe(1);
|
||||
const failedGeneration = session.packages["repair-failure"].resultGeneration || 0;
|
||||
internal.finishRun();
|
||||
expect((internal.runContexts as Map<string, unknown>).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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user