fix(playback): hide VLC RC console on Windows (#1533)

* fix(playback): hide VLC RC console on Windows

* fix(playback): preserve VLC retry arguments containing quiet flag
This commit is contained in:
4gray authored and GitHub committed 2026-09-05 11:33:05 +02:00
1 parent 0245d73d78
commit 07ccf45f32
5 files changed
+185 -5

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: playback
issues: [871]
---
VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled.
@@ -47,6 +47,7 @@ import { externalPlayerSessions } from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service';
const originalPlatform = process.platform;
const spawnMock = spawn as unknown as jest.Mock;
const streamUrl = 'https://example.com/stream.m3u8';
const rcWrites: string[] = [];
@@ -136,6 +137,7 @@ describe('vlc-session.service process lifecycle', () => {
// Drop any process tracked for reuse so tests stay isolated.
shutdownVlcSession();
consoleErrorSpy.mockRestore();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
describe('instance reuse', () => {
@@ -1219,12 +1221,14 @@ describe('vlc-session.service process lifecycle', () => {
});
it('retries without the RC interface when VLC exits with code 1', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
});
const proc = createMockChildProcess();
await openTrackedVlcInstance(proc);
expect(spawnMock.mock.calls[0][1]).toContain('--rc-quiet');
const retryProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(retryProc);
@@ -1234,6 +1238,7 @@ describe('vlc-session.service process lifecycle', () => {
const retryArgs = spawnMock.mock.calls[1][1] as string[];
expect(retryArgs.join(' ')).not.toContain('--extraintf');
expect(retryArgs.join(' ')).not.toContain('--rc-host');
expect(retryArgs).not.toContain('--rc-quiet');
// Retry processes are never tracked for reuse.
expect(spawnMock.mock.calls[1][2]).toMatchObject({
detached: true,
@@ -1262,6 +1267,7 @@ describe('vlc-session.service process lifecycle', () => {
});
it('retries without the RC interface after a start error', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
@@ -1272,11 +1278,14 @@ describe('vlc-session.service process lifecycle', () => {
const openPromise = openVlcPlayer({ title: 'S', url: streamUrl });
await waitForSpawnCallCount(1);
expect(spawnMock.mock.calls[0][1]).toContain('--rc-quiet');
proc.emit('error', new Error('rc unsupported'));
await waitForSpawnCallCount(2);
const retryArgs = spawnMock.mock.calls[1][1] as string[];
expect(retryArgs.join(' ')).not.toContain('--extraintf');
expect(retryArgs.join(' ')).not.toContain('--rc-host');
expect(retryArgs).not.toContain('--rc-quiet');
retryProc.emit('spawn');
const session = await openPromise;
expect(session.status).toBe('opened');
@@ -39,6 +39,7 @@ import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { createServer } from 'net';
import {
VLC_PLAYER_ARGUMENTS,
VLC_PLAYER_PATH,
VLC_REUSE_INSTANCE,
store,
@@ -51,6 +52,7 @@ import {
shutdownVlcSession,
} from './vlc-session.service';
const originalPlatform = process.platform;
const spawnMock = spawn as unknown as jest.Mock;
const streamUrl = 'https://example.com/stream.m3u8';
@@ -86,6 +88,8 @@ describe('vlc-session.service helpers and launch args', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
(createServer as unknown as jest.Mock).mockImplementation(() => ({
unref: jest.fn(),
@@ -103,7 +107,10 @@ describe('vlc-session.service helpers and launch args', () => {
afterEach(() => {
// Drop any process tracked for reuse so tests stay isolated.
shutdownVlcSession();
jest.clearAllTimers();
jest.useRealTimers();
consoleErrorSpy.mockRestore();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
describe('buildVlcEnqueueCommands', () => {
@@ -177,6 +184,152 @@ describe('vlc-session.service helpers and launch args', () => {
});
describe('openVlcPlayer launch args', () => {
it.each(['error', 'exit'])(
'preserves data containing --rc-quiet during an RC retry (%s)',
async (event) => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
[VLC_PLAYER_ARGUMENTS]: '--fullscreen\n--rc-quiet',
});
const proc = createMockChildProcess();
const retryProc = createMockChildProcess();
spawnMock
.mockReturnValueOnce(proc)
.mockReturnValueOnce(retryProc);
const url = `${streamUrl}?option=--rc-quiet`;
const openPromise = openVlcPlayer({
title: 'Movie --rc-quiet',
url,
userAgent: 'Agent --rc-quiet',
});
await waitForSpawnCallCount(1);
proc.emit('spawn');
await openPromise;
proc.emit(event, event === 'exit' ? 1 : new Error('RC failed'));
await waitForSpawnCallCount(2);
expect(spawnMock.mock.lastCall[1]).toEqual([
'--fullscreen',
// Custom arguments are preserved; only the managed flag goes.
'--rc-quiet',
':http-user-agent=Agent --rc-quiet',
url,
':meta-title=Movie --rc-quiet',
]);
retryProc.emit('spawn');
retryProc.emit('exit', 0);
}
);
it.each(['win32', 'darwin', 'linux'] as const)(
'adds quiet mode only for managed Windows RC launches (%s)',
async (platform) => {
Object.defineProperty(process, 'platform', { value: platform });
for (const reuseInstance of [false, true]) {
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: reuseInstance,
});
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
const openPromise = openVlcPlayer({
title: 'RC Stream',
url: streamUrl,
// Exercise progress-only and reuse-only RC separately.
...(!reuseInstance && {
contentInfo: {
playlistId: 'playlist',
contentXtreamId: 1,
contentType: 'vod' as const,
},
}),
});
await waitForSpawnCallCount(reuseInstance ? 2 : 1);
proc.emit('spawn');
await openPromise;
expect(spawnMock.mock.lastCall[1]).toEqual([
'--extraintf=rc',
'--rc-host=127.0.0.1:43210',
...(platform === 'win32' ? ['--rc-quiet'] : []),
streamUrl,
':meta-title=RC Stream',
]);
expect(spawnMock.mock.lastCall[2].detached).toBe(
!reuseInstance
);
proc.emit('exit', 0);
}
}
);
it.each(['--rc-quiet', '--no-rc-quiet'])(
'keeps custom arguments before managed quiet mode (%s)',
async (customQuiet) => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
[VLC_PLAYER_ARGUMENTS]: `--fullscreen\n${customQuiet}`,
});
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
const openPromise = openVlcPlayer({
title: 'Custom',
url: streamUrl,
});
await waitForSpawnCallCount(1);
proc.emit('spawn');
await openPromise;
expect(spawnMock.mock.lastCall[1]).toEqual([
'--fullscreen',
customQuiet,
'--extraintf=rc',
'--rc-host=127.0.0.1:43210',
'--rc-quiet',
streamUrl,
':meta-title=Custom',
]);
proc.emit('exit', 0);
}
);
it.each([false, true])(
'does not add quiet mode without an RC port (reuse: %s)',
async (reuseInstance) => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: reuseInstance,
});
(createServer as unknown as jest.Mock).mockImplementation(
() => {
throw new Error('No free port');
}
);
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
const openPromise = openVlcPlayer({
title: 'No RC',
url: streamUrl,
});
await waitForSpawnCallCount(1);
proc.emit('spawn');
await openPromise;
expect(spawnMock.mock.lastCall[1]).toEqual([
streamUrl,
':meta-title=No RC',
]);
expect(createServer).toHaveBeenCalledTimes(
reuseInstance ? 1 : 0
);
}
);
it('spawns a detached VLC process with http options and start time', async () => {
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
@@ -249,6 +249,10 @@ export async function openVlcPlayer({
if (rcPort > 0) {
args.push('--extraintf=rc');
args.push(`--rc-host=127.0.0.1:${rcPort}`);
if (process.platform === 'win32') {
// Keep TCP control without VLC's separate DOS console.
args.push('--rc-quiet');
}
}
if (effectiveUserAgent) {
@@ -521,7 +525,7 @@ export async function openVlcPlayer({
(arg) =>
!arg.includes('--extraintf') &&
!arg.includes('--rc-host') &&
!arg.includes('--rc-quiet')
arg !== '--rc-quiet'
);
spawnVlc(retryArgs, true);
} else {
@@ -572,7 +576,7 @@ export async function openVlcPlayer({
(arg) =>
!arg.includes('--extraintf') &&
!arg.includes('--rc-host') &&
!arg.includes('--rc-quiet')
arg !== '--rc-quiet'
);
spawnVlc(retryArgs, true);
return;
+10 -3
View File
@@ -867,15 +867,22 @@ path-only setting; extra flags are stored separately as `mpvPlayerArguments` and
Argument fields are line-oriented: one non-empty trimmed line becomes one argv
entry. IPTVnator prepends those custom entries before its stream-specific runtime
arguments, then keeps the stream URL last. This avoids shell parsing, keeps paths
with spaces safe, and preserves existing settings for users who never configured
extra arguments.
arguments. This avoids shell parsing, keeps paths with spaces safe, and preserves
existing settings for users who never configured extra arguments.
The arguments apply only when IPTVnator spawns a new external player process. If
MPV or VLC instance reuse is active and an existing process is reused, subsequent
streams are loaded through MPV IPC or VLC RC commands and new process arguments
are not re-applied until a fresh process starts.
VLC enables its TCP RC interface when content metadata requires progress
tracking or instance reuse is enabled. On Windows, these managed RC launches
also append `--rc-quiet` after custom arguments to suppress VLC's DOS console
while retaining TCP control. macOS/Linux and launches without an allocated RC
port receive no automatic quiet flag. Both launch-error and exit-code-1 retries
remove the app-generated RC flags, including `--rc-quiet`; custom arguments
continue to be prepended unchanged.
## Electron External Player Ownership
External MPV/VLC integration is split across focused main-process modules: