diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 6810cc3c1..fe92a4208 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -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 { await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); } +interface RangeServerRequest { + ifRange?: string; + range?: string; +} + +interface ThrottledRangeServer { + close: () => Promise; + 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 { + 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((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((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>; + }, 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(); + } + }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index f232d88d4..771a48359 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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) => diff --git a/apps/electron-backend/src/app/events/database/download-broadcast.ts b/apps/electron-backend/src/app/events/database/download-broadcast.ts new file mode 100644 index 000000000..fbd3f1666 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-broadcast.ts @@ -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'); + } +} diff --git a/apps/electron-backend/src/app/events/database/download-file-path.spec.ts b/apps/electron-backend/src/app/events/database/download-file-path.spec.ts index 56ef927b2..df7c58ebc 100644 --- a/apps/electron-backend/src/app/events/database/download-file-path.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-file-path.spec.ts @@ -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 }); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-file-path.ts b/apps/electron-backend/src/app/events/database/download-file-path.ts index 6bab7324f..0e106f0d5 100644 --- a/apps/electron-backend/src/app/events/database/download-file-path.ts +++ b/apps/electron-backend/src/app/events/database/download-file-path.ts @@ -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; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-finalize.spec.ts b/apps/electron-backend/src/app/events/database/download-finalize.spec.ts new file mode 100644 index 000000000..c0b261a2d --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-finalize.spec.ts @@ -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(); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-finalize.ts b/apps/electron-backend/src/app/events/database/download-finalize.ts new file mode 100644 index 000000000..955fe1ac4 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-finalize.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-recovery.spec.ts b/apps/electron-backend/src/app/events/database/download-recovery.spec.ts index 4456b0585..776717c79 100644 --- a/apps/electron-backend/src/app/events/database/download-recovery.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-recovery.spec.ts @@ -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 | 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(); + } }); }); diff --git a/apps/electron-backend/src/app/events/database/download-recovery.ts b/apps/electron-backend/src/app/events/database/download-recovery.ts index 09f3fc8f3..227529070 100644 --- a/apps/electron-backend/src/app/events/database/download-recovery.ts +++ b/apps/electron-backend/src/app/events/database/download-recovery.ts @@ -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 { 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); diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts new file mode 100644 index 000000000..68835167e --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -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] + >(() => ({ + 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] + >(() => ({ + 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(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index deb643ac7..515402aa9 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -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 | undefined { - return headers - ? { - 'User-Agent': headers.userAgent || '', - Origin: headers.origin || '', - Referer: headers.referer || '', - } - : undefined; + if (!headers) { + return undefined; + } + + const result: Record = {}; + 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 | 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 | 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; + const headers = STORED_HEADER_ALLOWLIST.reduce>( + (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 + ); +} diff --git a/apps/electron-backend/src/app/events/database/download-reserve.spec.ts b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts new file mode 100644 index 000000000..46941058f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts @@ -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); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-resume.spec.ts b/apps/electron-backend/src/app/events/database/download-resume.spec.ts new file mode 100644 index 000000000..5688fa4cc --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-resume.spec.ts @@ -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; + status: number; + }; + /** 'enoent' makes stat() report a missing target file. */ + finalSize: number | 'enoent'; +} + +function createTask(overrides: Partial = {}): 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 { + for (let attempt = 0; attempt < 20; attempt++) { + if (set.mock.calls.some(([value]) => value?.status === status)) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(set).toHaveBeenCalledWith(expect.objectContaining({ status })); +} + +async function setupResumeHarness( + options: ResumeHarnessOptions +): Promise { + 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', + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts b/apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts new file mode 100644 index 000000000..35679e7c9 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts @@ -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, + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts index 64b7bfd2b..9856b8de9 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts @@ -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 { - for (let attempt = 0; attempt < 20; attempt++) { - if (mock.mock.calls.length === expectedCallCount) { - return; - } - await new Promise((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 | undefined; - const download = jest - .fn() - .mockImplementationOnce( - async ( - _window: unknown, - _url: string, - options: { - onCancel: (item: DownloadItem) => Promise; - } - ) => { - 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; - } - ) => { - 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 | undefined; - const download = jest - .fn() - .mockImplementationOnce( - async ( - _window: unknown, - _url: string, - options: { - onCompleted: (file: { - fileSize: number; - filename: string; - path: string; - }) => Promise; - } - ) => { - 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; - } - ) => { - 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; - } - ) => { - 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(); diff --git a/apps/electron-backend/src/app/events/database/download-runtime.ts b/apps/electron-backend/src/app/events/database/download-runtime.ts index c5837524c..4e670a310 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.ts @@ -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 { +export async function pauseDownload(downloadId: number): Promise { if (activeDownload?.id === downloadId) { - requestDownloadCancellation(activeDownload); + requestDownloadPause(activeDownload); return true; } @@ -52,8 +67,7 @@ export async function cancelDownload(downloadId: number): Promise { .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 { return true; } +export async function cancelDownload(downloadId: number): Promise { + 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 { } 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>, - reservation: ReturnType -): (item: Parameters[0]) => Promise { - let cancellationPromise: Promise | 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 { + 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 { @@ -145,40 +191,26 @@ async function startDownload(task: DownloadTask): Promise { .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 - | 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 { }) .where(eq(schema.downloads.id, task.id)); - const downloadOptions: Parameters[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; - } - ).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[0] -): void { +async function reserveTarget( + task: DownloadTask +): Promise { + 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 { + 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 { + 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); } } diff --git a/apps/electron-backend/src/app/events/database/download-task.ts b/apps/electron-backend/src/app/events/database/download-task.ts index 93c0f890e..ec6bdea98 100644 --- a/apps/electron-backend/src/app/events/database/download-task.ts +++ b/apps/electron-backend/src/app/events/database/download-task.ts @@ -1,4 +1,15 @@ -import type { DownloadItem } from 'electron'; +import type { getDatabase } from '../../database/connection'; + +export type DownloadsDatabase = Awaited>; + +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; 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(); } diff --git a/apps/electron-backend/src/app/events/database/download-transfer.ts b/apps/electron-backend/src/app/events/database/download-transfer.ts new file mode 100644 index 000000000..cb8df4a81 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-transfer.ts @@ -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 { + 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( + 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, + '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; + 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 { + 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 { + 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; + 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, + 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); +} diff --git a/apps/electron-backend/src/app/events/database/download.test-helpers.ts b/apps/electron-backend/src/app/events/database/download.test-helpers.ts new file mode 100644 index 000000000..7fe5e4442 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download.test-helpers.ts @@ -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 { + for (let attempt = 0; attempt < 20; attempt++) { + if (mock.mock.calls.length === expectedCallCount) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(mock).toHaveBeenCalledTimes(expectedCallCount); +} + +export async function waitForStatus( + set: jest.Mock, + status: string +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (set.mock.calls.some(([value]) => value?.status === status)) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(set).toHaveBeenCalledWith(expect.objectContaining({ status })); +} + +export async function waitForStatusCount( + set: jest.Mock, + status: string, + expectedCount: number +): Promise { + 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((resolve) => setImmediate(resolve)); + } + + expect( + set.mock.calls.filter(([value]) => value?.status === status) + ).toHaveLength(expectedCount); +} diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts new file mode 100644 index 000000000..ccced7e98 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -0,0 +1,331 @@ +type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; + +const mockRegisteredHandlers = new Map(); +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() + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index 2f41b83f0..57c8e7ae0 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -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); diff --git a/apps/electron-backend/src/app/events/database/stale-download-files.ts b/apps/electron-backend/src/app/events/database/stale-download-files.ts deleted file mode 100644 index 71d1f159e..000000000 --- a/apps/electron-backend/src/app/events/database/stale-download-files.ts +++ /dev/null @@ -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 - ); - } - } -} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ed10b1fad..771be4a7c 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e0d58c5b7..3f867dc10 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 485e0f796..2951c8474 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index ee8c4cbb6..860ad97ce 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 92828089d..b7100e1ec 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c88c47858..05d7dc92f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 7cdbc4bde..133151e42 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 569cb0a9e..28fe4e577 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c16dcf61e..6c6825239 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1fe40c681..adf2fa981 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d64b729cd..05a9a4511 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index dd5af527d..3b67ccf84 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index bffa8dc82..7d1f64bc4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 360be5be3..6afa19dbb 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 02d4026a7..ee2abbae2 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f699a9cf5..3d044c8d3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4a6cf0e4a..4e928c56f 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 7bf0abebe..c0914a0f0 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": { diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 0608f1fb1..3217951e6 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -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=-` 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 diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index e33245a04..461e43fbf 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -228,6 +228,7 @@ @if ( item.status === 'downloading' || + item.status === 'paused' || item.status === 'completed' ) { @@ -253,6 +254,17 @@ @if (item.status === 'downloading') { + + } @else if ( + item.status === 'paused' && item.totalBytes + ) { @switch (item.status) { @case ('queued') { + + + + } @case ('completed') { + } @else if (isPausedDownload()) { + } @else if (isDownloading()) { + } @else if ( + isEpisodePaused(episode) + ) { + } @else if ( isEpisodeDownloading(episode) ) { @@ -413,6 +432,22 @@ > folder_open + } @else if (isEpisodePaused(episode)) { + } @else if (isEpisodeDownloading(episode)) { + } @else if (isPausedDownload()) { + } @else if (isDownloading()) {