diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 558e413..42fe9f2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1110,7 +1110,7 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { const normalizeArchiveMatchName = (value: string): string => - path.basename(String(value || "")).replace(/ \(\d+\)(?=\.[^.]+$)/, ""); + stripDuplicateSuffixBeforeExtension(path.basename(String(value || ""))); const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); // Helper: get item basename (try targetPath first, then fileName) @@ -1192,6 +1192,54 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download return []; } +function stripDuplicateSuffixBeforeExtension(fileName: string): string { + return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); +} + +function hasDuplicateSuffixBeforeExtension(fileName: string): boolean { + const normalized = stripDuplicateSuffixBeforeExtension(fileName); + return normalized !== String(fileName || ""); +} + +function startupDuplicateStateRank(item: DownloadItem, diskExists: boolean): number { + let rank = diskExists ? 40 : 0; + switch (item.status) { + case "completed": + rank += 40; + break; + case "downloading": + case "validating": + case "integrity_check": + rank += 25; + break; + case "queued": + case "reconnect_wait": + case "paused": + rank += 10; + break; + case "failed": + rank += 5; + break; + default: + break; + } + const fullStatus = String(item.fullStatus || "").trim(); + if (isExtractedLabel(fullStatus)) { + rank += 65; + } else if (/^fertig\b/i.test(fullStatus)) { + rank += 30; + } else if (isTransientExtractStatus(fullStatus)) { + rank += 20; + } else if (isExtractErrorLabel(fullStatus)) { + rank += 5; + } + rank += Math.max(0, Math.min(9, Math.floor(Number(item.progressPercent || 0) / 12))); + if (Number(item.downloadedBytes || 0) > 0) { + rank += 1; + } + return rank; +} + export function extractArchiveNameFromExtractorLogMessage(message: string): string | null { const text = String(message || "").trim(); if (!text) { @@ -1375,11 +1423,11 @@ export class DownloadManager extends EventEmitter { } this.applyOnStartCleanupPolicy(); this.normalizeSessionStatuses(); + this.restoreTargetPathReservations(); + this.resolveExistingQueuedOpaqueFilenames(); + this.revalidateCompletedItems(); void this.recoverRetryableItems("startup").catch((err) => logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`)); this.recoverPostProcessingOnStartup(); - this.resolveExistingQueuedOpaqueFilenames(); - this.restoreTargetPathReservations(); - this.revalidateCompletedItems(); this.checkExistingRapidgatorLinks(); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`)); } @@ -4928,10 +4976,137 @@ export class DownloadManager extends EventEmitter { if (restored > 0) { logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`); } + this.reconcileDuplicateSuffixSessionItems(); // Fix legacy (N) suffix files: rename back to original if original path is free this.fixDuplicateSuffixFiles(); } + private reconcileDuplicateSuffixSessionItems(): void { + let merged = 0; + const touchedPackageIds = new Set(); + + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + + for (const itemId of [...pkg.itemIds]) { + const duplicateItem = this.session.items[itemId]; + if (!duplicateItem) { + continue; + } + const duplicateTargetPath = String(duplicateItem.targetPath || "").trim(); + if (!duplicateTargetPath) { + continue; + } + const duplicateBaseName = path.basename(duplicateTargetPath); + if (!hasDuplicateSuffixBeforeExtension(duplicateBaseName)) { + continue; + } + + const canonicalBaseName = stripDuplicateSuffixBeforeExtension(duplicateBaseName); + const canonicalPath = path.join(path.dirname(duplicateTargetPath), canonicalBaseName); + const canonicalKey = pathKey(canonicalPath); + let primaryItem = Object.values(this.session.items).find((candidate) => + candidate.packageId === packageId + && candidate.id !== duplicateItem.id + && ( + pathKey(String(candidate.targetPath || "")) === canonicalKey + || ( + !candidate.targetPath + && stripDuplicateSuffixBeforeExtension(candidate.fileName || "") === canonicalBaseName + ) + ) + ); + if (!primaryItem) { + continue; + } + + const duplicateExists = fs.existsSync(duplicateTargetPath); + let canonicalExists = fs.existsSync(canonicalPath); + const primaryWins = startupDuplicateStateRank(primaryItem, canonicalExists) >= startupDuplicateStateRank(duplicateItem, duplicateExists); + + if (duplicateExists && !canonicalExists) { + try { + fs.renameSync(duplicateTargetPath, canonicalPath); + canonicalExists = true; + logger.info(`startupDuplicateMerge: ${path.basename(duplicateTargetPath)} → ${canonicalBaseName}`); + } catch (err) { + logger.warn(`startupDuplicateMerge: Umbenennung fehlgeschlagen ${duplicateTargetPath}: ${compactErrorText(err)}`); + } + } else if (duplicateExists && canonicalExists && primaryWins) { + try { + fs.rmSync(duplicateTargetPath, { force: true }); + } catch { + // ignore, stale duplicate can remain on disk if Windows still holds a handle + } + } else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") { + try { + fs.rmSync(canonicalPath, { force: true }); + fs.renameSync(duplicateTargetPath, canonicalPath); + canonicalExists = true; + logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`); + } catch (err) { + logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`); + } + } + + const duplicateShouldWin = !primaryWins || (duplicateItem.status === "completed" && primaryItem.status !== "completed"); + if (duplicateShouldWin) { + primaryItem.status = duplicateItem.status; + primaryItem.fullStatus = duplicateItem.fullStatus; + primaryItem.lastError = duplicateItem.lastError; + primaryItem.downloadedBytes = Math.max(Number(primaryItem.downloadedBytes || 0), Number(duplicateItem.downloadedBytes || 0)); + primaryItem.totalBytes = Math.max(Number(primaryItem.totalBytes || 0), Number(duplicateItem.totalBytes || 0)) || primaryItem.totalBytes; + primaryItem.progressPercent = Math.max(Number(primaryItem.progressPercent || 0), Number(duplicateItem.progressPercent || 0)); + } + + if (canonicalExists) { + try { + const stat = fs.statSync(canonicalPath); + primaryItem.downloadedBytes = Math.max(Number(primaryItem.downloadedBytes || 0), stat.size); + if (!primaryItem.totalBytes || primaryItem.totalBytes < stat.size) { + primaryItem.totalBytes = stat.size; + } + if (primaryItem.status === "completed") { + primaryItem.progressPercent = 100; + } + } catch { + // ignore stat failures; persisted metadata remains as-is + } + } + + primaryItem.fileName = canonicalBaseName; + primaryItem.targetPath = canonicalPath; + primaryItem.updatedAt = Math.max(Number(primaryItem.updatedAt || 0), Number(duplicateItem.updatedAt || 0), nowMs()); + this.claimedTargetPathByItem.set(primaryItem.id, canonicalPath); + this.reservedTargetPaths.set(canonicalKey, primaryItem.id); + + this.retryAfterByItem.delete(duplicateItem.id); + this.retryStateByItem.delete(duplicateItem.id); + this.releaseTargetPath(duplicateItem.id); + this.dropItemContribution(duplicateItem.id); + delete this.session.items[duplicateItem.id]; + pkg.itemIds = pkg.itemIds.filter((candidateId) => candidateId !== duplicateItem.id); + this.itemCount = Math.max(0, this.itemCount - 1); + merged += 1; + touchedPackageIds.add(packageId); + } + } + + if (merged > 0) { + for (const packageId of touchedPackageIds) { + const pkg = this.session.packages[packageId]; + if (pkg) { + this.refreshPackageStatus(pkg); + } + } + logger.info(`reconcileDuplicateSuffixSessionItems: ${merged} Duplikat-Items zusammengeführt`); + this.persistSoon(); + } + } + /** Re-validate "completed" items on startup: if the file on disk is significantly * smaller than expected, the item was incorrectly marked completed (e.g. by the * old 50% recovery threshold). Reset to "queued" so it gets re-downloaded. */ diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 92c581e..cd5bc9a 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -319,6 +319,196 @@ describe("download manager", () => { expect((manager as any).session.packages[packageId].status).toBe("queued"); }); + it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "startup-dup-pkg"; + const originalItemId = "startup-dup-original"; + const duplicateItemId = "startup-dup-copy"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Startup Duplicate Merge"); + const extractDir = path.join(root, "extract", "Startup Duplicate Merge"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const canonicalPath = path.join(outputDir, "episode.part1.rar"); + const duplicatePath = path.join(outputDir, "episode.part1 (1).rar"); + fs.writeFileSync(duplicatePath, Buffer.alloc(128, 7)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Startup Duplicate Merge", + outputDir, + extractDir, + status: "failed", + itemIds: [originalItemId, duplicateItemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt, + updatedAt: createdAt + }; + session.items[originalItemId] = { + id: originalItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 128, + progressPercent: 0, + fileName: "episode.part1.rar", + targetPath: canonicalPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + session.items[duplicateItemId] = { + id: duplicateItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 128, + totalBytes: 128, + progressPercent: 100, + fileName: "episode.part1.rar", + targetPath: duplicatePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (128 B)", + createdAt, + updatedAt: createdAt + 5_000 + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const current = (manager as any).session; + expect(current.packages[packageId].itemIds).toEqual([originalItemId]); + expect(current.items[duplicateItemId]).toBeUndefined(); + expect(current.items[originalItemId].status).toBe("completed"); + expect(current.items[originalItemId].fullStatus).toBe("Fertig (128 B)"); + expect(current.items[originalItemId].targetPath).toBe(canonicalPath); + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.existsSync(duplicatePath)).toBe(false); + }); + + it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "startup-dup-keep-pkg"; + const originalItemId = "startup-dup-keep-original"; + const duplicateItemId = "startup-dup-keep-copy"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Startup Duplicate Keep"); + const extractDir = path.join(root, "extract", "Startup Duplicate Keep"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const canonicalPath = path.join(outputDir, "episode.part1.rar"); + const duplicatePath = path.join(outputDir, "episode.part1 (1).rar"); + fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Startup Duplicate Keep", + outputDir, + extractDir, + status: "completed", + itemIds: [originalItemId, duplicateItemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt, + updatedAt: createdAt + }; + session.items[originalItemId] = { + id: originalItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 256, + totalBytes: 256, + progressPercent: 100, + fileName: "episode.part1.rar", + targetPath: canonicalPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (1.0s)", + createdAt, + updatedAt: createdAt + 10_000 + }; + session.items[duplicateItemId] = { + id: duplicateItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 256, + totalBytes: 256, + progressPercent: 100, + fileName: "episode.part1.rar", + targetPath: duplicatePath, + resumable: true, + attempts: 1, + lastError: "Checksum error", + fullStatus: "Entpack-Fehler: Checksum error", + createdAt, + updatedAt: createdAt + 5_000 + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const current = (manager as any).session; + expect(current.packages[packageId].itemIds).toEqual([originalItemId]); + expect(current.items[duplicateItemId]).toBeUndefined(); + expect(current.items[originalItemId].status).toBe("completed"); + expect(current.items[originalItemId].fullStatus).toBe("Entpackt - Done (1.0s)"); + expect(current.items[originalItemId].targetPath).toBe(canonicalPath); + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.existsSync(duplicatePath)).toBe(false); + }); + function createCompletedArchiveSession(root: string, packageName: string, extractedFileName: string): { session: ReturnType; packageId: string;