From 8a685ebf206eb1557ef361bf0d36acf89bb08ce9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 22:04:34 +0200 Subject: [PATCH] feat(downloads): recover missing completed files --- .../src/app/api/main.preload.ts | 2 + .../database/download-redownload.spec.ts | 203 ++++++++++++++++++ .../events/database/download-redownload.ts | 112 ++++++++++ .../app/events/database/download-requests.ts | 2 +- .../events/database/downloads-actions.spec.ts | 16 ++ .../app/events/database/downloads.events.ts | 6 + .../events/database/downloads.test-helpers.ts | 5 + .../lib/runtime-capabilities.service.spec.ts | 8 + .../src/lib/runtime-capabilities.service.ts | 1 + .../src/lib/electron-api.interface.ts | 7 + 10 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend/src/app/events/database/download-redownload.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-redownload.ts diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 28927ef91..14b8976c1 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -978,6 +978,8 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder), downloadsRetry: (downloadId: number, downloadFolder: string) => ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder), + downloadsRedownloadMissing: (downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_REDOWNLOAD_MISSING', downloadId), downloadsRemove: (downloadId: number) => ipcRenderer.invoke('DOWNLOADS_REMOVE', downloadId), downloadsGetList: (playlistId?: string) => diff --git a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts new file mode 100644 index 000000000..e2b62ea75 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts @@ -0,0 +1,203 @@ +interface SetupOptions { + accessError?: Error; + cleanupError?: Error; + claim?: { changes: number }; + fileReappeared?: boolean; + row?: Record | null; + urlError?: Error; +} + +async function setup(options: SetupOptions = {}) { + jest.resetModules(); + + const row = + options.row === null + ? null + : { + filePath: '/downloads/movie.mp4', + id: 42, + requestHeaders: JSON.stringify({ + Authorization: 'not-allowed', + Referer: 'https://example.test/', + 'User-Agent': 'IPTVnator', + }), + status: 'completed', + url: 'https://example.test/movie.mp4', + ...options.row, + }; + const limit = jest.fn().mockResolvedValue(row ? [row] : []); + const where = jest + .fn() + .mockResolvedValue(options.claim ?? { changes: 1 }); + const set = jest.fn(() => ({ where })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ set })), + }; + const accessSync = jest.fn(() => { + if (options.accessError) { + throw options.accessError; + } + }); + const lstatSync = jest.fn(() => { + if (!options.fileReappeared) { + throw new Error('ENOENT'); + } + return { + isFile: () => true, + isSymbolicLink: () => false, + }; + }); + const assertRemoteUrlAllowed = jest.fn(async () => { + if (options.urlError) { + throw options.urlError; + } + }); + const removePartialDownloadFile = jest.fn(() => { + if (options.cleanupError) { + throw options.cleanupError; + } + }); + const enqueueDownload = jest.fn(); + + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + accessSync, + lstatSync, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ assertRemoteUrlAllowed })); + jest.doMock('./download-file-path', () => ({ removePartialDownloadFile })); + jest.doMock('./download-runtime', () => ({ enqueueDownload })); + + const { redownloadMissingRequest } = await import('./download-redownload'); + return { + accessSync, + assertRemoteUrlAllowed, + enqueueDownload, + lstatSync, + redownloadMissingRequest, + removePartialDownloadFile, + set, + where, + }; +} + +describe('redownload missing completed file', () => { + it('requeues the managed row at its retained destination', async () => { + const harness = await setup(); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + success: true, + }); + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + errorMessage: null, + resumeValidator: null, + status: 'queued', + totalBytes: null, + }) + ); + expect(harness.enqueueDownload).toHaveBeenCalledWith({ + directory: '/downloads', + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + headers: { + Referer: 'https://example.test/', + 'User-Agent': 'IPTVnator', + }, + id: 42, + resumeValidator: null, + totalBytes: null, + url: 'https://example.test/movie.mp4', + }); + }); + + it('recovers a file that reappeared without updating or enqueueing', async () => { + const harness = await setup({ fileReappeared: true }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + recovered: true, + success: true, + }); + expect(harness.set).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('rejects a row that is no longer completed', async () => { + const harness = await setup({ row: { status: 'queued' } }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Can only re-download missing completed files', + success: false, + }); + expect(harness.lstatSync).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('rejects an unknown row', async () => { + const harness = await setup({ row: null }); + + await expect(harness.redownloadMissingRequest(404)).resolves.toEqual({ + error: 'Download not found', + success: false, + }); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing retained path', { row: { filePath: null } }], + [ + 'unavailable retained directory', + { accessError: new Error('EACCES') }, + ], + ])('rejects an %s', async (_label, options) => { + const harness = await setup(options); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Download folder is unavailable', + success: false, + }); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('does not mutate or enqueue when the remote URL is unsafe', async () => { + const unsafe = new Error('Blocked private destination'); + const harness = await setup({ urlError: unsafe }); + + await expect(harness.redownloadMissingRequest(42)).rejects.toBe(unsafe); + expect(harness.set).not.toHaveBeenCalled(); + expect(harness.removePartialDownloadFile).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('keeps the completed row when partial cleanup fails', async () => { + const harness = await setup({ + cleanupError: new Error('EPERM: locked'), + }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Could not delete the previous partial file', + success: false, + }); + expect(harness.set).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('does not enqueue after losing the conditional completed-row claim', async () => { + const harness = await setup({ claim: { changes: 0 } }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Download is no longer recoverable', + success: false, + }); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-redownload.ts b/apps/electron-backend/src/app/events/database/download-redownload.ts new file mode 100644 index 000000000..b34f49f2c --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-redownload.ts @@ -0,0 +1,112 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { accessSync, constants } from 'node:fs'; +import { basename, dirname } from 'node:path'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { assertRemoteUrlAllowed } from '../url-safety'; +import { isAvailableDownloadFile } from './download-file-availability'; +import { removePartialDownloadFile } from './download-file-path'; +import { parseStoredHeaders } from './download-requests'; +import { enqueueDownload } from './download-runtime'; + +export interface RedownloadMissingResult { + error?: string; + recovered?: boolean; + success: boolean; +} + +function isWritableDirectory(directory: string): boolean { + try { + accessSync(directory, constants.W_OK); + return true; + } catch { + return false; + } +} + +function hasNoChanges(result: unknown): boolean { + return ( + typeof result === 'object' && + result !== null && + 'changes' in result && + (result as { changes: number }).changes === 0 + ); +} + +export async function redownloadMissingRequest( + downloadId: number +): Promise { + const db = await getDatabase(); + const rows = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + const item = rows[0]; + + if (!item) { + return { error: 'Download not found', success: false }; + } + if (item.status !== 'completed') { + return { + error: 'Can only re-download missing completed files', + success: false, + }; + } + if (isAvailableDownloadFile(item.filePath)) { + return { recovered: true, success: true }; + } + if (!item.filePath || !isWritableDirectory(dirname(item.filePath))) { + return { error: 'Download folder is unavailable', success: false }; + } + + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + + try { + removePartialDownloadFile(item.filePath); + } catch (error) { + console.error( + '[Downloads] Failed to delete partial before missing-file recovery:', + error + ); + return { + error: 'Could not delete the previous partial file', + success: false, + }; + } + + const claim = await db + .update(schema.downloads) + .set({ + bytesDownloaded: 0, + errorMessage: null, + resumeValidator: null, + status: 'queued', + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and( + eq(schema.downloads.id, item.id), + eq(schema.downloads.status, 'completed') + ) + ); + if (hasNoChanges(claim)) { + return { + error: 'Download is no longer recoverable', + success: false, + }; + } + + enqueueDownload({ + directory: dirname(item.filePath), + fileName: basename(item.filePath), + filePath: item.filePath, + headers: parseStoredHeaders(item.requestHeaders), + id: item.id, + resumeValidator: null, + totalBytes: null, + url: item.url, + }); + return { success: true }; +} 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 515402aa9..622bf686e 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -74,7 +74,7 @@ function serializeHeaders( const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const; -function parseStoredHeaders( +export function parseStoredHeaders( value: string | null ): Record | undefined { if (!value) { diff --git a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts index 5cef2a160..a65252d41 100644 --- a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts @@ -6,6 +6,7 @@ import { mockManagedPath, mockOpenPath, mockPauseDownload, + mockRedownloadMissingRequest, mockResumeDownloadRequest, mockShowItemInFolder, setupDownloadsEventsHarness, @@ -71,6 +72,21 @@ describe('downloads events: pause, resume, and reveal', () => { ); }); + it('forwards missing-file recovery by managed download id', async () => { + mockRedownloadMissingRequest.mockResolvedValue({ + recovered: true, + success: true, + }); + + await expect( + getHandler('DOWNLOADS_REDOWNLOAD_MISSING')(null, 42) + ).resolves.toEqual({ + recovered: true, + success: true, + }); + expect(mockRedownloadMissingRequest).toHaveBeenCalledWith(42); + }); + describe.each([ { channel: 'DOWNLOADS_REVEAL_FILE', 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 e28920693..71fc37ceb 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -16,6 +16,7 @@ import { startDownloadRequest, type StartDownloadRequest, } from './download-requests'; +import { redownloadMissingRequest } from './download-redownload'; import { resetStaleDownloads } from './download-recovery'; import { broadcastDownloadUpdate, @@ -162,6 +163,11 @@ ipcMain.handle( } ); +ipcMain.handle( + 'DOWNLOADS_REDOWNLOAD_MISSING', + async (_event, downloadId: number) => redownloadMissingRequest(downloadId) +); + ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { try { console.log('[Downloads] Remove download:', downloadId); diff --git a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts index 30af03b53..cd4067bc3 100644 --- a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts +++ b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts @@ -14,6 +14,7 @@ export const mockRemoveDownloadFromRuntime = jest.fn(); export const mockBroadcastDownloadUpdate = jest.fn(); export const mockRemovePartialDownloadFile = jest.fn(); export const mockPauseDownload = jest.fn(); +export const mockRedownloadMissingRequest = jest.fn(); export const mockResumeDownloadRequest = jest.fn(); export const mockLstatSync = jest.fn(); export const mockOpenPath = jest.fn(); @@ -54,6 +55,7 @@ export async function setupDownloadsEventsHarness(): Promise { mockBroadcastDownloadUpdate.mockReset(); mockRemovePartialDownloadFile.mockReset(); mockPauseDownload.mockReset(); + mockRedownloadMissingRequest.mockReset(); mockResumeDownloadRequest.mockReset(); mockLstatSync.mockReset(); mockOpenPath.mockReset().mockResolvedValue(''); @@ -98,6 +100,9 @@ export async function setupDownloadsEventsHarness(): Promise { jest.doMock('./download-file-path', () => ({ removePartialDownloadFile: mockRemovePartialDownloadFile, })); + jest.doMock('./download-redownload', () => ({ + redownloadMissingRequest: mockRedownloadMissingRequest, + })); jest.doMock('./download-runtime', () => ({ broadcastDownloadUpdate: mockBroadcastDownloadUpdate, cancelDownload: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 02b73edd2..824fb9969 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -100,6 +100,7 @@ describe('RuntimeCapabilitiesService', () => { downloadsPause: jest.fn(), downloadsResume: jest.fn(), downloadsRetry: jest.fn(), + downloadsRedownloadMissing: jest.fn(), downloadsRemove: jest.fn(), downloadsGetList: jest.fn(), downloadsGet: jest.fn(), @@ -382,6 +383,13 @@ describe('RuntimeCapabilitiesService', () => { onDownloadsUpdate: jest.fn(), }; + expect(service.supportsDownloads).toBe(false); + + testWindow.electron = { + ...testWindow.electron, + downloadsRedownloadMissing: jest.fn(), + }; + expect(service.supportsDownloads).toBe(true); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index caae0e069..5f76ade69 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -186,6 +186,7 @@ export class RuntimeCapabilitiesService { 'downloadsPause', 'downloadsResume', 'downloadsRetry', + 'downloadsRedownloadMissing', 'downloadsRemove', 'downloadsGetList', 'downloadsGet', diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 5833096df..3512d0101 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -535,6 +535,10 @@ export interface ElectronBridgeDownloadStartResult extends ElectronBridgeErrorRe id?: number; } +export interface ElectronBridgeDownloadRedownloadResult extends ElectronBridgeErrorResult { + recovered?: boolean; +} + export interface ElectronDownloadItem { id: number; playlistId: string; @@ -1078,6 +1082,9 @@ export interface ElectronBridgeApi { downloadId: number, downloadFolder: string ) => Promise; + downloadsRedownloadMissing: ( + downloadId: number + ) => Promise; downloadsRemove: (downloadId: number) => Promise; downloadsGetList: (playlistId?: string) => Promise; downloadsGet: (downloadId: number) => Promise;