mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
cover playlist event handling
This commit is contained in:
1 parent
ade043d427
commit
aac634627c
2 files changed
+433
-14
No files matched your search
@@ -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<unknown>;
|
||||
|
||||
type MockIpcEvent = {
|
||||
sender: {
|
||||
isDestroyed: jest.Mock<boolean, []>;
|
||||
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<string, IpcHandler>();
|
||||
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> = {}): 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 });
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
|
||||
async function fetchPlaylistFromUrl(
|
||||
url: string,
|
||||
title?: string
|
||||
): Promise<any> {
|
||||
): Promise<Playlist> {
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
@@ -85,7 +85,7 @@ async function fetchPlaylistFromUrl(
|
||||
async function fetchPlaylistFromFile(
|
||||
filePath: string,
|
||||
title: string
|
||||
): Promise<any> {
|
||||
): Promise<Playlist> {
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user