fix(downloads): validate partials before resuming

This commit is contained in:
4gray committed 2026-08-01 20:56:19 +02:00
1 parent b3c3119070
commit 8b7ecb02e3
10 files changed
+116 -27

No files matched your search

@@ -1,7 +1,9 @@
import type { DownloadsDatabase } from './download-task';
function createDatabase(playlistType: 'xtream' | 'stalker') {
const limit = jest.fn().mockResolvedValue([{ type: playlistType }]);
function createDatabase(playlistType?: 'xtream' | 'stalker') {
const limit = jest
.fn()
.mockResolvedValue(playlistType ? [{ type: playlistType }] : []);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
@@ -45,6 +47,21 @@ describe('stored download request headers', () => {
).resolves.toBeUndefined();
});
it('adds the player fallback when a legacy row outlives its deleted source', async () => {
const { db } = createDatabase();
const { resolveStoredDownloadHeaders } =
await import('./download-request-headers');
await expect(
resolveStoredDownloadHeaders(db, {
playlistId: 'deleted-playlist',
requestHeaders: null,
})
).resolves.toEqual({
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
});
});
it('preserves an explicit stored User-Agent without querying the playlist', async () => {
const { db, select } = createDatabase('xtream');
const { resolveStoredDownloadHeaders } =
@@ -51,10 +51,14 @@ export async function resolveStoredDownloadHeaders(
.from(schema.playlists)
.where(eq(schema.playlists.id, item.playlistId))
.limit(1);
if (playlists[0]?.type !== 'xtream') {
const playlistType = playlists[0]?.type;
if (playlistType !== undefined && playlistType !== 'xtream') {
return headers;
}
// Download rows intentionally survive individual playlist deletion. Older
// rows have no stored provider marker, so a missing source must use the
// IPTV-player fallback as the only recoverable identity-compatible default.
return {
...headers,
'User-Agent': XTREAM_CLIENT_USER_AGENT,
@@ -62,6 +62,7 @@ describe('destination collision handling', () => {
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 100,
});
await waitForStatus(set, 'completed');
@@ -151,6 +151,34 @@ describe('download resume validation', () => {
);
});
it('restarts without Range when a retained partial has no validator', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
partialSize: 50,
response: {
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4', etag: '"etag-new"' },
status: 200,
},
});
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
totalBytes: 54,
})
);
await waitForStatus(harness.set, 'completed');
const requestOptions =
harness.requestWithValidatedRedirects.mock.calls[0][1];
expect(requestOptions.headers).toEqual({});
expect(harness.createWriteStream).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
{ flags: 'w' }
);
});
it('restarts from byte zero when a resume request is answered with 200', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
@@ -215,6 +243,7 @@ describe('download resume validation', () => {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 54,
})
);
@@ -251,6 +280,7 @@ describe('download resume validation', () => {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 100,
})
);
@@ -392,6 +422,12 @@ describe('download resume validation', () => {
label: 'response without an advertised total',
partialSizeAfterTransferError: 20,
},
{
code: 'ECONNRESET',
headers: { 'content-length': '100' },
label: 'response without a representation validator',
partialSizeAfterTransferError: 20,
},
{
code: 'ECONNRESET',
headers: { 'content-length': '100' },
@@ -42,17 +42,16 @@ describe('download runtime pause and resume', () => {
it('persists active pause without deleting the partial file', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
const stream = new PassThrough();
const requestWithValidatedRedirects = jest.fn(
async (
_url: string,
options: { signal?: AbortSignal }
) => {
async (_url: string, options: { signal?: AbortSignal }) => {
options.signal?.addEventListener('abort', () => {
stream.destroy(new Error('aborted'));
});
@@ -119,10 +118,12 @@ describe('download runtime pause and resume', () => {
}
});
it('uses an HTTP Range header when a partial file already exists', async () => {
it('uses an HTTP Range header when a validated partial file already exists', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
update: jest.fn(() => ({ set })),
};
@@ -168,13 +169,17 @@ describe('download runtime pause and resume', () => {
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
});
await waitForCallCount(requestWithValidatedRedirects, 1);
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
'https://example.test/movie.mp4',
expect.objectContaining({
headers: expect.objectContaining({ Range: 'bytes=50-' }),
headers: expect.objectContaining({
'If-Range': '"etag-1"',
Range: 'bytes=50-',
}),
}),
{ allowPrivateNetworks: true }
);
@@ -184,7 +189,9 @@ describe('download runtime pause and resume', () => {
it('deletes a queued resumed partial file when the queued task is canceled', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
update: jest.fn(() => ({ set })),
};
@@ -271,7 +278,9 @@ describe('download runtime pause and resume', () => {
it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = { update: jest.fn(() => ({ set })) };
const removePartialDownloadFile = jest.fn(() => {
throw new Error('EPERM: locked');
@@ -338,7 +347,9 @@ describe('download runtime pause and resume', () => {
it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
@@ -60,7 +60,13 @@ export async function transferToPartialFile(
task: DownloadTask,
reservation: ReservedPartialDownloadFile
): Promise<TransferProgress> {
const resumeOffset = getResumeOffset(task, reservation);
const retainedOffset = getResumeOffset(task, reservation);
const resumeOffset = task.resumeValidator ? retainedOffset : 0;
if (retainedOffset > 0 && resumeOffset === 0) {
console.warn(
`[Downloads] Restarting ${reservation.filename} from the beginning (saved partial has no ETag or Last-Modified validator)`
);
}
const headers = {
...(task.headers ?? {}),
@@ -156,7 +162,8 @@ export async function transferToPartialFile(
error,
reservation,
effectiveOffset,
totalBytes
totalBytes,
task.resumeValidator
);
if (interruptedProgress) {
await persistProgress(db, task, interruptedProgress.progress);
@@ -181,7 +188,8 @@ function getInterruptedTransferProgress(
error: unknown,
reservation: ReservedPartialDownloadFile,
initialBytes: number,
totalBytes: number | null
totalBytes: number | null,
resumeValidator: string | null | undefined
): { networkCode: string; progress: TransferProgress } | null {
const networkCode =
error && typeof error === 'object' && 'code' in error
@@ -189,7 +197,8 @@ function getInterruptedTransferProgress(
: '';
if (
!RETAINABLE_NETWORK_ERROR_CODES.has(networkCode) ||
totalBytes === null
totalBytes === null ||
!resumeValidator
) {
return null;
}