mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
fix(downloads): preserve archive ownership on removal and resubmission
This commit is contained in:
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', () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user