fix(downloads): protect archive removal and missing-file recovery

This commit is contained in:
4gray committed 2026-09-08 02:24:36 +02:00
1 parent edbaa2de25
commit afa4d8c33a
12 files changed
+477 -130

No files matched your search

@@ -2,7 +2,13 @@ import {
getDownloadPlayPaths,
installDownloadPlayCapture,
} from './downloads.e2e-support';
import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
import {
mkdirSync,
readFileSync,
readdirSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import type { Page } from '@playwright/test';
import {
@@ -500,6 +506,58 @@ test('@downloads @epg @xtream @electron downloads a completed archive into the l
expect(
readdirSync(folder).filter((name) => name.endsWith('.ts'))
).toHaveLength(1);
// Missing-file recovery must reserve fresh output and preserve a foreign
// entry at the old partial name, even with a completed journal present.
unlinkSync(row.filePath);
writeFileSync(row.filePath + '.part', 'unrelated retained file');
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRedownloadMissing(id),
row.id
)
).toEqual({ success: true });
await expect
.poll(
async () =>
(
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id)?.status
)
.toBe('completed');
const redownloaded = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
if (!redownloaded?.filePath)
throw new Error('Missing re-downloaded archive');
expect(redownloaded.filePath).not.toBe(row.filePath);
expect(readFileSync(row.filePath + '.part', 'utf8')).toBe(
'unrelated retained file'
);
expect(readFileSync(redownloaded.filePath)).toEqual(
readFileSync('apps/xtream-mock-server/src/fixtures/live.mpegts')
);
writeFileSync(
redownloaded.filePath + '.part',
'unrelated terminal file'
);
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRemove(id),
row.id
)
).toEqual({ success: true });
expect(readFileSync(redownloaded.filePath + '.part', 'utf8')).toBe(
'unrelated terminal file'
);
expect(
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
@@ -0,0 +1,69 @@
import {
mkdtempSync,
lstatSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
unlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { removeJournaledCatchupPartial } from './download-catchup-removal';
import type { ArchivePartialProof } from './download-catchup-journal';
jest.mock('node:fs', () => {
const actual = jest.requireActual('node:fs');
return {
...actual,
renameSync: jest.fn(actual.renameSync),
unlinkSync: jest.fn(actual.unlinkSync),
};
});
const actual = jest.requireActual<typeof import('node:fs')>('node:fs');
let directory: string, filePath: string, proof: ArchivePartialProof;
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'archive-remove-'));
filePath = join(directory, 'show.ts');
writeFileSync(filePath + '.part', 'owned bytes');
proof = {
version: 1,
phase: 'transfer',
filePath,
partialIdentity: lstatSync(filePath + '.part'),
};
jest.mocked(renameSync).mockReset().mockImplementation(actual.renameSync);
jest.mocked(unlinkSync).mockReset().mockImplementation(actual.unlinkSync);
});
afterEach(() => rmSync(directory, { recursive: true, force: true }));
it('removes only the journaled partial', () => {
removeJournaledCatchupPartial(filePath, proof);
expect(() => lstatSync(filePath + '.part')).toThrow();
});
it('preserves entries without proof', () => {
removeJournaledCatchupPartial(filePath, undefined);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
it('preserves a replaced regular partial in place', () => {
renameSync(filePath + '.part', join(directory, 'original'));
writeFileSync(filePath + '.part', 'unrelated bytes');
removeJournaledCatchupPartial(filePath, proof);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('unrelated bytes');
});
it('restores a replacement captured at the cleanup boundary', () => {
jest.mocked(renameSync).mockImplementationOnce((from, to) => {
actual.renameSync(from, join(directory, 'original'));
writeFileSync(from, 'unrelated bytes');
actual.renameSync(from, to);
});
removeJournaledCatchupPartial(filePath, proof);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('unrelated bytes');
});
it('restores an owned partial and reports an I/O error for retry', () => {
jest.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error('locked'), { code: 'EACCES' });
});
expect(() => removeJournaledCatchupPartial(filePath, proof)).toThrow(
'locked'
);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
@@ -0,0 +1,66 @@
import {
lstatSync,
type Stats,
mkdtempSync,
renameSync,
linkSync,
unlinkSync,
rmdirSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import type { ArchiveDownloadProof } from './download-catchup-journal';
import type { ArchiveFileIdentity } from './download-catchup-output';
/** IPC removal stays synchronous after its runtime guard, like VOD cleanup. */
export function removeJournaledCatchupPartial(
filePath: string | null,
proof: ArchiveDownloadProof | undefined
): void {
// No proof means no authority to remove the retained entry.
if (!filePath || !proof || proof.filePath !== filePath) return;
const path = `${filePath}.part`;
const matches = (file: Stats, identity: ArchiveFileIdentity) =>
file.isFile() && file.dev === identity.dev && file.ino === identity.ino;
try {
if (!matches(lstatSync(path), proof.partialIdentity)) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
const directory = mkdtempSync(join(dirname(path), '.iptvnator-cleanup-'));
const captured = join(directory, 'entry');
try {
renameSync(path, captured);
if (matches(lstatSync(captured), proof.partialIdentity)) {
unlinkSync(captured);
} else {
try {
linkSync(captured, path);
unlinkSync(captured);
} catch {
console.warn(
'[Downloads] Replaced file retained for recovery:',
captured
);
}
}
} catch (error) {
// Keep owned files reachable for another Remove attempt after I/O errors.
try {
linkSync(captured, path);
unlinkSync(captured);
} catch {
console.warn(
'[Downloads] Partial retained for recovery:',
captured
);
}
throw error;
} finally {
try {
rmdirSync(directory);
} catch {
/* never recursively remove a capture */
}
}
}
@@ -67,6 +67,8 @@ async function setup(options: SetupOptions = {}) {
}
});
const enqueueDownload = jest.fn();
const proof = { version: 1, phase: 'transfer' };
const removeJournaledCatchupPartial = jest.fn();
jest.doMock('node:fs', () => ({
...jest.requireActual<typeof import('node:fs')>('node:fs'),
@@ -78,6 +80,14 @@ async function setup(options: SetupOptions = {}) {
}));
jest.doMock('../url-safety', () => ({ assertRemoteUrlAllowed }));
jest.doMock('./download-file-path', () => ({ removePartialDownloadFile }));
jest.doMock('./download-catchup-journal', () => ({
readArchiveFinalizations: jest
.fn()
.mockResolvedValue(new Map([[42, proof]])),
}));
jest.doMock('./download-catchup-removal', () => ({
removeJournaledCatchupPartial,
}));
jest.doMock('./download-runtime', () => ({ enqueueDownload }));
const { redownloadMissingRequest } = await import('./download-redownload');
@@ -85,6 +95,8 @@ async function setup(options: SetupOptions = {}) {
accessSync,
assertRemoteUrlAllowed,
enqueueDownload,
removeJournaledCatchupPartial,
proof,
lstatSync,
redownloadMissingRequest,
removePartialDownloadFile,
@@ -124,6 +136,29 @@ describe('redownload missing completed file', () => {
});
});
it('reserves a fresh archive path and uses journal-backed previous-partial cleanup', async () => {
const catchup = {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
};
const h = await setup({ row: { contentType: 'catchup', catchup } });
await expect(h.redownloadMissingRequest(42)).resolves.toEqual({
success: true,
});
expect(h.removeJournaledCatchupPartial).toHaveBeenCalledWith(
'/downloads/movie.mp4',
h.proof
);
expect(h.removePartialDownloadFile).not.toHaveBeenCalled();
expect(h.set).toHaveBeenCalledWith(
expect.objectContaining({ status: 'queued', filePath: null })
);
expect(h.enqueueDownload).toHaveBeenCalledWith(
expect.objectContaining({ catchup, filePath: null })
);
});
it('adds the Xtream fallback User-Agent to a legacy row', async () => {
const harness = await setup({
playlistType: 'xtream',
@@ -1,3 +1,5 @@
import { readArchiveFinalizations } from './download-catchup-journal';
import { removeJournaledCatchupPartial } from './download-catchup-removal';
import { catchupForDownload } from './download-catchup';
import { and, eq, sql } from 'drizzle-orm';
import { accessSync, constants } from 'node:fs';
@@ -66,7 +68,12 @@ export async function redownloadMissingRequest(
const headers = await resolveStoredDownloadHeaders(db, item);
try {
removePartialDownloadFile(item.filePath);
if (catchup) {
const proof = (await readArchiveFinalizations(db, [item.id])).get(
item.id
);
removeJournaledCatchupPartial(item.filePath, proof);
} else removePartialDownloadFile(item.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to delete partial before missing-file recovery:',
@@ -82,6 +89,7 @@ export async function redownloadMissingRequest(
.update(schema.downloads)
.set({
bytesDownloaded: 0,
...(catchup ? { filePath: null } : {}),
errorMessage: null,
resumeValidator: null,
status: 'queued',
@@ -105,7 +113,7 @@ export async function redownloadMissingRequest(
catchup,
directory: dirname(item.filePath),
fileName: basename(item.filePath),
filePath: item.filePath,
filePath: catchup ? null : item.filePath,
headers,
id: item.id,
resumeValidator: null,
@@ -0,0 +1,147 @@
import { and, eq, inArray } from 'drizzle-orm';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { readArchiveFinalizations } from './download-catchup-journal';
import { removeJournaledCatchupPartial } from './download-catchup-removal';
import { removePartialDownloadFile } from './download-file-path';
import {
broadcastDownloadUpdate,
isDownloadCommitting,
hasRuntimeDownload,
removeDownloadFromRuntime,
} from './download-runtime';
const removablePartialStatuses = new Set([
'queued',
'paused',
'completed',
'failed',
'canceled',
]);
export async function removeDownloadRequest(downloadId: number) {
try {
console.log('[Downloads] Remove download:', downloadId);
const db = await getDatabase();
const rows = await db
.select({
filePath: schema.downloads.filePath,
contentType: schema.downloads.contentType,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
const row = rows[0];
const proof =
row?.contentType === 'catchup'
? (await readArchiveFinalizations(db, [downloadId])).get(
downloadId
)
: undefined;
if (isDownloadCommitting(downloadId))
return {
success: false,
error: 'Download is completing; try again shortly',
};
if (row?.filePath && removablePartialStatuses.has(row.status)) {
try {
if (row.contentType === 'catchup')
removeJournaledCatchupPartial(row.filePath, proof);
else removePartialDownloadFile(row.filePath);
} catch (cleanupError) {
// Keep the row (and its runtime entry) so the .part is never
// orphaned, but answer with a structured failure the UI can
// surface instead of an opaque IPC rejection. Retrying the
// remove re-attempts the deletion.
console.error(
'[Downloads] Failed to delete partial file on remove:',
row.filePath,
cleanupError
);
return {
error: 'Could not delete the partial file',
success: false,
};
}
}
if (removeDownloadFromRuntime(downloadId) === false)
return {
success: false,
error: 'Download is completing; try again shortly',
};
await db
.delete(schema.downloads)
.where(eq(schema.downloads.id, downloadId));
broadcastDownloadUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error removing download:', error);
throw error;
}
}
export async function clearCompletedDownloadsRequest(playlistId?: string) {
try {
const db = await getDatabase();
const terminalStatus = inArray(schema.downloads.status, [
'completed',
'failed',
'canceled',
]);
const terminalFilter = playlistId
? and(eq(schema.downloads.playlistId, playlistId), terminalStatus)
: terminalStatus;
const rows = await db
.select({
id: schema.downloads.id,
filePath: schema.downloads.filePath,
contentType: schema.downloads.contentType,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(terminalFilter);
const proofs = await readArchiveFinalizations(
db,
rows
.filter((row) => row.contentType === 'catchup')
.map((row) => row.id)
);
const downloadIdsToDelete: number[] = [];
for (const row of rows) {
if (hasRuntimeDownload(row.id)) continue;
if (row.filePath && removablePartialStatuses.has(row.status)) {
try {
if (row.contentType === 'catchup')
removeJournaledCatchupPartial(
row.filePath,
proofs.get(row.id)
);
else removePartialDownloadFile(row.filePath);
} catch (error) {
console.error(
'[Downloads] Retaining download after partial cleanup failed:',
error
);
continue;
}
}
downloadIdsToDelete.push(row.id);
}
if (downloadIdsToDelete.length > 0) {
await db
.delete(schema.downloads)
.where(
and(
terminalFilter,
inArray(schema.downloads.id, downloadIdsToDelete)
)
);
broadcastDownloadUpdate();
}
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
}
@@ -134,6 +134,13 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
return true;
}
export function hasRuntimeDownload(downloadId: number): boolean {
return (
activeDownload?.id === downloadId ||
downloadQueue.some((task) => task.id === downloadId)
);
}
export function isDownloadCommitting(downloadId: number): boolean {
return (
activeDownload?.id === downloadId &&
@@ -5,6 +5,9 @@ import {
mockDownloadRow,
mockRemoveDownloadFromRuntime,
mockIsDownloadCommitting,
mockHasRuntimeDownload,
mockRemoveJournaledPartial,
mockArchiveProofs,
mockRemovePartialDownloadFile,
mockTerminalRows,
setupDownloadsEventsHarness,
@@ -35,7 +38,7 @@ describe('downloads events: partial-file cleanup', () => {
const { deleteWhere } = mockTerminalRows([
createDownloadRow('completed'),
]);
mockIsDownloadCommitting.mockReturnValue(true);
mockHasRuntimeDownload.mockReturnValue(true);
await expect(
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
).resolves.toEqual({ success: true });
@@ -43,6 +46,40 @@ describe('downloads events: partial-file cleanup', () => {
expect(deleteWhere).not.toHaveBeenCalled();
});
it('routes archive Remove through durable cleanup instead of pathname unlink', async () => {
const { deleteWhere } = mockDownloadRow({
...createDownloadRow('failed'),
contentType: 'catchup',
});
const proof = { version: 1, phase: 'transfer' };
mockArchiveProofs.mockResolvedValue(new Map([[42, proof]]));
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual(
{ success: true }
);
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
'/downloads/resume.mp4',
proof
);
expect(mockRemovePartialDownloadFile).not.toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled();
});
it('routes archive Clear completed through durable cleanup', async () => {
mockTerminalRows([
{ ...createDownloadRow('canceled'), contentType: 'catchup' },
]);
const proof = { version: 1, phase: 'transfer' };
mockArchiveProofs.mockResolvedValue(new Map([[1, proof]]));
await expect(
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
).resolves.toEqual({ success: true });
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
'/downloads/resume.mp4',
proof
);
expect(mockRemovePartialDownloadFile).not.toHaveBeenCalled();
});
it('removes queued resumed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
@@ -1,5 +1,9 @@
import {
clearCompletedDownloadsRequest,
removeDownloadRequest,
} from './download-removal-requests';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import { and, eq, inArray } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { app, dialog, ipcMain, shell } from 'electron';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -10,7 +14,6 @@ import {
decorateDownloadItemAsync,
isAvailableDownloadFile,
} from './download-file-availability';
import { removePartialDownloadFile } from './download-file-path';
import { updateDownloadMetadataRequest } from './download-metadata-update';
import {
resumeDownloadRequest,
@@ -23,20 +26,10 @@ import { resetStaleDownloads } from './download-recovery';
import {
broadcastDownloadUpdate,
cancelDownload,
isDownloadCommitting,
pauseDownload,
removeDownloadFromRuntime,
setMainWindow,
} from './download-runtime';
const removablePartialStatuses = new Set([
'queued',
'paused',
'completed',
'failed',
'canceled',
]);
function getDownloadAuthorizationPath(): string {
return join(
app.getPath('userData'),
@@ -177,58 +170,9 @@ ipcMain.handle(
}
);
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Remove download:', downloadId);
const db = await getDatabase();
const rows = await db
.select({
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
const row = rows[0];
if (isDownloadCommitting(downloadId))
return {
success: false,
error: 'Download is completing; try again shortly',
};
if (row?.filePath && removablePartialStatuses.has(row.status)) {
try {
removePartialDownloadFile(row.filePath);
} catch (cleanupError) {
// Keep the row (and its runtime entry) so the .part is never
// orphaned, but answer with a structured failure the UI can
// surface instead of an opaque IPC rejection. Retrying the
// remove re-attempts the deletion.
console.error(
'[Downloads] Failed to delete partial file on remove:',
row.filePath,
cleanupError
);
return {
error: 'Could not delete the partial file',
success: false,
};
}
}
if (removeDownloadFromRuntime(downloadId) === false)
return {
success: false,
error: 'Download is completing; try again shortly',
};
await db
.delete(schema.downloads)
.where(eq(schema.downloads.id, downloadId));
broadcastDownloadUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error removing download:', error);
throw error;
}
});
ipcMain.handle('DOWNLOADS_REMOVE', (_event, downloadId: number) =>
removeDownloadRequest(downloadId)
);
ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
try {
@@ -315,63 +259,8 @@ ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
: { error: 'File not found', success: false };
});
ipcMain.handle(
'DOWNLOADS_CLEAR_COMPLETED',
async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
const terminalStatus = inArray(schema.downloads.status, [
'completed',
'failed',
'canceled',
]);
const terminalFilter = playlistId
? and(
eq(schema.downloads.playlistId, playlistId),
terminalStatus
)
: terminalStatus;
const rows = await db
.select({
id: schema.downloads.id,
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(terminalFilter);
const downloadIdsToDelete: number[] = [];
for (const row of rows) {
if (isDownloadCommitting(row.id)) continue;
if (row.filePath && removablePartialStatuses.has(row.status)) {
try {
removePartialDownloadFile(row.filePath);
} catch (error) {
console.error(
'[Downloads] Retaining download after partial cleanup failed:',
error
);
continue;
}
}
downloadIdsToDelete.push(row.id);
}
if (downloadIdsToDelete.length > 0) {
await db
.delete(schema.downloads)
.where(
and(
terminalFilter,
inArray(schema.downloads.id, downloadIdsToDelete)
)
);
broadcastDownloadUpdate();
}
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
}
ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', (_event, playlistId?: string) =>
clearCompletedDownloadsRequest(playlistId)
);
export { resetStaleDownloads, setMainWindow };
@@ -12,6 +12,9 @@ export const mockRegisteredHandlers = new Map<string, IpcHandler>();
export const mockGetDatabase = jest.fn();
export const mockRemoveDownloadFromRuntime = jest.fn();
export const mockIsDownloadCommitting = jest.fn();
export const mockHasRuntimeDownload = jest.fn();
export const mockArchiveProofs = jest.fn();
export const mockRemoveJournaledPartial = jest.fn();
export const mockBroadcastDownloadUpdate = jest.fn();
export const mockRemovePartialDownloadFile = jest.fn();
export const mockPauseDownload = jest.fn();
@@ -55,6 +58,9 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
mockGetDatabase.mockReset();
mockRemoveDownloadFromRuntime.mockReset();
mockIsDownloadCommitting.mockReset().mockReturnValue(false);
mockHasRuntimeDownload.mockReset().mockReturnValue(false);
mockArchiveProofs.mockReset().mockResolvedValue(new Map());
mockRemoveJournaledPartial.mockReset();
mockBroadcastDownloadUpdate.mockReset();
mockRemovePartialDownloadFile.mockReset();
mockPauseDownload.mockReset();
@@ -113,10 +119,17 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
jest.doMock('./download-redownload', () => ({
redownloadMissingRequest: mockRedownloadMissingRequest,
}));
jest.doMock('./download-catchup-journal', () => ({
readArchiveFinalizations: mockArchiveProofs,
}));
jest.doMock('./download-catchup-removal', () => ({
removeJournaledCatchupPartial: mockRemoveJournaledPartial,
}));
jest.doMock('./download-runtime', () => ({
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
cancelDownload: jest.fn(),
isDownloadCommitting: mockIsDownloadCommitting,
hasRuntimeDownload: mockHasRuntimeDownload,
pauseDownload: mockPauseDownload,
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
setMainWindow: jest.fn(),
@@ -171,6 +184,7 @@ export function expectManagedPathLookup(
}
export function mockDownloadRow(row: {
contentType?: string;
filePath: string | null;
status: string;
}) {
@@ -190,7 +204,11 @@ export function mockDownloadRow(row: {
}
export function mockTerminalRows(
rows: Array<{ filePath: string | null; status: string }>
rows: Array<{
filePath: string | null;
status: string;
contentType?: string;
}>
) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const selectWhere = jest
+8 -2
View File
@@ -87,7 +87,12 @@ before awaited partial cleanup and the SQLite completion write. Pause/cancel the
return false without setting flags; a command is never accepted and subsequently
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.
journal until completion finishes. Remove, Clear completed and missing-file
re-download use journal-backed private capture for archive partial cleanup;
unknown or replaced entries are preserved. Cleanup remains synchronous after the
runtime guard, so a completion transition cannot interleave with unlink.
Missing archive re-downloads claim a fresh reservation instead of inheriting the
completed file's identity.
A kill between exclusive copy-file creation and its identity journal commit can
leave an unowned **empty** destination: no bytes are written before the commit.
Recovery preserves that file rather than guessing ownership; Retry uses a
@@ -125,7 +130,8 @@ available after restart and after the source archive expires.
## Backend responsibilities
- **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)**
`DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, retry/resume flows in `download-resume-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts` (ordinary file promotion in `download-file-finalize.ts`), cancellation/pause persistence in `download-runtime-persistence.ts`, and the renderer update broadcast in `download-broadcast.ts`.
`DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, retry/resume flows in `download-resume-requests.ts`, removal/terminal cleanup in
`download-removal-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts` (ordinary file promotion in `download-file-finalize.ts`), cancellation/pause persistence in `download-runtime-persistence.ts`, and the renderer update broadcast in `download-broadcast.ts`.
- **Range-aware transfer (`download-transfer.ts`)**
The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`, and always requests `Accept-Encoding: identity`: Range offsets, totals, and the persisted `.part` must describe the same representation, and Axios's transparent gzip/brotli decoding would put decoded bytes on disk while every counter speaks encoded bytes. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Fresh Xtream movie and series-episode downloads propagate the playlist's configured headers, using its User-Agent when present and otherwise sharing the provider-compatible `XTREAM_CLIENT_USER_AGENT` used by Xtream API requests and stream probes. Retry, resume, and missing-file recovery resolve the owning playlist type and add that fallback to legacy Xtream rows without a stored User-Agent; known Stalker rows are left unchanged. Download rows deliberately outlive individually deleted playlists, so a headerless legacy row whose source no longer exists receives the same IPTV-player fallback because its original provider type cannot be recovered. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed). The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator`; a partial carrying that validator resumes with `Range: bytes=<offset>-` plus `If-Range`, so the server itself proves the entity is unchanged. A retained partial **without** a validator resumes through overlap verification instead (`download-overlap.ts`): the `Range` request rewinds by up to 256 KiB (`OVERLAP_VERIFICATION_BYTES`) and a transform stream compares that replayed window byte-for-byte against the partial's tail before anything is appended — a self-made validator for the many Xtream panels that send neither header. A mismatching overlap truncates the `.part` and restarts the transfer from byte zero (`OverlapMismatchError`); a partial smaller than the overlap window is verified in full from byte zero over a plain request and appended to — never rewritten in place, so a reconnect that dies early can only grow the file. Success requires the verifier to have consumed its ENTIRE window: a response that ends inside the overlap is an ordinary retained interruption when the stream died early, but a response that delivered its complete AUTHORITATIVE total inside the window — whether it then closed cleanly or reset — proves the remote entity shrank and restarts from scratch; the old suffix is never finalized as a completed file. An HTTP 416 answer to a resume request is classified by `classifyRangeNotSatisfiable()`: it COMPLETES only an exact-EOF request with identity proof (`If-Range`-backed, or the EOF probe that follows a fully verified overlap replay) whose stated `bytes */N` equals the partial — a bare length match on a rewound request proves nothing about whose bytes are on disk; it RESTARTS only when a STATED total proves the entity shrank — the total sits below a rewound request's first byte, or at it (the rewound range beginning exactly at the new EOF), or below the partial at an exact-EOF request; every length-less, ambiguous, or contradictory 416 RETAINS the partial, and none of these paths ever reaches generic cleanup. A validator promoted by a complete overlap match survives mid-append failures too: the promotion also runs on the error path, and retained-failure and pause persistence write `resume_validator` from the task, so later attempts resume via `If-Range` instead of replaying the window — without this, a server whose per-connection cap barely exceeds the window would stall out on sub-threshold progress. A verify-append attempt promotes the response's `ETag`/`Last-Modified` onto the row only after the complete overlap matched; until then the retained bytes are unproven and blessing them with a validator would let the next resume `If-Range`-append onto a foreign prefix. The response's TOTAL stays equally uncommitted (task and row) until the overlap matched — a persisted total equal to the unverified partial's size would let the completed-partial shortcut finalize unproven bytes after a pause, crash, or retained failure. Retained-interruption persistence keeps the live task in sync with the row (a stale falsified total would make the next reconnect's resume-offset guard reject the partial). Overlap replay re-counts bytes from the rewound offset, so reported progress is floored at the partial's retained size whenever the transfer appends — a response that ends inside the overlap can never move displayed progress backwards. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer Line truncated
- **Destination collision policy**
+9 -2
View File
@@ -36,8 +36,15 @@
{
"path": "apps/electron-backend/src/app/events/database/downloads.events.ts",
"statements": {
"minimumCovered": 86,
"minimumPercent": 58.5
"minimumCovered": 79,
"minimumPercent": 62.69
}
},
{
"path": "apps/electron-backend/src/app/events/database/download-removal-requests.ts",
"statements": {
"minimumCovered": 53,
"minimumPercent": 91.37
}
}
]