feat(downloads): derive completed file availability

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 263f0f3d52
commit bd61426d8e
7 files changed
+326 -23

No files matched your search

@@ -0,0 +1,97 @@
import {
decorateDownloadItem,
getDownloadFileAvailability,
isAvailableDownloadFile,
type DownloadLstat,
} from './download-file-availability';
function lstatResult(options: {
isFile: boolean;
isSymbolicLink?: boolean;
}): DownloadLstat {
return () => ({
isFile: () => options.isFile,
isSymbolicLink: () => options.isSymbolicLink ?? false,
});
}
describe('download file availability', () => {
it('marks only a completed regular non-symbolic-link file available', () => {
const lstat = lstatResult({ isFile: true });
expect(
getDownloadFileAvailability(
{
filePath: '/downloads/movie.mp4',
status: 'completed',
},
lstat
)
).toBe('available');
expect(
isAvailableDownloadFile('/downloads/movie.mp4', lstat)
).toBe(true);
});
it.each([
['missing path', undefined, lstatResult({ isFile: true })],
['empty path', '', lstatResult({ isFile: true })],
[
'filesystem error',
'/downloads/missing.mp4',
(() => {
throw new Error('ENOENT');
}) as DownloadLstat,
],
[
'directory',
'/downloads/folder',
lstatResult({ isFile: false }),
],
[
'symbolic link',
'/downloads/link.mp4',
lstatResult({ isFile: true, isSymbolicLink: true }),
],
])('marks a completed %s missing', (_label, filePath, lstat) => {
expect(
getDownloadFileAvailability(
{ filePath, status: 'completed' },
lstat
)
).toBe('missing');
expect(isAvailableDownloadFile(filePath, lstat)).toBe(false);
});
it('does not inspect unfinished downloads', () => {
const lstat = jest.fn<ReturnType<DownloadLstat>, [string]>();
expect(
getDownloadFileAvailability(
{
filePath: '/downloads/queued.mp4',
status: 'queued',
},
lstat
)
).toBe('not-applicable');
expect(lstat).not.toHaveBeenCalled();
});
it('decorates a row without mutating it', () => {
const row = Object.freeze({
filePath: '/downloads/movie.mp4',
id: 42,
status: 'completed' as const,
title: 'Movie',
});
expect(decorateDownloadItem(row, lstatResult({ isFile: true }))).toEqual(
{
...row,
fileAvailability: 'available',
}
);
expect(row).not.toHaveProperty('fileAvailability');
});
});
@@ -0,0 +1,54 @@
import {
type ElectronBridgeDownloadStatus,
type ElectronDownloadFileAvailability,
} from '@iptvnator/shared/interfaces';
import { lstatSync, type Stats } from 'node:fs';
interface DownloadFileRow {
filePath?: string | null;
status: ElectronBridgeDownloadStatus;
}
export type DownloadLstat = (filePath: string) => Pick<
Stats,
'isFile' | 'isSymbolicLink'
>;
export function isAvailableDownloadFile(
filePath: string | null | undefined,
lstat: DownloadLstat = lstatSync
): boolean {
if (!filePath) {
return false;
}
try {
const stats = lstat(filePath);
return stats.isFile() && !stats.isSymbolicLink();
} catch {
return false;
}
}
export function getDownloadFileAvailability(
download: DownloadFileRow,
lstat: DownloadLstat = lstatSync
): ElectronDownloadFileAvailability {
if (download.status !== 'completed') {
return 'not-applicable';
}
return isAvailableDownloadFile(download.filePath, lstat)
? 'available'
: 'missing';
}
export function decorateDownloadItem<T extends DownloadFileRow>(
download: T,
lstat: DownloadLstat = lstatSync
): T & { fileAvailability: ElectronDownloadFileAvailability } {
return {
...download,
fileAvailability: getDownloadFileAvailability(download, lstat),
};
}
@@ -2,7 +2,7 @@ import {
expectManagedPathLookup,
getHandler,
MANAGED_PATH_STATE,
mockExistsSync,
mockLstatSync,
mockManagedPath,
mockOpenPath,
mockPauseDownload,
@@ -85,7 +85,6 @@ describe('downloads events: pause, resume, and reveal', () => {
])('$operation managed-path boundary', ({ channel, filePath }) => {
it('rejects an unmanaged database path before accessing the filesystem', async () => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED);
mockExistsSync.mockReturnValue(true);
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
error: 'File not found',
@@ -93,14 +92,16 @@ describe('downloads events: pause, resume, and reveal', () => {
});
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).not.toHaveBeenCalled();
expect(mockLstatSync).not.toHaveBeenCalled();
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
it('rejects a managed database path that is missing from disk', async () => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockExistsSync.mockReturnValue(false);
mockLstatSync.mockImplementation(() => {
throw new Error('ENOENT');
});
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
error: 'File not found',
@@ -108,15 +109,44 @@ describe('downloads events: pause, resume, and reveal', () => {
});
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockLstatSync).toHaveBeenCalledTimes(1);
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
it.each([
[
'directory',
{
isFile: () => false,
isSymbolicLink: () => false,
},
],
[
'symbolic link',
{
isFile: () => true,
isSymbolicLink: () => true,
},
],
])('rejects a managed %s', async (_label, stats) => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockLstatSync.mockReturnValue(stats);
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
error: 'File not found',
success: false,
});
expectManagedPathLookup(lookup, filePath);
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
it('fails closed when the managed-path database query rejects', async () => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR);
mockExistsSync.mockReturnValue(true);
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
@@ -140,7 +170,7 @@ describe('downloads events: pause, resume, and reveal', () => {
}
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).not.toHaveBeenCalled();
expect(mockLstatSync).not.toHaveBeenCalled();
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
@@ -149,15 +179,18 @@ describe('downloads events: pause, resume, and reveal', () => {
it('reveals a managed file that exists on disk', async () => {
const filePath = '/downloads/reveal-success.mp4';
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockExistsSync.mockReturnValue(true);
mockLstatSync.mockReturnValue({
isFile: () => true,
isSymbolicLink: () => false,
});
await expect(
getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath)
).resolves.toEqual({ success: true });
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockLstatSync).toHaveBeenCalledTimes(1);
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
expect(mockShowItemInFolder).toHaveBeenCalledTimes(1);
expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).not.toHaveBeenCalled();
@@ -166,7 +199,10 @@ describe('downloads events: pause, resume, and reveal', () => {
it('waits for the native shell before reporting a managed file as played', async () => {
const filePath = '/downloads/play-success.mp4';
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockExistsSync.mockReturnValue(true);
mockLstatSync.mockReturnValue({
isFile: () => true,
isSymbolicLink: () => false,
});
let resolveOpenPath!: (value: string) => void;
const openPathResult = new Promise<string>((resolve) => {
resolveOpenPath = resolve;
@@ -183,8 +219,8 @@ describe('downloads events: pause, resume, and reveal', () => {
await new Promise<void>((resolve) => setImmediate(resolve));
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockLstatSync).toHaveBeenCalledTimes(1);
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).toHaveBeenCalledTimes(1);
expect(mockOpenPath).toHaveBeenCalledWith(filePath);
expect(mockShowItemInFolder).not.toHaveBeenCalled();
@@ -0,0 +1,100 @@
import {
getHandler,
mockGetDatabase,
mockLstatSync,
setupDownloadsEventsHarness,
} from './downloads.test-helpers';
function regularFile() {
return {
isFile: () => true,
isSymbolicLink: () => false,
};
}
describe('downloads events: file availability', () => {
beforeEach(async () => {
await setupDownloadsEventsHarness();
});
it('decorates every download in the list from the current filesystem state', async () => {
const rows = [
{
filePath: '/downloads/available.mp4',
id: 1,
status: 'completed',
},
{
filePath: '/downloads/missing.mp4',
id: 2,
status: 'completed',
},
{
filePath: '/downloads/queued.mp4',
id: 3,
status: 'queued',
},
];
const orderBy = jest.fn().mockResolvedValue(rows);
mockGetDatabase.mockResolvedValue({
select: jest.fn(() => ({
from: jest.fn(() => ({ orderBy })),
})),
});
mockLstatSync.mockImplementation((filePath) => {
if (filePath === '/downloads/missing.mp4') {
throw new Error('ENOENT');
}
return regularFile();
});
await expect(
getHandler('DOWNLOADS_GET_LIST')(null)
).resolves.toEqual([
{ ...rows[0], fileAvailability: 'available' },
{ ...rows[1], fileAvailability: 'missing' },
{ ...rows[2], fileAvailability: 'not-applicable' },
]);
expect(mockLstatSync).toHaveBeenCalledTimes(2);
});
it('decorates an individual download from the current filesystem state', async () => {
const row = {
filePath: '/downloads/missing.mp4',
id: 2,
status: 'completed',
};
mockGetDatabase.mockResolvedValue({
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([row]),
})),
})),
})),
});
mockLstatSync.mockImplementation(() => {
throw new Error('ENOENT');
});
await expect(getHandler('DOWNLOADS_GET')(null, 2)).resolves.toEqual({
...row,
fileAvailability: 'missing',
});
});
it('keeps a missing individual download response nullable', async () => {
mockGetDatabase.mockResolvedValue({
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([]),
})),
})),
})),
});
await expect(getHandler('DOWNLOADS_GET')(null, 404)).resolves.toBeNull();
expect(mockLstatSync).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,14 @@
import { and, eq, inArray } from 'drizzle-orm';
import { app, dialog, ipcMain, shell } from 'electron';
import { existsSync } from 'node:fs';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import {
decorateDownloadItem,
isAvailableDownloadFile,
} from './download-file-availability';
import { removePartialDownloadFile } from './download-file-path';
import {
resumeDownloadRequest,
@@ -207,11 +210,12 @@ ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
const query = db.select().from(schema.downloads);
return playlistId
const rows = await (playlistId
? query
.where(eq(schema.downloads.playlistId, playlistId))
.orderBy(schema.downloads.createdAt)
: query.orderBy(schema.downloads.createdAt);
: query.orderBy(schema.downloads.createdAt));
return rows.map((row) => decorateDownloadItem(row));
} catch (error) {
console.error('[Downloads] Error getting download list:', error);
throw error;
@@ -226,7 +230,7 @@ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
return result[0] || null;
return result[0] ? decorateDownloadItem(result[0]) : null;
} catch (error) {
console.error('[Downloads] Error getting download:', error);
throw error;
@@ -252,7 +256,10 @@ ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => {
});
ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) {
if (
!(await isManagedDownloadFile(filePath)) ||
!isAvailableDownloadFile(filePath)
) {
return { error: 'File not found', success: false };
}
shell.showItemInFolder(filePath);
@@ -260,7 +267,10 @@ ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
});
ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) {
if (
!(await isManagedDownloadFile(filePath)) ||
!isAvailableDownloadFile(filePath)
) {
return { error: 'File not found', success: false };
}
await shell.openPath(filePath);
@@ -15,7 +15,7 @@ export const mockBroadcastDownloadUpdate = jest.fn();
export const mockRemovePartialDownloadFile = jest.fn();
export const mockPauseDownload = jest.fn();
export const mockResumeDownloadRequest = jest.fn();
export const mockExistsSync = jest.fn();
export const mockLstatSync = jest.fn();
export const mockOpenPath = jest.fn();
export const mockShowItemInFolder = jest.fn();
export const mockEq = jest.fn();
@@ -55,14 +55,14 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
mockRemovePartialDownloadFile.mockReset();
mockPauseDownload.mockReset();
mockResumeDownloadRequest.mockReset();
mockExistsSync.mockReset();
mockLstatSync.mockReset();
mockOpenPath.mockReset().mockResolvedValue('');
mockShowItemInFolder.mockReset();
mockEq.mockReset();
jest.doMock('node:fs', () => ({
...jest.requireActual<typeof import('node:fs')>('node:fs'),
existsSync: mockExistsSync,
lstatSync: mockLstatSync,
}));
jest.doMock('drizzle-orm', () => {
const actual =
@@ -137,6 +137,11 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
export type ElectronBridgeDownloadStatus =
(typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES];
export type ElectronDownloadFileAvailability =
| 'available'
| 'missing'
| 'not-applicable';
export const ELECTRON_BRIDGE_APP_UPDATE_STATUSES = {
Unsupported: 'unsupported',
Idle: 'idle',
@@ -544,6 +549,7 @@ export interface ElectronDownloadItem {
filePath?: string;
posterUrl?: string;
status: ElectronBridgeDownloadStatus;
fileAvailability: ElectronDownloadFileAvailability;
bytesDownloaded?: number;
totalBytes?: number;
errorMessage?: string;