diff --git a/.changes/playlist-open-from-file-association.md b/.changes/playlist-open-from-file-association.md new file mode 100644 index 000000000..412344500 --- /dev/null +++ b/.changes/playlist-open-from-file-association.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playlist +--- + +Opening an .m3u/.m3u8 file from the command line or by double-clicking it now +actually imports the playlist — previously nothing happened at all. Opening a +playlist while IPTVnator is already running works too, and on macOS the file +arrives through the system "open with" event. diff --git a/AGENTS.md b/AGENTS.md index b4a8b5223..3d24958a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ This file provides guidance to coding agents working in this repository. - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black. - If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field. -- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue. ### Trace / Debug Startup diff --git a/CLAUDE.md b/CLAUDE.md index 5ffa4af4e..e7eed5c33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ nx run electron-backend:make - When the task is Electron automation/debugging, use the `electron` skill - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`. -- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue. For startup tracing or white-screen debugging: @@ -591,7 +591,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication -- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. +- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded. **Database**: @@ -623,6 +623,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - **Event handlers**: `apps/electron-backend/src/app/events/` - `database.events.ts` - Database CRUD operations - `playlist.events.ts` - Playlist import/update + - `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts` - `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts` - `xtream.events.ts` - Xtream Codes API - `stalker.events.ts` - Stalker portal API @@ -644,6 +645,27 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Xtream Codes API (`username`, `password`, `serverUrl`) - Stalker portal (`macAddress`, `url`) +**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed +on the command line, opened through a file association, or delivered by macOS' +`open-file` event is normalized to an absolute path in the main process +(`services/playlist-open-request.ts`) and queued there. The renderer +(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the +`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is +what makes the main process flush. `OPEN_FILE` is the only way out of the +queue, and a request stays there until the renderer confirms receipt via +`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the +listener runs, and a reload or dead render process keeps the `WebContents` +alive, so a successful push is not proof of delivery. Anything unacknowledged +is replayed to the next renderer that announces itself. The renderer +imports them on a single promise chain so a burst arrives in a deterministic +order. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`) +for the same reason: each action carries a different playlist, so a newer add +must never cancel an older one's write, EPG fetch and navigation. The import +itself reuses the normal file path +(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence, +playlist-scoped EPG, and the navigation to the new playlist all behave exactly +like a dialog import. + **Video Players**: - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 80f4f119a..920427dcf 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -81,7 +81,13 @@ type ElectronFixtures = { }; export type LaunchElectronAppOptions = { + /** Electron/Chromium switches — they must precede the entry point. */ args?: readonly string[]; + /** + * Arguments for the app itself, appended *after* the entry point so they + * land in `process.argv` the way a file-association launch does. + */ + appArgs?: readonly string[]; environmentInheritance?: 'all' | 'runtime-only'; env?: Record; omitEnvKeys?: readonly string[]; @@ -184,10 +190,14 @@ export { expect }; * * Headless Linux CI has no usable sandbox or GPU, and an Electron started * without these flags there dies on a signal instead of running — so any - * helper that spawns the app itself has to use the same list. + * helper that spawns the app itself has to use the same list. `appArgs` land + * after the entry point, which is where the OS puts an opened file's path. */ -function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] { - const args = [...extraArgs, electronMainPath]; +function buildElectronLaunchArgs( + extraArgs: readonly string[] = [], + appArgs: readonly string[] = [] +): string[] { + const args = [...extraArgs, electronMainPath, ...appArgs]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); @@ -207,7 +217,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = buildElectronLaunchArgs(options.args); + const args = buildElectronLaunchArgs(options.args, options.appArgs); const electronApp = await electron.launch({ args, @@ -476,11 +486,16 @@ function attachElectronProcessDiagnostics( */ export async function launchCompetingElectronInstance( dataDir: string, - timeoutMs = 30000 + options: { + /** Arguments for the app itself, e.g. a playlist path to open. */ + appArgs?: readonly string[]; + timeoutMs?: number; + } = {} ): Promise { + const { appArgs = [], timeoutMs = 30000 } = options; // In a Node context the `electron` package resolves to its binary path. const electronBinaryPath = require('electron') as unknown as string; - const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), { + const child = spawn(electronBinaryPath, buildElectronLaunchArgs([], appArgs), { env: { ...process.env, ELECTRON_IS_DEV: '0', diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index 03d3c8f81..dca15c6a2 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -2,6 +2,7 @@ import { closeElectronApp, expect, importM3uPlaylistFromNativeDialog, + launchCompetingElectronInstance, launchElectronApp, m3uFixturePath, test, @@ -24,4 +25,54 @@ test.describe('Electron Native Playlist Import', () => { await closeElectronApp(app); } }); + + test('@m3u @electron opens a playlist passed on the command line', async ({ + dataDir, + }) => { + // Mirrors a file-association double-click on Windows/Linux: the path + // is the app's own argv, resolved and queued in the main process + // before the renderer exists. + const app = await launchElectronApp(dataDir, { + appArgs: [m3uFixturePath], + }); + + try { + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30000, + }); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(4); + } finally { + await closeElectronApp(app); + } + }); + + test('@m3u @electron opens a playlist handed over by a second launch', async ({ + dataDir, + }) => { + // The second process never gets a window: the single-instance guard + // makes it quit and forwards its argv to the running app. + const app = await launchElectronApp(dataDir); + + try { + const competing = await launchCompetingElectronInstance(dataDir, { + appArgs: [m3uFixturePath], + }); + + expect( + competing.timedOut, + `Competing instance did not exit. stderr: ${competing.stderr}` + ).toBe(false); + + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30000, + }); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(4); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 54d2ddc9f..b624e65fa 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -9,6 +9,9 @@ import { APP_UPDATE_GET_STATUS, APP_UPDATE_INSTALL, APP_UPDATE_STATUS_CHANGED, + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, } from '@iptvnator/shared/interfaces'; import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; import { @@ -190,6 +193,38 @@ describe('main preload DB IPC contract', () => { ); }); + it('exposes the playlist open-request announcement and push channel', async () => { + const api = getExposedApi(); + const callback = jest.fn(); + const request = { + fileName: 'weekend.m3u', + filePath: '/home/user/weekend.m3u', + requestId: 'playlist-open-1', + }; + + await api.announcePlaylistOpenListener(); + await api.acknowledgePlaylistOpenRequest(request.requestId); + const unsubscribe = api.onPlaylistOpenRequest(callback); + const handler = mockIpcRenderer.on.mock.calls.at(-1)?.[1]; + + handler({}, request); + unsubscribe(); + + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith( + ANNOUNCE_PLAYLIST_OPEN_LISTENER + ); + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith( + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + 'playlist-open-1' + ); + expect(mockIpcRenderer.on).toHaveBeenCalledWith( + OPEN_FILE, + expect.any(Function) + ); + expect(callback).toHaveBeenCalledWith(request); + expect(mockIpcRenderer.off).toHaveBeenCalledWith(OPEN_FILE, handler); + }); + it('forwards request-scoped DB operation events and unregisters the listener', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index f9c4c5fd3..d65a15ab6 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -6,6 +6,9 @@ import { APP_UPDATE_GET_STATUS, APP_UPDATE_INSTALL, APP_UPDATE_STATUS_CHANGED, + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, } from '@iptvnator/shared/interfaces/ipc-commands'; import { attachEmbeddedMpvFrameView, @@ -34,6 +37,7 @@ import type { ElectronBridgeEpgProgress, ElectronBridgePlaybackPositionInput, ElectronBridgePlaylistInput, + ElectronBridgePlaylistOpenRequest, ElectronBridgePlaylistUpsertInput, ElectronBridgeRemoteControlCommand, ElectronBridgeRemoteControlStatus, @@ -419,6 +423,20 @@ const electronApi: ElectronBridgeApi = { updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), + onPlaylistOpenRequest: ( + callback: (request: ElectronBridgePlaylistOpenRequest) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + request: ElectronBridgePlaylistOpenRequest + ) => callback(request); + ipcRenderer.on(OPEN_FILE, handler); + return () => ipcRenderer.off(OPEN_FILE, handler); + }, + announcePlaylistOpenListener: () => + ipcRenderer.invoke(ANNOUNCE_PLAYLIST_OPEN_LISTENER), + acknowledgePlaylistOpenRequest: (requestId: string) => + ipcRenderer.invoke(ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, requestId), getPathForFile: (file: File) => webUtils.getPathForFile(file), saveFileDialog: ( defaultPath: string, diff --git a/apps/electron-backend/src/app/events/playlist-open.events.spec.ts b/apps/electron-backend/src/app/events/playlist-open.events.spec.ts new file mode 100644 index 000000000..93b1a9844 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-open.events.spec.ts @@ -0,0 +1,156 @@ +import { + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, +} from '@iptvnator/shared/interfaces'; + +type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => unknown; + +type MockIpcEvent = { + sender: { + isDestroyed: jest.Mock; + send: jest.Mock; + }; +}; + +const mockRegisteredHandlers = new Map(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, +})); + +function createEvent(isDestroyed = false): MockIpcEvent { + return { + sender: { + isDestroyed: jest.fn(() => isDestroyed), + send: jest.fn(), + }, + }; +} + +const startupRequest = { + fileName: 'startup.m3u', + filePath: '/tmp/startup.m3u', + requestId: 'startup-1', +}; + +describe('PlaylistOpenEvents', () => { + let playlistOpenRequests: typeof import('../services/playlist-open-request').playlistOpenRequests; + let announceListener: IpcHandler; + let acknowledge: IpcHandler; + + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + + ({ playlistOpenRequests } = await import( + '../services/playlist-open-request' + )); + const { default: PlaylistOpenEvents } = await import( + './playlist-open.events' + ); + + PlaylistOpenEvents.bootstrapPlaylistOpenEvents(); + announceListener = mockRegisteredHandlers.get( + ANNOUNCE_PLAYLIST_OPEN_LISTENER + ) as IpcHandler; + acknowledge = mockRegisteredHandlers.get( + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST + ) as IpcHandler; + }); + + it('registers the announcement and acknowledgement handlers', () => { + expect(announceListener).toBeDefined(); + expect(acknowledge).toBeDefined(); + }); + + it('pushes requests queued before the renderer was ready', () => { + playlistOpenRequests.enqueue(startupRequest); + const event = createEvent(); + + announceListener(event); + + expect(event.sender.send).toHaveBeenCalledWith( + OPEN_FILE, + startupRequest + ); + expect(playlistOpenRequests.pendingRequests).toEqual([]); + expect(playlistOpenRequests.unacknowledgedRequests).toEqual([ + startupRequest, + ]); + }); + + it('drops a request once the renderer acknowledges it', () => { + playlistOpenRequests.enqueue(startupRequest); + announceListener(createEvent()); + + acknowledge(createEvent(), startupRequest.requestId); + + expect(playlistOpenRequests.unacknowledgedRequests).toEqual([]); + }); + + // A reload keeps the WebContents alive, so the push "succeeds" while the + // new document never sees it. The replay is what saves the file. + it('replays a request the renderer never acknowledged', () => { + playlistOpenRequests.enqueue(startupRequest); + announceListener(createEvent()); + + const reloadedEvent = createEvent(); + announceListener(reloadedEvent); + + expect(reloadedEvent.sender.send).toHaveBeenCalledWith( + OPEN_FILE, + startupRequest + ); + }); + + it('pushes later requests to the renderer that announced itself', () => { + const event = createEvent(); + + announceListener(event); + const liveRequest = { + fileName: 'second-launch.m3u', + filePath: '/tmp/second-launch.m3u', + requestId: 'second-launch-1', + }; + playlistOpenRequests.enqueue(liveRequest); + + expect(event.sender.send).toHaveBeenCalledWith(OPEN_FILE, liveRequest); + }); + + // A renderer that dies must not take the queued files with it: nothing + // leaves the queue before its push succeeded, so the next renderer to + // announce itself still receives them. + it('keeps requests queued when the announcing renderer is gone', () => { + playlistOpenRequests.enqueue(startupRequest); + const deadEvent = createEvent(true); + + announceListener(deadEvent); + + expect(deadEvent.sender.send).not.toHaveBeenCalled(); + expect(playlistOpenRequests.pendingRequests).toEqual([startupRequest]); + + const nextEvent = createEvent(); + announceListener(nextEvent); + + expect(nextEvent.sender.send).toHaveBeenCalledWith( + OPEN_FILE, + startupRequest + ); + }); + + it('keeps a request queued when the push itself throws', () => { + playlistOpenRequests.enqueue(startupRequest); + const event = createEvent(); + event.sender.send.mockImplementation(() => { + throw new Error('Object has been destroyed'); + }); + + expect(() => announceListener(event)).not.toThrow(); + expect(playlistOpenRequests.pendingRequests).toEqual([startupRequest]); + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist-open.events.ts b/apps/electron-backend/src/app/events/playlist-open.events.ts new file mode 100644 index 000000000..6f21799d0 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-open.events.ts @@ -0,0 +1,49 @@ +/** + * Bridges the main-process playlist open queue to the renderer. + * + * The renderer subscribes to `OPEN_FILE` and then announces itself with a + * single invoke. That invoke carries no payload on purpose: everything — + * including the files queued before the window existed — leaves the queue + * through the same push, and only once the renderer acknowledges it. A + * renderer that reloads or dies mid-flush therefore leaves its requests in the + * queue, and the next one to announce itself receives them. + */ + +import { ipcMain } from 'electron'; +import { + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, +} from '@iptvnator/shared/interfaces'; +import { playlistOpenRequests } from '../services/playlist-open-request'; + +export default class PlaylistOpenEvents { + static bootstrapPlaylistOpenEvents(): Electron.IpcMain { + ipcMain.handle( + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + (_event, requestId: string): void => { + playlistOpenRequests.acknowledge(requestId); + } + ); + + ipcMain.handle(ANNOUNCE_PLAYLIST_OPEN_LISTENER, (event): void => { + playlistOpenRequests.setDelivery((request) => { + if (event.sender.isDestroyed()) { + return false; + } + + try { + event.sender.send(OPEN_FILE, request); + } catch { + // The WebContents went away between the check and the + // send; keep the request for the next renderer. + return false; + } + + return true; + }); + }); + + return ipcMain; + } +} diff --git a/apps/electron-backend/src/app/services/playlist-open-request.spec.ts b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts new file mode 100644 index 000000000..e7e4f2b97 --- /dev/null +++ b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts @@ -0,0 +1,267 @@ +import { resolve } from 'node:path'; +import { + createPlaylistOpenRequest, + extractPlaylistOpenRequestFromArgv, + isPlaylistFilePath, + PlaylistOpenRequest, + PlaylistOpenRequestQueue, +} from './playlist-open-request'; + +describe('createPlaylistOpenRequest', () => { + it('accepts m3u and m3u8 paths regardless of case', () => { + expect(createPlaylistOpenRequest('/tmp/list.m3u')).toMatchObject({ + fileName: 'list.m3u', + filePath: '/tmp/list.m3u', + }); + expect(createPlaylistOpenRequest('/tmp/List.M3U8')).toMatchObject({ + fileName: 'List.M3U8', + filePath: '/tmp/List.M3U8', + }); + }); + + it('rejects paths that are not playlist files', () => { + expect(createPlaylistOpenRequest('/tmp/movie.mkv')).toBeNull(); + expect(createPlaylistOpenRequest(' ')).toBeNull(); + expect(createPlaylistOpenRequest('')).toBeNull(); + }); + + it('resolves a relative path against the supplied working directory', () => { + expect( + createPlaylistOpenRequest('sub/list.m3u', '/home/user/playlists') + ).toMatchObject({ + fileName: 'list.m3u', + filePath: resolve('/home/user/playlists', 'sub/list.m3u'), + }); + }); + + it('falls back to the process working directory when none is supplied', () => { + expect(createPlaylistOpenRequest('list.m3u')).toMatchObject({ + fileName: 'list.m3u', + filePath: resolve(process.cwd(), 'list.m3u'), + }); + }); + + it('gives every request its own id', () => { + const first = createPlaylistOpenRequest('/tmp/a.m3u'); + const second = createPlaylistOpenRequest('/tmp/a.m3u'); + + expect(first?.requestId).toBeTruthy(); + expect(first?.requestId).not.toEqual(second?.requestId); + }); + + it('recognizes playlist extensions through the exported guard', () => { + expect(isPlaylistFilePath('a.m3u')).toBe(true); + expect(isPlaylistFilePath('a.m3u8')).toBe(true); + expect(isPlaylistFilePath('a.m3u.txt')).toBe(false); + }); +}); + +describe('extractPlaylistOpenRequestFromArgv', () => { + it('finds the playlist path in a packaged launch', () => { + expect( + extractPlaylistOpenRequestFromArgv([ + '/opt/iptvnator/iptvnator', + '/home/user/list.m3u', + ]) + ).toMatchObject({ + fileName: 'list.m3u', + filePath: '/home/user/list.m3u', + }); + }); + + it('skips the executable, switches and the development entry point', () => { + expect( + extractPlaylistOpenRequestFromArgv([ + '/usr/bin/electron', + '--remote-debugging-port=9222', + '--ozone-platform=x11', + '/workspace/dist/apps/electron-backend/main.js', + '/home/user/list.m3u8', + ]) + ).toMatchObject({ + fileName: 'list.m3u8', + filePath: '/home/user/list.m3u8', + }); + }); + + it('never treats the executable itself as the playlist', () => { + expect( + extractPlaylistOpenRequestFromArgv(['/opt/weird.m3u']) + ).toBeNull(); + }); + + it('returns null when no playlist argument is present', () => { + expect( + extractPlaylistOpenRequestFromArgv([ + '/opt/iptvnator/iptvnator', + '--no-sandbox', + ]) + ).toBeNull(); + }); + + it('resolves a relative argument against the forwarded working directory', () => { + expect( + extractPlaylistOpenRequestFromArgv( + ['/opt/iptvnator/iptvnator', 'list.m3u'], + '/home/user/tv' + ) + ).toMatchObject({ + fileName: 'list.m3u', + filePath: resolve('/home/user/tv', 'list.m3u'), + }); + }); +}); + +describe('PlaylistOpenRequestQueue', () => { + const request = (name: string): PlaylistOpenRequest => ({ + fileName: `${name}.m3u`, + filePath: `/tmp/${name}.m3u`, + requestId: name, + }); + + it('holds requests until a delivery target is registered', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.enqueue(request('first')); + queue.enqueue(request('second')); + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + + expect(delivered).toEqual([request('first'), request('second')]); + }); + + it('pushes straight through once a delivery target exists', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + queue.enqueue(request('live')); + + expect(delivered).toEqual([request('live')]); + }); + + it('ignores empty requests', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.enqueue(null); + queue.enqueue(undefined); + + expect(queue.pendingRequests).toEqual([]); + }); + + it('only drops a request once the renderer acknowledged it', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.enqueue(request('pending')); + expect(queue.pendingRequests).toEqual([request('pending')]); + + queue.setDelivery(() => true); + // Delivered, but `send()` returning proves nothing yet. + expect(queue.pendingRequests).toEqual([]); + expect(queue.unacknowledgedRequests).toEqual([request('pending')]); + + queue.acknowledge('pending'); + expect(queue.unacknowledgedRequests).toEqual([]); + }); + + // A reload or a dead render process keeps the WebContents alive, so + // delivery cannot detect it — only the missing ack can. + it('replays an unacknowledged request to the next renderer', () => { + const queue = new PlaylistOpenRequestQueue(); + const secondRendererGot: PlaylistOpenRequest[] = []; + + queue.setDelivery(() => true); + queue.enqueue(request('reloaded-away')); + expect(queue.unacknowledgedRequests).toEqual([ + request('reloaded-away'), + ]); + + queue.setDelivery((entry) => { + secondRendererGot.push(entry); + return true; + }); + + expect(secondRendererGot).toEqual([request('reloaded-away')]); + }); + + it('replays unacknowledged requests ahead of newly queued ones', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery(() => true); + queue.enqueue(request('older')); + queue.setDelivery(null); + queue.enqueue(request('newer')); + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + + expect(delivered).toEqual([request('older'), request('newer')]); + }); + + it('ignores an acknowledgement for an unknown request', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.setDelivery(() => true); + queue.enqueue(request('known')); + + expect(() => queue.acknowledge('stale')).not.toThrow(); + expect(queue.unacknowledgedRequests).toEqual([request('known')]); + }); + + it('keeps a request queued when delivery reports a dead target', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.setDelivery(() => false); + queue.enqueue(request('orphan')); + + expect(queue.pendingRequests).toEqual([request('orphan')]); + expect(queue.unacknowledgedRequests).toEqual([]); + }); + + it('re-arms for the next renderer after a failed delivery', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery(() => false); + queue.enqueue(request('orphan')); + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + + expect(delivered).toEqual([request('orphan')]); + }); + + it('stops delivering at the first failure and preserves order', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + let accept = true; + + queue.enqueue(request('one')); + queue.enqueue(request('two')); + queue.setDelivery((entry) => { + if (!accept) { + return false; + } + + delivered.push(entry); + accept = false; + return true; + }); + + expect(delivered).toEqual([request('one')]); + expect(queue.pendingRequests).toEqual([request('two')]); + expect(queue.unacknowledgedRequests).toEqual([request('one')]); + }); +}); diff --git a/apps/electron-backend/src/app/services/playlist-open-request.ts b/apps/electron-backend/src/app/services/playlist-open-request.ts new file mode 100644 index 000000000..033028b5a --- /dev/null +++ b/apps/electron-backend/src/app/services/playlist-open-request.ts @@ -0,0 +1,177 @@ +/** + * Playlist files handed to the app by the operating system. + * + * Three entry points converge here: + * - a first launch with a path argument (`iptvnator playlist.m3u`, which is + * also what Windows/Linux file associations do), + * - a second launch while the app is already running, whose argv the + * single-instance guard forwards, + * - macOS, which never puts the path in argv and emits `open-file` instead. + * + * All three can fire before the renderer exists, so requests are queued in the + * main process until a renderer announces itself. A request leaves the queue + * only once that renderer *acknowledges* it: `webContents.send()` returns + * before the listener runs, and a reload or a dead render process keeps the + * `WebContents` alive, so a successful push is not proof of receipt. Anything + * unacknowledged is replayed to the next renderer that announces itself. + */ + +import { basename, isAbsolute, resolve } from 'node:path'; + +export interface PlaylistOpenRequest { + readonly fileName: string; + readonly filePath: string; + /** Identifies the request so the renderer can acknowledge it. */ + readonly requestId: string; +} + +/** Returns `false` when the target is gone and the request must stay queued. */ +export type PlaylistOpenRequestDelivery = ( + request: PlaylistOpenRequest +) => boolean; + +const PLAYLIST_FILE_PATTERN = /\.(m3u|m3u8)$/i; + +let requestCounter = 0; + +function nextRequestId(): string { + requestCounter += 1; + return `playlist-open-${requestCounter}`; +} + +export function isPlaylistFilePath(candidate: string): boolean { + return PLAYLIST_FILE_PATTERN.test(candidate.trim()); +} + +/** + * Normalizes an OS-supplied path into an open request, or returns `null` when + * it is not a playlist file. Relative paths are resolved against the working + * directory of the process that supplied them — for a forwarded second launch + * that is *not* this process' cwd. + */ +export function createPlaylistOpenRequest( + filePath: string, + workingDirectory?: string +): PlaylistOpenRequest | null { + const candidate = filePath?.trim(); + + if (!candidate || !isPlaylistFilePath(candidate)) { + return null; + } + + const absolutePath = isAbsolute(candidate) + ? candidate + : resolve(workingDirectory?.trim() || process.cwd(), candidate); + + return { + fileName: basename(absolutePath), + filePath: absolutePath, + requestId: nextRequestId(), + }; +} + +/** + * Picks the playlist path out of a process argv. + * + * `argv[0]` is the executable and is always skipped; so is every switch, since + * Electron and Chromium add plenty of them (`--remote-debugging-port=9222`, + * `--ozone-platform=x11`, …) and a switch value is never the user's file. + * In development the renderer entry (`main.js`) sits in argv too, but it is + * not a playlist file so the extension check filters it out. + */ +export function extractPlaylistOpenRequestFromArgv( + argv: readonly string[], + workingDirectory?: string +): PlaylistOpenRequest | null { + for (const argument of argv.slice(1)) { + if (typeof argument !== 'string' || argument.startsWith('-')) { + continue; + } + + const request = createPlaylistOpenRequest(argument, workingDirectory); + + if (request) { + return request; + } + } + + return null; +} + +export class PlaylistOpenRequestQueue { + private delivery: PlaylistOpenRequestDelivery | null = null; + private flushing = false; + private pending: PlaylistOpenRequest[] = []; + /** Pushed, waiting for the renderer to confirm it got them. */ + private awaitingAck: PlaylistOpenRequest[] = []; + + enqueue(request: PlaylistOpenRequest | null | undefined): void { + if (!request) { + return; + } + + this.pending.push(request); + this.flush(); + } + + /** Queued requests, in arrival order. Exposed for assertions. */ + get pendingRequests(): readonly PlaylistOpenRequest[] { + return this.pending; + } + + /** Delivered but not yet acknowledged. Exposed for assertions. */ + get unacknowledgedRequests(): readonly PlaylistOpenRequest[] { + return this.awaitingAck; + } + + /** + * Drops a request for good. `webContents.send()` resolves nothing and + * returns before the renderer has run its listener, so a push that merely + * *left* the main process is not proof of receipt — only this is. + */ + acknowledge(requestId: string): void { + this.awaitingAck = this.awaitingAck.filter( + (request) => request.requestId !== requestId + ); + } + + /** + * Points the queue at a renderer. Anything an earlier renderer never + * acknowledged goes back to the front of the queue: a reload or a crashed + * render process leaves the `WebContents` alive, so delivery cannot detect + * it and the request would otherwise be gone. + */ + setDelivery(delivery: PlaylistOpenRequestDelivery | null): void { + this.pending = [...this.awaitingAck, ...this.pending]; + this.awaitingAck = []; + this.delivery = delivery; + this.flush(); + } + + private flush(): void { + if (this.flushing) { + return; + } + + this.flushing = true; + try { + while (this.delivery && this.pending.length > 0) { + const next = this.pending[0]; + + if (!this.delivery(next)) { + // The renderer is gone. Keep everything queued for the + // next one instead of pushing into a dead window. + this.delivery = null; + return; + } + + this.pending.shift(); + this.awaitingAck.push(next); + } + } finally { + this.flushing = false; + } + } +} + +export const playlistOpenRequests = new PlaylistOpenRequestQueue(); diff --git a/apps/electron-backend/src/app/services/single-instance.spec.ts b/apps/electron-backend/src/app/services/single-instance.spec.ts index 6baec2e37..0714a32f2 100644 --- a/apps/electron-backend/src/app/services/single-instance.spec.ts +++ b/apps/electron-backend/src/app/services/single-instance.spec.ts @@ -7,6 +7,17 @@ import { type SingleInstanceWindow, } from './single-instance'; +/** + * Electron's real `second-instance` listener signature. The guard has to keep + * working when Electron hands over an empty command line, so the argv and + * working directory are modelled as optional here. + */ +type SecondInstanceHandler = ( + event: unknown, + argv: string[] | undefined, + workingDirectory: string | undefined +) => void; + function createApp(hasLock: boolean): jest.Mocked { return { quit: jest.fn(), @@ -60,7 +71,7 @@ describe('acquireSingleInstanceLock', () => { const app = createApp(true); expect( - acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + acquireSingleInstanceLock(app, () => null, jest.fn(), { env: {} }) ).toBe(true); expect(app.quit).not.toHaveBeenCalled(); expect(app.on).toHaveBeenCalledWith( @@ -73,7 +84,7 @@ describe('acquireSingleInstanceLock', () => { const app = createApp(false); expect( - acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + acquireSingleInstanceLock(app, () => null, jest.fn(), { env: {} }) ).toBe(false); expect(app.quit).toHaveBeenCalledTimes(1); expect(app.on).not.toHaveBeenCalled(); @@ -87,7 +98,9 @@ describe('acquireSingleInstanceLock', () => { }); const createMainWindow = jest.fn(); - acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + acquireSingleInstanceLock(app, () => window, createMainWindow, { + env: {}, + }); const [, handler] = app.on.mock.calls[0]; (handler as () => void)(); @@ -112,7 +125,9 @@ describe('acquireSingleInstanceLock', () => { : null; const createMainWindow = jest.fn(); - acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + acquireSingleInstanceLock(app, () => window, createMainWindow, { + env: {}, + }); const [, handler] = app.on.mock.calls[0]; (handler as () => void)(); @@ -127,12 +142,76 @@ describe('acquireSingleInstanceLock', () => { expect( acquireSingleInstanceLock(app, () => null, jest.fn(), { - [ALLOW_MULTIPLE_INSTANCES_ENV]: '1', + env: { [ALLOW_MULTIPLE_INSTANCES_ENV]: '1' }, }) ).toBe(true); expect(app.requestSingleInstanceLock).not.toHaveBeenCalled(); expect(app.quit).not.toHaveBeenCalled(); }); + + it('forwards a second launch command line before focusing the window', () => { + const app = createApp(true); + const window = createWindow(); + const onSecondInstance = jest.fn(); + + acquireSingleInstanceLock(app, () => window, jest.fn(), { + env: {}, + onSecondInstance, + }); + const [, handler] = app.on.mock.calls[0]; + (handler as SecondInstanceHandler)( + {}, + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u'], + '/home/user' + ); + + expect(onSecondInstance).toHaveBeenCalledWith( + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u'], + '/home/user' + ); + expect(onSecondInstance.mock.invocationCallOrder[0]).toBeLessThan( + window.focus.mock.invocationCallOrder[0] + ); + }); + + it('forwards a second launch that carries no arguments', () => { + const app = createApp(true); + const window = createWindow(); + const onSecondInstance = jest.fn(); + + acquireSingleInstanceLock(app, () => window, jest.fn(), { + env: {}, + onSecondInstance, + }); + const [, handler] = app.on.mock.calls[0]; + (handler as SecondInstanceHandler)({}, undefined, undefined); + + expect(onSecondInstance).toHaveBeenCalledWith([], ''); + expect(window.focus).toHaveBeenCalledTimes(1); + }); + + it('still forwards when the lock owner has to rebuild its window', () => { + const app = createApp(true); + const createMainWindow = jest.fn(); + const onSecondInstance = jest.fn(); + + acquireSingleInstanceLock(app, () => null, createMainWindow, { + env: {}, + onSecondInstance, + }); + const [, handler] = app.on.mock.calls[0]; + (handler as SecondInstanceHandler)( + {}, + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u8'], + '/home/user' + ); + + expect(onSecondInstance).toHaveBeenCalledWith( + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u8'], + '/home/user' + ); + expect(createMainWindow).toHaveBeenCalledTimes(1); + }); }); describe('focusExistingWindow', () => { diff --git a/apps/electron-backend/src/app/services/single-instance.ts b/apps/electron-backend/src/app/services/single-instance.ts index 8186ed2ce..e6f2b762b 100644 --- a/apps/electron-backend/src/app/services/single-instance.ts +++ b/apps/electron-backend/src/app/services/single-instance.ts @@ -17,7 +17,14 @@ /** Minimal Electron `App` surface used by the guard, kept narrow for tests. */ export interface SingleInstanceApp { quit(): void; - on(event: 'second-instance', listener: () => void): unknown; + on( + event: 'second-instance', + listener: ( + event: unknown, + argv: string[], + workingDirectory: string + ) => void + ): unknown; requestSingleInstanceLock(): boolean; } @@ -74,6 +81,18 @@ function needsNewWindow( return !window || window.isDestroyed(); } +export interface SingleInstanceOptions { + env?: NodeJS.ProcessEnv; + /** + * Receives the second launch's command line before its window is focused. + * Electron discards that argv otherwise, and it is the only place a + * "open this playlist in the running app" request can come from on + * Windows/Linux. `workingDirectory` belongs to the *other* process, so a + * relative path must be resolved against it rather than against `cwd()`. + */ + onSecondInstance?: (argv: string[], workingDirectory: string) => void; +} + /** * Acquires the single instance lock. * @@ -88,9 +107,9 @@ export function acquireSingleInstanceLock( app: SingleInstanceApp, getMainWindow: () => SingleInstanceWindow | null | undefined, createMainWindow: () => void, - env: NodeJS.ProcessEnv = process.env + options: SingleInstanceOptions = {} ): boolean { - if (allowsMultipleInstances(env)) { + if (allowsMultipleInstances(options.env ?? process.env)) { return true; } @@ -99,7 +118,9 @@ export function acquireSingleInstanceLock( return false; } - app.on('second-instance', () => { + app.on('second-instance', (_event, argv, workingDirectory) => { + options.onSecondInstance?.(argv ?? [], workingDirectory ?? ''); + const mainWindow = getMainWindow(); if (needsNewWindow(mainWindow)) { diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index b745ad244..7a155251a 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -19,6 +19,7 @@ import { shutdownMpvSession } from './app/events/mpv-session.service'; import PlayerEvents from './app/events/player.events'; import { shutdownVlcSession } from './app/events/vlc-session.service'; import PlaylistEvents from './app/events/playlist.events'; +import PlaylistOpenEvents from './app/events/playlist-open.events'; import RemoteControlEvents from './app/events/remote-control.events'; import SettingsEvents from './app/events/settings.events'; import SharedEvents from './app/events/shared.events'; @@ -38,6 +39,11 @@ import { import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; import { acquireSingleInstanceLock } from './app/services/single-instance'; +import { + createPlaylistOpenRequest, + extractPlaylistOpenRequestFromArgv, + playlistOpenRequests, +} from './app/services/playlist-open-request'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -140,6 +146,7 @@ export default class Main { WindowEvents.bootstrapWindowEvents(); EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); PlaylistEvents.bootstrapPlaylistEvents(); + PlaylistOpenEvents.bootstrapPlaylistOpenEvents(); SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); SettingsEvents.bootstrapSettingsEvents(); @@ -192,6 +199,23 @@ export default class Main { } runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { + // macOS never puts the opened file in argv — Launch Services delivers it + // through `open-file`, which can fire before `whenReady`. Registering the + // listener here (and calling preventDefault, or Electron logs a warning + // and treats the event as unhandled) is the only way to catch a launch by + // double-clicking a playlist in Finder. + app.on('open-file', (event, filePath) => { + event.preventDefault(); + playlistOpenRequests.enqueue(createPlaylistOpenRequest(filePath)); + }); + + // Windows/Linux file associations and plain `iptvnator playlist.m3u` + // launches arrive as an argument instead. The renderer drains the queue + // once it is ready. + playlistOpenRequests.enqueue( + extractPlaylistOpenRequestFromArgv(process.argv) + ); + // handle setup events as quickly as possible Main.initialize(); @@ -203,7 +227,20 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { !acquireSingleInstanceLock( app, () => App.mainWindow, - () => App.ensureMainWindow() + () => App.ensureMainWindow(), + { + // A second launch is also how the OS says "open this playlist + // in the app you already have running". Its argv is the only + // carrier for that, and it is relative to *its* cwd. + onSecondInstance: (argv, workingDirectory) => { + playlistOpenRequests.enqueue( + extractPlaylistOpenRequestFromArgv( + argv, + workingDirectory + ) + ); + }, + } ) ) { return; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 11a918152..cea7f4f70 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -21,13 +21,13 @@ import { import { AUTO_UPDATE_PLAYLISTS, Language, - OPEN_FILE, Settings, STORE_KEY, Theme, createDevLogger, } from '@iptvnator/shared/interfaces'; import { SettingsService } from './services/settings.service'; +import { PlaylistOpenRequestService } from './services/playlist-open-request.service'; import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; const debugAppComponent = createDevLogger('AppComponent'); @@ -59,6 +59,7 @@ export class AppComponent implements OnInit { private translate = inject(TranslateService); private settingsService = inject(SettingsService); private settingsStore = inject(SettingsStore); + private playlistOpenRequests = inject(PlaylistOpenRequestService); private runtime = inject(RuntimeCapabilitiesService); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); @@ -72,27 +73,12 @@ export class AppComponent implements OnInit { document.body.classList.add('frameless-platform'); } - const electronProcess = this.dataService.remote?.process; - if ( - this.dataService.isElectron && - electronProcess && - (electronProcess.platform === 'linux' || - electronProcess.platform === 'win32') && - electronProcess.argv.length > 2 - ) { - const filePath = electronProcess.argv.find( - (filepath: string) => - filepath.endsWith('.m3u') || filepath.endsWith('.m3u8') - ); - if (filePath) { - const filePathsArray = filePath.split('/'); - const fileName = filePathsArray[filePathsArray.length - 1]; - this.dataService.sendIpcEvent(OPEN_FILE, { - filePath, - fileName, - }); - } - } + // Playlist files the OS asked us to open (command line argument, file + // association, macOS `open-file`) are resolved in the main process and + // queued there until the renderer subscribes. Start listening as early + // as possible so a first-launch file is not delayed behind app init. + this.playlistOpenRequests.start(); + effect(() => { const size = this.settingsStore.coverSize?.() ?? 'medium'; document.documentElement.dataset.coverSize = size; diff --git a/apps/web/src/app/services/playlist-open-request.service.spec.ts b/apps/web/src/app/services/playlist-open-request.service.spec.ts new file mode 100644 index 000000000..5c37e74f3 --- /dev/null +++ b/apps/web/src/app/services/playlist-open-request.service.spec.ts @@ -0,0 +1,218 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; +import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; +import { PlaylistOpenRequestService } from './playlist-open-request.service'; + +type BridgeMock = { + acknowledgePlaylistOpenRequest: jest.Mock; + announcePlaylistOpenListener: jest.Mock; + onPlaylistOpenRequest: jest.Mock; +}; + +describe('PlaylistOpenRequestService', () => { + let service: PlaylistOpenRequestService; + let importService: { importFromPath: jest.Mock }; + let snackBar: { open: jest.Mock }; + let bridge: BridgeMock; + let pushRequest: (request: ElectronBridgePlaylistOpenRequest) => void; + let unsubscribe: jest.Mock; + let originalElectron: typeof window.electron | undefined; + + const request = (name: string): ElectronBridgePlaylistOpenRequest => ({ + fileName: `${name}.m3u`, + filePath: `/tmp/${name}.m3u`, + requestId: name, + }); + + /** Lets every queued promise callback settle. */ + const settle = () => + new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); + + beforeEach(() => { + originalElectron = window.electron; + pushRequest = () => undefined; + unsubscribe = jest.fn(); + importService = { + importFromPath: jest.fn().mockResolvedValue({ + ok: true, + title: 'Playlist', + }), + }; + snackBar = { open: jest.fn() }; + bridge = { + acknowledgePlaylistOpenRequest: jest + .fn() + .mockResolvedValue(undefined), + announcePlaylistOpenListener: jest + .fn() + .mockResolvedValue(undefined), + onPlaylistOpenRequest: jest.fn((callback) => { + pushRequest = callback; + return unsubscribe; + }), + }; + window.electron = bridge as unknown as typeof window.electron; + + TestBed.configureTestingModule({ + providers: [ + PlaylistOpenRequestService, + { provide: PlaylistFileImportService, useValue: importService }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + + service = TestBed.inject(PlaylistOpenRequestService); + }); + + afterEach(() => { + window.electron = originalElectron as typeof window.electron; + jest.restoreAllMocks(); + }); + + // The announcement makes the main process flush its queue at once, so a + // listener attached afterwards would miss every startup file. + it('subscribes before announcing itself', () => { + service.start(); + + expect( + bridge.onPlaylistOpenRequest.mock.invocationCallOrder[0] + ).toBeLessThan( + bridge.announcePlaylistOpenListener.mock.invocationCallOrder[0] + ); + }); + + it('imports a playlist the main process pushes', async () => { + service.start(); + pushRequest(request('startup')); + await settle(); + + expect(importService.importFromPath).toHaveBeenCalledWith( + '/tmp/startup.m3u', + 'startup.m3u' + ); + }); + + // `send()` returns before the listener runs, so the main process holds the + // request until this confirms a live renderer got it. + it('acknowledges a request as soon as it arrives', async () => { + importService.importFromPath.mockReturnValue(new Promise(() => undefined)); + + service.start(); + pushRequest(request('startup')); + + expect(bridge.acknowledgePlaylistOpenRequest).toHaveBeenCalledWith( + 'startup' + ); + }); + + it('keeps importing when acknowledging fails', async () => { + bridge.acknowledgePlaylistOpenRequest.mockRejectedValue( + new Error('gone') + ); + + service.start(); + pushRequest(request('startup')); + await settle(); + + expect(importService.importFromPath).toHaveBeenCalledWith( + '/tmp/startup.m3u', + 'startup.m3u' + ); + }); + + it('reports a failed import to the user', async () => { + importService.importFromPath.mockResolvedValue({ + ok: false, + reason: 'read-error', + }); + + service.start(); + pushRequest(request('broken')); + await settle(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.FILE_UPLOAD.OPEN_FAILED', + 'CLOSE', + expect.objectContaining({ panelClass: ['error-snackbar'] }) + ); + }); + + // The main-process queue flushes back to back, so a burst of pushes must + // not fan out into parallel file reads landing in arbitrary order — the + // last playlist imported is the one the app navigates to. + it('never runs two imports at the same time', async () => { + let inFlight = 0; + let maxInFlight = 0; + const resolvers: (() => void)[] = []; + + importService.importFromPath.mockImplementation(() => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + + return new Promise((resolvePromise) => { + resolvers.push(() => { + inFlight -= 1; + resolvePromise({ ok: true, title: 'Playlist' }); + }); + }); + }); + + service.start(); + pushRequest(request('one')); + pushRequest(request('two')); + pushRequest(request('three')); + + for (let step = 0; step < 12; step += 1) { + resolvers.shift()?.(); + await settle(); + } + + expect(maxInFlight).toBe(1); + expect( + importService.importFromPath.mock.calls.map(([path]) => path) + ).toEqual(['/tmp/one.m3u', '/tmp/two.m3u', '/tmp/three.m3u']); + }); + + it('keeps importing after one file fails', async () => { + importService.importFromPath + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ ok: true, title: 'Playlist' }); + + service.start(); + pushRequest(request('broken')); + pushRequest(request('healthy')); + await settle(); + + expect(importService.importFromPath).toHaveBeenCalledWith( + '/tmp/healthy.m3u', + 'healthy.m3u' + ); + }); + + it('starts only once', () => { + service.start(); + service.start(); + + expect(bridge.announcePlaylistOpenListener).toHaveBeenCalledTimes(1); + }); + + it('does nothing without the Electron bridge', () => { + window.electron = undefined as unknown as typeof window.electron; + + expect(() => service.start()).not.toThrow(); + expect(importService.importFromPath).not.toHaveBeenCalled(); + }); + + it('releases the listener on stop', () => { + service.start(); + service.stop(); + + expect(unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/services/playlist-open-request.service.ts b/apps/web/src/app/services/playlist-open-request.service.ts new file mode 100644 index 000000000..06af1781b --- /dev/null +++ b/apps/web/src/app/services/playlist-open-request.service.ts @@ -0,0 +1,122 @@ +import { Injectable, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; +import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; + +type PlaylistOpenRequestBridge = { + acknowledgePlaylistOpenRequest?: (requestId: string) => Promise; + announcePlaylistOpenListener?: () => Promise; + onPlaylistOpenRequest?: ( + callback: (request: ElectronBridgePlaylistOpenRequest) => void + ) => () => void; +}; + +/** + * Imports playlist files the operating system handed to the app: a command + * line argument, a file association double-click, or macOS' `open-file`. + * + * The main process queues those requests until this service announces itself, + * so the listener has to be attached *before* the announcement — otherwise the + * push that follows it has nowhere to land. Electron only; in the PWA the + * bridge is absent and `start()` is a no-op. + */ +@Injectable({ providedIn: 'root' }) +export class PlaylistOpenRequestService { + private readonly importService = inject(PlaylistFileImportService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + + private started = false; + private unsubscribe: (() => void) | undefined; + + /** + * Imports run strictly one after another, in arrival order. The main + * process flushes its queue back to back, so without this a burst — two + * playlists picked in Finder at once — would fan out into parallel + * file reads and land in a nondeterministic order, with an arbitrary one + * of them ending up the active playlist. `addPlaylist$` serialises the + * persistence side separately. + */ + private importChain: Promise = Promise.resolve(); + + start(): void { + if (this.started) { + return; + } + + const bridge = this.getBridge(); + if (!bridge?.announcePlaylistOpenListener) { + return; + } + + this.started = true; + + // Subscribe first: announcing makes the main process flush its queue + // immediately, and every request — queued or live — arrives this way. + this.unsubscribe = bridge.onPlaylistOpenRequest?.((request) => { + // Acknowledge on receipt rather than after the import: the main + // process only needs to know the request reached a live renderer, + // and holding the ack back until a slow import finished would + // replay it if the window were closed meanwhile. + void bridge + .acknowledgePlaylistOpenRequest?.(request?.requestId) + .catch(() => undefined); + this.chain(() => this.open(request)); + }); + + void bridge.announcePlaylistOpenListener().catch((error) => { + console.error( + 'Failed to announce the playlist open listener:', + error + ); + }); + } + + /** Appends a step to the import chain, never breaking it on failure. */ + private chain(step: () => Promise): void { + this.importChain = this.importChain.then(step).catch((error) => { + console.error('Failed to open a playlist file:', error); + }); + } + + stop(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.started = false; + } + + private async open( + request: ElectronBridgePlaylistOpenRequest + ): Promise { + if (!request?.filePath) { + return; + } + + const result = await this.importService.importFromPath( + request.filePath, + request.fileName + ); + + if (result.ok) { + return; + } + + this.snackBar.open( + this.translate.instant('HOME.FILE_UPLOAD.OPEN_FAILED', { + filename: request.fileName || request.filePath, + }), + this.translate.instant('CLOSE'), + { duration: 7000, panelClass: ['error-snackbar'] } + ); + } + + private getBridge(): PlaylistOpenRequestBridge | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as Window & { electron?: PlaylistOpenRequestBridge }) + .electron; + } +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1c99e218b..7d0c0ee5f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -151,7 +151,8 @@ "OR": "أو", "CHOOSE_PLAYLIST": "اختر قائمة تشغيل", "BROWSE": "تصفح الملفات", - "REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}." + "REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}.", + "OPEN_FAILED": "تعذر فتح \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "رابط قائمة التشغيل (M3U/M3U8)", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 10ee715e0..6904a9681 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -151,7 +151,8 @@ "OR": "أو", "CHOOSE_PLAYLIST": "اختار قائمة تشغيل", "BROWSE": "تصفح الملفات", - "REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}." + "REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}.", + "OPEN_FAILED": "ما تقدرش تحل \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "رابط قائمة التشغيل (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 16c89096b..ab9eeba35 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -151,7 +151,8 @@ "OR": "або", "CHOOSE_PLAYLIST": "выберыце плэйліст", "BROWSE": "Агляд файлаў", - "REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}." + "REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}.", + "OPEN_FAILED": "Не атрымалася адкрыць \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Спасылка на плэйліст (*.m3u або *.m3u8)", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 79049e257..24b3b7b0c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -151,7 +151,8 @@ "OR": "oder", "CHOOSE_PLAYLIST": "Playlist auswählen", "BROWSE": "Dateien durchsuchen", - "REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}." + "REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}.", + "OPEN_FAILED": "\"{{filename}}\" konnte nicht geöffnet werden." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL der Playlist", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ce7d8c585..22dad3dc8 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -151,7 +151,8 @@ "OR": "or", "CHOOSE_PLAYLIST": "επιλέξτε μια λίστα αναπαραγωγής", "BROWSE": "Περιήγηση αρχείων", - "REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}." + "REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}.", + "OPEN_FAILED": "Δεν ήταν δυνατό το άνοιγμα του \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Διεύθηνση URL λίστας αναπαραγωγής (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 505219f7d..15d96ff2b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -151,7 +151,8 @@ "OR": "or", "CHOOSE_PLAYLIST": "click to browse", "BROWSE": "Browse files", - "REJECTED": "File was rejected, unsupported file format {{filename}}." + "REJECTED": "File was rejected, unsupported file format {{filename}}.", + "OPEN_FAILED": "Could not open \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Playlist URL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 5e20f9cb3..373fb2a1c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -151,7 +151,8 @@ "OR": "o", "CHOOSE_PLAYLIST": "Escoga una lista de reproducción", "BROWSE": "Explorar archivos", - "REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}." + "REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}.", + "OPEN_FAILED": "No se pudo abrir \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Lista de reproducción (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 5998517d3..5fca1b97c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -151,7 +151,8 @@ "OR": "ou", "CHOOSE_PLAYLIST": "Choisir une liste de lecture", "BROWSE": "Parcourir les fichiers", - "REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}." + "REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}.", + "OPEN_FAILED": "Impossible d’ouvrir \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL de la liste de lecture (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 686784f15..d512f6ecb 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -151,7 +151,8 @@ "OR": "vagy", "CHOOSE_PLAYLIST": "kattintson a tallózáshoz", "BROWSE": "Fájlok tallózása", - "REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum." + "REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum.", + "OPEN_FAILED": "A(z) \"{{filename}}\" nem nyitható meg." }, "URL_UPLOAD": { "PLAYLIST_URL": "Lejátszási lista URL-címe (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 14522bbf9..e58b0ad34 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -151,7 +151,8 @@ "OR": "o", "CHOOSE_PLAYLIST": "scegli una playlist", "BROWSE": "Sfoglia file", - "REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}." + "REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}.", + "OPEN_FAILED": "Impossibile aprire \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL playlist (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ac1184b39..6d6d021ef 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -151,7 +151,8 @@ "OR": "または", "CHOOSE_PLAYLIST": "プレイリストを選択", "BROWSE": "ファイルを参照", - "REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}" + "REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}", + "OPEN_FAILED": "\"{{filename}}\" を開けませんでした。" }, "URL_UPLOAD": { "PLAYLIST_URL": "プレイリストURL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ba5191362..423671970 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -151,7 +151,8 @@ "OR": "또는", "CHOOSE_PLAYLIST": "재생 목록을 선택", "BROWSE": "파일 찾아보기", - "REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}." + "REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}.", + "OPEN_FAILED": "\"{{filename}}\" 파일을 열 수 없습니다." }, "URL_UPLOAD": { "PLAYLIST_URL": "재생 목록 URL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 7d2621ee5..59d91aba2 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -151,7 +151,8 @@ "OR": "of", "CHOOSE_PLAYLIST": "kies een afspeellijst", "BROWSE": "Bestanden bladeren", - "REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}." + "REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}.", + "OPEN_FAILED": "Kan \"{{filename}}\" niet openen." }, "URL_UPLOAD": { "PLAYLIST_URL": "Afspeellijst-URL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e3990a36f..51fc4ed48 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -151,7 +151,8 @@ "OR": "lub", "CHOOSE_PLAYLIST": "wybierz listę odtwarzania", "BROWSE": "Przeglądaj pliki", - "REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}." + "REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}.", + "OPEN_FAILED": "Nie udało się otworzyć \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL listy odtwarzania (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 5ce03f2fa..21e9210cd 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -151,7 +151,8 @@ "OR": "ou", "CHOOSE_PLAYLIST": "escolha uma playlist", "BROWSE": "Procurar arquivos", - "REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}." + "REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}.", + "OPEN_FAILED": "Não foi possível abrir \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL da playlist (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 3ba76249e..a56dd0f7d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -151,7 +151,8 @@ "OR": "или", "CHOOSE_PLAYLIST": "выберите плейлист", "BROWSE": "Выбрать файлы", - "REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}." + "REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}.", + "OPEN_FAILED": "Не удалось открыть \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f82fa81cd..2eba777aa 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -151,7 +151,8 @@ "OR": "veya", "CHOOSE_PLAYLIST": "oynatma listesi seçin", "BROWSE": "Dosyalara göz at", - "REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}." + "REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}.", + "OPEN_FAILED": "\"{{filename}}\" açılamadı." }, "URL_UPLOAD": { "PLAYLIST_URL": "Oynatma listesi URL'si (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 7f85bedbb..9c132c893 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -151,7 +151,8 @@ "OR": "或", "CHOOSE_PLAYLIST": "选择一个播放列表", "BROWSE": "浏览文件", - "REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。" + "REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。", + "OPEN_FAILED": "无法打开 \"{{filename}}\"。" }, "URL_UPLOAD": { "PLAYLIST_URL": "播放一个播放列表(m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 0fae55d20..ae5a90624 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -151,7 +151,8 @@ "OR": "或者", "CHOOSE_PLAYLIST": "選擇播放清單", "BROWSE": "瀏覽檔案", - "REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。" + "REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。", + "OPEN_FAILED": "無法開啟 \"{{filename}}\"。" }, "URL_UPLOAD": { "PLAYLIST_URL": "播放清單網址(m3u,m3u8)", diff --git a/libs/m3u-state/src/lib/add-playlist.effect.spec.ts b/libs/m3u-state/src/lib/add-playlist.effect.spec.ts new file mode 100644 index 000000000..774c01a1b --- /dev/null +++ b/libs/m3u-state/src/lib/add-playlist.effect.spec.ts @@ -0,0 +1,152 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { Router } from '@angular/router'; +import { Actions } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { PlaylistsService, SettingsStore } from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { EMPTY, Subject } from 'rxjs'; +import { PlaylistActions } from './actions'; +import { PlaylistEffects } from './effects'; + +/** + * `addPlaylist$` persists a playlist and then fetches its EPG and navigates to + * it. Each action carries a different playlist, so a newer add must never + * cancel an older one's write — which is exactly what the effect's original + * `switchMap` did as soon as two playlists were added in quick succession + * (the OS handing over several files at once). + * + * Package sub-path exports (`@angular/core/testing`, `@angular/material/*`) + * do not resolve in this Jest target — the constraint the `libs/services` + * specs already document. So there is no TestBed here: the effect is built in + * a plain injection context, and the collaborators `addPlaylist$` never + * touches resolve to empty stubs through a permissive parent injector rather + * than being imported. + */ +describe('PlaylistEffects addPlaylist$', () => { + let actions$: Subject; + let effects: PlaylistEffects; + let writes: Map>; + let navigated: string[]; + + const playlist = (id: string): Playlist => + ({ + _id: id, + title: id, + filename: id, + playlist: { items: [] }, + favorites: [], + }) as unknown as Playlist; + + function startWrite(id: string): Subject { + const subject = new Subject(); + writes.set(id, subject); + return subject; + } + + function createEffects(): PlaylistEffects { + // Anything `addPlaylist$` does not reach (snack bar, storage, store, + // translations) resolves to an empty object rather than throwing. + const permissiveParent = { + get: () => ({}), + } as unknown as Injector; + + const injector = Injector.create({ + parent: permissiveParent, + providers: [ + { provide: Actions, useValue: new Actions(actions$) }, + // Sibling effects read the store while their fields + // initialise, so this one needs a real observable. + { + provide: Store, + useValue: { dispatch: jest.fn(), select: () => EMPTY }, + }, + { + provide: PlaylistsService, + useValue: { + addPlaylist: (target: Playlist) => + writes.get(target._id), + }, + }, + { provide: EpgService, useValue: { fetchEpg: jest.fn() } }, + { + provide: Router, + useValue: { + navigate: (commands: unknown[]) => { + navigated.push(String(commands.at(-1))); + return Promise.resolve(true); + }, + }, + }, + { + provide: SettingsStore, + useValue: { getSettings: () => ({ epgUrl: [] }) }, + }, + ], + }); + + return runInInjectionContext(injector, () => new PlaylistEffects()); + } + + beforeEach(() => { + actions$ = new Subject(); + writes = new Map(); + navigated = []; + effects = createEffects(); + effects.addPlaylist$.subscribe(); + }); + + it('navigates to a playlist once its write completes', () => { + const write = startWrite('solo'); + + actions$.next( + PlaylistActions.addPlaylist({ playlist: playlist('solo') }) + ); + write.next(playlist('solo')); + write.complete(); + + expect(navigated).toEqual(['solo']); + }); + + // The regression: under switchMap the second action unsubscribed the + // first write, so 'first' never reached its navigation/EPG side effects. + it('completes an in-flight add before starting the next one', () => { + const firstWrite = startWrite('first'); + const secondWrite = startWrite('second'); + + actions$.next( + PlaylistActions.addPlaylist({ playlist: playlist('first') }) + ); + actions$.next( + PlaylistActions.addPlaylist({ playlist: playlist('second') }) + ); + + // The second add must not have started while the first is pending. + expect(secondWrite.observed).toBe(false); + + firstWrite.next(playlist('first')); + firstWrite.complete(); + + expect(navigated).toEqual(['first']); + expect(secondWrite.observed).toBe(true); + + secondWrite.next(playlist('second')); + secondWrite.complete(); + + expect(navigated).toEqual(['first', 'second']); + }); + + it('skips temporary playlists without touching storage', () => { + const write = startWrite('temp'); + + actions$.next( + PlaylistActions.handleAddingPlaylistByUrl({ + playlist: playlist('temp'), + isTemporary: true, + }) + ); + + expect(write.observed).toBe(false); + expect(navigated).toEqual([]); + }); +}); diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index d827eb6cd..126b2f4c5 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -13,6 +13,7 @@ import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateService } from '@ngx-translate/core'; import { EMPTY, + concatMap, filter, firstValueFrom, from, @@ -340,6 +341,12 @@ export class PlaylistEffects { ); }); + // concatMap, not switchMap: every action carries a *different* playlist, + // so a newer one must never cancel the previous playlist's write. Under + // switchMap two adds in quick succession — the OS handing over several + // playlist files at once is the realistic case — dropped the first + // playlist's EPG fetch and navigation on the floor. Serialising also keeps + // the last-added playlist as the one that ends up active. addPlaylist$ = createEffect( () => { return this.actions$.pipe( @@ -347,7 +354,7 @@ export class PlaylistEffects { PlaylistActions.addPlaylist, PlaylistActions.handleAddingPlaylistByUrl ), - switchMap((action) => { + concatMap((action) => { if ('isTemporary' in action && action.isTemporary) { return EMPTY; } diff --git a/libs/m3u-state/tsconfig.json b/libs/m3u-state/tsconfig.json index 7cac292e3..70cbd99dc 100644 --- a/libs/m3u-state/tsconfig.json +++ b/libs/m3u-state/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "isolatedModules": true, "target": "es2022", "moduleResolution": "bundler", "strict": true, diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts index c3872dbbf..8e6b5fb1d 100644 --- a/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts @@ -116,4 +116,94 @@ describe('PlaylistFileImportService', () => { expect(result).toEqual({ ok: false, reason: 'cancelled' }); expect(store.dispatch).not.toHaveBeenCalled(); }); + + describe('importFromPath', () => { + const playlist = { + _id: 'playlist-2', + title: 'CLI Source', + filename: 'cli-source', + count: 1, + importDate: '2026-05-04T12:00:00.000Z', + lastUsage: '2026-05-04T12:00:00.000Z', + autoRefresh: false, + filePath: '/tmp/cli-source.m3u', + favorites: [], + playlist: { + items: [], + }, + } as Playlist; + + it('parses an OS-supplied path and adds the playlist', async () => { + const updatePlaylistFromFilePath = jest + .fn() + .mockResolvedValue(playlist); + window.electron = { + updatePlaylistFromFilePath, + } as unknown as typeof window.electron; + + const result = await service.importFromPath( + '/tmp/cli-source.m3u', + 'cli-source.m3u' + ); + + expect(updatePlaylistFromFilePath).toHaveBeenCalledWith( + '/tmp/cli-source.m3u', + 'cli-source' + ); + expect(result).toEqual({ ok: true, title: 'CLI Source' }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.addPlaylist({ playlist }) + ); + }); + + it('derives the title from the path when no file name is supplied', async () => { + const updatePlaylistFromFilePath = jest + .fn() + .mockResolvedValue(playlist); + window.electron = { + updatePlaylistFromFilePath, + } as unknown as typeof window.electron; + + await service.importFromPath('C:\\Users\\tv\\Weekend.m3u8'); + + expect(updatePlaylistFromFilePath).toHaveBeenCalledWith( + 'C:\\Users\\tv\\Weekend.m3u8', + 'Weekend' + ); + }); + + it('rejects paths that are not playlists without touching the bridge', async () => { + const updatePlaylistFromFilePath = jest.fn(); + window.electron = { + updatePlaylistFromFilePath, + } as unknown as typeof window.electron; + + const result = await service.importFromPath('/tmp/movie.mkv'); + + expect(result).toEqual({ ok: false, reason: 'unsupported' }); + expect(updatePlaylistFromFilePath).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('reports a read error when the main process cannot parse the file', async () => { + window.electron = { + updatePlaylistFromFilePath: jest + .fn() + .mockRejectedValue(new Error('ENOENT')), + } as unknown as typeof window.electron; + + const result = await service.importFromPath('/tmp/missing.m3u'); + + expect(result).toEqual({ ok: false, reason: 'read-error' }); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('reports a read error outside Electron', async () => { + window.electron = undefined as unknown as typeof window.electron; + + const result = await service.importFromPath('/tmp/cli-source.m3u'); + + expect(result).toEqual({ ok: false, reason: 'read-error' }); + }); + }); }); diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts index a2ae09384..cba5ee8ad 100644 --- a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts @@ -17,6 +17,10 @@ type NativePlaylistFileImportWindow = Window & { electron?: { getPathForFile?: (file: File) => string; openPlaylistFromFile?: () => Promise; + updatePlaylistFromFilePath?: ( + filePath: string, + title: string + ) => Promise; }; }; @@ -29,7 +33,11 @@ export class PlaylistFileImportService { } isSupportedFile(file: File): boolean { - const lower = file.name.toLowerCase(); + return this.isSupportedPath(file.name); + } + + isSupportedPath(fileNameOrPath: string): boolean { + const lower = fileNameOrPath.trim().toLowerCase(); return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext)); } @@ -57,6 +65,43 @@ export class PlaylistFileImportService { } } + /** + * Imports a playlist the main process already resolved to a path — the OS + * "open with" / command line route, where there is no `File` handle. The + * extension guard mirrors {@link isSupportedFile} so a path that slipped + * through is rejected here rather than read. + */ + async importFromPath( + filePath: string, + fileName?: string + ): Promise { + const title = this.normalizeTitle( + fileName?.trim() || this.baseNameFromPath(filePath) + ); + + if (!this.isSupportedPath(filePath)) { + return { ok: false, reason: 'unsupported' }; + } + + const parseFromPath = this.getNativeFilePathParser(); + if (!parseFromPath) { + return { ok: false, reason: 'read-error' }; + } + + try { + const playlist = await parseFromPath(filePath, title); + if (!playlist) { + return { ok: false, reason: 'read-error' }; + } + + this.store.dispatch(PlaylistActions.addPlaylist({ playlist })); + + return { ok: true, title: playlist.title || title }; + } catch { + return { ok: false, reason: 'read-error' }; + } + } + async importFile(file: File): Promise { if (!this.isSupportedFile(file)) { return { ok: false, reason: 'unsupported' }; @@ -86,6 +131,12 @@ export class PlaylistFileImportService { return { ok: true, title }; } + /** Renderer-side `basename`; handles both POSIX and Windows separators. */ + private baseNameFromPath(filePath: string): string { + const segments = filePath.trim().split(/[\\/]/); + return segments[segments.length - 1] || filePath; + } + private normalizeTitle(filename: string): string { const trimmed = filename.trim(); if (!trimmed) { @@ -124,6 +175,17 @@ export class PlaylistFileImportService { ?.openPlaylistFromFile; } + private getNativeFilePathParser(): + | ((filePath: string, title: string) => Promise) + | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as NativePlaylistFileImportWindow).electron + ?.updatePlaylistFromFilePath; + } + private getNativeFilePathResolver(): | ((file: File) => string) | undefined { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index de3695b46..d67b3cad5 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -177,6 +177,18 @@ export interface ElectronBridgeWindowState { isFullScreen: boolean; } +/** + * A playlist file the operating system asked the app to open — a command line + * argument, a file association double-click, or macOS' `open-file` event. The + * main process resolves the path to an absolute one before handing it over. + */ +export interface ElectronBridgePlaylistOpenRequest { + fileName: string; + filePath: string; + /** Pass back to `acknowledgePlaylistOpenRequest` once received. */ + requestId: string; +} + export interface ElectronBridgeAppUpdateRelease { version: string; releaseDate?: string; @@ -561,6 +573,21 @@ export interface ElectronBridgeApi { title: string ) => Promise; openPlaylistFromFile: () => Promise; + onPlaylistOpenRequest: ( + callback: (request: ElectronBridgePlaylistOpenRequest) => void + ) => () => void; + /** + * Tells the main process that an `onPlaylistOpenRequest` listener is + * attached, so it can push the playlist files the OS handed over — both + * the ones queued before this renderer existed and any that arrive later. + * Call it *after* subscribing. + */ + announcePlaylistOpenListener: () => Promise; + /** + * Confirms a pushed request reached this renderer. Until it does, the main + * process keeps the request and replays it to the next renderer. + */ + acknowledgePlaylistOpenRequest: (requestId: string) => Promise; getPathForFile: (file: File) => string; saveFileDialog: ( defaultPath: string, diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 7bd02a66b..fc95950dd 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -18,7 +18,27 @@ export const PLAYLIST_CANCEL_REFRESH = 'PLAYLIST:CANCEL_REFRESH'; // General export const ERROR = 'ERROR'; +/** + * Main -> renderer push carrying an `ElectronBridgePlaylistOpenRequest`: a + * playlist file the OS asked the app to open, either before the window existed + * (first launch with a path argument) or while it was already running (second + * launch, macOS `open-file`). This is the only channel requests leave the + * main-process queue through. + */ export const OPEN_FILE = 'OPEN_FILE'; +/** + * Renderer -> main invoke announcing that an `OPEN_FILE` listener is attached. + * Carries no payload: it only tells the queue where to push. + */ +export const ANNOUNCE_PLAYLIST_OPEN_LISTENER = + 'announce-playlist-open-listener'; +/** + * Renderer -> main invoke confirming a pushed request was received. Until it + * arrives the request stays in the queue, so a renderer that reloads or dies + * mid-delivery gets it replayed instead of losing it. + */ +export const ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST = + 'acknowledge-playlist-open-request'; // Views export const VIEW_SETTINGS = 'VIEW:SETTINGS';