Restore target path reservations on startup to prevent (1) duplicates

After restart, reservedTargetPaths (in-memory) was empty so claimTargetPath
could not distinguish between "file belongs to this item" and "file belongs
to another item". The naive fix (allow overwrite if unclaimed) would have
risked overwriting completed files from other items.

Proper fix: restore reservedTargetPaths from persisted session data on init.
This way each item's targetPath is correctly claimed, and claimTargetPath
can safely reuse the item's own file while protecting other items' files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-07 19:26:29 +01:00
parent dadc2d1b57
commit 0e0c211d35

View File

@ -1073,6 +1073,7 @@ export class DownloadManager extends EventEmitter {
void this.recoverRetryableItems("startup").catch((err) => logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`)); void this.recoverRetryableItems("startup").catch((err) => logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`));
this.recoverPostProcessingOnStartup(); this.recoverPostProcessingOnStartup();
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
this.restoreTargetPathReservations();
this.checkExistingRapidgatorLinks(); this.checkExistingRapidgatorLinks();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`));
} }
@ -3942,10 +3943,7 @@ export class DownloadManager extends EventEmitter {
const owner = this.reservedTargetPaths.get(key); const owner = this.reservedTargetPaths.get(key);
const existsOnDisk = fs.existsSync(candidate); const existsOnDisk = fs.existsSync(candidate);
const allowExistingCandidate = allowExistingFile && index === 0; const allowExistingCandidate = allowExistingFile && index === 0;
// If file exists on disk but no other item has reserved this path, allow overwrite. if ((!owner || owner === itemId) && (owner === itemId || !existsOnDisk || allowExistingCandidate)) {
// This prevents "(1)" suffixes after app restart when partial downloads remain on disk.
const unclaimedOnDisk = existsOnDisk && !owner && index === 0;
if ((!owner || owner === itemId) && (owner === itemId || !existsOnDisk || allowExistingCandidate || unclaimedOnDisk)) {
this.reservedTargetPaths.set(key, itemId); this.reservedTargetPaths.set(key, itemId);
this.claimedTargetPathByItem.set(itemId, candidate); this.claimedTargetPathByItem.set(itemId, candidate);
return candidate; return candidate;
@ -3971,6 +3969,27 @@ export class DownloadManager extends EventEmitter {
this.claimedTargetPathByItem.delete(itemId); this.claimedTargetPathByItem.delete(itemId);
} }
/** Restore reservedTargetPaths from persisted session on startup so claimTargetPath
* knows which files belong to which items. Without this, after restart all paths are
* unclaimed and a new download with the same filename would create a "(1)" copy
* instead of reusing its own partial file or worse, overwrite another item's file. */
private restoreTargetPathReservations(): void {
let restored = 0;
for (const item of Object.values(this.session.items)) {
const tp = String(item.targetPath || "").trim();
if (!tp) continue;
const key = pathKey(tp);
if (!this.reservedTargetPaths.has(key)) {
this.reservedTargetPaths.set(key, item.id);
this.claimedTargetPathByItem.set(item.id, tp);
restored += 1;
}
}
if (restored > 0) {
logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`);
}
}
private assignItemTargetPath(item: DownloadItem, targetPath: string): string { private assignItemTargetPath(item: DownloadItem, targetPath: string): string {
const rawTargetPath = String(targetPath || "").trim(); const rawTargetPath = String(targetPath || "").trim();
if (!rawTargetPath) { if (!rawTargetPath) {