Skip to content
Closed
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ The application is a cross-platform, open-source project built with Electron and
- Global favorites aggregated from all playlists
- Recently viewed live channel removal from row actions and context menus
- HTML video player with HLS.js support or Video.js-based player
- Remote playback controls for AirPlay, Google Cast, browser Remote Playback,
and Electron DLNA/UPnP renderers when the active stream is receiver-fetchable
- Internationalization with support for 16 languages:
- Arabic
- Moroccan arabic
Expand Down
11 changes: 9 additions & 2 deletions apps/electron-backend/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"command": "nx run electron-backend:serve-electron"
"command": "node tools/electron/serve-electron-dev.mjs"
}
},
"serve-electron": {
Expand All @@ -125,7 +126,6 @@
"executor": "nx-electron:execute",
"options": {
"buildTarget": "electron-backend:build",
"waitUntilTargets": ["web:serve"],
"remoteDebuggingPort": 9222
}
},
Expand Down Expand Up @@ -177,7 +177,14 @@
"lint": {
"command": "eslint apps/electron-backend/**/*.ts"
},
"test-serve-launcher": {
"executor": "nx:run-commands",
"options": {
"command": "node --test tools/electron/serve-electron-dev.test.mjs"
}
},
"test": {
"dependsOn": ["electron-backend:test-serve-launcher"],
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
Expand Down
5 changes: 5 additions & 0 deletions apps/electron-backend/src/app/api/main.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSession,
EmbeddedMpvSupport,
DlnaRendererDevice,
ElectronBridgeApi,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
Expand Down Expand Up @@ -741,6 +742,10 @@ const electronApi: ElectronBridgeApi = {
contentType
),
getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'),
discoverDlnaRenderers: (): Promise<DlnaRendererDevice[]> =>
ipcRenderer.invoke('CAST:DLNA_DISCOVER'),
startDlnaPlayback: (deviceId: string, playback: ResolvedPortalPlayback) =>
ipcRenderer.invoke('CAST:DLNA_START', deviceId, playback),
// Downloads
downloadsStart: (data: ElectronBridgeDownloadStartPayload) =>
ipcRenderer.invoke('DOWNLOADS_START', data),
Expand Down
30 changes: 30 additions & 0 deletions apps/electron-backend/src/app/events/casting.events.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));

jest.mock('../services/dlna-renderer.service', () => ({
DlnaRendererService: jest.fn().mockImplementation(() => ({
discover: jest.fn(),
startPlayback: jest.fn(),
})),
}));

import { ipcMain } from 'electron';
import CastingEvents from './casting.events';

describe('CastingEvents', () => {
it('registers DLNA handlers during bootstrap', () => {
CastingEvents.bootstrapCastingEvents();

expect(ipcMain.handle).toHaveBeenCalledWith(
'CAST:DLNA_DISCOVER',
expect.any(Function)
);
expect(ipcMain.handle).toHaveBeenCalledWith(
'CAST:DLNA_START',
expect.any(Function)
);
});
});
19 changes: 19 additions & 0 deletions apps/electron-backend/src/app/events/casting.events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ipcMain } from 'electron';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { DlnaRendererService } from '../services/dlna-renderer.service';

const dlnaRendererService = new DlnaRendererService();

