fix(downloads): preserve archive ownership on removal and resubmission

This commit is contained in:
4gray committed 2026-09-08 03:34:16 +02:00
1 parent 916f216655
commit 24ee7eac4b
9 files changed
+240 -8

No files matched your search

@@ -415,6 +415,48 @@ test('@downloads @epg @xtream @electron downloads a completed archive into the l
async () => (await window.electron.downloadsGetList()).length
)
).toBe(1);
// Submitting the programme again after its file disappears must use
// journal ownership too, not delete a replacement at the old .part path.
const firstPath = row.filePath;
unlinkSync(firstPath);
writeFileSync(firstPath + '.part', 'unrelated submission partial');
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect
.poll(async () => {
const current = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
return (
current?.status === 'completed' &&
!!current.filePath &&
current.filePath !== firstPath
);
})
.toBe(true);
const resubmitted = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
if (!resubmitted?.filePath)
throw new Error('Missing resubmitted archive');
expect(resubmitted.filePath).not.toBe(firstPath);
expect(readFileSync(firstPath + '.part', 'utf8')).toBe(
'unrelated submission partial'
);
row.filePath = resubmitted.filePath;
expect(readFileSync(row.filePath)).toEqual(
readFileSync('apps/xtream-mock-server/src/fixtures/live.mpegts')
);
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
@@ -13,6 +13,7 @@ import { join } from 'node:path';
import {
removeJournaledCatchupPartial,
cleanupStoredCatchupFinal,
cleanupStoredCatchupPartial,
} from './download-catchup-removal';
import type {
ArchivePartialProof,
@@ -191,3 +192,41 @@ it.each([false, true])(
} else expect(readFileSync(filePath, 'utf8')).toBe('unfinished copy');
}
);
it.each([false, true])(
'restart cleanup preserves completed promotion and removes unfinished copies (complete=%s)',
async (complete) => {
writeFileSync(filePath, 'archive');
let journal: ArchiveFinalizationProof = {
...proof,
phase: 'finalization',
size: complete ? 7 : 100,
finalIdentity: lstatSync(filePath),
};
const db = {
select: () => ({
from: () => ({
where: async () => [
{ downloadId: 1, proof: JSON.stringify(journal) },
],
}),
}),
update: () => ({
set: (value: { proof: string }) => ({
where: () => ({
run: () => {
journal = JSON.parse(value.proof);
return { changes: 1 };
},
}),
}),
}),
};
await expect(
cleanupStoredCatchupPartial(db as never, 1, filePath, 'incomplete')
).resolves.toBe(true);
expect(() => lstatSync(filePath + '.part')).toThrow();
if (complete) expect(readFileSync(filePath, 'utf8')).toBe('archive');
else expect(() => lstatSync(filePath)).toThrow();
}
);
@@ -12,6 +12,7 @@ import {
import { dirname, join } from 'node:path';
import {
readArchiveFinalizations,
verifiedArchiveSize,
recordArchiveCleanupPath,
type ArchiveDownloadProof,
} from './download-catchup-journal';
@@ -82,7 +83,7 @@ export async function cleanupStoredCatchupPartial(
db: DownloadsDatabase,
downloadId: number,
filePath: string | null | undefined,
removeFinal = false
removeFinal: boolean | 'incomplete' = false
): Promise<boolean> {
if (!filePath) return true;
try {
@@ -96,7 +97,9 @@ export async function cleanupStoredCatchupPartial(
if (proof)
recordArchiveCleanupPath(db, downloadId, proof, path, kind);
},
removeFinal
removeFinal === true ||
(removeFinal === 'incomplete' &&
verifiedArchiveSize(filePath, proof) === null)
);
return true;
} catch (error) {
@@ -3,6 +3,7 @@ import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import {
readArchiveFinalizations,
verifiedArchiveSize,
recordArchiveCleanupPath,
} from './download-catchup-journal';
import { removeJournaledCatchupPartial } from './download-catchup-removal';
@@ -78,7 +79,8 @@ export async function removeDownloadRequest(downloadId: number) {
kind
);
},
row.status !== 'completed'
row.status !== 'completed' &&
verifiedArchiveSize(row.filePath, proof) === null
);
else removePartialDownloadFile(row.filePath);
} catch (cleanupError) {
@@ -159,7 +161,11 @@ export async function clearCompletedDownloadsRequest(playlistId?: string) {
kind
);
},
row.status !== 'completed'
row.status !== 'completed' &&
verifiedArchiveSize(
row.filePath,
proofs.get(row.id)
) === null
);
else removePartialDownloadFile(row.filePath);
} catch (error) {
@@ -1,3 +1,13 @@
import {
mkdtemp,
writeFile,
readFile,
lstat,
rename,
rm,
} from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { Download } from '../../database/schema';
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
@@ -1057,3 +1067,85 @@ describe('download requests resume', () => {
expect(enqueueDownload).not.toHaveBeenCalled();
});
});
describe('catch-up submissions restarting terminal rows', () => {
it.each([false, true])(
'preserves replaced partials and retains ownership on cleanup errors (locked=%s)',
async (locked) => {
const directory = await mkdtemp(
join(tmpdir(), 'archive-resubmit-')
);
const filePath = join(directory, 'show.ts');
try {
await writeFile(filePath + '.part', 'owned bytes');
const partialIdentity = await lstat(filePath + '.part');
await rename(filePath + '.part', join(directory, 'original'));
await writeFile(filePath + '.part', 'unrelated replacement');
const catchup = {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
};
const proof = {
version: 1,
phase: 'transfer',
filePath,
partialIdentity,
};
jest.doMock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
readArchiveFinalizations: jest.fn(async () => {
if (locked) throw new Error('SQLITE_BUSY');
return new Map([[42, proof]]);
}),
recordArchiveCleanupPath: jest.fn(),
}));
jest.dontMock('./download-catchup-removal');
const h = await setupStartMetadataRequest(
createStartDownloadRow({
id: 42,
contentType: 'catchup',
status: 'completed',
filePath,
catchup,
programmeStart: 100,
})
);
await expect(
h.startDownloadRequest(
{
contentType: 'catchup',
catchup,
playlistId: 'playlist-1',
xtreamId: 77,
title: 'Show',
url: 'https://provider.test/archive.ts',
downloadFolder: directory,
},
h.authorizer
)
).resolves.toMatchObject({ success: !locked });
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'unrelated replacement'
);
if (locked) {
expect(h.set).not.toHaveBeenCalled();
expect(h.enqueueDownload).not.toHaveBeenCalled();
} else {
expect(h.set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: null,
status: 'queued',
})
);
expect(h.enqueueDownload).toHaveBeenCalledWith(
expect.not.objectContaining({ filePath })
);
}
} finally {
jest.dontMock('./download-catchup-journal');
await rm(directory, { recursive: true, force: true });
}
}
);
});
@@ -6,6 +6,7 @@ import {
type StartDownloadRequest,
} from './download-request-options';
export type { StartDownloadRequest } from './download-request-options';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import { catchupForDownload } from './download-catchup';
import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces';
import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interfaces';
@@ -125,8 +126,17 @@ export async function startDownloadRequest(
// A terminal row can still reference a retained .part; delete it
// before the restart clears filePath, or the file is orphaned.
// An unavailable or slow .part must keep its database owner.
const cleanup = await removePartialDownloadFileAsync(item.filePath);
if (cleanup === 'unknown') {
const cleaned =
item.contentType === 'catchup'
? await cleanupStoredCatchupPartial(
db,
item.id,
item.filePath,
'incomplete'
)
: (await removePartialDownloadFileAsync(item.filePath)) !==
'unknown';
if (!cleaned) {
console.error(
'[Downloads] Could not verify retained partial cleanup'
);
@@ -9,6 +9,7 @@ import {
mockPrepareArchiveRemoval,
mockRemoveJournaledPartial,
mockArchiveProofs,
mockVerifiedArchiveSize,
mockRecordArchiveCleanupPath,
mockRemovePartialDownloadFile,
mockTerminalRows,
@@ -109,6 +110,39 @@ describe('downloads events: partial-file cleanup', () => {
}
);
it.each(['remove', 'clear'])(
'preserves a proven completed archive after failed status writes on %s',
async (action) => {
const proof = { version: 1, phase: 'finalization', size: 188 };
const id = action === 'remove' ? 42 : 1;
const row = {
...createDownloadRow(
action === 'remove' ? 'downloading' : 'failed'
),
contentType: 'catchup',
};
if (action === 'remove') mockDownloadRow(row);
else mockTerminalRows([row]);
mockArchiveProofs.mockResolvedValue(new Map([[id, proof]]));
mockVerifiedArchiveSize.mockReturnValue(188);
const result =
action === 'remove'
? getHandler('DOWNLOADS_REMOVE')(null, id)
: getHandler('DOWNLOADS_CLEAR_COMPLETED')(null);
await expect(result).resolves.toEqual({ success: true });
expect(mockVerifiedArchiveSize).toHaveBeenCalledWith(
row.filePath,
proof
);
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
row.filePath,
proof,
expect.any(Function),
false
);
}
);
it('keeps a committing archive row and journal intact on Remove', async () => {
const { deleteWhere } = mockDownloadRow(
createDownloadRow('downloading')
@@ -15,6 +15,7 @@ export const mockIsDownloadCommitting = jest.fn();
export const mockHasRuntimeDownload = jest.fn();
export const mockPrepareArchiveRemoval = jest.fn();
export const mockArchiveProofs = jest.fn();
export const mockVerifiedArchiveSize = jest.fn();
export const mockRecordArchiveCleanupPath = jest.fn();
export const mockRemoveJournaledPartial = jest.fn();
export const mockBroadcastDownloadUpdate = jest.fn();
@@ -62,6 +63,7 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
mockIsDownloadCommitting.mockReset().mockReturnValue(false);
mockHasRuntimeDownload.mockReset().mockReturnValue(false);
mockPrepareArchiveRemoval.mockReset().mockResolvedValue(true);
mockVerifiedArchiveSize.mockReset().mockReturnValue(null);
mockArchiveProofs.mockReset().mockResolvedValue(new Map());
mockRecordArchiveCleanupPath.mockReset();
mockRemoveJournaledPartial.mockReset();
@@ -125,6 +127,7 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
}));
jest.doMock('./download-catchup-journal', () => ({
readArchiveFinalizations: mockArchiveProofs,
verifiedArchiveSize: mockVerifiedArchiveSize,
recordArchiveCleanupPath: mockRecordArchiveCleanupPath,
}));
jest.doMock('./download-catchup-removal', () => ({
+5 -2
View File
@@ -91,8 +91,11 @@ 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. 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;
journal cleanup before row deletion. Remove/Clear preserve a final file whose
journaled identity and full size prove completed promotion, even when completion
status writes failed and its stored status is stale. Remove, Clear completed and missing-file
re-download and repeated programme submissions 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)
in the existing ownership proof. Active cancellation/failure, promotion and