mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
fix(playlist): open every playlist of a multi-file selection
`%U` is the plural exec code, so selecting several playlists in a Linux file manager is one launch carrying one argument per file. Both argv paths called `extractPlaylistOpenRequestFromArgv`, which returned at the first match, so everything after the first playlist was silently discarded — a gap this PR itself opened by making the desktop entry reachable in the first place. The extractor is now plural and returns every match in argument order, and the queue gained `enqueueAll` so a selection is pushed under a single flush: a delivery that fails partway leaves the untouched remainder queued in arrival order rather than interleaved. Covered by unit tests over a mixed argv (percent-encoded `file://` URI, a non-playlist argument, a second URI) and by a new Electron E2E that launches with two playlist arguments and asserts both are imported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96d89b35ce
commit
381bede7ad
5 files changed
+147
-39
No files matched your search
@@ -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**:
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user