export default class CastingEvents {
static bootstrapCastingEvents(): Electron.IpcMain {
ipcMain.handle('CAST:DLNA_DISCOVER', () =>
dlnaRendererService.discover()
);
ipcMain.handle(
'CAST:DLNA_START',
(_event, deviceId: string, playback: ResolvedPortalPlayback) =>
dlnaRendererService.startPlayback(deviceId, playback)
);
return ipcMain;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ export interface EffectiveExternalPlaybackRequest {
headerFields: string[];
}

export interface MpvReusePropertyCommand {
property: 'user-agent' | 'referrer' | 'http-header-fields';
value: string;
}

export function buildMpvReusePropertyCommands(options: {
userAgent?: string;
referer?: string;
headerFields: string[];
}): MpvReusePropertyCommand[] {
return [
{
property: 'user-agent',
value: options.userAgent ?? '',
},
{
property: 'referrer',
value: options.referer ?? '',
},
{
property: 'http-header-fields',
value: options.headerFields.join(','),
},
];
}

export function buildHttpHeaderFields(
origin?: string,
headers?: Record<string, string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(updates.at(-1)?.status).toBe('opened');
});

it('marks protected playback without exposing request credentials', () => {
const session = registry.beginSession({
player: 'mpv',
title: 'Header protected stream',
streamUrl: 'https://example.com/video.m3u8',
requiresRequestHeaders: true,
});

expect(session).toEqual(
expect.objectContaining({
requiresRequestHeaders: true,
})
);
expect(session).not.toHaveProperty('headers');
expect(session).not.toHaveProperty('userAgent');
expect(session).not.toHaveProperty('referer');
expect(session).not.toHaveProperty('origin');
});

it('keeps close capability and closes the session explicitly', async () => {
const close = jest.fn();
const session = registry.beginSession({
Expand All @@ -47,6 +66,22 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(registry.getActiveSessionId()).toBeNull();
});

it('marks a session closed without swallowing close failures', async () => {
const session = registry.beginSession({
player: 'vlc',
title: 'Example',
streamUrl: 'https://example.com/video.m3u8',
});
registry.attachCloser(session.id, () => {
throw new Error('close failed');
});

await expect(registry.closeSession(session.id)).rejects.toThrow(
'close failed'
);
expect(registry.getSession(session.id)?.status).toBe('closed');
});

it('marks runtime failures as errors without clearing the active id', () => {
const session = registry.beginSession({
player: 'mpv',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface CreateExternalPlayerSessionOptions {
title: string;
thumbnail?: string | null;
streamUrl: string;
requiresRequestHeaders?: boolean;
contentInfo?: PlayerContentInfo;
}

Expand Down Expand Up @@ -42,6 +43,7 @@ export class ExternalPlayerSessionRegistry {
title: options.title,
thumbnail: options.thumbnail ?? null,
streamUrl: options.streamUrl,
requiresRequestHeaders: options.requiresRequestHeaders,
contentInfo: options.contentInfo,
startedAt,
updatedAt: startedAt,
Expand Down Expand Up @@ -131,7 +133,9 @@ export class ExternalPlayerSessionRegistry {
try {
await runtime.close?.();
} finally {
return this.markClosed(id);
this.markClosed(id);
}

return this.getSession(id);
}
}
20 changes: 19 additions & 1 deletion apps/electron-backend/src/app/events/mpv-session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ import {
VLC_REUSE_INSTANCE,
store,
} from '../services/store.service';
import { openMpvPlayer, shutdownMpvSession } from './mpv-session.service';
import {
openMpvPlayer,
shutdownMpvSession,
} from './mpv-session.service';
import { buildMpvReusePropertyCommands } from './external-player-playback-request';
import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service';

function createMockChildProcess(): ChildProcess {
Expand Down Expand Up @@ -148,3 +152,17 @@ describe('external player shutdown on app quit', () => {
expect(proc.kill).toHaveBeenCalledTimes(1);
});
});

describe('MPV reuse request headers', () => {
it('actively clears request properties before loading an unprotected stream', () => {
expect(
buildMpvReusePropertyCommands({
headerFields: [],
})
).toEqual([
{ property: 'user-agent', value: '' },
{ property: 'referrer', value: '' },
{ property: 'http-header-fields', value: '' },
]);
});
});
58 changes: 29 additions & 29 deletions apps/electron-backend/src/app/events/mpv-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
shouldReuseMpvInstance,
shouldUseMpvSocketBridge,
} from './external-player-launch-context';
import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request';
import {
buildMpvReusePropertyCommands,
resolveEffectiveExternalPlaybackRequest,
} from './external-player-playback-request';
import {
buildPlayerStartError,
externalPlayerSessions,
Expand Down Expand Up @@ -221,11 +224,29 @@ export async function openMpvPlayer({
startTime,
headers,
}: OpenExternalPlayerRequest) {
const {
effectiveOrigin,
effectiveReferer,
effectiveUserAgent,
headerFields,
} = resolveEffectiveExternalPlaybackRequest({
url,
userAgent,
referer,
origin,
headers,
});
const session = externalPlayerSessions.beginSession({
player: 'mpv',
title,
thumbnail,
streamUrl: url,
requiresRequestHeaders: Boolean(
effectiveUserAgent ||
effectiveReferer ||
effectiveOrigin ||
headerFields.length
),
contentInfo,
});

Expand All @@ -243,19 +264,6 @@ export async function openMpvPlayer({
isFlatpak
);
const useMpvSocketBridge = shouldUseMpvSocketBridge(isFlatpak);
const {
effectiveOrigin,
effectiveReferer,
effectiveUserAgent,
headerFields,
} = resolveEffectiveExternalPlaybackRequest({
url,
userAgent,
referer,
origin,
headers,
});

traceExternalPlayer('open mpv player', {
path: mpvLaunchContext.playerPath,
launchMode: mpvLaunchContext.mode,
Expand All @@ -280,22 +288,14 @@ export async function openMpvPlayer({
) {
traceExternalPlayer('reuse existing mpv instance');
try {
if (effectiveUserAgent) {
await sendMpvCommand('set_property', [
'user-agent',
effectiveUserAgent,
]);
}
if (effectiveReferer) {
await sendMpvCommand('set_property', [
'referrer',
effectiveReferer,
]);
}
if (headerFields.length > 0) {
for (const command of buildMpvReusePropertyCommands({
userAgent: effectiveUserAgent,
referer: effectiveReferer,
headerFields,
})) {
await sendMpvCommand('set_property', [
'http-header-fields',
headerFields.join(','),
command.property,
command.value,
]);
}

Expand Down
30 changes: 18 additions & 12 deletions apps/electron-backend/src/app/events/vlc-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,29 @@ export async function openVlcPlayer({
startTime,
headers,
}: OpenVlcPlayerRequest) {
const {
mergedHeaders,
effectiveOrigin,
effectiveReferer,
effectiveUserAgent,
} = resolveEffectiveExternalPlaybackRequest({
url,
userAgent,
referer,
origin,
headers,
});
const session = externalPlayerSessions.beginSession({
player: 'vlc',
title,
thumbnail,
streamUrl: url,
requiresRequestHeaders: Boolean(
effectiveUserAgent ||
effectiveReferer ||
effectiveOrigin ||
Object.keys(mergedHeaders).length
),
contentInfo,
});

Expand All @@ -331,18 +349,6 @@ export async function openVlcPlayer({
requestedReuseInstance,
isFlatpak
);
const {
mergedHeaders,
effectiveOrigin,
effectiveReferer,
effectiveUserAgent,
} = resolveEffectiveExternalPlaybackRequest({
url,
userAgent,
referer,
origin,
headers,
});
traceExternalPlayer('open vlc player', {
path: vlcLaunchContext.playerPath,
launchMode: vlcLaunchContext.mode,
Expand Down
Loading
Loading