diff --git a/CLAUDE.md b/CLAUDE.md index 5c5c596bc..c9c62a2af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -675,10 +675,14 @@ desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for deb/rpm/pacman. Two traps: it assigns the derived `MimeType` *after* spreading `linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and must not be used; and it appends `%U` to `Exec`, so Linux file managers hand -over a percent-encoded `file://` URI rather than a path — -`createPlaylistOpenRequest` decodes it before the extension check. Adding an -exec code to `linux.executableArgs` would suppress the `%U` but also pass that -code to the app as a real argument, so it is not an option. +over percent-encoded `file://` URIs rather than paths — +`createPlaylistOpenRequest` decodes them before the extension check. `%U` is +also the *plural* exec code, so a multi-file selection arrives as one launch +with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all +of them and `enqueueAll` queues the batch, because stopping at the first match +would silently drop the rest of the selection. Adding an exec code to +`linux.executableArgs` would suppress the `%U` but also pass that code to the +app as a real argument, so it is not an option. **Video Players**: diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index dca15c6a2..22f264e56 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -1,3 +1,5 @@ +import { copyFileSync } from 'node:fs'; +import { join } from 'node:path'; import { closeElectronApp, expect, @@ -5,6 +7,7 @@ import { launchCompetingElectronInstance, launchElectronApp, m3uFixturePath, + openSources, test, } from './electron-test-fixtures'; @@ -48,6 +51,32 @@ test.describe('Electron Native Playlist Import', () => { } }); + test('@m3u @electron opens every playlist of a multi-file selection', async ({ + dataDir, + }) => { + // Selecting several playlists in a file manager is a single launch + // with one argument per file, because the generated desktop entry + // ends in `%U`. Stopping at the first would drop the rest. + const secondPlaylist = join(dataDir, 'second-selection.m3u'); + copyFileSync(m3uFixturePath, secondPlaylist); + + const app = await launchElectronApp(dataDir, { + appArgs: [m3uFixturePath, secondPlaylist], + }); + + try { + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30000, + }); + await openSources(app.mainWindow); + await expect( + app.mainWindow.locator('app-playlist-item') + ).toHaveCount(2, { timeout: 30000 }); + } finally { + await closeElectronApp(app); + } + }); + test('@m3u @electron opens a playlist handed over by a second launch', async ({ dataDir, }) => { 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 index d1f8c72de..d74913d67 100644 --- a/apps/electron-backend/src/app/services/playlist-open-request.spec.ts +++ b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createPlaylistOpenRequest, - extractPlaylistOpenRequestFromArgv, + extractPlaylistOpenRequestsFromArgv, isPlaylistFilePath, PlaylistOpenRequest, PlaylistOpenRequestQueue, @@ -88,59 +88,85 @@ describe('createPlaylistOpenRequest', () => { }); }); -describe('extractPlaylistOpenRequestFromArgv', () => { +describe('extractPlaylistOpenRequestsFromArgv', () => { it('finds the playlist path in a packaged launch', () => { expect( - extractPlaylistOpenRequestFromArgv([ + extractPlaylistOpenRequestsFromArgv([ '/opt/iptvnator/iptvnator', '/home/user/list.m3u', ]) - ).toMatchObject({ - fileName: 'list.m3u', - filePath: '/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([ + extractPlaylistOpenRequestsFromArgv([ '/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', - }); + ).toMatchObject([ + { + fileName: 'list.m3u8', + filePath: '/home/user/list.m3u8', + }, + ]); + }); + + // `%U` hands over the whole selection, one argument per file. + it('keeps every playlist of a multi-file selection, in order', () => { + const requests = extractPlaylistOpenRequestsFromArgv([ + '/opt/iptvnator/iptvnator', + '--ozone-platform=x11', + 'file:///home/user/first.m3u', + '/home/user/movie.mkv', + 'file:///home/user/second%20list.m3u8', + ]); + + expect(requests).toHaveLength(2); + expect(requests).toMatchObject([ + { fileName: 'first.m3u', filePath: '/home/user/first.m3u' }, + { + fileName: 'second list.m3u8', + filePath: '/home/user/second list.m3u8', + }, + ]); }); it('never treats the executable itself as the playlist', () => { - expect( - extractPlaylistOpenRequestFromArgv(['/opt/weird.m3u']) - ).toBeNull(); + expect(extractPlaylistOpenRequestsFromArgv(['/opt/weird.m3u'])).toEqual( + [] + ); }); - it('returns null when no playlist argument is present', () => { + it('returns nothing when no playlist argument is present', () => { expect( - extractPlaylistOpenRequestFromArgv([ + extractPlaylistOpenRequestsFromArgv([ '/opt/iptvnator/iptvnator', '--no-sandbox', ]) - ).toBeNull(); + ).toEqual([]); }); it('resolves a relative argument against the forwarded working directory', () => { expect( - extractPlaylistOpenRequestFromArgv( + extractPlaylistOpenRequestsFromArgv( ['/opt/iptvnator/iptvnator', 'list.m3u'], '/home/user/tv' ) - ).toMatchObject({ - fileName: 'list.m3u', - filePath: resolve('/home/user/tv', 'list.m3u'), - }); + ).toMatchObject([ + { + fileName: 'list.m3u', + filePath: resolve('/home/user/tv', 'list.m3u'), + }, + ]); }); }); @@ -250,6 +276,33 @@ describe('PlaylistOpenRequestQueue', () => { expect(queue.unacknowledgedRequests).toEqual([request('known')]); }); + it('delivers a whole selection in arrival order', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + queue.enqueueAll([request('one'), null, request('two')]); + + expect(delivered).toEqual([request('one'), request('two')]); + }); + + it('keeps the untouched remainder of a selection queued', () => { + const queue = new PlaylistOpenRequestQueue(); + let accept = true; + + queue.enqueueAll([request('one'), request('two')]); + queue.setDelivery(() => { + const outcome = accept; + accept = false; + return outcome; + }); + + expect(queue.pendingRequests).toEqual([request('two')]); + }); + it('keeps a request queued when delivery reports a dead target', () => { const queue = new PlaylistOpenRequestQueue(); diff --git a/apps/electron-backend/src/app/services/playlist-open-request.ts b/apps/electron-backend/src/app/services/playlist-open-request.ts index a7e646a10..039eb4998 100644 --- a/apps/electron-backend/src/app/services/playlist-open-request.ts +++ b/apps/electron-backend/src/app/services/playlist-open-request.ts @@ -98,18 +98,25 @@ export function createPlaylistOpenRequest( } /** - * Picks the playlist path out of a process argv. + * Picks every playlist path out of a process argv, in the order given. * * `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. + * + * Selecting several playlists in a Linux file manager delivers them as one + * launch with one argument each, because the desktop entry ends in `%U` — the + * plural exec code — so stopping at the first match would silently drop the + * rest of the selection. */ -export function extractPlaylistOpenRequestFromArgv( +export function extractPlaylistOpenRequestsFromArgv( argv: readonly string[], workingDirectory?: string -): PlaylistOpenRequest | null { +): PlaylistOpenRequest[] { + const requests: PlaylistOpenRequest[] = []; + for (const argument of argv.slice(1)) { if (typeof argument !== 'string' || argument.startsWith('-')) { continue; @@ -118,11 +125,11 @@ export function extractPlaylistOpenRequestFromArgv( const request = createPlaylistOpenRequest(argument, workingDirectory); if (request) { - return request; + requests.push(request); } } - return null; + return requests; } export class PlaylistOpenRequestQueue { @@ -141,6 +148,20 @@ export class PlaylistOpenRequestQueue { this.flush(); } + /** + * Queues a whole selection at once. One flush for the batch, so a delivery + * that fails partway leaves the untouched remainder in arrival order. + */ + enqueueAll(requests: readonly (PlaylistOpenRequest | null | undefined)[]): void { + for (const request of requests) { + if (request) { + this.pending.push(request); + } + } + + this.flush(); + } + /** Queued requests, in arrival order. Exposed for assertions. */ get pendingRequests(): readonly PlaylistOpenRequest[] { return this.pending; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 7a155251a..dde0be3e6 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -41,7 +41,7 @@ import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedd import { acquireSingleInstanceLock } from './app/services/single-instance'; import { createPlaylistOpenRequest, - extractPlaylistOpenRequestFromArgv, + extractPlaylistOpenRequestsFromArgv, playlistOpenRequests, } from './app/services/playlist-open-request'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; @@ -210,10 +210,11 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { }); // 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) + // launches arrive as arguments instead — one per file, since selecting + // several playlists at once is a single launch. The renderer drains the + // queue once it is ready. + playlistOpenRequests.enqueueAll( + extractPlaylistOpenRequestsFromArgv(process.argv) ); // handle setup events as quickly as possible @@ -233,8 +234,8 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { // 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( + playlistOpenRequests.enqueueAll( + extractPlaylistOpenRequestsFromArgv( argv, workingDirectory )