mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(downloads): pause and resume support for the download manager (#1147)
Adds a paused state to the Electron download manager with a full partial-file lifecycle: - Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable. - Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed. - Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update. - Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids. - Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only. - UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales. - Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly. Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
0273ded8e2
commit
188f5c4b56
63 files changed
+4986
-683
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { mkdirSync, readdirSync } from 'fs';
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { createServer } from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { join } from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
@@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise<void> {
|
||||
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
||||
}
|
||||
|
||||
interface RangeServerRequest {
|
||||
ifRange?: string;
|
||||
range?: string;
|
||||
}
|
||||
|
||||
interface ThrottledRangeServer {
|
||||
close: () => Promise<void>;
|
||||
payload: Buffer;
|
||||
requests: RangeServerRequest[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
|
||||
|
||||
/**
|
||||
* Serves a payload slowly on the first (full) request so the UI has a wide
|
||||
* window to pause mid-transfer, and answers Range requests with an immediate
|
||||
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
|
||||
*/
|
||||
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
|
||||
const payload = Buffer.from(
|
||||
Array.from({ length: 96 * 1024 }, (_, index) =>
|
||||
String(index % 10)
|
||||
).join('')
|
||||
);
|
||||
const requests: RangeServerRequest[] = [];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const range = req.headers.range;
|
||||
const ifRange = req.headers['if-range'];
|
||||
requests.push({
|
||||
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
|
||||
range: typeof range === 'string' ? range : undefined,
|
||||
});
|
||||
|
||||
const offset = range
|
||||
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
|
||||
: 0;
|
||||
if (range && Number.isFinite(offset)) {
|
||||
res.writeHead(206, {
|
||||
'Content-Length': payload.length - offset,
|
||||
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: RANGE_SERVER_ETAG,
|
||||
});
|
||||
res.end(payload.subarray(offset));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Length': payload.length,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: RANGE_SERVER_ETAG,
|
||||
});
|
||||
// First 16 KiB immediately, then a trickle: the transfer stays alive
|
||||
// for tens of seconds unless it is paused or resumed via Range.
|
||||
let sent = 16 * 1024;
|
||||
res.write(payload.subarray(0, sent));
|
||||
const timer = setInterval(() => {
|
||||
if (sent >= payload.length) {
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.write(payload.subarray(sent, sent + 2 * 1024));
|
||||
sent += 2 * 1024;
|
||||
}, 150);
|
||||
res.on('close', () => clearInterval(timer));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
payload,
|
||||
requests,
|
||||
url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* On a cold profile the renderer can query SQLite while the DB worker is
|
||||
* still creating tables ("database is locked" / "no such table" on slow CI
|
||||
@@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => {
|
||||
await fileServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const rangeServer = await createThrottledRangeServer();
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Pause Portal',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openDownloadsPage(app.mainWindow);
|
||||
|
||||
const downloadsDir = join(dataDir, 'e2e-pause-downloads');
|
||||
mkdirSync(downloadsDir, { recursive: true });
|
||||
await app.electronApp.evaluate(({ dialog }, folder) => {
|
||||
dialog.showOpenDialog = async () =>
|
||||
({
|
||||
canceled: false,
|
||||
filePaths: [folder],
|
||||
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
|
||||
}, downloadsDir);
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Change Folder' })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.locator('.downloads__folder-inline-path')
|
||||
).toContainText('e2e-pause-downloads');
|
||||
|
||||
const startResult = await app.mainWindow.evaluate(
|
||||
async ({ url, folder }) =>
|
||||
window.electron?.downloadsStart?.({
|
||||
playlistId: 'e2e-playlist',
|
||||
xtreamId: 7373,
|
||||
contentType: 'vod',
|
||||
title: 'E2E Pause Movie',
|
||||
url,
|
||||
downloadFolder: folder,
|
||||
}),
|
||||
{ url: rangeServer.url, folder: downloadsDir }
|
||||
);
|
||||
expect(startResult?.error ?? null).toBeNull();
|
||||
|
||||
const item = app.mainWindow.locator('.downloads__item');
|
||||
await expect(item).toHaveCount(1, { timeout: 20000 });
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Downloading',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
|
||||
// Pause mid-transfer: the row flips to Paused and only a .part
|
||||
// file exists on disk (final file absent, progress retained).
|
||||
await item
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'pause' })
|
||||
.click();
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Paused',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
const pausedFiles = readdirSync(downloadsDir);
|
||||
expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']);
|
||||
const pausedBytes = statSync(
|
||||
join(downloadsDir, 'E2E Pause Movie.mp4.part')
|
||||
).size;
|
||||
expect(pausedBytes).toBeGreaterThan(0);
|
||||
expect(pausedBytes).toBeLessThan(rangeServer.payload.length);
|
||||
|
||||
// Resume: the runtime must continue via Range/If-Range instead of
|
||||
// restarting, and the assembled file must match the payload.
|
||||
await item
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'play_arrow' })
|
||||
.click();
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Completed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
const resumeRequest = rangeServer.requests.find(
|
||||
(entry) => entry.range
|
||||
);
|
||||
expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/);
|
||||
expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG);
|
||||
|
||||
expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']);
|
||||
const finalFile = readFileSync(
|
||||
join(downloadsDir, 'E2E Pause Movie.mp4')
|
||||
);
|
||||
expect(finalFile.equals(rangeServer.payload)).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rangeServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user