feat(downloads): recover missing completed files

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent bd61426d8e
commit 8a685ebf20
10 files changed
+361 -1

No files matched your search

@@ -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) =>
@@ -0,0 +1,203 @@
interface SetupOptions {
accessError?: Error;
cleanupError?: Error;
claim?: { changes: number };
fileReappeared?: boolean;
row?: Record<string, unknown> | 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<typeof import('node:fs')>('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();
});
});
@@ -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<RedownloadMissingResult> {
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 };
}
@@ -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<string, string> | undefined {
if (!value) {
@@ -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',
@@ -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);
@@ -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<void> {
mockBroadcastDownloadUpdate.mockReset();
mockRemovePartialDownloadFile.mockReset();
mockPauseDownload.mockReset();
mockRedownloadMissingRequest.mockReset();
mockResumeDownloadRequest.mockReset();
mockLstatSync.mockReset();
mockOpenPath.mockReset().mockResolvedValue('');
@@ -98,6 +100,9 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile: mockRemovePartialDownloadFile,
}));
jest.doMock('./download-redownload', () => ({
redownloadMissingRequest: mockRedownloadMissingRequest,
}));
jest.doMock('./download-runtime', () => ({
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
cancelDownload: jest.fn(),
@@ -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);
});
@@ -186,6 +186,7 @@ export class RuntimeCapabilitiesService {
'downloadsPause',
'downloadsResume',
'downloadsRetry',
'downloadsRedownloadMissing',
'downloadsRemove',
'downloadsGetList',
'downloadsGet',
@@ -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<ElectronBridgeErrorResult>;
downloadsRedownloadMissing: (
downloadId: number
) => Promise<ElectronBridgeDownloadRedownloadResult>;
downloadsRemove: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsGetList: (playlistId?: string) => Promise<ElectronDownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<ElectronDownloadItem | null>;