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:
Sucukdeluxe
2026-08-23 11:54:17 +02:00
parent e52957a983
commit 6f922c458d
10 changed files with 126 additions and 644 deletions
+14
View File
@@ -2,6 +2,20 @@
All notable changes to Multi-Debrid Downloader are documented in this file. 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 ## [2.0.64] - 2026-08-23
### Manual extraction repair ### Manual extraction repair
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.64", "version": "2.0.65",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.64", "version": "2.0.65",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.64", "version": "2.0.65",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+49 -322
View File
@@ -197,12 +197,6 @@ type ManualExtractionPlan = {
archiveFilter?: Set<string>; archiveFilter?: Set<string>;
itemFiles: Map<string, ArchiveCleanupTarget>; 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; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000;
@@ -315,15 +309,15 @@ function resolvePreallocResumeMismatchThreshold(pathHint: string): number {
: PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES; : PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES;
} }
function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null {
if (item.targetPath) { if (item.targetPath) {
return item.targetPath; return item.targetPath;
} }
if (item.fileName && pkg.outputDir) { if (item.fileName && pkg.outputDir) {
return path.join(pkg.outputDir, item.fileName); return path.join(pkg.outputDir, item.fileName);
} }
return null; return null;
} }
function inspectPackageItemDiskState(pkg: PackageEntry, item: DownloadItem): PackageItemDiskState { function inspectPackageItemDiskState(pkg: PackageEntry, item: DownloadItem): PackageItemDiskState {
const minBytes = itemExpectedMinBytes(item); 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 || ""}`; 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 { 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}`; 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 { public getSnapshotForEmit(forceFull = false): UiSnapshot {
const base = this.getSnapshot(); const base = this.getSnapshot();
@@ -3465,13 +3459,9 @@ export class DownloadManager extends EventEmitter {
} }
const pkg = this.session.packages[item.packageId]; const pkg = this.session.packages[item.packageId];
let removedByPackageCleanup = false; let removedByPackageCleanup = false;
if (pkg) { if (pkg) {
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
pkg.manualExtractionRepairItemIds = (pkg.manualExtractionRepairItemIds || []).filter((id) => id !== itemId); if (pkg.itemIds.length === 0) {
if (pkg.manualExtractionRepairItemIds.length === 0) {
pkg.manualExtractionPending = false;
}
if (pkg.itemIds.length === 0) {
this.removePackageFromSession(item.packageId, [itemId]); this.removePackageFromSession(item.packageId, [itemId]);
removedByPackageCleanup = true; removedByPackageCleanup = true;
} else { } else {
@@ -6919,8 +6909,6 @@ export class DownloadManager extends EventEmitter {
pkg.cancelled = false; pkg.cancelled = false;
pkg.enabled = true; pkg.enabled = true;
pkg.postProcessLabel = undefined; pkg.postProcessLabel = undefined;
pkg.manualExtractionPending = false;
pkg.manualExtractionRepairItemIds = [];
pkg.audioStripSummary = undefined; pkg.audioStripSummary = undefined;
pkg.cleanedCompletedItemCount = 0; pkg.cleanedCompletedItemCount = 0;
pkg.cleanedExtractedItemCount = 0; pkg.cleanedExtractedItemCount = 0;
@@ -7011,8 +6999,6 @@ export class DownloadManager extends EventEmitter {
if (pkg) { if (pkg) {
pkg.cancelled = false; pkg.cancelled = false;
pkg.postProcessLabel = undefined; pkg.postProcessLabel = undefined;
pkg.manualExtractionPending = false;
pkg.manualExtractionRepairItemIds = [];
pkg.audioStripSummary = undefined; pkg.audioStripSummary = undefined;
pkg.downloadCompletedAt = 0; pkg.downloadCompletedAt = 0;
this.beginPackageResultGeneration(pkgId, false, true); this.beginPackageResultGeneration(pkgId, false, true);
@@ -9534,47 +9520,18 @@ export class DownloadManager extends EventEmitter {
} }
const success = items.filter((item) => item.status === "completed").length; const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length; const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length; const cancelled = items.filter((item) => item.status === "cancelled").length;
const allDone = this.areAllPackageItemRefsFinished(pkg); const allDone = this.areAllPackageItemRefsFinished(pkg);
if (!allDone && success + failed + cancelled >= items.length) { if (!allDone && success + failed + cancelled >= items.length) {
logger.warn( logger.warn(
`Post-Processing wartet trotz gefiltert fertiger Items: ` + `Post-Processing wartet trotz gefiltert fertiger Items: ` +
`pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` +
`success=${success}, failed=${failed}, cancelled=${cancelled}` `success=${success}, failed=${failed}, cancelled=${cancelled}`
); );
} }
if (pkg.manualExtractionPending === true) { if (!allDone && this.settings.autoExtract && this.settings.hybridExtract && success > 0 && failed === 0) {
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)); const needsExtraction = items.some((item) => item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus));
if (needsExtraction) { if (needsExtraction) {
pkg.status = "queued"; pkg.status = "queued";
@@ -9649,9 +9606,8 @@ export class DownloadManager extends EventEmitter {
} }
private triggerPendingExtractions(packageFilter?: ReadonlySet<string>): void { private triggerPendingExtractions(packageFilter?: ReadonlySet<string>): void {
if (!this.settings.autoExtract if (!this.settings.autoExtract) {
&& !Object.values(this.session.packages).some((pkg) => pkg.manualExtractionPending === true)) { return;
return;
} }
for (const packageId of this.session.packageOrder) { for (const packageId of this.session.packageOrder) {
if (packageFilter && !packageFilter.has(packageId)) { if (packageFilter && !packageFilter.has(packageId)) {
@@ -9661,12 +9617,9 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) { if (!pkg || pkg.cancelled || !pkg.enabled) {
continue; continue;
} }
if (!this.settings.autoExtract && pkg.manualExtractionPending !== true) {
continue;
}
if (this.packagePostProcessTasks.has(packageId)) { if (this.packagePostProcessTasks.has(packageId)) {
continue; continue;
} }
@@ -9674,21 +9627,11 @@ export class DownloadManager extends EventEmitter {
if (items.length === 0) { if (items.length === 0) {
continue; continue;
} }
const success = items.filter((item) => item.status === "completed").length; const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length; const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length; const cancelled = items.filter((item) => item.status === "cancelled").length;
const allDone = this.areAllPackageItemRefsFinished(pkg); const allDone = this.areAllPackageItemRefsFinished(pkg);
if (pkg.manualExtractionPending === true && !this.manualExtractPackages.has(packageId)) { if (!allDone && success + failed + cancelled >= items.length) {
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( logger.warn(
`Post-Processing wartet trotz gefiltert fertiger Items: ` + `Post-Processing wartet trotz gefiltert fertiger Items: ` +
`pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + `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 { private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean {
const pkg = this.session.packages[plan.packageId]; const pkg = this.session.packages[plan.packageId];
if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) { if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) {
@@ -10016,45 +9818,20 @@ export class DownloadManager extends EventEmitter {
this.reactivateStandalonePackageResult(plan.packageId); this.reactivateStandalonePackageResult(plan.packageId);
} }
private async executeManualExtractionPlans( private async executeManualExtractionPlans(plans: ManualExtractionPlan[]): Promise<void> {
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) { for (const plan of plans) {
if (!await this.prepareManualExtractionPlan(plan)) { if (!await this.prepareManualExtractionPlan(plan)) {
throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert");
} }
} }
for (const plan of repairPlans) { if (!plans.every((plan) => this.isManualExtractionPlanCurrent(plan))) {
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"); 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) { for (const plan of plans) {
this.commitManualExtractionPlan(plan); this.commitManualExtractionPlan(plan);
} }
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
if (repairItemIds.length > 0) {
await this.startItems(repairItemIds);
}
for (const plan of plans) { for (const plan of plans) {
const pkg = this.session.packages[plan.packageId]; const pkg = this.session.packages[plan.packageId];
logger.info(`Jetzt entpacken: pkg=${pkg?.name || plan.packageId}, targeted=${plan.targetItemIds.size}`); 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)}`)); 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> { public async extractNow(target: string | ExtractNowRequest): Promise<void> {
@@ -10095,28 +9863,23 @@ export class DownloadManager extends EventEmitter {
} }
} }
const plans: ManualExtractionPlan[] = []; const plans: ManualExtractionPlan[] = [];
const repairPlans: ManualExtractionRepairPlan[] = [];
for (const packageId of packageIds) { for (const packageId of packageIds) {
const plan = await this.resolveManualExtractionPlan(packageId); const plan = await this.resolveManualExtractionPlan(packageId);
const repairPlan = this.resolveManualExtractionRepairPlan(packageId);
if (plan) plans.push(plan); if (plan) plans.push(plan);
if (repairPlan) repairPlans.push(repairPlan); else rejected += 1;
if (!plan && !repairPlan) rejected += 1;
} }
for (const [packageId, itemIds] of itemIdsByPackage) { for (const [packageId, itemIds] of itemIdsByPackage) {
const plan = await this.resolveManualExtractionPlan(packageId, itemIds); const plan = await this.resolveManualExtractionPlan(packageId, itemIds);
const repairPlan = this.resolveManualExtractionRepairPlan(packageId, itemIds);
if (plan) plans.push(plan); if (plan) plans.push(plan);
if (repairPlan) repairPlans.push(repairPlan); else rejected += 1;
if (!plan && !repairPlan) rejected += 1;
} }
if (plans.length === 0 && repairPlans.length === 0) { if (plans.length === 0) {
throw new Error("Kein entpackbarer Archivsatz ausgewählt"); throw new Error("Kein entpackbarer Archivsatz ausgewählt");
} }
if (rejected > 0) { 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 { 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)) { if ([...this.activeTasks.values()].some((task) => task.packageId === packageId)) {
return true; 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) return this.packagePostProcessTasks.has(packageId)
|| repairLifecycleActive
|| this.packageDiskRetryPlans.has(packageId) || this.packageDiskRetryPlans.has(packageId)
|| this.hasDeferredPostProcessPending(packageId) || this.hasDeferredPostProcessPending(packageId)
|| (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0 || (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0
@@ -14968,26 +14720,8 @@ export class DownloadManager extends EventEmitter {
setupMs, setupMs,
recoveryMs recoveryMs
}); });
const allDone = this.areAllPackageItemRefsFinished(pkg); 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 manualExtraction = this.manualExtractPackages.has(packageId);
const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId);
const shouldExtract = this.settings.autoExtract || manualExtraction; const shouldExtract = this.settings.autoExtract || manualExtraction;
@@ -15034,13 +14768,11 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
const completedItems = items.filter((item) => item.status === "completed"); const completedItems = items.filter((item) => item.status === "completed");
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus));
let extractedCount = 0; let extractedCount = 0;
let manualRepairExtractionAttempted = false;
if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
manualRepairExtractionAttempted = manualRepairPending && repairDownloadsComplete;
pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.postProcessLabel = "Entpacken vorbereiten...";
pkg.status = "extracting"; pkg.status = "extracting";
this.emitState(); this.emitState();
@@ -15421,15 +15153,10 @@ export class DownloadManager extends EventEmitter {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0) { } else if (cancelled > 0) {
pkg.status = success > 0 ? "completed" : "cancelled"; pkg.status = success > 0 ? "completed" : "cancelled";
} else { } else {
pkg.status = "completed"; pkg.status = "completed";
} }
if (manualRepairExtractionAttempted) {
pkg.manualExtractionPending = false;
pkg.manualExtractionRepairItemIds = [];
}
pkg.postProcessLabel = undefined; pkg.postProcessLabel = undefined;
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
+7 -16
View File
@@ -1034,12 +1034,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
.map((value) => normalizeSessionId(value)) .map((value) => normalizeSessionId(value))
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled), 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", 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), audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary),
cleanedCompletedItemCount: clampNumber(pkg.cleanedCompletedItemCount, 0, 0, 1_000_000), cleanedCompletedItemCount: clampNumber(pkg.cleanedCompletedItemCount, 0, 0, 1_000_000),
cleanedExtractedItemCount: clampNumber(pkg.cleanedExtractedItemCount, 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`); logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
} }
for (const pkg of Object.values(packagesById)) { for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => { pkg.itemIds = pkg.itemIds.filter((itemId) => {
const item = itemsById[itemId]; const item = itemsById[itemId];
return Boolean(item) && item.packageId === pkg.id; 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 rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>(); const seenOrder = new Set<string>();
@@ -15,19 +15,14 @@ export interface ExtractNowContextInput {
items: Record<string, DownloadItem>; items: Record<string, DownloadItem>;
} }
function canExtractItem(item: DownloadItem | undefined, pkg: PackageEntry | undefined): item is DownloadItem { function canExtractItem(item: DownloadItem | undefined): item is DownloadItem {
return Boolean(item && ( return Boolean(item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || ""));
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 { export function buildExtractNowContextAction(input: ExtractNowContextInput): ExtractNowContextAction | null {
const packageIds = [...new Set(input.selectedPackageIds)].filter((packageId) => { const packageIds = [...new Set(input.selectedPackageIds)].filter((packageId) => {
const entry = input.packages[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 packageSet = new Set(packageIds);
const selectedItemIds = input.selectedItemIds.length > 0 const selectedItemIds = input.selectedItemIds.length > 0
@@ -37,7 +32,7 @@ export function buildExtractNowContextAction(input: ExtractNowContextInput): Ext
: []; : [];
const itemIds = [...new Set(selectedItemIds)].filter((itemId) => { const itemIds = [...new Set(selectedItemIds)].filter((itemId) => {
const item = input.items[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; const targetCount = packageIds.length + itemIds.length;
if (targetCount === 0) { if (targetCount === 0) {
-2
View File
@@ -581,8 +581,6 @@ export interface PackageEntry {
enabled: boolean; enabled: boolean;
priority?: PackagePriority; priority?: PackagePriority;
postProcessLabel?: string; postProcessLabel?: string;
manualExtractionPending?: boolean;
manualExtractionRepairItemIds?: string[];
audioStripSummary?: AudioStripSummary; audioStripSummary?: AudioStripSummary;
cleanedCompletedItemCount?: number; cleanedCompletedItemCount?: number;
cleanedExtractedItemCount?: number; cleanedExtractedItemCount?: number;
+45 -263
View File
@@ -17434,263 +17434,6 @@ describe("post-processing lifecycle audit", () => {
expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler"); 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 () => { it("recognizes and arms an opaque archive file by signature", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-"));
tempDirs.push(root); tempDirs.push(root);
@@ -17752,7 +17495,7 @@ describe("post-processing lifecycle audit", () => {
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-"));
tempDirs.push(root); tempDirs.push(root);
const zip = new AdmZip(); const zip = new AdmZip();
@@ -17764,12 +17507,12 @@ describe("post-processing lifecycle audit", () => {
const internal = manager as any; const internal = manager as any;
internal.runPackagePostProcessing = vi.fn(async () => {}); internal.runPackagePostProcessing = vi.fn(async () => {});
await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i); await manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] });
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package"); expect(internal.runPackagePostProcessing).toHaveBeenCalledWith("valid-package");
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-"));
tempDirs.push(root); tempDirs.push(root);
const zip = new AdmZip(); const zip = new AdmZip();
@@ -17782,13 +17525,52 @@ describe("post-processing lifecycle audit", () => {
internal.runPackagePostProcessing = vi.fn(async () => {}); internal.runPackagePostProcessing = vi.fn(async () => {});
const opaquePath = session.items["atomic-opaque-item"].targetPath; 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"].fileName).toBe("download.bin");
expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath); expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath);
expect(fs.existsSync(opaquePath)).toBe(true); expect(fs.existsSync(opaquePath)).toBe(true);
expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false); 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 () => { it("exposes and drains stop for standalone manual extraction without starting a download run", async () => {
-21
View File
@@ -98,25 +98,4 @@ describe("extract now context action", () => {
items items
})).toBeNull(); })).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
});
});
}); });
+4 -8
View File
@@ -1498,10 +1498,8 @@ describe("settings storage", () => {
status: "downloading", status: "downloading",
itemIds: ["item1", "item2", "item3", "item4"], itemIds: ["item1", "item2", "item3", "item4"],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
manualExtractionPending: true, downloadStartedAt: 0,
manualExtractionRepairItemIds: ["item1", "missing-item", "item1"],
downloadStartedAt: 0,
downloadCompletedAt: 0, downloadCompletedAt: 0,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now() updatedAt: Date.now()
@@ -1597,10 +1595,8 @@ describe("settings storage", () => {
expect(loaded.items["item3"].status).toBe("completed"); expect(loaded.items["item3"].status).toBe("completed");
expect(loaded.items["item4"].status).toBe("queued"); expect(loaded.items["item4"].status).toBe("queued");
expect(loaded.items["item1"].downloadedBytes).toBe(5000); 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", () => { it("preserves cleaned package progress aggregates while normalizing a session", () => {
const session = emptySession(); const session = emptySession();