diff --git a/apps/electron-backend/src/app/events/database/download-removal-requests.ts b/apps/electron-backend/src/app/events/database/download-removal-requests.ts index c0c850034..c35648d43 100644 --- a/apps/electron-backend/src/app/events/database/download-removal-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-removal-requests.ts @@ -56,7 +56,13 @@ export async function removeDownloadRequest(downloadId: number) { success: false, error: 'Download is completing; try again shortly', }; - if (row?.filePath && removablePartialStatuses.has(row.status)) { + // A settled archive can still say downloading when its cancellation + // status write failed. Its journal must be drained before row deletion. + if ( + row?.filePath && + (row.contentType === 'catchup' || + removablePartialStatuses.has(row.status)) + ) { try { if (row.contentType === 'catchup') removeJournaledCatchupPartial( diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts index 2fb4ccf9a..157a78583 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -78,6 +78,37 @@ describe('downloads events: partial-file cleanup', () => { ); }); + it.each([false, true])( + 'retries settled archive cleanup despite a failed cancellation status write (locked=%s)', + async (locked) => { + const { deleteWhere } = mockDownloadRow({ + ...createDownloadRow('downloading'), + contentType: 'catchup', + }); + const proof = { + version: 1, + phase: 'transfer', + partialCleanupPath: '/downloads/.iptvnator-cleanup-test/entry', + }; + mockArchiveProofs.mockResolvedValue(new Map([[42, proof]])); + if (locked) + mockRemoveJournaledPartial.mockImplementation(() => { + throw new Error('EACCES'); + }); + await expect( + getHandler('DOWNLOADS_REMOVE')(null, 42) + ).resolves.toMatchObject({ success: !locked }); + expect(mockRemoveJournaledPartial).toHaveBeenCalledWith( + '/downloads/resume.mp4', + proof, + expect.any(Function), + true + ); + if (locked) expect(deleteWhere).not.toHaveBeenCalled(); + else expect(deleteWhere).toHaveBeenCalled(); + } + ); + it('keeps a committing archive row and journal intact on Remove', async () => { const { deleteWhere } = mockDownloadRow( createDownloadRow('downloading') diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 40e10bcb4..487c64a58 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -89,7 +89,9 @@ overwritten by completion. Remove rejects this committing row before partial cleanup or deletion, and Clear completed skips it, preserving the cascading journal until completion finishes. Remove waits for an accepted active archive cancellation to settle before reading/deleting its row; queued archives are -canceled first, and a concurrent new runtime attempt blocks removal. Remove, Clear completed and missing-file +canceled first, and a concurrent new runtime attempt blocks removal. A settled +archive still marked downloading after a failed status write also retries +journal cleanup before row deletion. Remove, Clear completed and missing-file re-download use journal-backed private capture for archive partial cleanup; unknown or replaced entries are preserved. Before capture, a synchronous SQLite write records `partialCleanupPath` (or `finalCleanupPath` for failed promotion)