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