diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 0ed9d06..558e413 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -589,6 +589,13 @@ function isExtractErrorLabel(statusText: string): boolean { || /^entpacken\b.*\btimeout\b/i.test(text); } +function isTransientExtractStatus(statusText: string): boolean { + const text = String(statusText || "").trim(); + return /^entpacken\b/i.test(text) + || /^passwort\b/i.test(text) + || /^finalisieren\b/i.test(text); +} + function shouldAutoRetryExtraction(statusText: string): boolean { return !isExtractedLabel(statusText) && !isExtractErrorLabel(statusText); } @@ -1102,11 +1109,13 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( } export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { - const entryLower = archiveName.toLowerCase(); + const normalizeArchiveMatchName = (value: string): string => + path.basename(String(value || "")).replace(/ \(\d+\)(?=\.[^.]+$)/, ""); + const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); // Helper: get item basename (try targetPath first, then fileName) const itemBaseName = (item: DownloadItem): string => - path.basename(item.targetPath || item.fileName || ""); + normalizeArchiveMatchName(item.targetPath || item.fileName || ""); // Try pattern-based matching first (for multipart archives) let pattern: RegExp | null = null; @@ -5296,9 +5305,11 @@ export class DownloadManager extends EventEmitter { continue; } - if (/^Entpacken\b/i.test(currentStatus) || /^Passwort\b/i.test(currentStatus) || /^Finalisieren\b/i.test(currentStatus)) { + if (isTransientExtractStatus(currentStatus)) { const previousStatus = String(previousStatuses.get(entry.id) || "").trim(); - entry.fullStatus = previousStatus || `Fertig (${humanSize(entry.downloadedBytes)})`; + entry.fullStatus = isTransientExtractStatus(previousStatus) + ? `Fertig (${humanSize(entry.downloadedBytes)})` + : previousStatus || `Fertig (${humanSize(entry.downloadedBytes)})`; entry.updatedAt = appliedAt; } } @@ -9719,16 +9730,6 @@ export class DownloadManager extends EventEmitter { this.emitState(); }; - // Mark all items as pending before extraction starts - for (const entry of completedItems) { - if (!isExtractedLabel(entry.fullStatus)) { - preExtractStatuses.set(entry.id, String(entry.fullStatus || "").trim()); - entry.fullStatus = "Entpacken - Ausstehend"; - entry.updatedAt = nowMs(); - } - } - this.emitState(); - const extractTimeoutMs = getPostExtractTimeoutMs(); const extractAbortController = new AbortController(); let timedOut = false; @@ -9774,6 +9775,23 @@ export class DownloadManager extends EventEmitter { } const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); + const fullExtractItemIds = new Set(); + for (const archivePath of fullArchiveSet) { + const archiveItems = resolveArchiveItems(path.basename(archivePath)); + for (const entry of archiveItems) { + fullExtractItemIds.add(entry.id); + } + } + const pendingAt = nowMs(); + for (const entry of completedItems) { + if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) { + continue; + } + preExtractStatuses.set(entry.id, String(entry.fullStatus || "").trim()); + entry.fullStatus = "Entpacken - Ausstehend"; + entry.updatedAt = pendingAt; + } + this.emitState(); const result = await extractPackageArchives({ packageDir: pkg.outputDir, targetDir: pkg.extractDir, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index c9835cb..92c581e 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -5,7 +5,7 @@ import http from "node:http"; import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DownloadManager, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal } from "../src/main/download-manager"; +import { DownloadManager, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager"; import { defaultSettings } from "../src/main/constants"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; @@ -29,6 +29,20 @@ describe("extractArchiveNameFromExtractorLogMessage", () => { }); }); +describe("resolveArchiveItemsFromList", () => { + it("includes duplicate-suffixed archive copies in multipart matches", () => { + const items = [ + { id: "dup-1", fileName: "show.s01e26.part1.rar" }, + { id: "dup-2", fileName: "show.s01e26.part1.rar", targetPath: "C:\\Downloads\\show.s01e26.part1 (1).rar" }, + { id: "dup-3", fileName: "show.s01e26.part2.rar" } + ] as any[]; + + const resolved = resolveArchiveItemsFromList("show.s01e26.part1.rar", items); + + expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]); + }); +}); + async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise { const started = Date.now(); while (!predicate()) { @@ -3904,6 +3918,54 @@ describe("download manager", () => { expect(completedItems[3].fullStatus).toBe("Fertig (200 MB)"); }); + it("clears stale pending extraction labels for untouched items when another archive fails", () => { + const createdAt = Date.now() - 10_000; + const completedItems = [ + { + id: "stale-fail-item-1", + status: "completed", + fileName: "show.s01e01.part1.rar", + downloadedBytes: 100 * 1024 * 1024, + fullStatus: "Fertig (100 MB)", + updatedAt: createdAt + }, + { + id: "stale-fail-item-2", + status: "completed", + fileName: "show.s01e01.part2.rar", + downloadedBytes: 100 * 1024 * 1024, + fullStatus: "Fertig (100 MB)", + updatedAt: createdAt + }, + { + id: "stale-fail-item-3", + status: "completed", + fileName: "show.s01e05.part2.rar", + downloadedBytes: 180 * 1024 * 1024, + fullStatus: "Entpacken - Ausstehend", + updatedAt: createdAt + } + ] as any[]; + const previousStatuses = new Map(completedItems.map((item: any) => [item.id, item.fullStatus])); + + completedItems[0].fullStatus = "Entpacken - Error"; + completedItems[1].fullStatus = "Entpacken - Error"; + + (DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call( + {}, + completedItems, + (archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems), + new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), + "Checksum error in the encrypted file", + previousStatuses, + createdAt + 5_000 + ); + + expect(completedItems[0].fullStatus).toBe("Entpack-Fehler: Checksum error in the encrypted file"); + expect(completedItems[1].fullStatus).toBe("Entpack-Fehler: Checksum error in the encrypted file"); + expect(completedItems[2].fullStatus).toBe("Fertig (180 MB)"); + }); + it("detects start conflicts when extract output already exists", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);