mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
feat(downloads): recover missing completed files
This commit is contained in:
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>;
|
||||
|
||||
Reference in new issue
Block a user