From bd61426d8ec32cb670c07584e0cd78595fc9de26 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 22:02:00 +0200 Subject: [PATCH] feat(downloads): derive completed file availability --- .../download-file-availability.spec.ts | 97 +++++++++++++++++ .../database/download-file-availability.ts | 54 ++++++++++ .../events/database/downloads-actions.spec.ts | 64 ++++++++--- ...downloads-file-availability.events.spec.ts | 100 ++++++++++++++++++ .../app/events/database/downloads.events.ts | 22 ++-- .../events/database/downloads.test-helpers.ts | 6 +- .../src/lib/electron-api.interface.ts | 6 ++ 7 files changed, 326 insertions(+), 23 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/download-file-availability.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-file-availability.ts create mode 100644 apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts new file mode 100644 index 000000000..6ed2950db --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts @@ -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, [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'); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.ts b/apps/electron-backend/src/app/events/database/download-file-availability.ts new file mode 100644 index 000000000..53ac05838 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-file-availability.ts @@ -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( + download: T, + lstat: DownloadLstat = lstatSync +): T & { fileAvailability: ElectronDownloadFileAvailability } { + return { + ...download, + fileAvailability: getDownloadFileAvailability(download, lstat), + }; +} 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 51234846f..5cef2a160 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 @@ -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((resolve) => { resolveOpenPath = resolve; @@ -183,8 +219,8 @@ describe('downloads events: pause, resume, and reveal', () => { await new Promise((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(); diff --git a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts new file mode 100644 index 000000000..cb08b0ab4 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts @@ -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(); + }); +}); 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 57c8e7ae0..e28920693 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -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); 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 21f2dd4ab..30af03b53 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 @@ -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 { mockRemovePartialDownloadFile.mockReset(); mockPauseDownload.mockReset(); mockResumeDownloadRequest.mockReset(); - mockExistsSync.mockReset(); + mockLstatSync.mockReset(); mockOpenPath.mockReset().mockResolvedValue(''); mockShowItemInFolder.mockReset(); mockEq.mockReset(); jest.doMock('node:fs', () => ({ ...jest.requireActual('node:fs'), - existsSync: mockExistsSync, + lstatSync: mockLstatSync, })); jest.doMock('drizzle-orm', () => { const actual = diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index e8905a06e..5833096df 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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;