mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -842,6 +842,10 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DOWNLOADS_START', data),
|
||||
downloadsCancel: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId),
|
||||
downloadsPause: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_PAUSE', downloadId),
|
||||
downloadsResume: (downloadId: number, downloadFolder: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder),
|
||||
downloadsRetry: (downloadId: number, downloadFolder: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder),
|
||||
downloadsRemove: (downloadId: number) =>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
export function setMainWindow(win: BrowserWindow): void {
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
export function broadcastDownloadUpdate(): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
findAvailableFinalPath,
|
||||
getPartialDownloadPath,
|
||||
getPartialDownloadSize,
|
||||
removePartialDownload,
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile,
|
||||
reserveAvailableDownloadFile,
|
||||
} from './download-file-path';
|
||||
|
||||
@@ -51,6 +58,45 @@ describe('reserveAvailableDownloadFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserveAvailablePartialDownloadFile', () => {
|
||||
it('reserves a .part path while keeping the final path free', () => {
|
||||
const reserveFile = jest.fn();
|
||||
|
||||
expect(
|
||||
reserveAvailablePartialDownloadFile(
|
||||
'/downloads',
|
||||
'movie.mp4',
|
||||
reserveFile,
|
||||
() => false
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'movie.mp4',
|
||||
partialPath: join('/downloads', 'movie.mp4.part'),
|
||||
path: join('/downloads', 'movie.mp4'),
|
||||
});
|
||||
expect(reserveFile).toHaveBeenCalledWith(
|
||||
join('/downloads', 'movie.mp4.part')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips candidates when the final file already exists', () => {
|
||||
const reserveFile = jest.fn();
|
||||
|
||||
expect(
|
||||
reserveAvailablePartialDownloadFile(
|
||||
'/downloads',
|
||||
'movie.mp4',
|
||||
reserveFile,
|
||||
(filePath) => filePath.endsWith('movie.mp4')
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'movie (1).mp4',
|
||||
partialPath: join('/downloads', 'movie (1).mp4.part'),
|
||||
path: join('/downloads', 'movie (1).mp4'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePartialDownload', () => {
|
||||
it('removes only the actual partial save path', () => {
|
||||
const requestedPath = join('/downloads', 'movie.mp4');
|
||||
@@ -88,3 +134,73 @@ describe('removePartialDownload', () => {
|
||||
expect(removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial download helpers', () => {
|
||||
it('derives, removes, and sizes .part files from the final path', () => {
|
||||
const finalPath = join('/downloads', 'movie.mp4');
|
||||
const partialPath = join('/downloads', 'movie.mp4.part');
|
||||
const removeFile = jest.fn();
|
||||
|
||||
expect(getPartialDownloadPath(finalPath)).toBe(partialPath);
|
||||
expect(
|
||||
removePartialDownloadFile(
|
||||
finalPath,
|
||||
(filePath) => filePath === partialPath,
|
||||
removeFile
|
||||
)
|
||||
).toBe(true);
|
||||
expect(removeFile).toHaveBeenCalledWith(partialPath);
|
||||
expect(
|
||||
getPartialDownloadSize(finalPath, (filePath) => {
|
||||
expect(filePath).toBe(partialPath);
|
||||
return { size: 128 };
|
||||
})
|
||||
).toBe(128);
|
||||
});
|
||||
|
||||
it('refuses to size a .part that is not a regular file', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-'));
|
||||
const finalPath = join(directory, 'movie.mp4');
|
||||
const partialPath = `${finalPath}.part`;
|
||||
|
||||
try {
|
||||
writeFileSync(join(directory, 'target.bin'), 'attacker');
|
||||
symlinkSync(join(directory, 'target.bin'), partialPath);
|
||||
|
||||
expect(() => getPartialDownloadSize(finalPath)).toThrow(
|
||||
'not a regular file'
|
||||
);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('finds the next numbered destination whose final and partial paths are free', () => {
|
||||
const occupied = new Set([
|
||||
join('/downloads', 'movie.mp4'),
|
||||
join('/downloads', 'movie (1).mp4'),
|
||||
join('/downloads', 'movie (2).mp4.part'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findAvailableFinalPath(join('/downloads', 'movie.mp4'), (path) =>
|
||||
occupied.has(path)
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'movie (3).mp4',
|
||||
path: join('/downloads', 'movie (3).mp4'),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports zero for a missing .part with the default stat reader', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-'));
|
||||
|
||||
try {
|
||||
expect(
|
||||
getPartialDownloadSize(join(directory, 'missing.mp4'))
|
||||
).toBe(0);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,61 @@
|
||||
import { closeSync, existsSync, openSync, unlinkSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
|
||||
export interface ReservedDownloadFile {
|
||||
filename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ReservedPartialDownloadFile extends ReservedDownloadFile {
|
||||
partialPath: string;
|
||||
}
|
||||
|
||||
function createExclusiveFile(filePath: string): void {
|
||||
const descriptor = openSync(filePath, 'wx');
|
||||
closeSync(descriptor);
|
||||
}
|
||||
|
||||
function createExistsError(filePath: string): NodeJS.ErrnoException {
|
||||
const error = new Error(`File already exists: ${filePath}`) as NodeJS.ErrnoException;
|
||||
error.code = 'EEXIST';
|
||||
return error;
|
||||
}
|
||||
|
||||
export function getPartialDownloadPath(filePath: string): string {
|
||||
return `${filePath}.part`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next numbered destination whose final and .part paths are both free. Used
|
||||
* when a retained download's recorded destination got occupied while it was
|
||||
* paused or failed — the occupying file is never inspected or deleted.
|
||||
*/
|
||||
export function findAvailableFinalPath(
|
||||
filePath: string,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): { filename: string; path: string } {
|
||||
const directory = dirname(filePath);
|
||||
const extension = extname(filePath);
|
||||
const stem = basename(filePath, extension);
|
||||
|
||||
for (let suffix = 1; ; suffix++) {
|
||||
const filename = `${stem} (${suffix})${extension}`;
|
||||
const candidate = join(directory, filename);
|
||||
if (
|
||||
!pathExists(candidate) &&
|
||||
!pathExists(getPartialDownloadPath(candidate))
|
||||
) {
|
||||
return { filename, path: candidate };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveAvailableDownloadFile(
|
||||
directory: string,
|
||||
requestedFilename: string,
|
||||
@@ -38,6 +83,38 @@ export function reserveAvailableDownloadFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveAvailablePartialDownloadFile(
|
||||
directory: string,
|
||||
requestedFilename: string,
|
||||
reserveFile: (filePath: string) => void = createExclusiveFile,
|
||||
pathExists: (filePath: string) => boolean = existsSync
|
||||
): ReservedPartialDownloadFile {
|
||||
const extension = extname(requestedFilename);
|
||||
const stem = extension
|
||||
? requestedFilename.slice(0, -extension.length)
|
||||
: requestedFilename;
|
||||
let candidate = requestedFilename;
|
||||
let suffix = 1;
|
||||
|
||||
for (;;) {
|
||||
const filePath = join(directory, candidate);
|
||||
const partialPath = getPartialDownloadPath(filePath);
|
||||
try {
|
||||
if (pathExists(filePath)) {
|
||||
throw createExistsError(filePath);
|
||||
}
|
||||
reserveFile(partialPath);
|
||||
return { filename: candidate, partialPath, path: filePath };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
candidate = `${stem} (${suffix})${extension}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadSavePathItem {
|
||||
getSavePath(): string;
|
||||
}
|
||||
@@ -55,3 +132,49 @@ export function removePartialDownload(
|
||||
removeFile(savePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removePartialDownloadFile(
|
||||
filePath: string | null | undefined,
|
||||
pathExists: (filePath: string) => boolean = existsSync,
|
||||
removeFile: (filePath: string) => void = unlinkSync
|
||||
): boolean {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const partialPath = getPartialDownloadPath(filePath);
|
||||
if (!pathExists(partialPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
removeFile(partialPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getRegularFileStats(filePath: string): { size: number } {
|
||||
// lstat + isFile so appending to a retained .part can never follow a
|
||||
// symlink planted in its place while the download was paused.
|
||||
const stats = lstatSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Partial download is not a regular file: ${filePath}`);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function getPartialDownloadSize(
|
||||
filePath: string | null | undefined,
|
||||
getStats: (filePath: string) => { size: number } = getRegularFileStats
|
||||
): number {
|
||||
if (!filePath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return getStats(getPartialDownloadPath(filePath)).size;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import {
|
||||
createTask,
|
||||
waitForCallCount,
|
||||
waitForStatus,
|
||||
} from './download.test-helpers';
|
||||
|
||||
describe('download finalization', () => {
|
||||
it('falls back to copying the completed partial when hard links are unsupported', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
}) as never
|
||||
);
|
||||
const link = jest.fn(async () => {
|
||||
const error = new Error('not supported') as NodeJS.ErrnoException;
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
});
|
||||
const copyFile = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile,
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
expect(link).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(copyFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4',
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes when copied output exists but partial cleanup fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
}) as never
|
||||
);
|
||||
const link = jest.fn(async () => {
|
||||
const error = new Error('not supported') as NodeJS.ErrnoException;
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
});
|
||||
const copyFile = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => {
|
||||
const error = new Error('cleanup denied') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
});
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile,
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
expect(copyFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4',
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
errorMessage: null,
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a completed partial when finalization fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
}) as never
|
||||
);
|
||||
const link = jest.fn(async () => {
|
||||
const error = new Error('not supported') as NodeJS.ErrnoException;
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
});
|
||||
const copyFile = jest.fn(async () => {
|
||||
const error = new Error('disk full') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOSPC';
|
||||
throw error;
|
||||
});
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile,
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
await waitForStatus(set, 'failed');
|
||||
|
||||
expect(copyFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4',
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
errorMessage: 'disk full',
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'failed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { copyFile, link, stat, unlink } from 'node:fs/promises';
|
||||
import * as schema from '../../database/schema';
|
||||
import {
|
||||
getPartialDownloadPath,
|
||||
getPartialDownloadSize,
|
||||
removePartialDownloadFile,
|
||||
type ReservedPartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
import type {
|
||||
CompletedPartialProgress,
|
||||
DownloadsDatabase,
|
||||
DownloadTask,
|
||||
TransferProgress,
|
||||
} from './download-task';
|
||||
import { describeError, TruncatedTransferError } from './download-transfer';
|
||||
|
||||
/**
|
||||
* Persistence for a failed startDownload() attempt, after its cancel/pause
|
||||
* checkpoints have been ruled out. Chooses between: retaining a truncated
|
||||
* transfer for a Range retry, committing an already-finalized file,
|
||||
* retaining a completed partial, or the generic delete-partial failure.
|
||||
*/
|
||||
export async function handleDownloadFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile | undefined,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
if (error instanceof TruncatedTransferError && reservation) {
|
||||
// The short response is retained so a retry can continue the
|
||||
// transfer via Range instead of starting over.
|
||||
await persistCompletedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
{
|
||||
bytesDownloaded: error.progress.bytesDownloaded,
|
||||
filePath: reservation.path,
|
||||
totalBytes: error.progress.totalBytes,
|
||||
},
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCompletedFileProgress =
|
||||
await getExistingCompletedFileProgress(task);
|
||||
if (existingCompletedFileProgress) {
|
||||
removePartialFile(existingCompletedFileProgress.filePath);
|
||||
await persistCompletion(
|
||||
db,
|
||||
task,
|
||||
task.fileName,
|
||||
existingCompletedFileProgress.filePath,
|
||||
existingCompletedFileProgress.bytesDownloaded,
|
||||
existingCompletedFileProgress.totalBytes
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const completedPartialProgress = getCompletedPartialProgress(task);
|
||||
if (completedPartialProgress) {
|
||||
await persistCompletedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
completedPartialProgress,
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[Downloads] Error downloading ${task.fileName}:`,
|
||||
describeError(error)
|
||||
);
|
||||
removePartialFile(task.filePath);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: describeError(error),
|
||||
filePath: null,
|
||||
resumeValidator: null,
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
}
|
||||
|
||||
export async function completeDownloadFromPartial(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
progress: TransferProgress
|
||||
): Promise<void> {
|
||||
let fileSize: number;
|
||||
try {
|
||||
fileSize = await finalizePartialDownload(
|
||||
reservation,
|
||||
progress.bytesDownloaded
|
||||
);
|
||||
} catch (error) {
|
||||
if (task.cancelRequested || task.pauseRequested) {
|
||||
throw error;
|
||||
}
|
||||
await persistFinalizationFailure(
|
||||
db,
|
||||
task,
|
||||
reservation,
|
||||
progress,
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await persistCompletion(
|
||||
db,
|
||||
task,
|
||||
reservation.filename,
|
||||
reservation.path,
|
||||
fileSize,
|
||||
progress.totalBytes
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistCompletion(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
totalBytes: number | null
|
||||
): Promise<void> {
|
||||
console.log(`[Downloads] Completed: ${fileName}`);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: fileSize,
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath,
|
||||
resumeValidator: null,
|
||||
status: 'completed',
|
||||
totalBytes: totalBytes ?? fileSize,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
}
|
||||
|
||||
async function persistFinalizationFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
progress: TransferProgress,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
await persistRetainedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
reservation.filename,
|
||||
reservation.path,
|
||||
progress,
|
||||
error,
|
||||
`[Downloads] Error finalizing ${reservation.filename}:`
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistCompletedPartialFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
progress: CompletedPartialProgress,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
await persistRetainedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
task.fileName,
|
||||
progress.filePath,
|
||||
progress,
|
||||
error,
|
||||
`[Downloads] Error downloading ${task.fileName}:`
|
||||
);
|
||||
}
|
||||
|
||||
async function persistRetainedPartialFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
progress: TransferProgress,
|
||||
error: unknown,
|
||||
logMessage: string
|
||||
): Promise<void> {
|
||||
console.error(logMessage, describeError(error));
|
||||
const totalBytes = progress.totalBytes ?? progress.bytesDownloaded;
|
||||
task.totalBytes = totalBytes;
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.bytesDownloaded,
|
||||
errorMessage: describeError(error),
|
||||
fileName,
|
||||
filePath,
|
||||
status: 'failed',
|
||||
totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
}
|
||||
|
||||
export async function getExistingCompletedFileProgress(
|
||||
task: DownloadTask
|
||||
): Promise<CompletedPartialProgress | null> {
|
||||
if (
|
||||
!task.filePath ||
|
||||
task.totalBytes === null ||
|
||||
task.totalBytes === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStats = await stat(task.filePath);
|
||||
if (fileStats.size !== task.totalBytes) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bytesDownloaded: fileStats.size,
|
||||
filePath: task.filePath,
|
||||
totalBytes: task.totalBytes,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[Downloads] Failed to inspect target file:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompletedPartialProgress(
|
||||
task: DownloadTask
|
||||
): CompletedPartialProgress | null {
|
||||
if (
|
||||
!task.filePath ||
|
||||
task.totalBytes === null ||
|
||||
task.totalBytes === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existsSync(getPartialDownloadPath(task.filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytesDownloaded = getPartialDownloadSize(task.filePath);
|
||||
if (bytesDownloaded !== task.totalBytes) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bytesDownloaded,
|
||||
filePath: task.filePath,
|
||||
totalBytes: task.totalBytes,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to inspect partial file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPausedByteCount(task: DownloadTask): number {
|
||||
try {
|
||||
return getPartialDownloadSize(task.filePath);
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to inspect partial file:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizePartialDownload(
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
expectedFileSize: number
|
||||
): Promise<number> {
|
||||
try {
|
||||
await link(reservation.partialPath, reservation.path);
|
||||
} catch (error) {
|
||||
if (!canCopyCompletedPartialAfterLinkFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
await copyFile(
|
||||
reservation.partialPath,
|
||||
reservation.path,
|
||||
constants.COPYFILE_EXCL
|
||||
);
|
||||
}
|
||||
try {
|
||||
await unlink(reservation.partialPath);
|
||||
} catch (error) {
|
||||
const fileSize = await getExpectedFinalFileSize(
|
||||
reservation.path,
|
||||
expectedFileSize
|
||||
);
|
||||
if (fileSize !== null) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete completed partial file:',
|
||||
error
|
||||
);
|
||||
return fileSize;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const fileStats = await stat(reservation.path);
|
||||
return fileStats.size;
|
||||
}
|
||||
|
||||
async function getExpectedFinalFileSize(
|
||||
filePath: string,
|
||||
expectedFileSize: number
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
return fileStats.size === expectedFileSize ? fileStats.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
|
||||
const errorCode = (error as NodeJS.ErrnoException).code;
|
||||
return (
|
||||
errorCode === 'EACCES' ||
|
||||
errorCode === 'ENOSYS' ||
|
||||
errorCode === 'ENOTSUP' ||
|
||||
errorCode === 'EOPNOTSUPP' ||
|
||||
errorCode === 'EPERM' ||
|
||||
errorCode === 'EXDEV'
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns false when a .part exists but could not be deleted. */
|
||||
export function removePartialFile(filePath: string | null | undefined): boolean {
|
||||
try {
|
||||
removePartialDownloadFile(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to delete partial file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,397 @@
|
||||
import { cleanupStaleDownloadFiles } from './stale-download-files';
|
||||
type StatSyncStub = (path: string) => { isFile: () => boolean; size: number };
|
||||
let statSyncOverride: StatSyncStub | null = null;
|
||||
jest.mock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
statSync: (path: string) =>
|
||||
statSyncOverride
|
||||
? statSyncOverride(path)
|
||||
: jest.requireActual('node:fs').statSync(path),
|
||||
}));
|
||||
|
||||
describe('cleanupStaleDownloadFiles', () => {
|
||||
it('removes every persisted partial path and ignores empty paths', () => {
|
||||
const removeFile = jest.fn();
|
||||
describe('resetStaleDownloads', () => {
|
||||
it('keeps interrupted partial downloads as paused and pauses queued rows', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
cleanupStaleDownloadFiles(
|
||||
[
|
||||
{ filePath: '/downloads/one.mp4' },
|
||||
{ filePath: null },
|
||||
{ filePath: '/downloads/two.mp4' },
|
||||
],
|
||||
removeFile
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 1,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
{
|
||||
filePath: null,
|
||||
id: 2,
|
||||
status: 'queued',
|
||||
totalBytes: null,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 64),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 64,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
|
||||
expect(removeFile).toHaveBeenCalledTimes(2);
|
||||
expect(removeFile).toHaveBeenNthCalledWith(1, '/downloads/one.mp4');
|
||||
expect(removeFile).toHaveBeenNthCalledWith(2, '/downloads/two.mp4');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
expect(set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'failed' })
|
||||
);
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues cleaning other stale files after one removal fails', () => {
|
||||
const removeFile = jest.fn((filePath: string) => {
|
||||
if (filePath.endsWith('one.mp4')) {
|
||||
throw new Error('locked');
|
||||
}
|
||||
it('commits a finalized download whose completion was interrupted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
// Crash happened after finalizePartialDownload() (final file on disk,
|
||||
// .part gone) but before persistCompletion() flipped the row.
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 9,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
statSyncOverride = () => ({ isFile: () => true, size: 100 });
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
try {
|
||||
const recovery = await import('./download-recovery');
|
||||
await recovery.resetStaleDownloads();
|
||||
} finally {
|
||||
statSyncOverride = null;
|
||||
}
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 100,
|
||||
errorMessage: null,
|
||||
status: 'completed',
|
||||
})
|
||||
);
|
||||
// Any leftover .part from the interrupted commit is cleaned up, and
|
||||
// the final file's path is never cleared or deleted.
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'failed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the retained partial of a resumed download that was still queued', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/resumed.mp4',
|
||||
id: 7,
|
||||
status: 'queued',
|
||||
totalBytes: 1000,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 900),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 900,
|
||||
errorMessage: null,
|
||||
status: 'paused',
|
||||
totalBytes: 1000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('removes non-recoverable interrupted partial files before clearing the persisted path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/empty.mp4',
|
||||
id: 1,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/empty.mp4'
|
||||
);
|
||||
expect(removePartialDownloadFile.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
set.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the persisted path when non-recoverable partial cleanup fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/locked.mp4',
|
||||
id: 1,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const deleteError = new Error('permission denied');
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw deleteError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
cleanupStaleDownloadFiles(
|
||||
[
|
||||
{ filePath: '/downloads/one.mp4' },
|
||||
{ filePath: '/downloads/two.mp4' },
|
||||
],
|
||||
removeFile
|
||||
);
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
expect(removeFile).toHaveBeenCalledTimes(2);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Downloads] Failed to delete stale partial file:',
|
||||
'/downloads/one.mp4',
|
||||
expect.any(Error)
|
||||
);
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
consoleError.mockRestore();
|
||||
try {
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/locked.mp4'
|
||||
);
|
||||
const failedUpdate = (set.mock.calls as unknown[][]).find(
|
||||
([value]) =>
|
||||
(value as { status?: string } | undefined)?.status ===
|
||||
'failed'
|
||||
)?.[0] as Record<string, unknown> | undefined;
|
||||
expect(failedUpdate).toEqual(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
expect(failedUpdate).not.toHaveProperty('filePath');
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Downloads] Failed to delete interrupted partial file:',
|
||||
'/downloads/locked.mp4',
|
||||
deleteError
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes leftover partial files for completed downloads without changing their status', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(downloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps completed downloads completed when leftover partial cleanup still fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(downloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const cleanupError = new Error('locked');
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw cleanupError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
try {
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Downloads] Failed to delete completed partial file:',
|
||||
'/downloads/movie.mp4',
|
||||
cleanupError
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,215 @@
|
||||
import { inArray, sql } from 'drizzle-orm';
|
||||
import { statSync } from 'node:fs';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { cleanupStaleDownloadFiles } from './stale-download-files';
|
||||
import {
|
||||
getPartialDownloadSize,
|
||||
removePartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
|
||||
interface StaleDownload {
|
||||
filePath: string | null;
|
||||
id: number;
|
||||
status: string;
|
||||
totalBytes: number | null;
|
||||
}
|
||||
|
||||
function getRecoverablePartialSize(download: StaleDownload): number {
|
||||
if (
|
||||
(download.status !== 'downloading' && download.status !== 'queued') ||
|
||||
!download.filePath
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return getPartialDownloadSize(download.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to inspect interrupted partial file:',
|
||||
error
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A crash between finalizePartialDownload() and persistCompletion() leaves a
|
||||
* 'downloading' row whose .part is gone but whose final file is fully on
|
||||
* disk. Recognize that file (size must match the recorded total) so recovery
|
||||
* commits the completion instead of orphaning the file and re-downloading.
|
||||
*/
|
||||
function getFinalizedFileSize(download: StaleDownload): number | null {
|
||||
if (
|
||||
download.status !== 'downloading' ||
|
||||
!download.filePath ||
|
||||
download.totalBytes === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = statSync(download.filePath);
|
||||
return stats.isFile() && stats.size === download.totalBytes
|
||||
? stats.size
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeFailedPartial(download: StaleDownload): boolean {
|
||||
if (!download.filePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
removePartialDownloadFile(download.filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete interrupted partial file:',
|
||||
download.filePath,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeCompletedPartial(download: StaleDownload): void {
|
||||
if (!download.filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
removePartialDownloadFile(download.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete completed partial file:',
|
||||
download.filePath,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetStaleDownloads(): Promise<void> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const staleDownloads = await db
|
||||
const downloads = await db
|
||||
.select({
|
||||
filePath: schema.downloads.filePath,
|
||||
id: schema.downloads.id,
|
||||
status: schema.downloads.status,
|
||||
totalBytes: schema.downloads.totalBytes,
|
||||
})
|
||||
.from(schema.downloads)
|
||||
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
|
||||
const ownedReservations = staleDownloads.filter(
|
||||
(item) => item.filePath
|
||||
.where(
|
||||
inArray(schema.downloads.status, [
|
||||
'queued',
|
||||
'downloading',
|
||||
'completed',
|
||||
])
|
||||
);
|
||||
const completedDownloads = downloads.filter(
|
||||
(download) => download.status === 'completed'
|
||||
);
|
||||
const staleDownloads = downloads.filter(
|
||||
(download) => download.status !== 'completed'
|
||||
);
|
||||
const finalizedDownloads = staleDownloads
|
||||
.map((download) => ({
|
||||
...download,
|
||||
finalizedSize: getFinalizedFileSize(download),
|
||||
}))
|
||||
.filter((download) => download.finalizedSize !== null);
|
||||
const finalizedIds = new Set(
|
||||
finalizedDownloads.map((download) => download.id)
|
||||
);
|
||||
// Queued rows are recoverable even without partial bytes: a resumed
|
||||
// download waiting behind an active one is persisted as 'queued' with
|
||||
// its retained .part, and a never-started queued row loses nothing by
|
||||
// becoming 'paused' instead of 'failed'.
|
||||
const recoverableDownloads = staleDownloads
|
||||
.filter((download) => !finalizedIds.has(download.id))
|
||||
.map((download) => ({
|
||||
...download,
|
||||
bytesDownloaded: getRecoverablePartialSize(download),
|
||||
}))
|
||||
.filter(
|
||||
(download) =>
|
||||
download.status === 'queued' || download.bytesDownloaded > 0
|
||||
);
|
||||
const recoverableIds = new Set(
|
||||
recoverableDownloads.map((download) => download.id)
|
||||
);
|
||||
const failedDownloads = staleDownloads.filter(
|
||||
(download) =>
|
||||
!recoverableIds.has(download.id) &&
|
||||
!finalizedIds.has(download.id)
|
||||
);
|
||||
const cleanupResult = failedDownloads.map((download) => ({
|
||||
...download,
|
||||
partialRemoved: removeFailedPartial(download),
|
||||
}));
|
||||
const failedIdsWithRemovedPartials = cleanupResult
|
||||
.filter((download) => download.partialRemoved)
|
||||
.map((download) => download.id);
|
||||
const failedIdsWithRetainedPartials = cleanupResult
|
||||
.filter((download) => !download.partialRemoved)
|
||||
.map((download) => download.id);
|
||||
|
||||
cleanupStaleDownloadFiles(ownedReservations);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
|
||||
completedDownloads.forEach(removeCompletedPartial);
|
||||
|
||||
if (ownedReservations.length > 0) {
|
||||
for (const download of finalizedDownloads) {
|
||||
// The interrupted commit may also have left the .part behind.
|
||||
removeCompletedPartial(download);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({ filePath: null })
|
||||
.where(
|
||||
inArray(
|
||||
schema.downloads.id,
|
||||
ownedReservations.map((item) => item.id)
|
||||
)
|
||||
);
|
||||
.set({
|
||||
bytesDownloaded: download.finalizedSize,
|
||||
errorMessage: null,
|
||||
status: 'completed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, [download.id]));
|
||||
}
|
||||
|
||||
for (const download of recoverableDownloads) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: download.bytesDownloaded,
|
||||
errorMessage: null,
|
||||
status: 'paused',
|
||||
totalBytes: download.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, [download.id]));
|
||||
}
|
||||
|
||||
if (failedIdsWithRemovedPartials.length > 0) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, failedIdsWithRemovedPartials));
|
||||
}
|
||||
|
||||
if (failedIdsWithRetainedPartials.length > 0) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, failedIdsWithRetainedPartials));
|
||||
}
|
||||
|
||||
console.log('[Downloads] Reset stale downloads');
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error resetting stale downloads:', error);
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
|
||||
describe('download requests resume', () => {
|
||||
it('enqueues a paused download with stored headers and original target path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }),
|
||||
resumeValidator: '"etag-9"',
|
||||
status: 'paused',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue([row]);
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { resumeDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
resumeDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
// DB-recorded retained paths stay usable after folder switches.
|
||||
expect(authorizer.requireAuthorized).not.toHaveBeenCalled();
|
||||
expect(enqueueDownload).toHaveBeenCalledWith({
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
headers: { 'User-Agent': 'IPTVnator' },
|
||||
id: 42,
|
||||
resumeValidator: '"etag-9"',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enqueue when a concurrent resume already claimed the row', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
requestHeaders: null,
|
||||
resumeValidator: null,
|
||||
status: 'paused',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue([row]);
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({
|
||||
// The conditional status='paused' claim matched no rows.
|
||||
where: jest.fn().mockResolvedValue({ changes: 0 }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { resumeDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
resumeDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
expect(enqueueDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a failed download with a retained partial at the original target path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }),
|
||||
resumeValidator: '"etag-9"',
|
||||
status: 'failed',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue([row]);
|
||||
const set = jest.fn<
|
||||
{ where: jest.Mock },
|
||||
[Record<string, unknown>]
|
||||
>(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { retryDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
retryDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
const update = set.mock.calls[0][0];
|
||||
expect(authorizer.requireAuthorized).not.toHaveBeenCalled();
|
||||
expect(update).toEqual(
|
||||
expect.objectContaining({
|
||||
errorMessage: null,
|
||||
fileName: 'movie.mp4',
|
||||
status: 'queued',
|
||||
})
|
||||
);
|
||||
expect(update).not.toHaveProperty('bytesDownloaded');
|
||||
expect(update).not.toHaveProperty('filePath');
|
||||
expect(update).not.toHaveProperty('totalBytes');
|
||||
expect(enqueueDownload).toHaveBeenCalledWith({
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
headers: { 'User-Agent': 'IPTVnator' },
|
||||
id: 42,
|
||||
resumeValidator: '"etag-9"',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the retained partial before re-downloading a failed row from scratch', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const failedRow = {
|
||||
contentType: 'vod',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
playlistId: 'playlist-1',
|
||||
status: 'failed',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
};
|
||||
const limit = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 'playlist-1' }])
|
||||
.mockResolvedValueOnce([failedRow]);
|
||||
const set = jest.fn<
|
||||
{ where: jest.Mock },
|
||||
[Record<string, unknown>]
|
||||
>(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { startDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
startDownloadRequest(
|
||||
{
|
||||
contentType: 'vod',
|
||||
downloadFolder: '/downloads',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
},
|
||||
authorizer
|
||||
)
|
||||
).resolves.toEqual({ id: 42, success: true });
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: null,
|
||||
resumeValidator: null,
|
||||
status: 'queued',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the re-download when the retained partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const failedRow = {
|
||||
contentType: 'vod',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
playlistId: 'playlist-1',
|
||||
status: 'failed',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
};
|
||||
const limit = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 'playlist-1' }])
|
||||
.mockResolvedValueOnce([failedRow]);
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
});
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const { startDownloadRequest } = await import(
|
||||
'./download-requests'
|
||||
);
|
||||
|
||||
await expect(
|
||||
startDownloadRequest(
|
||||
{
|
||||
contentType: 'vod',
|
||||
downloadFolder: '/downloads',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
},
|
||||
authorizer
|
||||
)
|
||||
).resolves.toEqual({
|
||||
error: 'Could not delete the previous partial file',
|
||||
id: 42,
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
|
||||
// The row keeps its filePath ownership and nothing is enqueued.
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
expect(enqueueDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { extname } from 'node:path';
|
||||
import { basename, dirname, extname } from 'node:path';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { assertRemoteUrlAllowed } from '../url-safety';
|
||||
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import { enqueueDownload } from './download-runtime';
|
||||
|
||||
export interface StartDownloadRequest {
|
||||
@@ -25,18 +26,21 @@ export interface StartDownloadRequest {
|
||||
macAddress?: string;
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
}
|
||||
|
||||
function getExtensionFromUrl(url: string): string {
|
||||
try {
|
||||
return extname(new URL(url).pathname) || '.mp4';
|
||||
// Sanitize too: URL pathnames may legally contain characters like ':'
|
||||
// that would create NTFS alternate data streams on Windows.
|
||||
const extension = sanitizeFilename(extname(new URL(url).pathname));
|
||||
return extension.startsWith('.') ? extension : '.mp4';
|
||||
} catch {
|
||||
return '.mp4';
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
}
|
||||
|
||||
function createFileName(title: string, url: string): string {
|
||||
return sanitizeFilename(title) + getExtensionFromUrl(url);
|
||||
}
|
||||
@@ -44,13 +48,62 @@ function createFileName(title: string, url: string): string {
|
||||
function createHeaders(
|
||||
headers: StartDownloadRequest['headers']
|
||||
): Record<string, string> | undefined {
|
||||
return headers
|
||||
? {
|
||||
'User-Agent': headers.userAgent || '',
|
||||
Origin: headers.origin || '',
|
||||
Referer: headers.referer || '',
|
||||
}
|
||||
: undefined;
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
if (headers.userAgent) {
|
||||
result['User-Agent'] = headers.userAgent;
|
||||
}
|
||||
if (headers.origin) {
|
||||
result.Origin = headers.origin;
|
||||
}
|
||||
if (headers.referer) {
|
||||
result.Referer = headers.referer;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function serializeHeaders(
|
||||
headers: Record<string, string> | undefined
|
||||
): string | null {
|
||||
return headers ? JSON.stringify(headers) : null;
|
||||
}
|
||||
|
||||
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
|
||||
|
||||
function parseStoredHeaders(
|
||||
value: string | null
|
||||
): Record<string, string> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Re-apply the write-time allowlist so a tampered or imported
|
||||
// database row cannot smuggle arbitrary headers into requests.
|
||||
const entries = parsed as Record<string, unknown>;
|
||||
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
|
||||
(acc, key) => {
|
||||
const headerValue = entries[key];
|
||||
if (typeof headerValue === 'string') {
|
||||
acc[key] = headerValue;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDownloadRequest(
|
||||
@@ -98,6 +151,7 @@ export async function startDownloadRequest(
|
||||
)
|
||||
.limit(1);
|
||||
const fileName = createFileName(data.title, data.url);
|
||||
const headers = createHeaders(data.headers);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const item = existing[0];
|
||||
@@ -109,6 +163,26 @@ export async function startDownloadRequest(
|
||||
};
|
||||
}
|
||||
|
||||
if (item.status === 'failed' && item.filePath) {
|
||||
// A failed row can still reference a retained .part; delete it
|
||||
// before the restart clears filePath, or the file is orphaned.
|
||||
// A locked .part must keep its database owner, so fail the
|
||||
// restart instead of proceeding without the cleanup.
|
||||
try {
|
||||
removePartialDownloadFile(item.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete retained partial before re-download:',
|
||||
error
|
||||
);
|
||||
return {
|
||||
error: 'Could not delete the previous partial file',
|
||||
id: item.id,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
@@ -116,6 +190,8 @@ export async function startDownloadRequest(
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath: null,
|
||||
requestHeaders: serializeHeaders(headers),
|
||||
resumeValidator: null,
|
||||
status: 'queued',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
@@ -125,7 +201,7 @@ export async function startDownloadRequest(
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
headers: createHeaders(data.headers),
|
||||
headers,
|
||||
id: item.id,
|
||||
url: data.url,
|
||||
});
|
||||
@@ -138,6 +214,7 @@ export async function startDownloadRequest(
|
||||
fileName,
|
||||
playlistId: data.playlistId,
|
||||
posterUrl: data.posterUrl,
|
||||
requestHeaders: serializeHeaders(headers),
|
||||
seasonNumber: data.seasonNumber,
|
||||
seriesXtreamId: data.seriesXtreamId,
|
||||
status: 'queued',
|
||||
@@ -149,7 +226,7 @@ export async function startDownloadRequest(
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
headers: createHeaders(data.headers),
|
||||
headers,
|
||||
id: insertedId,
|
||||
url: data.url,
|
||||
});
|
||||
@@ -162,7 +239,6 @@ export async function retryDownloadRequest(
|
||||
authorizer: DownloadDirectoryAuthorizer
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Downloads] Retry download:', downloadId);
|
||||
const directory = await authorizer.requireAuthorized(downloadFolder);
|
||||
const db = await getDatabase();
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -183,24 +259,127 @@ export async function retryDownloadRequest(
|
||||
};
|
||||
}
|
||||
|
||||
const fileName = createFileName(item.title, item.url);
|
||||
const retainedFilePath =
|
||||
item.status === 'failed' && item.filePath ? item.filePath : null;
|
||||
// A retained filePath was written by the main process after its folder
|
||||
// was authorized; requiring the folder to still be the CURRENT selection
|
||||
// would strand the retry after the user switches download folders.
|
||||
const directory = retainedFilePath
|
||||
? dirname(retainedFilePath)
|
||||
: await authorizer.requireAuthorized(downloadFolder);
|
||||
const fileName = retainedFilePath
|
||||
? basename(retainedFilePath)
|
||||
: createFileName(item.title, item.url);
|
||||
const queuedUpdate = retainedFilePath
|
||||
? {
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
status: 'queued' as const,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
}
|
||||
: {
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath: null,
|
||||
resumeValidator: null,
|
||||
status: 'queued' as const,
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath: null,
|
||||
status: 'queued',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.set(queuedUpdate)
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
filePath: retainedFilePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
id: item.id,
|
||||
resumeValidator: retainedFilePath ? item.resumeValidator : null,
|
||||
totalBytes: retainedFilePath ? item.totalBytes : null,
|
||||
url: item.url,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function resumeDownloadRequest(
|
||||
downloadId: number,
|
||||
downloadFolder: string,
|
||||
authorizer: DownloadDirectoryAuthorizer
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Downloads] Resume download:', downloadId);
|
||||
const db = await getDatabase();
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return { error: 'Download not found', success: false };
|
||||
}
|
||||
|
||||
const item = existing[0];
|
||||
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
|
||||
if (item.status !== 'paused') {
|
||||
return {
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// See retryDownloadRequest: DB-recorded retained paths stay usable after
|
||||
// the user switches download folders.
|
||||
const directory = item.filePath
|
||||
? dirname(item.filePath)
|
||||
: await authorizer.requireAuthorized(downloadFolder);
|
||||
const fileName = item.filePath
|
||||
? basename(item.filePath)
|
||||
: createFileName(item.title, item.url);
|
||||
|
||||
// Claim the row atomically: a concurrent resume for the same id loses
|
||||
// this conditional update and must not enqueue a second task.
|
||||
const claim = await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
status: 'queued',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.downloads.id, downloadId),
|
||||
eq(schema.downloads.status, 'paused')
|
||||
)
|
||||
);
|
||||
if (hasNoChanges(claim)) {
|
||||
return {
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
filePath: item.filePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
id: item.id,
|
||||
resumeValidator: item.resumeValidator,
|
||||
totalBytes: item.totalBytes,
|
||||
url: item.url,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function hasNoChanges(result: unknown): boolean {
|
||||
return (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'changes' in result &&
|
||||
(result as { changes: number }).changes === 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import { createTask, waitForStatus } from './download.test-helpers';
|
||||
|
||||
describe('destination collision handling', () => {
|
||||
it('redirects a retained partial to a numbered destination instead of unlinking', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const occupied = new Set([
|
||||
'/downloads/movie.mp4',
|
||||
'/downloads/movie.mp4.part',
|
||||
]);
|
||||
const renameMock = jest.fn(async () => undefined);
|
||||
const unlinkMock = jest.fn(async () => undefined);
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.alloc(50, 'r')]),
|
||||
headers: { 'content-range': 'bytes 50-99/100' },
|
||||
status: 206,
|
||||
}) as never
|
||||
);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn((path: string) => occupied.has(path)),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link: jest.fn(async () => undefined),
|
||||
rename: renameMock,
|
||||
stat: jest.fn(async () => ({ size: 100 })),
|
||||
unlink: unlinkMock,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
findAvailableFinalPath: jest.fn(() => ({
|
||||
filename: 'movie (1).mp4',
|
||||
path: '/downloads/movie (1).mp4',
|
||||
})),
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 50),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 100,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
// The foreign file at the recorded destination is never touched.
|
||||
expect(unlinkMock).not.toHaveBeenCalledWith('/downloads/movie.mp4');
|
||||
expect(renameMock).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie (1).mp4.part'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'movie (1).mp4',
|
||||
filePath: '/downloads/movie (1).mp4',
|
||||
})
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/downloads/movie (1).mp4',
|
||||
status: 'completed',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue deduplication', () => {
|
||||
it('ignores a second enqueue for an id that is already queued', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects: jest.fn(
|
||||
() => new Promise(() => undefined)
|
||||
),
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
// Occupy the active slot, then enqueue id 43 twice (double resume).
|
||||
runtime.enqueueDownload(createTask());
|
||||
runtime.enqueueDownload({ ...createTask(), id: 43 });
|
||||
runtime.enqueueDownload({ ...createTask(), id: 43 });
|
||||
|
||||
// Only one queue entry exists: the first cancel finds it, the second
|
||||
// finds nothing (and no paused DB row backs id 43 in this harness).
|
||||
const dbSelect = jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
(db as { select?: jest.Mock }).select = dbSelect;
|
||||
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import type { DownloadTask } from './download-task';
|
||||
|
||||
interface ResumeHarness {
|
||||
createWriteStream: jest.Mock;
|
||||
removePartialDownloadFile: jest.Mock;
|
||||
requestWithValidatedRedirects: jest.Mock;
|
||||
set: jest.Mock;
|
||||
runtime: typeof import('./download-runtime');
|
||||
}
|
||||
|
||||
interface ResumeHarnessOptions {
|
||||
partialSize: number;
|
||||
response: {
|
||||
data: Readable;
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
};
|
||||
/** 'enoent' makes stat() report a missing target file. */
|
||||
finalSize: number | 'enoent';
|
||||
}
|
||||
|
||||
function createTask(overrides: Partial<DownloadTask> = {}): DownloadTask {
|
||||
return {
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
id: 42,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForStatus(set: jest.Mock, status: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (set.mock.calls.some(([value]) => value?.status === status)) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ status }));
|
||||
}
|
||||
|
||||
async function setupResumeHarness(
|
||||
options: ResumeHarnessOptions
|
||||
): Promise<ResumeHarness> {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () => options.response as never
|
||||
);
|
||||
const createWriteStream = jest.fn(() => new PassThrough());
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream,
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => {
|
||||
if (options.finalSize === 'enoent') {
|
||||
const error = new Error('missing') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return { size: options.finalSize };
|
||||
}),
|
||||
unlink: jest.fn(async () => undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => options.partialSize),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
return {
|
||||
createWriteStream,
|
||||
removePartialDownloadFile,
|
||||
requestWithValidatedRedirects,
|
||||
set,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
describe('download resume validation', () => {
|
||||
it('sends the stored validator as If-Range alongside the Range header', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 54,
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('rest')]),
|
||||
headers: { 'content-range': 'bytes 50-53/54' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.requestWithValidatedRedirects).toHaveBeenCalledWith(
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'If-Range': '"etag-1"',
|
||||
Range: 'bytes=50-',
|
||||
}),
|
||||
}),
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
expect(harness.createWriteStream).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
{ flags: 'a' }
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts from byte zero when a resume request is answered with 200', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4', etag: '"etag-2"' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const consoleWarn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.createWriteStream).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
{ flags: 'w' }
|
||||
);
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
resumeValidator: '"etag-2"',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
expect(harness.set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'failed' })
|
||||
);
|
||||
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleWarn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails the transfer when the 206 response starts at the wrong offset', async () => {
|
||||
const body = new PassThrough();
|
||||
body.write('rest');
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: body,
|
||||
headers: { 'content-range': 'bytes 0-53/54' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'Server returned an invalid resume range',
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
expect(body.destroyed).toBe(true);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains the partial when a 206 ends before the advertised size', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 50,
|
||||
response: {
|
||||
// Only 20 of the 50 remaining bytes arrive before EOF.
|
||||
data: Readable.from([Buffer.alloc(20, 'r')]),
|
||||
headers: { 'content-range': 'bytes 50-99/100' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 100,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 70,
|
||||
errorMessage: 'Transfer ended before the advertised size',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('captures a strong ETag from the first response for later resumes', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 0,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4', etag: '"etag-3"' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(createTask());
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resumeValidator: '"etag-3"' })
|
||||
);
|
||||
});
|
||||
|
||||
it('pauses before reservation without a network request or file path', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 0,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
pauseRequested: true,
|
||||
});
|
||||
await waitForStatus(harness.set, 'paused');
|
||||
|
||||
expect(harness.requestWithValidatedRedirects).not.toHaveBeenCalled();
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
filePath: null,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to Last-Modified when the ETag is weak', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 0,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: {
|
||||
'content-length': '4',
|
||||
etag: 'W/"weak-etag"',
|
||||
'last-modified': 'Wed, 01 Jul 2026 10:00:00 GMT',
|
||||
},
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(createTask());
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resumeValidator: 'Wed, 01 Jul 2026 10:00:00 GMT',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createTask, waitForStatus } from './download.test-helpers';
|
||||
|
||||
describe('retained completed partials', () => {
|
||||
it('finalizes a retained completed partial without another HTTP request', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn();
|
||||
const link = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn((filePath: string) =>
|
||||
filePath.endsWith('.part')
|
||||
),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 4),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 4,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
|
||||
expect(link).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('finalizes a retained completed partial beside an occupied destination without touching it', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn();
|
||||
const link = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
const occupied = new Set([
|
||||
'/downloads/movie.mp4',
|
||||
'/downloads/movie.mp4.part',
|
||||
]);
|
||||
const rename = jest.fn(async (from: string, to: string) => {
|
||||
occupied.delete(from);
|
||||
occupied.add(to);
|
||||
});
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn((filePath: string) => occupied.has(filePath)),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link,
|
||||
rename,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
findAvailableFinalPath: jest.fn(() => ({
|
||||
filename: 'movie (1).mp4',
|
||||
path: '/downloads/movie (1).mp4',
|
||||
})),
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 4),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 4,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
// The occupied destination is never unlinked or overwritten; the
|
||||
// retained partial moves aside and finalizes under a numbered name.
|
||||
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
|
||||
expect(unlink).not.toHaveBeenCalledWith('/downloads/movie.mp4');
|
||||
expect(rename).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie (1).mp4.part'
|
||||
);
|
||||
expect(link).toHaveBeenCalledWith(
|
||||
'/downloads/movie (1).mp4.part',
|
||||
'/downloads/movie (1).mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
fileName: 'movie (1).mp4',
|
||||
filePath: '/downloads/movie (1).mp4',
|
||||
status: 'completed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +1,91 @@
|
||||
import type { DownloadItem } from 'electron';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import {
|
||||
attachDownloadItem,
|
||||
requestDownloadCancellation,
|
||||
type DownloadTask,
|
||||
requestDownloadPause,
|
||||
} from './download-task';
|
||||
import {
|
||||
createTask,
|
||||
waitForCallCount,
|
||||
waitForStatus,
|
||||
waitForStatusCount,
|
||||
} from './download.test-helpers';
|
||||
|
||||
function createTask(): DownloadTask {
|
||||
return {
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
id: 42,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForCallCount(
|
||||
mock: jest.Mock,
|
||||
expectedCallCount: number
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (mock.mock.calls.length === expectedCallCount) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
|
||||
}
|
||||
|
||||
describe('download runtime cancellation', () => {
|
||||
it('cancels the item when an earlier cancellation request reaches onStarted', () => {
|
||||
const task = createTask();
|
||||
const cancel = jest.fn();
|
||||
|
||||
requestDownloadCancellation(task);
|
||||
attachDownloadItem(task, { cancel } as unknown as DownloadItem);
|
||||
|
||||
expect(task.cancelRequested).toBe(true);
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels an already-started item immediately', () => {
|
||||
const cancel = jest.fn();
|
||||
describe('download task interruption', () => {
|
||||
it('aborts an active task when cancellation is requested', () => {
|
||||
const abort = jest.fn();
|
||||
const task = {
|
||||
...createTask(),
|
||||
downloadItem: { cancel } as unknown as DownloadItem,
|
||||
abortController: { abort } as unknown as AbortController,
|
||||
};
|
||||
|
||||
requestDownloadCancellation(task);
|
||||
|
||||
expect(task.cancelRequested).toBe(true);
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('continues the queue when cancellation persistence fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
class TestCancelError extends Error {}
|
||||
|
||||
let cancellationCallback: Promise<void> | undefined;
|
||||
const download = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCancel: (item: DownloadItem) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
cancellationCallback = options.onCancel({
|
||||
getSavePath: () => '/downloads/movie.mp4',
|
||||
} as unknown as DownloadItem);
|
||||
throw new TestCancelError();
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
await options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'second.mp4',
|
||||
path: '/downloads/second.mp4',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const where = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('database is busy'))
|
||||
.mockResolvedValue(undefined);
|
||||
const db = {
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({ where })),
|
||||
})),
|
||||
it('aborts an active task when pause is requested', () => {
|
||||
const abort = jest.fn();
|
||||
const task = {
|
||||
...createTask(),
|
||||
abortController: { abort } as unknown as AbortController,
|
||||
};
|
||||
|
||||
jest.doMock('electron-dl', () => ({
|
||||
CancelError: TestCancelError,
|
||||
download,
|
||||
}));
|
||||
requestDownloadPause(task);
|
||||
|
||||
expect(task.pauseRequested).toBe(true);
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
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 db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const stream = new PassThrough();
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async (
|
||||
_url: string,
|
||||
options: { signal?: AbortSignal }
|
||||
) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
stream.destroy(new Error('aborted'));
|
||||
});
|
||||
return {
|
||||
data: stream,
|
||||
headers: { 'content-length': '100' },
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownload: jest.fn(),
|
||||
reserveAvailableDownloadFile: jest.fn(
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValue(25),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
@@ -127,96 +94,134 @@ describe('download runtime cancellation', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
await expect(runtime.pauseDownload(42)).resolves.toBe(true);
|
||||
await waitForStatus(set, 'paused');
|
||||
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 25,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses an HTTP Range header when a partial file already exists', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('rest')]),
|
||||
headers: { 'content-range': 'bytes 50-53/54' },
|
||||
status: 206,
|
||||
}) as never
|
||||
);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => ({ size: 54 })),
|
||||
unlink: jest.fn(async () => undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 50),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
fileName: 'second.mp4',
|
||||
id: 43,
|
||||
filePath: '/downloads/movie.mp4',
|
||||
});
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
await waitForCallCount(download, 2);
|
||||
await expect(cancellationCallback).resolves.toBeUndefined();
|
||||
|
||||
consoleError.mockRestore();
|
||||
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Range: 'bytes=50-' }),
|
||||
}),
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
await waitForStatus(set, 'completed');
|
||||
});
|
||||
|
||||
it('continues the queue when completion persistence fails', async () => {
|
||||
it('deletes a queued resumed partial file when the queued task is canceled', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
class TestCancelError extends Error {}
|
||||
|
||||
let completionCallback: Promise<void> | undefined;
|
||||
const download = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
completionCallback = options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'movie.mp4',
|
||||
path: '/downloads/movie.mp4',
|
||||
});
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
await options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'second.mp4',
|
||||
path: '/downloads/second.mp4',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const where = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('database is busy'))
|
||||
.mockResolvedValue(undefined);
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({ where })),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const activeStream = new PassThrough();
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async (_url: string, options: { signal?: AbortSignal }) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
activeStream.destroy(new Error('aborted'));
|
||||
});
|
||||
return {
|
||||
data: activeStream,
|
||||
headers: { 'content-length': '100' },
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.doMock('electron-dl', () => ({
|
||||
CancelError: TestCancelError,
|
||||
download,
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownload: jest.fn(),
|
||||
reserveAvailableDownloadFile: jest.fn(
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
@@ -233,64 +238,66 @@ describe('download runtime cancellation', () => {
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
fileName: 'second.mp4',
|
||||
fileName: 'resume.mp4',
|
||||
filePath: '/downloads/resume.mp4',
|
||||
id: 43,
|
||||
});
|
||||
|
||||
await waitForCallCount(download, 2);
|
||||
await expect(completionCallback).resolves.toBeUndefined();
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/resume.mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
filePath: null,
|
||||
status: 'canceled',
|
||||
totalBytes: null,
|
||||
})
|
||||
);
|
||||
|
||||
await runtime.cancelDownload(42);
|
||||
await waitForStatusCount(set, 'canceled', 2);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues the queue when initial database access fails', async () => {
|
||||
it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
class TestCancelError extends Error {}
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
});
|
||||
|
||||
const download = jest.fn(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
await options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'second.mp4',
|
||||
path: '/downloads/second.mp4',
|
||||
});
|
||||
}
|
||||
);
|
||||
const where = jest.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({ where })),
|
||||
})),
|
||||
};
|
||||
const getDatabase = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
.mockResolvedValue(db);
|
||||
|
||||
jest.doMock('electron-dl', () => ({
|
||||
CancelError: TestCancelError,
|
||||
download,
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects: jest.fn(
|
||||
() => new Promise(() => undefined)
|
||||
),
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({ getDatabase }));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownload: jest.fn(),
|
||||
reserveAvailableDownloadFile: jest.fn(
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
@@ -306,18 +313,81 @@ describe('download runtime cancellation', () => {
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
// Occupy the active slot so the second task stays queued.
|
||||
runtime.enqueueDownload(createTask());
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
fileName: 'second.mp4',
|
||||
fileName: 'resume.mp4',
|
||||
filePath: '/downloads/resume.mp4',
|
||||
id: 43,
|
||||
});
|
||||
|
||||
await waitForCallCount(download, 1);
|
||||
expect(download).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({ filename: 'second.mp4' })
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
|
||||
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/downloads/resume.mp4',
|
||||
status: 'canceled',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
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 db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([
|
||||
{
|
||||
filePath: '/downloads/paused.mp4',
|
||||
status: 'paused',
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
});
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
await expect(runtime.cancelDownload(77)).resolves.toBe(true);
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/paused.mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/downloads/paused.mp4',
|
||||
status: 'canceled',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { CancelError, download } from 'electron-dl';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rename } from 'node:fs/promises';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { broadcastDownloadUpdate } from './download-broadcast';
|
||||
import {
|
||||
removePartialDownload,
|
||||
reserveAvailableDownloadFile,
|
||||
findAvailableFinalPath,
|
||||
getPartialDownloadPath,
|
||||
reserveAvailablePartialDownloadFile,
|
||||
type ReservedPartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
import {
|
||||
attachDownloadItem,
|
||||
completeDownloadFromPartial,
|
||||
getCompletedPartialProgress,
|
||||
getPausedByteCount,
|
||||
handleDownloadFailure,
|
||||
removePartialFile,
|
||||
} from './download-finalize';
|
||||
import {
|
||||
requestDownloadCancellation,
|
||||
requestDownloadPause,
|
||||
type DownloadsDatabase,
|
||||
type DownloadTask,
|
||||
} from './download-task';
|
||||
import { describeError, transferToPartialFile } from './download-transfer';
|
||||
|
||||
export {
|
||||
broadcastDownloadUpdate,
|
||||
setMainWindow,
|
||||
} from './download-broadcast';
|
||||
|
||||
const downloadQueue: DownloadTask[] = [];
|
||||
let activeDownload: DownloadTask | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
export function setMainWindow(win: BrowserWindow): void {
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
export function broadcastDownloadUpdate(): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueueDownload(task: DownloadTask): void {
|
||||
// A duplicate id (e.g. two rapid Resume clicks racing the status
|
||||
// refresh) must not produce two transfers for the same row.
|
||||
if (
|
||||
activeDownload?.id === task.id ||
|
||||
downloadQueue.some((queued) => queued.id === task.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadQueue.push(task);
|
||||
broadcastDownloadUpdate();
|
||||
void processQueue();
|
||||
}
|
||||
|
||||
export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
export async function pauseDownload(downloadId: number): Promise<boolean> {
|
||||
if (activeDownload?.id === downloadId) {
|
||||
requestDownloadCancellation(activeDownload);
|
||||
requestDownloadPause(activeDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -52,8 +67,7 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
filePath: null,
|
||||
status: 'canceled',
|
||||
status: 'paused',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
@@ -61,6 +75,52 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
if (activeDownload?.id === downloadId) {
|
||||
requestDownloadCancellation(activeDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
const queueIndex = downloadQueue.findIndex(
|
||||
(task) => task.id === downloadId
|
||||
);
|
||||
if (queueIndex !== -1) {
|
||||
const [queuedTask] = downloadQueue.splice(queueIndex, 1);
|
||||
const removed = removePartialFile(queuedTask?.filePath);
|
||||
const db = await getDatabase();
|
||||
await persistQueuedCancellation(
|
||||
db,
|
||||
downloadId,
|
||||
removed ? null : (queuedTask?.filePath ?? null)
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
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 item = rows[0];
|
||||
if (item?.status !== 'paused') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removed = removePartialFile(item.filePath);
|
||||
await persistQueuedCancellation(
|
||||
db,
|
||||
downloadId,
|
||||
removed ? null : item.filePath
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeDownloadFromRuntime(downloadId: number): void {
|
||||
if (activeDownload?.id === downloadId) {
|
||||
requestDownloadCancellation(activeDownload);
|
||||
@@ -90,7 +150,7 @@ async function processQueue(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Downloads] Unhandled error for ${task.fileName}:`,
|
||||
error
|
||||
describeError(error)
|
||||
);
|
||||
finishTask(task);
|
||||
}
|
||||
@@ -104,39 +164,25 @@ function finishTask(task: DownloadTask): void {
|
||||
void processQueue();
|
||||
}
|
||||
|
||||
function createCancellationHandler(
|
||||
task: DownloadTask,
|
||||
db: Awaited<ReturnType<typeof getDatabase>>,
|
||||
reservation: ReturnType<typeof reserveAvailableDownloadFile>
|
||||
): (item: Parameters<typeof removePartialDownload>[0]) => Promise<void> {
|
||||
let cancellationPromise: Promise<void> | undefined;
|
||||
|
||||
return (item) => {
|
||||
cancellationPromise ??= (async () => {
|
||||
console.log(`[Downloads] Canceled: ${reservation.filename}`);
|
||||
removePartialFile(item);
|
||||
try {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
filePath: null,
|
||||
status: 'canceled',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to persist cancellation:',
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
finishTask(task);
|
||||
}
|
||||
})();
|
||||
|
||||
return cancellationPromise;
|
||||
};
|
||||
async function persistQueuedCancellation(
|
||||
db: DownloadsDatabase,
|
||||
downloadId: number,
|
||||
// Keep the path when the retained .part could not be deleted, so a later
|
||||
// remove/clear can retry the cleanup instead of orphaning the file.
|
||||
retainedFilePath: string | null = null
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
filePath: retainedFilePath,
|
||||
resumeValidator: null,
|
||||
status: 'canceled',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
}
|
||||
|
||||
async function startDownload(task: DownloadTask): Promise<void> {
|
||||
@@ -145,40 +191,26 @@ async function startDownload(task: DownloadTask): Promise<void> {
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
filePath: null,
|
||||
status: 'downloading',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
console.error('[Downloads] No main window available');
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'No window available for download',
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
finishTask(task);
|
||||
return;
|
||||
}
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
const progressThrottleMs = 500;
|
||||
let handleCancellation:
|
||||
| ReturnType<typeof createCancellationHandler>
|
||||
| undefined;
|
||||
|
||||
let reservation: ReservedPartialDownloadFile | undefined;
|
||||
try {
|
||||
const reservation = reserveAvailableDownloadFile(
|
||||
task.directory,
|
||||
task.fileName
|
||||
);
|
||||
task.reservedPath = reservation.path;
|
||||
handleCancellation = createCancellationHandler(task, db, reservation);
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
reservation = await reserveTarget(task);
|
||||
task.fileName = reservation.filename;
|
||||
task.filePath = reservation.path;
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
@@ -189,107 +221,131 @@ async function startDownload(task: DownloadTask): Promise<void> {
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
const downloadOptions: Parameters<typeof download>[2] = {
|
||||
directory: task.directory,
|
||||
filename: reservation.filename,
|
||||
onStarted: (item) => {
|
||||
console.log(`[Downloads] Started: ${reservation.filename}`);
|
||||
attachDownloadItem(task, item);
|
||||
},
|
||||
onProgress: async (progress) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < progressThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastProgressUpdate = now;
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.transferredBytes,
|
||||
totalBytes: progress.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
},
|
||||
onCompleted: async (file) => {
|
||||
console.log(`[Downloads] Completed: ${file.filename}`);
|
||||
try {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: file.fileSize,
|
||||
errorMessage: null,
|
||||
fileName: file.filename,
|
||||
filePath: file.path,
|
||||
status: 'completed',
|
||||
totalBytes: file.fileSize,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to persist completion:',
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
finishTask(task);
|
||||
}
|
||||
},
|
||||
onCancel: handleCancellation,
|
||||
};
|
||||
|
||||
if (task.headers) {
|
||||
(
|
||||
downloadOptions as typeof downloadOptions & {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
).headers = task.headers;
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
await download(mainWindow, task.url, downloadOptions);
|
||||
} catch (error) {
|
||||
if (error instanceof CancelError) {
|
||||
const reservedPath = task.reservedPath;
|
||||
if (handleCancellation) {
|
||||
await handleCancellation(
|
||||
task.downloadItem ??
|
||||
(reservedPath
|
||||
? { getSavePath: () => reservedPath }
|
||||
: undefined)
|
||||
);
|
||||
} else {
|
||||
finishTask(task);
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
|
||||
const reservedPath = task.reservedPath;
|
||||
removePartialFile(
|
||||
task.downloadItem ??
|
||||
(reservedPath ? { getSavePath: () => reservedPath } : undefined)
|
||||
);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
const completedPartialProgress = getCompletedPartialProgress(task);
|
||||
if (completedPartialProgress) {
|
||||
await completeDownloadFromPartial(
|
||||
db,
|
||||
task,
|
||||
reservation,
|
||||
completedPartialProgress
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = await transferToPartialFile(db, task, reservation);
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
await completeDownloadFromPartial(db, task, reservation, progress);
|
||||
} catch (error) {
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDownloadFailure(db, task, reservation, error);
|
||||
} finally {
|
||||
task.abortController = undefined;
|
||||
finishTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
function removePartialFile(
|
||||
item: Parameters<typeof removePartialDownload>[0]
|
||||
): void {
|
||||
async function reserveTarget(
|
||||
task: DownloadTask
|
||||
): Promise<ReservedPartialDownloadFile> {
|
||||
if (task.filePath) {
|
||||
if (!existsSync(task.filePath)) {
|
||||
return {
|
||||
filename: task.fileName,
|
||||
partialPath: getPartialDownloadPath(task.filePath),
|
||||
path: task.filePath,
|
||||
};
|
||||
}
|
||||
|
||||
// Something now occupies the recorded destination — possibly a file
|
||||
// the user created while this download was paused or failed. Never
|
||||
// inspect or delete it: move the retained .part to the next free
|
||||
// numbered destination and finalize there instead.
|
||||
const redirected = findAvailableFinalPath(task.filePath);
|
||||
const currentPartial = getPartialDownloadPath(task.filePath);
|
||||
const redirectedPartial = getPartialDownloadPath(redirected.path);
|
||||
if (existsSync(currentPartial)) {
|
||||
await rename(currentPartial, redirectedPartial);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: redirected.filename,
|
||||
partialPath: redirectedPartial,
|
||||
path: redirected.path,
|
||||
};
|
||||
}
|
||||
|
||||
return reserveAvailablePartialDownloadFile(task.directory, task.fileName);
|
||||
}
|
||||
|
||||
async function persistCancellation(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask
|
||||
): Promise<void> {
|
||||
console.log(`[Downloads] Canceled: ${task.fileName}`);
|
||||
const removed = removePartialFile(task.filePath);
|
||||
try {
|
||||
removePartialDownload(item);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
filePath: removed ? null : (task.filePath ?? null),
|
||||
resumeValidator: null,
|
||||
status: 'canceled',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to delete partial file:', error);
|
||||
console.error('[Downloads] Failed to persist cancellation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistPause(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask
|
||||
): Promise<void> {
|
||||
console.log(`[Downloads] Paused: ${task.fileName}`);
|
||||
const bytesDownloaded = getPausedByteCount(task);
|
||||
try {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded,
|
||||
errorMessage: null,
|
||||
fileName: task.fileName,
|
||||
filePath: task.filePath ?? null,
|
||||
status: 'paused',
|
||||
totalBytes: task.totalBytes ?? null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to persist pause:', error);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { DownloadItem } from 'electron';
|
||||
import type { getDatabase } from '../../database/connection';
|
||||
|
||||
export type DownloadsDatabase = Awaited<ReturnType<typeof getDatabase>>;
|
||||
|
||||
export interface TransferProgress {
|
||||
bytesDownloaded: number;
|
||||
totalBytes: number | null;
|
||||
}
|
||||
|
||||
export interface CompletedPartialProgress extends TransferProgress {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface DownloadTask {
|
||||
id: number;
|
||||
@@ -7,21 +18,20 @@ export interface DownloadTask {
|
||||
directory: string;
|
||||
headers?: Record<string, string>;
|
||||
cancelRequested?: boolean;
|
||||
downloadItem?: DownloadItem;
|
||||
reservedPath?: string;
|
||||
}
|
||||
|
||||
export function attachDownloadItem(
|
||||
task: DownloadTask,
|
||||
item: DownloadItem
|
||||
): void {
|
||||
task.downloadItem = item;
|
||||
if (task.cancelRequested) {
|
||||
item.cancel();
|
||||
}
|
||||
pauseRequested?: boolean;
|
||||
abortController?: AbortController;
|
||||
filePath?: string | null;
|
||||
totalBytes?: number | null;
|
||||
/** ETag/Last-Modified of the entity the partial belongs to (If-Range). */
|
||||
resumeValidator?: string | null;
|
||||
}
|
||||
|
||||
export function requestDownloadCancellation(task: DownloadTask): void {
|
||||
task.cancelRequested = true;
|
||||
task.downloadItem?.cancel();
|
||||
task.abortController?.abort();
|
||||
}
|
||||
|
||||
export function requestDownloadPause(task: DownloadTask): void {
|
||||
task.pauseRequested = true;
|
||||
task.abortController?.abort();
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import * as schema from '../../database/schema';
|
||||
import { requestWithValidatedRedirects } from '../../util/validated-axios';
|
||||
import { broadcastDownloadUpdate } from './download-broadcast';
|
||||
import {
|
||||
getPartialDownloadSize,
|
||||
type ReservedPartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
import type {
|
||||
DownloadsDatabase,
|
||||
DownloadTask,
|
||||
TransferProgress,
|
||||
} from './download-task';
|
||||
|
||||
/**
|
||||
* Log transfer failures by message only: a raw AxiosError dumps its request
|
||||
* config, and download URLs can embed portal credentials.
|
||||
*/
|
||||
export function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* The response stream ended cleanly before the advertised representation
|
||||
* size was reached (e.g. a proxy that caps each response). The partial is
|
||||
* valid — the caller must retain it so a retry can continue via Range.
|
||||
*/
|
||||
export class TruncatedTransferError extends Error {
|
||||
constructor(readonly progress: TransferProgress) {
|
||||
super('Transfer ended before the advertised size');
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferToPartialFile(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile
|
||||
): Promise<TransferProgress> {
|
||||
const resumeOffset = getResumeOffset(task, reservation);
|
||||
|
||||
const headers = {
|
||||
...(task.headers ?? {}),
|
||||
};
|
||||
if (resumeOffset > 0) {
|
||||
headers.Range = `bytes=${resumeOffset}-`;
|
||||
if (task.resumeValidator) {
|
||||
headers['If-Range'] = task.resumeValidator;
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
task.abortController = abortController;
|
||||
if (task.cancelRequested || task.pauseRequested) {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
console.log(`[Downloads] Started: ${reservation.filename}`);
|
||||
const response = await requestWithValidatedRedirects<Readable>(
|
||||
task.url,
|
||||
{
|
||||
headers,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
signal: abortController.signal,
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
|
||||
const readable = response.data;
|
||||
let effectiveOffset = resumeOffset;
|
||||
try {
|
||||
effectiveOffset = validateResumeResponse(
|
||||
reservation,
|
||||
response.status,
|
||||
response.headers,
|
||||
resumeOffset
|
||||
);
|
||||
} catch (error) {
|
||||
// Abandon the unconsumed response body; swallow its error events so
|
||||
// destroying a stream nobody is piping cannot crash the process.
|
||||
readable.on('error', () => undefined);
|
||||
readable.destroy();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const totalBytes = getTotalBytes(response.headers, effectiveOffset);
|
||||
task.totalBytes = totalBytes;
|
||||
if (effectiveOffset === 0) {
|
||||
task.resumeValidator = getResponseValidator(response.headers);
|
||||
}
|
||||
await persistTransferStart(db, task, effectiveOffset, totalBytes);
|
||||
|
||||
let bytesDownloaded = effectiveOffset;
|
||||
let lastProgressUpdate = 0;
|
||||
const progressThrottleMs = 500;
|
||||
const output = createWriteStream(reservation.partialPath, {
|
||||
flags: effectiveOffset > 0 ? 'a' : 'w',
|
||||
});
|
||||
const abortStream = () => {
|
||||
readable.destroy(new Error('Download aborted'));
|
||||
};
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
abortStream();
|
||||
} else {
|
||||
abortController.signal.addEventListener('abort', abortStream, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
readable.on('data', (chunk: Buffer | string) => {
|
||||
bytesDownloaded += Buffer.isBuffer(chunk)
|
||||
? chunk.length
|
||||
: Buffer.byteLength(chunk);
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < progressThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastProgressUpdate = now;
|
||||
void persistProgress(db, task, {
|
||||
bytesDownloaded,
|
||||
totalBytes,
|
||||
}).catch((error) => {
|
||||
console.error('[Downloads] Failed to persist progress:', error);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(readable, output);
|
||||
} finally {
|
||||
abortController.signal.removeEventListener('abort', abortStream);
|
||||
}
|
||||
|
||||
await persistProgress(db, task, { bytesDownloaded, totalBytes });
|
||||
if (totalBytes !== null && bytesDownloaded < totalBytes) {
|
||||
throw new TruncatedTransferError({ bytesDownloaded, totalBytes });
|
||||
}
|
||||
return { bytesDownloaded, totalBytes };
|
||||
}
|
||||
|
||||
function getResumeOffset(
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile
|
||||
): number {
|
||||
const resumeOffset = getPartialDownloadSize(reservation.path);
|
||||
if (
|
||||
task.totalBytes !== null &&
|
||||
task.totalBytes !== undefined &&
|
||||
resumeOffset > task.totalBytes
|
||||
) {
|
||||
throw new Error('Partial download is larger than expected');
|
||||
}
|
||||
return resumeOffset;
|
||||
}
|
||||
|
||||
function validateResumeResponse(
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
status: number,
|
||||
headers: unknown,
|
||||
resumeOffset: number
|
||||
): number {
|
||||
if (resumeOffset === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (status !== 206) {
|
||||
// Either the server ignored Range or If-Range detected that the
|
||||
// remote entity changed. The retained partial is unusable either way,
|
||||
// so restart from byte zero instead of failing the download.
|
||||
console.warn(
|
||||
`[Downloads] Restarting ${reservation.filename} from the beginning (resume request answered with HTTP ${status})`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const contentRange = getHeaderValue(
|
||||
headers as Record<string, unknown>,
|
||||
'content-range'
|
||||
);
|
||||
const start = contentRange?.match(/^bytes\s+(\d+)-/i)?.[1];
|
||||
if (start === undefined || Number(start) !== resumeOffset) {
|
||||
throw new Error('Server returned an invalid resume range');
|
||||
}
|
||||
return resumeOffset;
|
||||
}
|
||||
|
||||
function getResponseValidator(headers: unknown): string | null {
|
||||
const headerMap = headers as Record<string, unknown>;
|
||||
const etag = getHeaderValue(headerMap, 'etag');
|
||||
// If-Range only accepts strong validators, so skip weak W/ ETags.
|
||||
if (etag && !etag.startsWith('W/')) {
|
||||
return etag;
|
||||
}
|
||||
return getHeaderValue(headerMap, 'last-modified') ?? null;
|
||||
}
|
||||
|
||||
async function persistTransferStart(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
bytesDownloaded: number,
|
||||
totalBytes: number | null
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded,
|
||||
resumeValidator: task.resumeValidator ?? null,
|
||||
totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
}
|
||||
|
||||
async function persistProgress(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
progress: TransferProgress
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.bytesDownloaded,
|
||||
totalBytes: progress.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
}
|
||||
|
||||
function getTotalBytes(headers: unknown, resumeOffset: number): number | null {
|
||||
const headerMap = headers as Record<string, unknown>;
|
||||
const contentRange = getHeaderValue(headerMap, 'content-range');
|
||||
if (contentRange) {
|
||||
const match = contentRange.match(/\/(\d+)$/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = getHeaderValue(headerMap, 'content-length');
|
||||
if (!contentLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(contentLength);
|
||||
return Number.isFinite(parsed) ? resumeOffset + parsed : null;
|
||||
}
|
||||
|
||||
function getHeaderValue(
|
||||
headers: Record<string, unknown>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const value = headers[name] ?? headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? String(value[0]) : undefined;
|
||||
}
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { DownloadTask } from './download-task';
|
||||
|
||||
export function createTask(): DownloadTask {
|
||||
return {
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
id: 42,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForCallCount(
|
||||
mock: jest.Mock,
|
||||
expectedCallCount: number
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (mock.mock.calls.length === expectedCallCount) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
|
||||
}
|
||||
|
||||
export async function waitForStatus(
|
||||
set: jest.Mock,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (set.mock.calls.some(([value]) => value?.status === status)) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ status }));
|
||||
}
|
||||
|
||||
export async function waitForStatusCount(
|
||||
set: jest.Mock,
|
||||
status: string,
|
||||
expectedCount: number
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const statusCount = set.mock.calls.filter(
|
||||
([value]) => value?.status === status
|
||||
).length;
|
||||
if (statusCount >= expectedCount) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(
|
||||
set.mock.calls.filter(([value]) => value?.status === status)
|
||||
).toHaveLength(expectedCount);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
const mockRegisteredHandlers = new Map<string, IpcHandler>();
|
||||
const mockGetDatabase = jest.fn();
|
||||
const mockRemoveDownloadFromRuntime = jest.fn();
|
||||
const mockBroadcastDownloadUpdate = jest.fn();
|
||||
const mockRemovePartialDownloadFile = jest.fn();
|
||||
const mockPauseDownload = jest.fn();
|
||||
const mockResumeDownloadRequest = jest.fn();
|
||||
|
||||
function getHandler(channel: string): IpcHandler {
|
||||
const handler = mockRegisteredHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected IPC handler for ${channel}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
function createDownloadRow(status: string) {
|
||||
return {
|
||||
filePath: '/downloads/resume.mp4',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe('downloads events', () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockRegisteredHandlers.clear();
|
||||
mockGetDatabase.mockReset();
|
||||
mockRemoveDownloadFromRuntime.mockReset();
|
||||
mockBroadcastDownloadUpdate.mockReset();
|
||||
mockRemovePartialDownloadFile.mockReset();
|
||||
mockPauseDownload.mockReset();
|
||||
mockResumeDownloadRequest.mockReset();
|
||||
|
||||
jest.doMock('electron', () => ({
|
||||
app: {
|
||||
getPath: jest.fn((name: string) =>
|
||||
name === 'userData' ? '/user-data' : '/downloads'
|
||||
),
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: jest.fn(),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: IpcHandler) => {
|
||||
mockRegisteredHandlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
openPath: jest.fn(),
|
||||
showItemInFolder: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: mockGetDatabase,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile: mockRemovePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
|
||||
cancelDownload: jest.fn(),
|
||||
pauseDownload: mockPauseDownload,
|
||||
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
|
||||
setMainWindow: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-requests', () => ({
|
||||
resumeDownloadRequest: mockResumeDownloadRequest,
|
||||
retryDownloadRequest: jest.fn(),
|
||||
startDownloadRequest: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-recovery', () => ({
|
||||
resetStaleDownloads: jest.fn(),
|
||||
}));
|
||||
|
||||
await import('./downloads.events');
|
||||
});
|
||||
|
||||
function mockDownloadRow(row: { filePath: string | null; status: string }) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([row]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
function mockTerminalRows(
|
||||
rows: Array<{ filePath: string | null; status: string }>
|
||||
) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const selectWhere = jest
|
||||
.fn()
|
||||
.mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row })));
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: selectWhere,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere, selectWhere };
|
||||
}
|
||||
|
||||
it('removes queued resumed partial files before deleting the row', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
|
||||
|
||||
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/resume.mp4'
|
||||
);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
|
||||
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes completed partial files before deleting the row', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('completed'));
|
||||
|
||||
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/resume.mp4'
|
||||
);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('keeps the queued runtime entry and row when partial cleanup fails', async () => {
|
||||
const cleanupError = new Error('permission denied');
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
|
||||
mockRemovePartialDownloadFile.mockImplementation(() => {
|
||||
throw cleanupError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
// The row and its .part must survive, but the renderer gets a
|
||||
// structured failure it can surface instead of an IPC rejection.
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REMOVE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Could not delete the partial file',
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(deleteWhere).not.toHaveBeenCalled();
|
||||
expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes completed, failed, and canceled partial files before clearing terminal downloads', async () => {
|
||||
const { deleteWhere } = mockTerminalRows([
|
||||
{ filePath: '/downloads/done.mp4', status: 'completed' },
|
||||
{ filePath: '/downloads/failed.mp4', status: 'failed' },
|
||||
{ filePath: '/downloads/canceled.mp4', status: 'canceled' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(3);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/downloads/done.mp4'
|
||||
);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/downloads/failed.mp4'
|
||||
);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/downloads/canceled.mp4'
|
||||
);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
|
||||
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retains only downloads whose partial cleanup fails when clearing terminal downloads', async () => {
|
||||
const cleanupError = new Error('permission denied');
|
||||
const { deleteWhere } = mockTerminalRows([
|
||||
{ filePath: '/downloads/done.mp4', status: 'completed' },
|
||||
{ filePath: '/downloads/failed.mp4', status: 'failed' },
|
||||
]);
|
||||
mockRemovePartialDownloadFile.mockImplementation((filePath) => {
|
||||
if (filePath !== '/downloads/failed.mp4') {
|
||||
return;
|
||||
}
|
||||
throw cleanupError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1);
|
||||
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes the row once a previously locked partial becomes deletable', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('paused'));
|
||||
mockRemovePartialDownloadFile
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
})
|
||||
.mockImplementation(() => true);
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REMOVE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Could not delete the partial file',
|
||||
success: false,
|
||||
});
|
||||
expect(deleteWhere).not.toHaveBeenCalled();
|
||||
|
||||
// Retry after the lock is released: cleanup and delete succeed.
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REMOVE')(null, 42)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps a successful runtime pause to a success response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(true);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockPauseDownload).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('maps an unknown pause target to an error response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(false);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Download not found in queue',
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards resume requests with the download folder and returns the result', async () => {
|
||||
mockResumeDownloadRequest.mockResolvedValue({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
|
||||
).resolves.toEqual({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import { join } from 'node:path';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import {
|
||||
resumeDownloadRequest,
|
||||
retryDownloadRequest,
|
||||
startDownloadRequest,
|
||||
type StartDownloadRequest,
|
||||
@@ -15,10 +17,19 @@ import { resetStaleDownloads } from './download-recovery';
|
||||
import {
|
||||
broadcastDownloadUpdate,
|
||||
cancelDownload,
|
||||
pauseDownload,
|
||||
removeDownloadFromRuntime,
|
||||
setMainWindow,
|
||||
} from './download-runtime';
|
||||
|
||||
const removablePartialStatuses = new Set([
|
||||
'queued',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
]);
|
||||
|
||||
function getDownloadAuthorizationPath(): string {
|
||||
return join(
|
||||
app.getPath('userData'),
|
||||
@@ -104,6 +115,34 @@ ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('DOWNLOADS_PAUSE', async (_event, downloadId: number) => {
|
||||
try {
|
||||
console.log('[Downloads] Pause download:', downloadId);
|
||||
return (await pauseDownload(downloadId))
|
||||
? { success: true }
|
||||
: { error: 'Download not found in queue', success: false };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error pausing download:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'DOWNLOADS_RESUME',
|
||||
async (_event, downloadId: number, downloadFolder: string) => {
|
||||
try {
|
||||
return await resumeDownloadRequest(
|
||||
downloadId,
|
||||
downloadFolder,
|
||||
downloadDirectoryAuthorizer
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error resuming download:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'DOWNLOADS_RETRY',
|
||||
async (_event, downloadId: number, downloadFolder: string) => {
|
||||
@@ -123,8 +162,36 @@ ipcMain.handle(
|
||||
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
|
||||
try {
|
||||
console.log('[Downloads] Remove download:', downloadId);
|
||||
removeDownloadFromRuntime(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 (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,
|
||||
};
|
||||
}
|
||||
}
|
||||
removeDownloadFromRuntime(downloadId);
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
@@ -210,17 +277,43 @@ ipcMain.handle(
|
||||
'failed',
|
||||
'canceled',
|
||||
]);
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(
|
||||
playlistId
|
||||
? and(
|
||||
eq(schema.downloads.playlistId, playlistId),
|
||||
terminalStatus
|
||||
)
|
||||
: terminalStatus
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
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 (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);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { removePartialDownload } from './download-file-path';
|
||||
|
||||
interface StaleDownloadFile {
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
function removePersistedPartial(filePath: string): void {
|
||||
removePartialDownload({ getSavePath: () => filePath });
|
||||
}
|
||||
|
||||
export function cleanupStaleDownloadFiles(
|
||||
downloads: readonly StaleDownloadFile[],
|
||||
removeFile: (filePath: string) => void = removePersistedPartial
|
||||
): void {
|
||||
for (const download of downloads) {
|
||||
if (!download.filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
removeFile(download.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete stale partial file:',
|
||||
download.filePath,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "لم يعد بالإمكان فتح هذا التنزيل في المكتبة لأنه تمت إزالة قائمة التشغيل المصدر.",
|
||||
"FILE_NOT_FOUND": "هذا الملف الذي تم تنزيله لم يعد متاحًا على القرص.",
|
||||
"FILE_ACTION_ERROR": "تعذر إكمال الإجراء على الملف.",
|
||||
"ACTION_FAILED": "فشل الإجراء",
|
||||
"PAUSE": "إيقاف مؤقت",
|
||||
"RESUME": "استئناف",
|
||||
"CANCEL": "إلغاء",
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"REMOVE": "إزالة",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "جارٍ التنزيل",
|
||||
"COMPLETED": "مكتمل",
|
||||
"FAILED": "فشل",
|
||||
"CANCELED": "ملغى"
|
||||
"CANCELED": "ملغى",
|
||||
"PAUSED": "متوقف مؤقتًا"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "هاد التحميل ما يقدرش يتحل في المكتبة حيت قائمة التشغيل المصدر تحيدات.",
|
||||
"FILE_NOT_FOUND": "هاد الملف المحمل ما بقاش متوفر على القرص.",
|
||||
"FILE_ACTION_ERROR": "العملية على الملف ما تكملاتش.",
|
||||
"ACTION_FAILED": "العملية ما نجحاتش",
|
||||
"PAUSE": "وقّف مؤقتاً",
|
||||
"RESUME": "كمّل",
|
||||
"CANCEL": "إلغاء",
|
||||
"RETRY": "حاول مرة أخرى",
|
||||
"REMOVE": "حيد",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "جاري التحميل",
|
||||
"COMPLETED": "كمل",
|
||||
"FAILED": "فشل",
|
||||
"CANCELED": "تلغى"
|
||||
"CANCELED": "تلغى",
|
||||
"PAUSED": "موقوف مؤقتاً"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Гэта спампаванне больш нельга адкрыць у бібліятэцы, бо яго зыходны плэйліст быў выдалены.",
|
||||
"FILE_NOT_FOUND": "Гэты спампаваны файл больш недаступны на дыску.",
|
||||
"FILE_ACTION_ERROR": "Не ўдалося выканаць дзеянне з файлам.",
|
||||
"ACTION_FAILED": "Дзеянне не выканана",
|
||||
"PAUSE": "Паўза",
|
||||
"RESUME": "Узнавіць",
|
||||
"CANCEL": "Скасаваць",
|
||||
"RETRY": "Паўтарыць",
|
||||
"REMOVE": "Выдаліць",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Спампоўваецца",
|
||||
"COMPLETED": "Завершана",
|
||||
"FAILED": "Няўдала",
|
||||
"CANCELED": "Скасавана"
|
||||
"CANCELED": "Скасавана",
|
||||
"PAUSED": "На паўзе"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Dieser Download kann nicht mehr in der Bibliothek geöffnet werden, weil die zugehörige Playlist entfernt wurde.",
|
||||
"FILE_NOT_FOUND": "Diese heruntergeladene Datei ist auf der Festplatte nicht mehr verfügbar.",
|
||||
"FILE_ACTION_ERROR": "Die Dateiaktion konnte nicht ausgeführt werden.",
|
||||
"ACTION_FAILED": "Aktion fehlgeschlagen",
|
||||
"PAUSE": "Pausieren",
|
||||
"RESUME": "Fortsetzen",
|
||||
"CANCEL": "Abbrechen",
|
||||
"RETRY": "Wiederholen",
|
||||
"REMOVE": "Entfernen",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Wird heruntergeladen",
|
||||
"COMPLETED": "Abgeschlossen",
|
||||
"FAILED": "Fehlgeschlagen",
|
||||
"CANCELED": "Abgebrochen"
|
||||
"CANCELED": "Abgebrochen",
|
||||
"PAUSED": "Pausiert"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Αυτή η λήψη δεν μπορεί πλέον να ανοίξει στη βιβλιοθήκη επειδή η πηγαία λίστα αναπαραγωγής αφαιρέθηκε.",
|
||||
"FILE_NOT_FOUND": "Αυτό το ληφθέν αρχείο δεν είναι πλέον διαθέσιμο στον δίσκο.",
|
||||
"FILE_ACTION_ERROR": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας αρχείου.",
|
||||
"ACTION_FAILED": "Η ενέργεια απέτυχε",
|
||||
"PAUSE": "Παύση",
|
||||
"RESUME": "Συνέχιση",
|
||||
"CANCEL": "Ακύρωση",
|
||||
"RETRY": "Επανάληψη",
|
||||
"REMOVE": "Αφαίρεση",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Λήψη",
|
||||
"COMPLETED": "Ολοκληρώθηκε",
|
||||
"FAILED": "Απέτυχε",
|
||||
"CANCELED": "Ακυρώθηκε"
|
||||
"CANCELED": "Ακυρώθηκε",
|
||||
"PAUSED": "Σε παύση"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "This download can no longer open in the library because its source playlist was removed.",
|
||||
"FILE_NOT_FOUND": "This downloaded file is no longer available on disk.",
|
||||
"FILE_ACTION_ERROR": "The file action could not be completed.",
|
||||
"ACTION_FAILED": "Action failed",
|
||||
"PAUSE": "Pause",
|
||||
"RESUME": "Resume",
|
||||
"CANCEL": "Cancel",
|
||||
"RETRY": "Retry",
|
||||
"REMOVE": "Remove",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Downloading",
|
||||
"COMPLETED": "Completed",
|
||||
"FAILED": "Failed",
|
||||
"CANCELED": "Canceled"
|
||||
"CANCELED": "Canceled",
|
||||
"PAUSED": "Paused"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Esta descarga ya no se puede abrir en la biblioteca porque su lista de reproducción de origen fue eliminada.",
|
||||
"FILE_NOT_FOUND": "Este archivo descargado ya no está disponible en el disco.",
|
||||
"FILE_ACTION_ERROR": "No se pudo completar la acción del archivo.",
|
||||
"ACTION_FAILED": "La acción ha fallado",
|
||||
"PAUSE": "Pausar",
|
||||
"RESUME": "Reanudar",
|
||||
"CANCEL": "Cancelar",
|
||||
"RETRY": "Reintentar",
|
||||
"REMOVE": "Eliminar",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Descargando",
|
||||
"COMPLETED": "Completado",
|
||||
"FAILED": "Fallido",
|
||||
"CANCELED": "Cancelado"
|
||||
"CANCELED": "Cancelado",
|
||||
"PAUSED": "En pausa"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Ce téléchargement ne peut plus être ouvert dans la bibliothèque car sa liste de lecture source a été supprimée.",
|
||||
"FILE_NOT_FOUND": "Ce fichier téléchargé n'est plus disponible sur le disque.",
|
||||
"FILE_ACTION_ERROR": "L'action sur le fichier n'a pas pu être effectuée.",
|
||||
"ACTION_FAILED": "Échec de l'action",
|
||||
"PAUSE": "Mettre en pause",
|
||||
"RESUME": "Reprendre",
|
||||
"CANCEL": "Annuler",
|
||||
"RETRY": "Réessayer",
|
||||
"REMOVE": "Supprimer",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "En cours",
|
||||
"COMPLETED": "Terminé",
|
||||
"FAILED": "Échec",
|
||||
"CANCELED": "Annulé"
|
||||
"CANCELED": "Annulé",
|
||||
"PAUSED": "En pause"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Questo download non può più essere aperto nella libreria perché la playlist di origine è stata rimossa.",
|
||||
"FILE_NOT_FOUND": "Questo file scaricato non è più disponibile su disco.",
|
||||
"FILE_ACTION_ERROR": "Impossibile completare l'azione sul file.",
|
||||
"ACTION_FAILED": "Azione non riuscita",
|
||||
"PAUSE": "Pausa",
|
||||
"RESUME": "Riprendi",
|
||||
"CANCEL": "Annulla",
|
||||
"RETRY": "Riprova",
|
||||
"REMOVE": "Rimuovi",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "In download",
|
||||
"COMPLETED": "Completato",
|
||||
"FAILED": "Non riuscito",
|
||||
"CANCELED": "Annullato"
|
||||
"CANCELED": "Annullato",
|
||||
"PAUSED": "In pausa"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "ソースのプレイリストが削除されたため、このダウンロードはライブラリで開けなくなりました。",
|
||||
"FILE_NOT_FOUND": "このダウンロードファイルはディスク上で見つかりませんでした。",
|
||||
"FILE_ACTION_ERROR": "ファイル操作を完了できませんでした。",
|
||||
"ACTION_FAILED": "操作に失敗しました",
|
||||
"PAUSE": "一時停止",
|
||||
"RESUME": "再開",
|
||||
"CANCEL": "キャンセル",
|
||||
"RETRY": "再試行",
|
||||
"REMOVE": "削除",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "ダウンロード中",
|
||||
"COMPLETED": "完了",
|
||||
"FAILED": "失敗",
|
||||
"CANCELED": "キャンセル済み"
|
||||
"CANCELED": "キャンセル済み",
|
||||
"PAUSED": "一時停止中"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "원본 재생목록이 제거되어 이 다운로드 항목을 라이브러리에서 더 이상 열 수 없습니다.",
|
||||
"FILE_NOT_FOUND": "이 다운로드한 파일을 디스크에서 더 이상 사용할 수 없습니다.",
|
||||
"FILE_ACTION_ERROR": "파일 작업을 완료하지 못했습니다.",
|
||||
"ACTION_FAILED": "작업 실패",
|
||||
"PAUSE": "일시정지",
|
||||
"RESUME": "재개",
|
||||
"CANCEL": "취소",
|
||||
"RETRY": "다시 시도",
|
||||
"REMOVE": "제거",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "다운로드 중",
|
||||
"COMPLETED": "완료됨",
|
||||
"FAILED": "실패",
|
||||
"CANCELED": "취소됨"
|
||||
"CANCELED": "취소됨",
|
||||
"PAUSED": "일시정지됨"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Deze download kan niet meer worden geopend in de bibliotheek omdat de bronafspeellijst is verwijderd.",
|
||||
"FILE_NOT_FOUND": "Dit gedownloade bestand is niet meer beschikbaar op de schijf.",
|
||||
"FILE_ACTION_ERROR": "De bestandsactie kon niet worden voltooid.",
|
||||
"ACTION_FAILED": "Actie mislukt",
|
||||
"PAUSE": "Pauzeren",
|
||||
"RESUME": "Hervatten",
|
||||
"CANCEL": "Annuleren",
|
||||
"RETRY": "Opnieuw proberen",
|
||||
"REMOVE": "Verwijderen",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Downloaden",
|
||||
"COMPLETED": "Voltooid",
|
||||
"FAILED": "Mislukt",
|
||||
"CANCELED": "Geannuleerd"
|
||||
"CANCELED": "Geannuleerd",
|
||||
"PAUSED": "Gepauzeerd"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Tego pobrania nie można już otworzyć w bibliotece, ponieważ jego źródłowa lista odtwarzania została usunięta.",
|
||||
"FILE_NOT_FOUND": "Ten pobrany plik nie jest już dostępny na dysku.",
|
||||
"FILE_ACTION_ERROR": "Nie udało się wykonać akcji na pliku.",
|
||||
"ACTION_FAILED": "Akcja nie powiodła się",
|
||||
"PAUSE": "Wstrzymaj",
|
||||
"RESUME": "Wznów",
|
||||
"CANCEL": "Anuluj",
|
||||
"RETRY": "Spróbuj ponownie",
|
||||
"REMOVE": "Usuń",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Pobieranie",
|
||||
"COMPLETED": "Ukończone",
|
||||
"FAILED": "Niepowodzenie",
|
||||
"CANCELED": "Anulowane"
|
||||
"CANCELED": "Anulowane",
|
||||
"PAUSED": "Wstrzymane"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Este download não pode mais ser aberto na biblioteca porque sua playlist de origem foi removida.",
|
||||
"FILE_NOT_FOUND": "Este arquivo baixado não está mais disponível no disco.",
|
||||
"FILE_ACTION_ERROR": "A ação do arquivo não pôde ser concluída.",
|
||||
"ACTION_FAILED": "Falha na ação",
|
||||
"PAUSE": "Pausar",
|
||||
"RESUME": "Retomar",
|
||||
"CANCEL": "Cancelar",
|
||||
"RETRY": "Tentar novamente",
|
||||
"REMOVE": "Remover",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Baixando",
|
||||
"COMPLETED": "Concluído",
|
||||
"FAILED": "Falhou",
|
||||
"CANCELED": "Cancelado"
|
||||
"CANCELED": "Cancelado",
|
||||
"PAUSED": "Pausado"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Эту загрузку больше нельзя открыть в библиотеке, потому что исходный плейлист был удален.",
|
||||
"FILE_NOT_FOUND": "Этот загруженный файл больше недоступен на диске.",
|
||||
"FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом.",
|
||||
"ACTION_FAILED": "Действие не выполнено",
|
||||
"PAUSE": "Пауза",
|
||||
"RESUME": "Возобновить",
|
||||
"CANCEL": "Отмена",
|
||||
"RETRY": "Повторить",
|
||||
"REMOVE": "Удалить",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "Скачивается",
|
||||
"COMPLETED": "Завершено",
|
||||
"FAILED": "Ошибка",
|
||||
"CANCELED": "Отменено"
|
||||
"CANCELED": "Отменено",
|
||||
"PAUSED": "На паузе"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "Bu indirme, kaynak oynatma listesi kaldırıldığı için kütüphanede artık açılamıyor.",
|
||||
"FILE_NOT_FOUND": "Bu indirilen dosya artık diskte mevcut değil.",
|
||||
"FILE_ACTION_ERROR": "Dosya işlemi tamamlanamadı.",
|
||||
"ACTION_FAILED": "İşlem başarısız oldu",
|
||||
"PAUSE": "Duraklat",
|
||||
"RESUME": "Devam et",
|
||||
"CANCEL": "İptal",
|
||||
"RETRY": "Tekrar Dene",
|
||||
"REMOVE": "Kaldır",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "İndiriliyor",
|
||||
"COMPLETED": "Tamamlandı",
|
||||
"FAILED": "Başarısız",
|
||||
"CANCELED": "İptal Edildi"
|
||||
"CANCELED": "İptal Edildi",
|
||||
"PAUSED": "Duraklatıldı"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "由于源播放列表已被移除,此下载项无法在库中打开。",
|
||||
"FILE_NOT_FOUND": "此下载文件已不在磁盘上。",
|
||||
"FILE_ACTION_ERROR": "无法完成此文件操作。",
|
||||
"ACTION_FAILED": "操作失败",
|
||||
"PAUSE": "暂停",
|
||||
"RESUME": "继续",
|
||||
"CANCEL": "取消",
|
||||
"RETRY": "重试",
|
||||
"REMOVE": "移除",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "下载中",
|
||||
"COMPLETED": "已完成",
|
||||
"FAILED": "失败",
|
||||
"CANCELED": "已取消"
|
||||
"CANCELED": "已取消",
|
||||
"PAUSED": "已暂停"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -1004,6 +1004,9 @@
|
||||
"SOURCE_PLAYLIST_MISSING": "此下載項目無法在媒體庫中開啟,因為其來源播放清單已被移除。",
|
||||
"FILE_NOT_FOUND": "此下載檔案在磁碟上已不存在。",
|
||||
"FILE_ACTION_ERROR": "無法完成檔案操作。",
|
||||
"ACTION_FAILED": "操作失敗",
|
||||
"PAUSE": "暫停",
|
||||
"RESUME": "繼續",
|
||||
"CANCEL": "取消",
|
||||
"RETRY": "重試",
|
||||
"REMOVE": "移除",
|
||||
@@ -1017,7 +1020,8 @@
|
||||
"DOWNLOADING": "下載中",
|
||||
"COMPLETED": "已完成",
|
||||
"FAILED": "失敗",
|
||||
"CANCELED": "已取消"
|
||||
"CANCELED": "已取消",
|
||||
"PAUSED": "已暫停"
|
||||
}
|
||||
},
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -5,27 +5,35 @@ The download manager is a desktop-only feature that layers a curated queue, prog
|
||||
## 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/progress helpers. Request validation and row creation live in `download-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()`.
|
||||
- **electron-dl integration**
|
||||
`startDownload()` calls `electron-dl`'s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper's payload into Drizzle updates. A cancellation requested before `onStarted` is remembered and applied as soon as Electron supplies the `DownloadItem`, so the request cannot be lost in the startup race.
|
||||
`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`, 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`, 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`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. 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) and sends `Range: bytes=<offset>-` plus `If-Range` with the stored entity validator. The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator` for exactly this purpose. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download.
|
||||
- **Destination collision policy**
|
||||
Existing destination files are never overwritten. Before starting Electron's
|
||||
download, the backend atomically reserves a free numbered filename with an
|
||||
exclusive filesystem create. Electron may overwrite that empty reservation,
|
||||
but cannot overwrite a file that existed before the reservation. The selected
|
||||
`filePath` and `fileName` are persisted before transfer begins. Errors,
|
||||
cancellations, and startup recovery remove that exact partial path and clear
|
||||
it from the row; completed downloads replace it with Electron's final values.
|
||||
Existing destination files are never overwritten, inspected, or deleted.
|
||||
Before starting a new transfer, the backend atomically reserves a free
|
||||
numbered `.part` path while leaving the final destination path absent. The
|
||||
selected final `filePath` and `fileName` are persisted before transfer
|
||||
begins. When a retained download's recorded destination got occupied while
|
||||
it was paused or failed (for example by a file the user created), the
|
||||
retained `.part` is renamed aside and finalized to the next free numbered
|
||||
destination (`Movie (1).mp4`) instead of resolving the collision by size or
|
||||
`unlink()`. Completion creates the final `filePath` from the `.part` without
|
||||
overwriting an existing file; cancel and ordinary transfer failures remove
|
||||
the `.part`, while finalization failures and completed-partial failures
|
||||
deliberately retain it (the row keeps `filePath` so a later retry can finish
|
||||
without re-downloading); pause and restart recovery keep it for a later
|
||||
resume. Re-downloading such a failed row from a detail page
|
||||
(`DOWNLOADS_START`) deletes the retained `.part` before the row is reset.
|
||||
- **IPC surface**
|
||||
The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store.
|
||||
The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/pause/resume/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store.
|
||||
|
||||
## Renderer architecture
|
||||
|
||||
- **Downloads service** (`libs/services/src/lib/downloads.service.ts`)
|
||||
Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service asks the main process for the authorized folder and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path.
|
||||
Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download/resume the service asks the main process for the authorized folder and calls the download IPC command. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `pauseDownload`, `resumeDownload`, `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path.
|
||||
- **Downloads view** (`libs/portal/downloads/feature`)
|
||||
A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives gradient cards with theme-aware styling through Angular Material system CSS variables (`var(--mat-sys-*)`, `var(--app-*)`, `color-mix`) — theming tracks the active Material theme rather than a `body.dark-theme` hook.
|
||||
Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action.
|
||||
Failed/canceled cards show retry/delete controls, queued/downloading cards show pause/cancel controls, paused cards show resume/cancel/delete controls, and completed cards render inline play/open buttons with `mat-icon` cues. Pause/resume/cancel/retry surface backend `success: false` results in a snackbar instead of failing silently. The header also shows the resolved download folder and a `CHANGE FOLDER` action. VOD and episode detail views render a paused download as an active "Resume" button (`DownloadsService.isPaused()` / `resumeDownloadByContent()`) rather than a disabled "Downloading" state.
|
||||
|
||||
## Global API surface
|
||||
|
||||
@@ -39,8 +47,15 @@ The download manager is a desktop-only feature that layers a curated queue, prog
|
||||
|
||||
## Queuing, persistence, and UX notes
|
||||
|
||||
- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, and Xtream identifiers. On startup, `download-recovery.ts` deletes persisted partial reservations before stale queued/downloading rows become `failed`.
|
||||
- Queue cancellation removes a queued task or records an active cancellation request and calls `downloadItem.cancel()` when the item is available; retries reuse the same database entry, preventing duplicate rows.
|
||||
- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; the `resume_validator` column is added through the idempotent column migrations.
|
||||
- On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`.
|
||||
- Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation.
|
||||
- A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released.
|
||||
- Resume claims the row atomically (`paused` → `queued` as a conditional update) and the runtime queue rejects duplicate ids, so two rapid Resume clicks racing the status refresh can never produce two transfers for the same download.
|
||||
- A response that ends cleanly before the advertised representation size (for example a proxy that caps each response) is never committed as completed: the transfer fails with `Transfer ended before the advertised size` while retaining the `.part` and `filePath`, so a retry continues via Range from where it stopped.
|
||||
- Retained `filePath`s recorded in the database stay usable after the user switches download folders — resume/retry of a retained row does not re-require the folder to be the current selection. Fresh downloads still authorize against the currently selected folder.
|
||||
- Startup recovery recognizes a finalization that crashed between creating the final file and committing the row (`downloading` row, no partial, final file present with the recorded size) and marks it `completed` instead of failing it and orphaning the file.
|
||||
- Pause/resume is covered end to end by `apps/electron-backend-e2e/src/downloads.e2e.ts`: a throttled Range-capable mock server verifies the paused `.part` on disk, the `Range`/`If-Range` resume request, and byte-exact assembly of the final file.
|
||||
- The OS downloads path is always authorized. A custom folder becomes
|
||||
authorized only after native folder selection, and the main process persists
|
||||
that selection under Electron `userData`. Renderer settings may display the
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
|
||||
@if (
|
||||
item.status === 'downloading' ||
|
||||
item.status === 'paused' ||
|
||||
item.status === 'completed'
|
||||
) {
|
||||
<span class="downloads__item-size">
|
||||
@@ -253,6 +254,17 @@
|
||||
</div>
|
||||
|
||||
@if (item.status === 'downloading') {
|
||||
<mat-progress-bar
|
||||
[mode]="
|
||||
item.totalBytes
|
||||
? 'determinate'
|
||||
: 'indeterminate'
|
||||
"
|
||||
[value]="getProgress(item)"
|
||||
></mat-progress-bar>
|
||||
} @else if (
|
||||
item.status === 'paused' && item.totalBytes
|
||||
) {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="getProgress(item)"
|
||||
@@ -274,6 +286,18 @@
|
||||
</button>
|
||||
@switch (item.status) {
|
||||
@case ('queued') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
pause(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PAUSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
@@ -288,6 +312,18 @@
|
||||
</button>
|
||||
}
|
||||
@case ('downloading') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
pause(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PAUSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
@@ -301,6 +337,46 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('paused') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
resume(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('completed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
@@ -442,6 +442,11 @@
|
||||
&.status-queued {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
&.status-paused {
|
||||
color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&-size {
|
||||
|
||||
@@ -212,6 +212,8 @@ export class DownloadsComponent {
|
||||
return 'schedule';
|
||||
case 'downloading':
|
||||
return 'downloading';
|
||||
case 'paused':
|
||||
return 'pause_circle';
|
||||
case 'completed':
|
||||
return 'check_circle';
|
||||
case 'failed':
|
||||
@@ -229,6 +231,8 @@ export class DownloadsComponent {
|
||||
return 'status-queued';
|
||||
case 'downloading':
|
||||
return 'status-downloading';
|
||||
case 'paused':
|
||||
return 'status-paused';
|
||||
case 'completed':
|
||||
return 'status-completed';
|
||||
case 'failed':
|
||||
@@ -257,15 +261,38 @@ export class DownloadsComponent {
|
||||
}
|
||||
|
||||
async cancel(item: DownloadItem) {
|
||||
await this.downloadsService.cancelDownload(item.id);
|
||||
const result = await this.downloadsService.cancelDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async pause(item: DownloadItem) {
|
||||
const result = await this.downloadsService.pauseDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async resume(item: DownloadItem) {
|
||||
const result = await this.downloadsService.resumeDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async retry(item: DownloadItem) {
|
||||
await this.downloadsService.retryDownload(item.id);
|
||||
const result = await this.downloadsService.retryDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(item: DownloadItem) {
|
||||
await this.downloadsService.removeDownload(item.id);
|
||||
const result = await this.downloadsService.removeDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async play(item: DownloadItem) {
|
||||
@@ -571,6 +598,17 @@ export class DownloadsComponent {
|
||||
return `${item.id}:${item.posterUrl ?? ''}`;
|
||||
}
|
||||
|
||||
private showActionError(error?: string): void {
|
||||
const message = error
|
||||
? `${this.translate.instant('DOWNLOADS.ACTION_FAILED')}: ${error}`
|
||||
: this.translate.instant('DOWNLOADS.ACTION_FAILED');
|
||||
|
||||
this.snackBar.open(message, undefined, {
|
||||
duration: 4000,
|
||||
horizontalPosition: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
private handleFileActionError(error?: string): void {
|
||||
const message =
|
||||
error === 'File not found'
|
||||
|
||||
@@ -213,6 +213,14 @@
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
|
||||
</button>
|
||||
} @else if (isPausedDownload()) {
|
||||
<button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="resumePausedDownload()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'DOWNLOADS.RESUME' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading()) {
|
||||
<button
|
||||
class="download-btn download-btn--downloading"
|
||||
|
||||
@@ -219,6 +219,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
|
||||
});
|
||||
|
||||
readonly isPausedDownload = computed(() => {
|
||||
const vodId = this.selectedVodId();
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) return false;
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isPaused(vodId, playlistId, 'vod');
|
||||
});
|
||||
|
||||
readonly trailerEmbedUrl = computed(() =>
|
||||
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
|
||||
);
|
||||
@@ -431,6 +439,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
this.playback.handleExternalFallbackRequest(request);
|
||||
}
|
||||
|
||||
async resumePausedDownload(): Promise<void> {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
this.selectedVodId(),
|
||||
playlistId,
|
||||
'vod'
|
||||
);
|
||||
}
|
||||
|
||||
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
|
||||
if (!vodItem) {
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,8 @@ type TestDownloadsService = {
|
||||
hasLoadedDownloads: Signal<boolean>;
|
||||
loadDownloads: DownloadsService['loadDownloads'];
|
||||
loadDownloadFolder: DownloadsService['loadDownloadFolder'];
|
||||
pauseDownload: DownloadsService['pauseDownload'];
|
||||
resumeDownload: DownloadsService['resumeDownload'];
|
||||
selectFolder: DownloadsService['selectFolder'];
|
||||
_isLoadingDownloads: WritableSignal<boolean>;
|
||||
_hasLoadedDownloads: WritableSignal<boolean>;
|
||||
@@ -29,6 +31,11 @@ type TestDownloadsService = {
|
||||
|
||||
type DownloadsElectronStub = {
|
||||
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
|
||||
downloadsPause?: jest.Mock<Promise<{ success: boolean }>, [number]>;
|
||||
downloadsResume?: jest.Mock<
|
||||
Promise<{ success: boolean }>,
|
||||
[number, string]
|
||||
>;
|
||||
downloadsSelectFolder?: jest.Mock<Promise<string | null>, []>;
|
||||
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
|
||||
};
|
||||
@@ -163,6 +170,45 @@ describe('DownloadsService', () => {
|
||||
expect(electron.downloadsSelectFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards pause requests to the main process', async () => {
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsPause: jest.fn(async (downloadId: number) => ({
|
||||
success: downloadId === 42,
|
||||
})),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(service.pauseDownload(42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(electron.downloadsPause).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('resolves the authorized folder before resuming a paused download', async () => {
|
||||
const electron = {
|
||||
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsResume: jest.fn(
|
||||
async (downloadId: number, downloadFolder: string) => ({
|
||||
success:
|
||||
downloadId === 42 && downloadFolder === '/downloads',
|
||||
})
|
||||
),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(service.resumeDownload(42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(electron.downloadsResume).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks downloads as loaded after a failed request while preserving existing data', async () => {
|
||||
const existing = createDownload(1);
|
||||
const error = new Error('download query failed');
|
||||
@@ -225,4 +271,45 @@ describe('DownloadsService', () => {
|
||||
'playlist-new'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports paused content and resumes it by content identity', async () => {
|
||||
const pausedItem = {
|
||||
...createDownload(7),
|
||||
status: 'paused' as DownloadItem['status'],
|
||||
};
|
||||
const service = createService([
|
||||
pausedItem,
|
||||
createDownload(8),
|
||||
]) as unknown as DownloadsService;
|
||||
const resumeDownload = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true });
|
||||
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
|
||||
resumeDownload;
|
||||
|
||||
expect(service.isPaused(7, 'playlist-1', 'vod')).toBe(true);
|
||||
expect(service.isPaused(8, 'playlist-1', 'vod')).toBe(false);
|
||||
|
||||
await expect(
|
||||
service.resumeDownloadByContent(7, 'playlist-1', 'vod')
|
||||
).resolves.toEqual({ success: true });
|
||||
expect(resumeDownload).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('rejects resume-by-content when the download is not paused', async () => {
|
||||
const service = createService([
|
||||
createDownload(8),
|
||||
]) as unknown as DownloadsService;
|
||||
const resumeDownload = jest.fn();
|
||||
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
|
||||
resumeDownload;
|
||||
|
||||
await expect(
|
||||
service.resumeDownloadByContent(8, 'playlist-1', 'vod')
|
||||
).resolves.toEqual({
|
||||
error: 'No paused download found',
|
||||
success: false,
|
||||
});
|
||||
expect(resumeDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
export type DownloadStatus =
|
||||
| 'queued'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
@@ -221,6 +222,53 @@ export class DownloadsService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a queued or active download
|
||||
*/
|
||||
async pauseDownload(
|
||||
downloadId: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false, error: 'Downloads not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electron.downloadsPause(downloadId);
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Error pausing download:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused download
|
||||
*/
|
||||
async resumeDownload(
|
||||
downloadId: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false, error: 'Downloads not available' };
|
||||
}
|
||||
|
||||
const folder = await this.loadDownloadFolder();
|
||||
if (!folder) {
|
||||
return { success: false, error: 'No download folder configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electron.downloadsResume(downloadId, folder);
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Error resuming download:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed download
|
||||
*/
|
||||
@@ -401,7 +449,7 @@ export class DownloadsService implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is currently downloading or queued
|
||||
* Check if content is currently downloading, queued, or paused
|
||||
*/
|
||||
isDownloading(
|
||||
xtreamId: number,
|
||||
@@ -409,7 +457,38 @@ export class DownloadsService implements OnDestroy {
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
return download?.status === 'downloading' || download?.status === 'queued';
|
||||
return (
|
||||
download?.status === 'downloading' ||
|
||||
download?.status === 'queued' ||
|
||||
download?.status === 'paused'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content has a paused download
|
||||
*/
|
||||
isPaused(
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
return download?.status === 'paused';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the paused download that belongs to a content item
|
||||
*/
|
||||
async resumeDownloadByContent(
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
if (!download || download.status !== 'paused') {
|
||||
return { success: false, error: 'No paused download found' };
|
||||
}
|
||||
return this.resumeDownload(download.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,6 +97,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
dbRestoreXtreamUserData: jest.fn(),
|
||||
downloadsStart: jest.fn(),
|
||||
downloadsCancel: jest.fn(),
|
||||
downloadsPause: jest.fn(),
|
||||
downloadsResume: jest.fn(),
|
||||
downloadsRetry: jest.fn(),
|
||||
downloadsRemove: jest.fn(),
|
||||
downloadsGetList: jest.fn(),
|
||||
@@ -366,6 +368,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
testWindow.electron = {
|
||||
downloadsStart: jest.fn(),
|
||||
downloadsCancel: jest.fn(),
|
||||
downloadsPause: jest.fn(),
|
||||
downloadsResume: jest.fn(),
|
||||
downloadsRetry: jest.fn(),
|
||||
downloadsRemove: jest.fn(),
|
||||
downloadsGetList: jest.fn(),
|
||||
|
||||
@@ -183,6 +183,8 @@ export class RuntimeCapabilitiesService {
|
||||
return [
|
||||
'downloadsStart',
|
||||
'downloadsCancel',
|
||||
'downloadsPause',
|
||||
'downloadsResume',
|
||||
'downloadsRetry',
|
||||
'downloadsRemove',
|
||||
'downloadsGetList',
|
||||
|
||||
@@ -25,6 +25,39 @@ describe('database schema statements', () => {
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
} = __databaseConnectionTestHooks;
|
||||
const ensureDownloadsPauseResumeSchema = (
|
||||
__databaseConnectionTestHooks as unknown as {
|
||||
ensureDownloadsPauseResumeSchema: (sqlite: {
|
||||
prepare: (statement: string) => {
|
||||
get?: () => unknown;
|
||||
run?: (...args: unknown[]) => unknown;
|
||||
};
|
||||
transaction: (callback: () => void) => () => void;
|
||||
}) => void;
|
||||
}
|
||||
).ensureDownloadsPauseResumeSchema;
|
||||
|
||||
function createRebuildSqlite(legacyTableSql: string | undefined) {
|
||||
const statements: string[] = [];
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
const prepare = jest.fn((statement: string) => {
|
||||
if (statement.includes('FROM sqlite_master')) {
|
||||
return {
|
||||
get: () =>
|
||||
legacyTableSql === undefined
|
||||
? undefined
|
||||
: { sql: legacyTableSql },
|
||||
};
|
||||
}
|
||||
return {
|
||||
run: () => {
|
||||
statements.push(compactSql(statement));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { prepare, statements, transaction };
|
||||
}
|
||||
|
||||
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
||||
const schemaSql = createTableStatements.map(compactSql).join('\n');
|
||||
@@ -45,6 +78,9 @@ describe('database schema statements', () => {
|
||||
'CREATE TABLE IF NOT EXISTS playback_positions'
|
||||
);
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
|
||||
expect(schemaSql).toContain('request_headers TEXT');
|
||||
expect(schemaSql).toContain('resume_validator TEXT');
|
||||
expect(schemaSql).toContain("'paused'");
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
|
||||
);
|
||||
@@ -383,6 +419,80 @@ describe('database schema statements', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rebuilds a legacy downloads table without paused status or stored headers', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
|
||||
const { statements } = sqlite;
|
||||
const renameIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('ALTER TABLE downloads RENAME TO')
|
||||
);
|
||||
const createIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
|
||||
);
|
||||
const copyIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('INSERT INTO downloads')
|
||||
);
|
||||
const dropLegacyIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('DROP TABLE downloads_pause_resume_legacy')
|
||||
);
|
||||
|
||||
expect(renameIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(createIndex).toBeGreaterThan(renameIndex);
|
||||
expect(copyIndex).toBeGreaterThan(createIndex);
|
||||
expect(dropLegacyIndex).toBeGreaterThan(copyIndex);
|
||||
// Indexes on the old table are dropped before the rename and
|
||||
// recreated after the copy.
|
||||
expect(
|
||||
statements.filter((statement) =>
|
||||
statement.startsWith('DROP INDEX IF EXISTS')
|
||||
).length
|
||||
).toBe(3);
|
||||
expect(
|
||||
statements
|
||||
.slice(dropLegacyIndex + 1)
|
||||
.filter((statement) => statement.includes('CREATE')).length
|
||||
).toBe(3);
|
||||
// The rebuilt table carries the new contract; legacy rows get NULL
|
||||
// stored headers, and the copy never references resume_validator so
|
||||
// it defaults to NULL.
|
||||
expect(statements[createIndex]).toContain(`'paused'`);
|
||||
expect(statements[createIndex]).toContain('request_headers TEXT');
|
||||
expect(statements[createIndex]).toContain('resume_validator TEXT');
|
||||
expect(statements[copyIndex]).toContain('NULL AS request_headers');
|
||||
expect(statements[copyIndex]).not.toContain('resume_validator');
|
||||
});
|
||||
|
||||
it('copies stored headers when only the paused status is missing', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
const copy = sqlite.statements.find((statement) =>
|
||||
statement.startsWith('INSERT INTO downloads')
|
||||
);
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy).not.toContain('NULL AS request_headers');
|
||||
expect(copy).toContain('request_headers');
|
||||
});
|
||||
|
||||
it('skips the downloads rebuild when the table already has the paused contract', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
expect(sqlite.transaction).not.toHaveBeenCalled();
|
||||
expect(sqlite.statements).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('backfills migrated EPG program source URLs in bounded batches', () => {
|
||||
const { backfillEpgProgramSourceUrls } =
|
||||
__databaseConnectionTestHooks;
|
||||
|
||||
@@ -85,6 +85,34 @@ const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
|
||||
fetched_at TEXT DEFAULT (datetime('now'))
|
||||
)`;
|
||||
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
|
||||
const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
|
||||
series_xtream_id INTEGER,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
file_path TEXT,
|
||||
poster_url TEXT,
|
||||
request_headers TEXT,
|
||||
resume_validator TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')),
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`;
|
||||
const DOWNLOADS_INDEX_STATEMENTS = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
|
||||
];
|
||||
|
||||
const CREATE_TABLE_STATEMENTS = [
|
||||
`CREATE TABLE IF NOT EXISTS playlists (
|
||||
@@ -293,30 +321,8 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC)`,
|
||||
// Downloads table
|
||||
`CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
|
||||
series_xtream_id INTEGER,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
file_path TEXT,
|
||||
poster_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
|
||||
DOWNLOADS_TABLE_SQL,
|
||||
...DOWNLOADS_INDEX_STATEMENTS,
|
||||
// TMDB metadata cache (details payloads + search match resolutions)
|
||||
TMDB_METADATA_TABLE_SQL,
|
||||
TMDB_METADATA_INDEX_SQL,
|
||||
@@ -354,6 +360,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
|
||||
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
|
||||
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
|
||||
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
|
||||
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
|
||||
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
|
||||
];
|
||||
|
||||
const INDEX_MIGRATION_STATEMENTS = [
|
||||
@@ -372,6 +380,7 @@ export const __databaseConnectionTestHooks = {
|
||||
createTableStatements: CREATE_TABLE_STATEMENTS,
|
||||
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
|
||||
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
|
||||
ensureDownloadsPauseResumeSchema,
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
backfillEpgProgramSourceUrls,
|
||||
@@ -820,12 +829,115 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing downloads tables had a status CHECK without 'paused'. SQLite cannot
|
||||
* alter CHECK constraints in place, so rebuild only when the table SQL still
|
||||
* reflects the old contract or lacks request_headers.
|
||||
*/
|
||||
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
try {
|
||||
const row = sqliteDb
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'`
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!row?.sql) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPausedStatus = row.sql.includes(`'paused'`);
|
||||
const hasRequestHeaders = row.sql.includes('request_headers');
|
||||
if (hasPausedStatus && hasRequestHeaders) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyHeadersSelect = hasRequestHeaders
|
||||
? 'request_headers'
|
||||
: 'NULL AS request_headers';
|
||||
const rebuild = sqliteDb.transaction(() => {
|
||||
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
||||
const match = statement.match(
|
||||
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+([^\s]+)\s+/i
|
||||
);
|
||||
if (match?.[1]) {
|
||||
sqliteDb.prepare(`DROP INDEX IF EXISTS ${match[1]}`).run();
|
||||
}
|
||||
}
|
||||
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`ALTER TABLE downloads RENAME TO downloads_pause_resume_legacy`
|
||||
)
|
||||
.run();
|
||||
sqliteDb.prepare(DOWNLOADS_TABLE_SQL).run();
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`INSERT INTO downloads (
|
||||
id,
|
||||
playlist_id,
|
||||
xtream_id,
|
||||
content_type,
|
||||
series_xtream_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
title,
|
||||
url,
|
||||
file_name,
|
||||
file_path,
|
||||
poster_url,
|
||||
request_headers,
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
error_message,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
playlist_id,
|
||||
xtream_id,
|
||||
content_type,
|
||||
series_xtream_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
title,
|
||||
url,
|
||||
file_name,
|
||||
file_path,
|
||||
poster_url,
|
||||
${legacyHeadersSelect},
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
error_message,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM downloads_pause_resume_legacy`
|
||||
)
|
||||
.run();
|
||||
sqliteDb
|
||||
.prepare(`DROP TABLE downloads_pause_resume_legacy`)
|
||||
.run();
|
||||
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
||||
sqliteDb.prepare(statement).run();
|
||||
}
|
||||
});
|
||||
|
||||
rebuild();
|
||||
console.log('[DB] Rebuilt downloads table with pause/resume schema');
|
||||
} catch (error) {
|
||||
console.warn('[DB] downloads pause/resume migration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations that may fail if already applied
|
||||
*/
|
||||
function runMigrations(sqliteDb: Database.Database): void {
|
||||
widenTmdbMetadataMediaTypeCheck(sqliteDb);
|
||||
cleanupLegacyTmdbSearchCache(sqliteDb);
|
||||
ensureDownloadsPauseResumeSchema(sqliteDb);
|
||||
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
|
||||
ensureContentTitleFts(sqliteDb);
|
||||
deduplicateXtreamCache(sqliteDb);
|
||||
|
||||
@@ -332,9 +332,18 @@ export const downloads = sqliteTable(
|
||||
fileName: text('file_name'),
|
||||
filePath: text('file_path'),
|
||||
posterUrl: text('poster_url'),
|
||||
requestHeaders: text('request_headers'),
|
||||
resumeValidator: text('resume_validator'),
|
||||
// Download progress
|
||||
status: text('status', {
|
||||
enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'],
|
||||
enum: [
|
||||
'queued',
|
||||
'downloading',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
],
|
||||
})
|
||||
.notNull()
|
||||
.default('queued'),
|
||||
|
||||
@@ -119,6 +119,7 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
|
||||
Completed: 'completed',
|
||||
Downloading: 'downloading',
|
||||
Failed: 'failed',
|
||||
Paused: 'paused',
|
||||
Queued: 'queued',
|
||||
} as const;
|
||||
|
||||
@@ -953,6 +954,11 @@ export interface ElectronBridgeApi {
|
||||
data: ElectronBridgeDownloadStartPayload
|
||||
) => Promise<ElectronBridgeDownloadStartResult>;
|
||||
downloadsCancel: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsPause: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsResume: (
|
||||
downloadId: number,
|
||||
downloadFolder: string
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsRetry: (
|
||||
downloadId: number,
|
||||
downloadFolder: string
|
||||
|
||||
@@ -173,6 +173,25 @@
|
||||
>
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</button>
|
||||
} @else if (
|
||||
isEpisodePaused(episode)
|
||||
) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="
|
||||
resumeEpisodeDownload(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
} @else if (
|
||||
isEpisodeDownloading(episode)
|
||||
) {
|
||||
@@ -413,6 +432,22 @@
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
} @else if (isEpisodePaused(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn"
|
||||
(click)="
|
||||
resumeEpisodeDownload(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
} @else if (isEpisodeDownloading(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
@@ -394,6 +394,14 @@ app-season-tabs {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.download-btn--paused {
|
||||
color: #fbbf24 !important;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, #fbbf24 20%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -414,6 +414,35 @@ export class SeasonContainerComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
|
||||
if (!this.playlistId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isPaused(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
async resumeEpisodeDownload(
|
||||
event: Event,
|
||||
episode: XtreamSerieEpisode
|
||||
): Promise<void> {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
|
||||
@@ -194,6 +194,14 @@
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
|
||||
</button>
|
||||
} @else if (isPausedDownload()) {
|
||||
<button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="resumePausedDownload()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'DOWNLOADS.RESUME' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading()) {
|
||||
<button class="download-btn download-btn--downloading" disabled>
|
||||
<mat-icon>downloading</mat-icon>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
import { createVodDownloadState } from './vod-download-state.util';
|
||||
|
||||
/**
|
||||
* Unified VOD details component for both Xtream and Stalker portals.
|
||||
@@ -189,23 +190,13 @@ export class VodDetailsComponent {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
/** Whether VOD is already downloaded */
|
||||
readonly isDownloaded = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
/** Whether VOD is currently downloading */
|
||||
readonly isDownloading = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
private readonly downloadState = createVodDownloadState(
|
||||
this.downloadsService,
|
||||
this.item
|
||||
);
|
||||
readonly isDownloaded = this.downloadState.isDownloaded;
|
||||
readonly isDownloading = this.downloadState.isDownloading;
|
||||
readonly isPausedDownload = this.downloadState.isPausedDownload;
|
||||
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback();
|
||||
@@ -343,6 +334,16 @@ export class VodDetailsComponent {
|
||||
this.downloadRequested.emit(this.item());
|
||||
}
|
||||
|
||||
/** Resume the paused download of this VOD */
|
||||
async resumePausedDownload(): Promise<void> {
|
||||
const item = this.item();
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
getVodNumericId(item),
|
||||
item.playlistId,
|
||||
'vod'
|
||||
);
|
||||
}
|
||||
|
||||
onInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { computed, Signal } from '@angular/core';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { getVodNumericId, VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Download-state signals for a VOD detail view. Reading
|
||||
* `downloadsService.downloads()` inside each computed creates the reactive
|
||||
* dependency on the download list.
|
||||
*/
|
||||
export function createVodDownloadState(
|
||||
downloadsService: DownloadsService,
|
||||
item: Signal<VodDetailsItem>
|
||||
) {
|
||||
const query = (
|
||||
check: (vodId: number, playlistId: string) => boolean
|
||||
): Signal<boolean> =>
|
||||
computed(() => {
|
||||
const currentItem = item();
|
||||
downloadsService.downloads();
|
||||
return check(getVodNumericId(currentItem), currentItem.playlistId);
|
||||
});
|
||||
|
||||
return {
|
||||
isDownloaded: query((vodId, playlistId) =>
|
||||
downloadsService.isDownloaded(vodId, playlistId, 'vod')
|
||||
),
|
||||
isDownloading: query((vodId, playlistId) =>
|
||||
downloadsService.isDownloading(vodId, playlistId, 'vod')
|
||||
),
|
||||
isPausedDownload: query((vodId, playlistId) =>
|
||||
downloadsService.isPaused(vodId, playlistId, 'vod')
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,16 @@
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
&--paused {
|
||||
background: rgba(251, 191, 36, 0.14);
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
color: #fbbf24;
|
||||
|
||||
&:hover {
|
||||
background: rgba(251, 191, 36, 0.22);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin mobile-action-buttons {
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-dl": "4.0.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"epg-parser": "^0.1.6",
|
||||
"fix-path": "5.0.0",
|
||||
|
||||
Generated
-75
@@ -110,9 +110,6 @@ importers:
|
||||
electron-conf:
|
||||
specifier: 1.3.0
|
||||
version: 1.3.0(electron@41.7.2)
|
||||
electron-dl:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
electron-updater:
|
||||
specifier: 6.8.9
|
||||
version: 6.8.9
|
||||
@@ -6397,10 +6394,6 @@ packages:
|
||||
peerDependencies:
|
||||
electron: '>=15.0.0'
|
||||
|
||||
electron-dl@4.0.0:
|
||||
resolution: {integrity: sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
electron-playwright-helpers@1.8.2:
|
||||
resolution: {integrity: sha512-sM9fDSEFDOptKDa8oiNphiXdR1KR1uFe8yHnlrnvyQE1ScnHizca/SoYvUdtn+5IuVNTkT8MesKWJ6seS4/zuA==}
|
||||
|
||||
@@ -6580,10 +6573,6 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-goat@4.0.0:
|
||||
resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
@@ -6807,14 +6796,6 @@ packages:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
ext-list@2.2.2:
|
||||
resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ext-name@5.0.0:
|
||||
resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
@@ -7738,10 +7719,6 @@ packages:
|
||||
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-plain-obj@1.1.0:
|
||||
resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-plain-obj@3.0.0:
|
||||
resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -9910,10 +9887,6 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pupa@3.3.0:
|
||||
resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
pure-rand@7.0.1:
|
||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||
|
||||
@@ -10653,14 +10626,6 @@ packages:
|
||||
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
sort-keys-length@1.0.1:
|
||||
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sort-keys@1.1.2:
|
||||
resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sorted-array-functions@1.3.0:
|
||||
resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==}
|
||||
|
||||
@@ -11468,10 +11433,6 @@ packages:
|
||||
uploadthing:
|
||||
optional: true
|
||||
|
||||
unused-filename@4.0.1:
|
||||
resolution: {integrity: sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
upath@2.0.1:
|
||||
resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -18931,12 +18892,6 @@ snapshots:
|
||||
ajv: 8.17.1
|
||||
electron: 41.7.2
|
||||
|
||||
electron-dl@4.0.0:
|
||||
dependencies:
|
||||
ext-name: 5.0.0
|
||||
pupa: 3.3.0
|
||||
unused-filename: 4.0.1
|
||||
|
||||
electron-playwright-helpers@1.8.2:
|
||||
dependencies:
|
||||
'@electron/asar': 3.4.1
|
||||
@@ -19289,8 +19244,6 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-goat@4.0.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@1.0.5: {}
|
||||
@@ -19628,15 +19581,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ext-list@2.2.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
ext-name@5.0.0:
|
||||
dependencies:
|
||||
ext-list: 2.2.2
|
||||
sort-keys-length: 1.0.1
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
extsprintf@1.4.1:
|
||||
@@ -20713,8 +20657,6 @@ snapshots:
|
||||
|
||||
is-obj@2.0.0: {}
|
||||
|
||||
is-plain-obj@1.1.0: {}
|
||||
|
||||
is-plain-obj@3.0.0: {}
|
||||
|
||||
is-plain-obj@4.1.0: {}
|
||||
@@ -23450,10 +23392,6 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pupa@3.3.0:
|
||||
dependencies:
|
||||
escape-goat: 4.0.0
|
||||
|
||||
pure-rand@7.0.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
@@ -24424,14 +24362,6 @@ snapshots:
|
||||
ip-address: 10.2.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sort-keys-length@1.0.1:
|
||||
dependencies:
|
||||
sort-keys: 1.1.2
|
||||
|
||||
sort-keys@1.1.2:
|
||||
dependencies:
|
||||
is-plain-obj: 1.1.0
|
||||
|
||||
sorted-array-functions@1.3.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -25288,11 +25218,6 @@ snapshots:
|
||||
ofetch: 1.5.1
|
||||
ufo: 1.6.3
|
||||
|
||||
unused-filename@4.0.1:
|
||||
dependencies:
|
||||
escape-string-regexp: 5.0.0
|
||||
path-exists: 5.0.0
|
||||
|
||||
upath@2.0.1: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
|
||||
Reference in new issue
Block a user