diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts new file mode 100644 index 000000000..9292d8d07 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -0,0 +1,408 @@ +import type { + Playlist, + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; +import { + AUTO_UPDATE_PLAYLISTS, + PLAYLIST_CANCEL_REFRESH, + PLAYLIST_REFRESH, + PLAYLIST_REFRESH_EVENT, +} from '@iptvnator/shared/interfaces'; + +type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise; + +type MockIpcEvent = { + sender: { + isDestroyed: jest.Mock; + send: jest.Mock; + }; +}; + +type MockWorker = { + emit: (event: string, ...args: unknown[]) => boolean; + on: jest.Mock; + postMessage: jest.Mock; + removeAllListeners: jest.Mock; + terminate: jest.Mock; +}; + +const mockRegisteredHandlers = new Map(); +const mockAxiosGet = jest.fn(); +const mockShowOpenDialog = jest.fn(); +const mockShowSaveDialog = jest.fn(); +const mockReadFile = jest.fn(); +const mockWriteFile = jest.fn(); +const mockParse = jest.fn(); +const mockCreatePlaylistObject = jest.fn(); +const mockGetFilenameFromUrl = jest.fn(); +const mockResolveWorkerRuntimeBootstrap = jest.fn(); +const mockWorkerInstances: MockWorker[] = []; + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/mock/app'), + isPackaged: false, + }, + dialog: { + showOpenDialog: (...args: unknown[]) => mockShowOpenDialog(...args), + showSaveDialog: (...args: unknown[]) => mockShowSaveDialog(...args), + }, + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, +})); + +jest.mock('axios', () => ({ + __esModule: true, + default: { + get: (...args: unknown[]) => mockAxiosGet(...args), + }, +})); + +jest.mock('iptv-playlist-parser', () => ({ + parse: (...args: unknown[]) => mockParse(...args), +})); + +jest.mock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: (...args: unknown[]) => + mockCreatePlaylistObject(...args), + getFilenameFromUrl: (...args: unknown[]) => mockGetFilenameFromUrl(...args), +})); + +jest.mock('node:fs/promises', () => ({ + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), +})); + +jest.mock('worker_threads', () => { + const { EventEmitter } = require('events'); + + class PlaylistRefreshMockWorker extends EventEmitter { + postMessage = jest.fn(); + removeAllListeners = jest.fn(() => { + super.removeAllListeners(); + return this; + }); + terminate = jest.fn().mockResolvedValue(0); + } + + return { + Worker: jest.fn().mockImplementation(() => { + const worker = new PlaylistRefreshMockWorker(); + mockWorkerInstances.push(worker as MockWorker); + return worker; + }), + }; +}); + +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + mockResolveWorkerRuntimeBootstrap(...args), +})); + +function createPlaylist(overrides: Partial = {}): Playlist { + return { + _id: 'playlist-new', + autoRefresh: false, + count: 1, + favorites: [], + filename: 'Created playlist', + importDate: '2026-06-02T00:00:00.000Z', + lastUsage: '2026-06-02T00:00:00.000Z', + playlist: { items: [{ id: 'channel-1', url: 'https://stream.test' }] }, + title: 'Created playlist', + ...overrides, + }; +} + +function createIpcEvent(): MockIpcEvent { + return { + sender: { + isDestroyed: jest.fn(() => false), + send: jest.fn(), + }, + }; +} + +function getHandler(channel: string): IpcHandler { + const handler = mockRegisteredHandlers.get(channel); + + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +describe('playlist IPC events', () => { + let consoleErrorSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + mockWorkerInstances.length = 0; + mockAxiosGet.mockReset(); + mockShowOpenDialog.mockReset(); + mockShowSaveDialog.mockReset(); + mockReadFile.mockReset(); + mockWriteFile.mockReset(); + mockParse.mockReset(); + mockCreatePlaylistObject.mockReset(); + mockGetFilenameFromUrl.mockReset(); + mockResolveWorkerRuntimeBootstrap.mockReset().mockReturnValue({ + nativeModuleSearchPaths: ['/mock/native/modules'], + workerPath: '/mock/workers/playlist-refresh.worker.js', + workerPathCandidates: ['/mock/workers/playlist-refresh.worker.js'], + }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await import('./playlist.events'); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('fetches a playlist URL, parses it, and returns the created playlist', async () => { + const parsedPlaylist = { items: [{ name: 'News' }] }; + const playlist = createPlaylist({ + title: 'remote.m3u', + url: 'https://example.test/remote.m3u', + }); + + mockAxiosGet.mockResolvedValue({ data: '#EXTM3U' }); + mockParse.mockReturnValue(parsedPlaylist); + mockGetFilenameFromUrl.mockReturnValue('remote.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const result = await getHandler('fetch-playlist-by-url')( + createIpcEvent(), + 'https://example.test/remote.m3u' + ); + + expect(mockAxiosGet).toHaveBeenCalledWith( + 'https://example.test/remote.m3u', + { httpsAgent: expect.any(Object) } + ); + expect(mockParse).toHaveBeenCalledWith('#EXTM3U'); + expect(mockCreatePlaylistObject).toHaveBeenCalledWith( + 'remote.m3u', + parsedPlaylist, + 'https://example.test/remote.m3u', + 'URL' + ); + expect(result).toEqual(playlist); + }); + + it('returns null when the open playlist dialog is cancelled', async () => { + mockShowOpenDialog.mockResolvedValue({ + canceled: true, + filePaths: [], + }); + + const result = await getHandler('open-playlist-from-file')( + createIpcEvent() + ); + + expect(result).toBeNull(); + expect(mockReadFile).not.toHaveBeenCalled(); + expect(mockParse).not.toHaveBeenCalled(); + }); + + it('auto-updates URL and file playlists while preserving user fields and skipping unusable entries', async () => { + const sourcePlaylists: Playlist[] = [ + createPlaylist({ + _id: 'url-playlist', + autoRefresh: true, + favorites: ['fav-channel'], + filePath: undefined, + title: 'URL playlist', + url: 'https://example.test/list.m3u', + userAgent: 'PlaylistAgent/1.0', + }), + createPlaylist({ + _id: 'file-playlist', + autoRefresh: false, + filePath: '/playlists/local.m3u', + importDate: '', + title: 'File playlist', + url: undefined, + }), + createPlaylist({ + _id: 'missing-source', + filePath: undefined, + title: 'Missing source', + url: undefined, + }), + ]; + const parsedPlaylist = { items: [{ name: 'Updated' }] }; + + mockAxiosGet.mockResolvedValue({ data: '#EXTM3U url' }); + mockReadFile.mockResolvedValue('#EXTM3U file'); + mockParse.mockReturnValue(parsedPlaylist); + mockGetFilenameFromUrl.mockReturnValue('list.m3u'); + mockCreatePlaylistObject + .mockReturnValueOnce( + createPlaylist({ + _id: 'new-url-playlist', + autoRefresh: false, + favorites: [], + title: 'Updated URL playlist', + url: 'https://example.test/list.m3u', + }) + ) + .mockReturnValueOnce( + createPlaylist({ + _id: 'new-file-playlist', + autoRefresh: true, + filePath: '/playlists/local.m3u', + title: 'Updated file playlist', + }) + ); + + const result = await getHandler(AUTO_UPDATE_PLAYLISTS)( + createIpcEvent(), + sourcePlaylists + ); + + expect(result).toEqual([ + expect.objectContaining({ + _id: 'url-playlist', + autoRefresh: true, + favorites: ['fav-channel'], + title: 'Updated URL playlist', + userAgent: 'PlaylistAgent/1.0', + }), + expect.objectContaining({ + _id: 'file-playlist', + autoRefresh: false, + favorites: [], + title: 'Updated file playlist', + }), + ]); + expect(mockAxiosGet).toHaveBeenCalledWith( + 'https://example.test/list.m3u', + { httpsAgent: expect.any(Object) } + ); + expect(mockReadFile).toHaveBeenCalledWith( + '/playlists/local.m3u', + 'utf-8' + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Skipping playlist "Missing source": no URL or file path found' + ); + }); + + it('forwards playlist refresh worker events, resolves successful responses, and cleans up the worker', async () => { + const ipcEvent = createIpcEvent(); + const payload: PlaylistRefreshPayload = { + operationId: 'refresh-1', + playlistId: 'playlist-1', + title: 'Playlist 1', + url: 'https://example.test/list.m3u', + }; + const workerEvent: PlaylistRefreshEvent = { + operationId: 'refresh-1', + phase: 'fetching', + playlistId: 'playlist-1', + status: 'started', + }; + const playlist = createPlaylist({ _id: 'playlist-1' }); + + const refreshPromise = getHandler(PLAYLIST_REFRESH)(ipcEvent, payload); + const worker = mockWorkerInstances[0]; + + expect(mockResolveWorkerRuntimeBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + developmentWorkerDir: expect.stringContaining('workers'), + workerFilename: 'playlist-refresh.worker.js', + }) + ); + + worker.emit('message', { type: 'ready' }); + expect(worker.postMessage).toHaveBeenCalledWith({ + payload, + type: 'request', + }); + + worker.emit('message', { event: workerEvent, type: 'event' }); + expect(ipcEvent.sender.send).toHaveBeenCalledWith( + PLAYLIST_REFRESH_EVENT, + workerEvent + ); + + ipcEvent.sender.send.mockClear(); + ipcEvent.sender.isDestroyed.mockReturnValue(true); + worker.emit('message', { event: workerEvent, type: 'event' }); + expect(ipcEvent.sender.send).not.toHaveBeenCalled(); + + worker.emit('message', { + result: playlist, + success: true, + type: 'response', + }); + + await expect(refreshPromise).resolves.toEqual(playlist); + expect(worker.removeAllListeners).toHaveBeenCalled(); + expect(worker.terminate).toHaveBeenCalled(); + }); + + it('routes refresh cancellation to the active worker and converts worker error responses to Error instances', async () => { + const payload: PlaylistRefreshPayload = { + operationId: 'refresh-error', + playlistId: 'playlist-error', + title: 'Playlist error', + filePath: '/playlists/error.m3u', + }; + const refreshPromise = getHandler(PLAYLIST_REFRESH)( + createIpcEvent(), + payload + ); + const worker = mockWorkerInstances[0]; + + expect( + await getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + 'refresh-error' + ) + ).toEqual({ success: true }); + expect(worker.postMessage).toHaveBeenCalledWith({ + operationId: 'refresh-error', + type: 'cancel', + }); + + const rejectedRefresh = expect(refreshPromise).rejects.toMatchObject({ + message: 'Refresh failed', + name: 'PlaylistRefreshFailure', + stack: 'worker stack', + }); + + worker.emit('message', { + error: { + message: 'Refresh failed', + name: 'PlaylistRefreshFailure', + stack: 'worker stack', + }, + success: false, + type: 'response', + }); + + await rejectedRefresh; + + expect( + await getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + 'refresh-error' + ) + ).toEqual({ success: false }); + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index f9920b322..f9926ea5d 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -52,7 +52,7 @@ const activePlaylistRefreshes = new Map(); async function fetchPlaylistFromUrl( url: string, title?: string -): Promise { +): Promise { const agent = new https.Agent({ rejectUnauthorized: false, }); @@ -85,7 +85,7 @@ async function fetchPlaylistFromUrl( async function fetchPlaylistFromFile( filePath: string, title: string -): Promise { +): Promise { const fileContent = await readFile(filePath, 'utf-8'); const parsedPlaylist = parse(fileContent); const playlistObject = createPlaylistObject( @@ -137,6 +137,24 @@ function createPlaylistRefreshError(error: { return workerError; } +function derivePlaylistTitleFromFilePath(filePath: string): string { + const filename = basename(filePath); + return filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file'; +} + +function preserveAutoUpdatedPlaylistFields( + playlistObject: Playlist, + playlist: Playlist +): Playlist { + return { + ...playlistObject, + _id: playlist._id, + autoRefresh: playlist.autoRefresh, + favorites: playlist.favorites || [], + userAgent: playlist.userAgent, + }; +} + ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { try { return await fetchPlaylistFromUrl(url, title); @@ -175,9 +193,7 @@ ipcMain.handle('open-playlist-from-file', async () => { const filePath = filePaths[0]; try { - // Extract filename from path and remove extension for a clean title - const filename = basename(filePath); - const title = filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file'; + const title = derivePlaylistTitleFromFilePath(filePath); return await fetchPlaylistFromFile(filePath, title); } catch (error) { @@ -189,7 +205,7 @@ ipcMain.handle('open-playlist-from-file', async () => { ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { console.log(`Auto-updating ${playlists.length} playlist(s)...`); - const updatedPlaylists = []; + const updatedPlaylists: Playlist[] = []; for (const playlist of playlists) { try { @@ -220,14 +236,9 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { continue; } - // Preserve user data when updating playlist - updatedPlaylists.push({ - ...playlistObject, - _id: playlist._id, - autoRefresh: playlist.autoRefresh, - favorites: playlist.favorites || [], // Preserve favorites - userAgent: playlist.userAgent, // Preserve custom user agent - }); + updatedPlaylists.push( + preserveAutoUpdatedPlaylistFields(playlistObject, playlist) + ); console.log(`Successfully updated playlist "${playlist.title}"`); } catch (error) {