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:
genrichh93-ui authored and GitHub committed 2026-07-24 18:40:30 +02:00
1 parent 0273ded8e2
commit 188f5c4b56
63 files changed
+4986 -683

No files matched your search

+187 -1
View File
@@ -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
);
}
}
}
+5 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+5 -1
View File
@@ -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 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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 -1
View File
@@ -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": {
+5 -1
View File
@@ -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 -1
View File
@@ -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": {
+30 -15
View File
@@ -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();
});
});
+81 -2
View File
@@ -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;
+136 -24
View File
@@ -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);
+10 -1
View File
@@ -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')
),
};
}
+10
View File
@@ -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 {
-1
View File
@@ -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",
-75
View File
@@ -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):