diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 77c7760fb..193fda817 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -150,6 +150,34 @@ export const stalkerSessionPreloadCases: PreloadInvokeCase[] = [ }, ]; +const externalPlaybackArgs = [ + 'https://stream.example/live.m3u8', + 'Managed stream', + 'https://images.example/channel.png', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'opaque-playback-context', +]; + +export const playbackContextPreloadCases: PreloadInvokeCase[] = [ + { + method: 'openInMpv', + args: externalPlaybackArgs, + channel: 'OPEN_MPV_PLAYER', + forwardedArgs: externalPlaybackArgs, + }, + { + method: 'openInVlc', + args: externalPlaybackArgs, + channel: 'OPEN_VLC_PLAYER', + forwardedArgs: externalPlaybackArgs, + }, +]; + export const dbPreloadCases: PreloadInvokeCase[] = [ { method: 'dbCreatePlaylist', 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 b9923399b..81225bfe7 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -15,6 +15,7 @@ import { dbPreloadCases, epgPreloadCases, operationId, + playbackContextPreloadCases, stalkerSessionPreloadCases, } from './main.preload.spec-data'; @@ -136,6 +137,20 @@ describe('main preload DB IPC contract', () => { } ); + it.each(playbackContextPreloadCases)( + '$method forwards opaque playback context through $channel', + async ({ method, args, channel, forwardedArgs }) => { + const api = getExposedApi(); + + await callExposedApiMethod(api, method, args); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + channel, + ...forwardedArgs + ); + } + ); + it('keeps Stalker session IPC narrow while preserving the legacy request bridge', async () => { const api = getExposedApi(); const legacyPayload = { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 64abda071..5376ed494 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -46,6 +46,7 @@ import type { PortalDebugEvent, ResolvedPortalPlayback, Settings, + StalkerPlaybackContextRef, TmdbCacheEntry, TmdbCacheMediaType, XtreamCategory, @@ -372,10 +373,10 @@ const electronApi: ElectronBridgeApi = { origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, - headers?: Record - ): Promise => - ipcRenderer.invoke( - 'OPEN_MPV_PLAYER', + headers?: Record, + playbackContextRef?: StalkerPlaybackContextRef + ): Promise => { + const args = [ url, title, thumbnail, @@ -384,8 +385,16 @@ const electronApi: ElectronBridgeApi = { origin, contentInfo, startTime, - headers - ), + headers, + ] as const; + return playbackContextRef === undefined + ? ipcRenderer.invoke('OPEN_MPV_PLAYER', ...args) + : ipcRenderer.invoke( + 'OPEN_MPV_PLAYER', + ...args, + playbackContextRef + ); + }, openInVlc: ( url: string, title: string, @@ -395,10 +404,10 @@ const electronApi: ElectronBridgeApi = { origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, - headers?: Record - ): Promise => - ipcRenderer.invoke( - 'OPEN_VLC_PLAYER', + headers?: Record, + playbackContextRef?: StalkerPlaybackContextRef + ): Promise => { + const args = [ url, title, thumbnail, @@ -407,8 +416,16 @@ const electronApi: ElectronBridgeApi = { origin, contentInfo, startTime, - headers - ), + headers, + ] as const; + return playbackContextRef === undefined + ? ipcRenderer.invoke('OPEN_VLC_PLAYER', ...args) + : ipcRenderer.invoke( + 'OPEN_VLC_PLAYER', + ...args, + playbackContextRef + ); + }, closeExternalPlayerSession: (sessionId: string) => ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId), getEmbeddedMpvSupport: (): Promise => diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts index cd086ee7c..5a493aa3f 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts @@ -6,16 +6,26 @@ jest.mock('electron', () => ({ const mockEmbeddedMpvService = { getSupport: jest.fn(), + loadPlayback: jest.fn(), setPaused: jest.fn(), }; +const mockConsumeStalkerPlaybackContext = jest.fn(); + jest.mock('../services/embedded-mpv-native.service', () => ({ EmbeddedMpvNativeService: class {}, embeddedMpvNativeService: mockEmbeddedMpvService, })); +jest.mock('../services/stalker-playback-context.service', () => ({ + stalkerPlaybackContextService: { + consume: mockConsumeStalkerPlaybackContext, + }, +})); + import { ipcMain } from 'electron'; import { + EMBEDDED_MPV_LOAD_PLAYBACK, EMBEDDED_MPV_SET_PAUSED, EMBEDDED_MPV_SUPPORT, } from '@iptvnator/shared/interfaces'; @@ -42,7 +52,9 @@ function getIpcMainHandler( describe('EmbeddedMpvEvents IPC handlers', () => { beforeEach(() => { mockEmbeddedMpvService.getSupport.mockReset(); + mockEmbeddedMpvService.loadPlayback.mockReset(); mockEmbeddedMpvService.setPaused.mockReset(); + mockConsumeStalkerPlaybackContext.mockReset(); }); it('forwards arguments to the native service and returns its result', async () => { @@ -79,4 +91,94 @@ describe('EmbeddedMpvEvents IPC handlers', () => { consoleErrorSpy.mockRestore(); } }); + + it('consumes a sender-bound context and strips renderer secrets and the ref before native playback', async () => { + mockConsumeStalkerPlaybackContext.mockReturnValue({ + Authorization: 'Bearer main-token', + Cookie: 'session=main-cookie', + Origin: 'https://main.example', + Referer: 'https://main.example/player/', + 'User-Agent': 'MainAgent/1.0', + }); + const handler = getIpcMainHandler(EMBEDDED_MPV_LOAD_PLAYBACK); + + await handler({ sender: { id: 44 } }, 'embedded-session', { + headers: { + Authorization: 'Bearer renderer-token', + Cookie: 'session=renderer-cookie', + }, + origin: 'https://renderer.example', + playbackContextRef: 'opaque-playback-context', + referer: 'https://renderer.example/referrer', + streamUrl: 'https://stream.example/live.m3u8', + title: 'Managed stream', + userAgent: 'RendererAgent/1.0', + }); + + expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledWith({ + contextRef: 'opaque-playback-context', + senderId: 44, + streamUrl: 'https://stream.example/live.m3u8', + }); + expect(mockEmbeddedMpvService.loadPlayback).toHaveBeenCalledWith( + 'embedded-session', + { + headers: { + Authorization: 'Bearer main-token', + Cookie: 'session=main-cookie', + Origin: 'https://main.example', + Referer: 'https://main.example/player/', + 'User-Agent': 'MainAgent/1.0', + }, + origin: 'https://main.example', + referer: 'https://main.example/player/', + streamUrl: 'https://stream.example/live.m3u8', + title: 'Managed stream', + userAgent: 'MainAgent/1.0', + } + ); + const nativePayload = + mockEmbeddedMpvService.loadPlayback.mock.calls[0][1]; + expect(JSON.stringify(nativePayload)).not.toMatch( + /renderer-token|renderer-cookie|opaque-playback-context/ + ); + }); + + it('fails invalid contexts closed and preserves legacy no-ref playback', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + const handler = getIpcMainHandler(EMBEDDED_MPV_LOAD_PLAYBACK); + const legacyPlayback = { + headers: { 'User-Agent': 'LegacyAgent/1.0' }, + streamUrl: 'https://stream.example/legacy.m3u8', + title: 'Legacy stream', + }; + + try { + mockConsumeStalkerPlaybackContext.mockReturnValue(null); + await expect( + handler({ sender: { id: 12 } }, 'embedded-session', { + ...legacyPlayback, + playbackContextRef: 'foreign-or-expired-context', + }) + ).rejects.toThrow('invalid-playback-context'); + expect(mockEmbeddedMpvService.loadPlayback).not.toHaveBeenCalled(); + + await expect( + handler( + { sender: { id: 12 } }, + 'embedded-session', + legacyPlayback + ) + ).resolves.toBeUndefined(); + expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledTimes(1); + expect(mockEmbeddedMpvService.loadPlayback).toHaveBeenCalledWith( + 'embedded-session', + legacyPlayback + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); }); diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index 38a76bf2f..569719b57 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -26,6 +26,8 @@ import { EmbeddedMpvNativeService, embeddedMpvNativeService, } from '../services/embedded-mpv-native.service'; +import { stalkerPlaybackContextService } from '../services/stalker-playback-context.service'; +import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request'; export default class EmbeddedMpvEvents { static bootstrapEmbeddedMpvEvents(): Electron.IpcMain { @@ -47,14 +49,20 @@ function handleEmbeddedMpv( channel: string, handler: (...args: Args) => unknown ): void { - ipcMain.handle(channel, async (_event, ...args: unknown[]) => { + handleEmbeddedMpvEvent(channel, (_event, ...args: Args) => + handler(...args) + ); +} + +function handleEmbeddedMpvEvent( + channel: string, + handler: (event: Electron.IpcMainInvokeEvent, ...args: Args) => unknown +): void { + ipcMain.handle(channel, async (event, ...args: unknown[]) => { try { - return await handler(...(args as Args)); + return await handler(event, ...(args as Args)); } catch (error) { - console.error( - `[Embedded MPV] ${channel} handler failed:`, - error - ); + console.error(`[Embedded MPV] ${channel} handler failed:`, error); throw error; } }); @@ -70,10 +78,49 @@ handleEmbeddedMpv( getService().createSession(bounds, title, initialVolume) ); -handleEmbeddedMpv( +handleEmbeddedMpvEvent( EMBEDDED_MPV_LOAD_PLAYBACK, - (sessionId: string, playback: ResolvedPortalPlayback) => - getService().loadPlayback(sessionId, playback) + (event, sessionId: string, playback: ResolvedPortalPlayback) => { + if (playback.playbackContextRef === undefined) { + return getService().loadPlayback(sessionId, playback); + } + + const mainOwnedHeaders = stalkerPlaybackContextService.consume({ + contextRef: playback.playbackContextRef, + senderId: event.sender.id, + streamUrl: playback.streamUrl, + }); + if (!mainOwnedHeaders) { + throw new Error('invalid-playback-context'); + } + + const { + playbackContextRef: _playbackContextRef, + headers: _headers, + userAgent: _userAgent, + referer: _referer, + origin: _origin, + ...safePlayback + } = playback; + const { + effectiveOrigin, + effectiveReferer, + effectiveUserAgent, + mergedHeaders, + } = resolveEffectiveExternalPlaybackRequest({ + url: playback.streamUrl, + mainOwnedHeaders, + }); + const managedPlayback: ResolvedPortalPlayback = { + ...safePlayback, + headers: mergedHeaders, + ...(effectiveOrigin ? { origin: effectiveOrigin } : {}), + ...(effectiveReferer ? { referer: effectiveReferer } : {}), + ...(effectiveUserAgent ? { userAgent: effectiveUserAgent } : {}), + }; + + return getService().loadPlayback(sessionId, managedPlayback); + } ); handleEmbeddedMpv( diff --git a/apps/electron-backend/src/app/events/external-player-playback-request.ts b/apps/electron-backend/src/app/events/external-player-playback-request.ts index d28f22223..9de22472f 100644 --- a/apps/electron-backend/src/app/events/external-player-playback-request.ts +++ b/apps/electron-backend/src/app/events/external-player-playback-request.ts @@ -51,26 +51,32 @@ export function resolveEffectiveExternalPlaybackRequest(options: { referer?: string; origin?: string; headers?: Record; + mainOwnedHeaders?: Record; }): EffectiveExternalPlaybackRequest { - const fallbackHeaders = getStalkerPlaybackContextHeaders(options.url) ?? {}; - const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders) - ? fallbackHeaders - : { - ...fallbackHeaders, - ...(options.headers ?? {}), - }; + const usesMainOwnedHeaders = options.mainOwnedHeaders !== undefined; + const fallbackHeaders = usesMainOwnedHeaders + ? {} + : (getStalkerPlaybackContextHeaders(options.url) ?? {}); + const mergedHeaders = usesMainOwnedHeaders + ? { ...options.mainOwnedHeaders } + : isStalkerDirectStreamProfile(fallbackHeaders) + ? fallbackHeaders + : { + ...fallbackHeaders, + ...(options.headers ?? {}), + }; const effectiveOrigin = - options.origin ?? + (usesMainOwnedHeaders ? undefined : options.origin) ?? mergedHeaders['Origin'] ?? mergedHeaders['origin'] ?? undefined; const effectiveReferer = - options.referer ?? + (usesMainOwnedHeaders ? undefined : options.referer) ?? mergedHeaders['Referer'] ?? mergedHeaders['referer'] ?? undefined; const effectiveUserAgent = - options.userAgent ?? + (usesMainOwnedHeaders ? undefined : options.userAgent) ?? mergedHeaders['User-Agent'] ?? mergedHeaders['user-agent'] ?? undefined; diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index fb6052f45..5aa05b185 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -39,6 +39,7 @@ export interface OpenExternalPlayerRequest { contentInfo?: PlayerContentInfo; startTime?: number; headers?: Record; + mainOwnedHeaders?: Record; } let mpvProcess: ChildProcess | null = null; @@ -220,6 +221,7 @@ export async function openMpvPlayer({ contentInfo, startTime, headers, + mainOwnedHeaders, }: OpenExternalPlayerRequest) { const session = externalPlayerSessions.beginSession({ player: 'mpv', @@ -254,6 +256,7 @@ export async function openMpvPlayer({ referer, origin, headers, + mainOwnedHeaders, }); traceExternalPlayer('open mpv player', { diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index be939905a..96f038d0d 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -26,8 +26,14 @@ jest.mock('../services/store.service', () => ({ }, })); +const mockConsumeStalkerPlaybackContext = jest.fn(); +const mockGetStalkerPlaybackContextHeaders = jest.fn(() => undefined); + jest.mock('../services/stalker-playback-context.service', () => ({ - getStalkerPlaybackContextHeaders: jest.fn(() => undefined), + getStalkerPlaybackContextHeaders: mockGetStalkerPlaybackContextHeaders, + stalkerPlaybackContextService: { + consume: mockConsumeStalkerPlaybackContext, + }, })); import { ipcMain } from 'electron'; @@ -52,6 +58,7 @@ import { shouldReuseVlcInstance, shouldUseMpvSocketBridge, } from './player.events'; +import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request'; import { openVlcPlayer } from './vlc-session.service'; function createPathExists(existingPaths: string[]) { @@ -388,6 +395,143 @@ describe('player.events external player path settings', () => { }); }); +describe('player.events Stalker playback contexts', () => { + beforeEach(() => { + mockConsumeStalkerPlaybackContext.mockReset(); + mockGetStalkerPlaybackContextHeaders.mockReset(); + mockGetStalkerPlaybackContextHeaders.mockReturnValue(undefined); + (spawn as unknown as jest.Mock).mockReset(); + (store.get as unknown as jest.Mock).mockImplementation( + (_key: string, fallback?: unknown) => fallback + ); + }); + + it('uses only main-owned context headers for a sender-bound MPV launch and consumes the ref once', async () => { + jest.useFakeTimers(); + try { + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockConsumeStalkerPlaybackContext + .mockReturnValueOnce({ + Authorization: 'Bearer main-token', + Cookie: 'session=main-cookie', + Origin: 'https://main.example', + Referer: 'https://main.example/player/', + 'User-Agent': 'MainAgent/1.0', + }) + .mockReturnValueOnce(null); + const handler = getIpcMainHandler('OPEN_MPV_PLAYER'); + const args = [ + 'https://stream.example/live.m3u8', + 'Managed stream', + '', + 'RendererAgent/1.0', + 'https://renderer.example/referrer', + 'https://renderer.example', + undefined, + undefined, + { + Authorization: 'Bearer renderer-token', + Cookie: 'session=renderer-cookie', + }, + 'opaque-playback-context', + ] as const; + + const firstLaunch = handler({ sender: { id: 71 } }, ...args); + jest.advanceTimersByTime(100); + await expect(firstLaunch).resolves.toMatchObject({ + player: 'mpv', + status: 'opened', + }); + + expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledWith({ + contextRef: 'opaque-playback-context', + senderId: 71, + streamUrl: 'https://stream.example/live.m3u8', + }); + expect(mockGetStalkerPlaybackContextHeaders).not.toHaveBeenCalled(); + const spawnArgs = (spawn as unknown as jest.Mock).mock.calls[0][1]; + const serializedArgs = JSON.stringify(spawnArgs); + expect(serializedArgs).toContain('Bearer main-token'); + expect(serializedArgs).toContain('session=main-cookie'); + expect(serializedArgs).toContain('MainAgent/1.0'); + expect(serializedArgs).not.toContain('renderer-token'); + expect(serializedArgs).not.toContain('renderer-cookie'); + expect(serializedArgs).not.toContain('RendererAgent/1.0'); + expect(serializedArgs).not.toContain('opaque-playback-context'); + + await expect( + handler({ sender: { id: 71 } }, ...args) + ).rejects.toThrow('invalid-playback-context'); + expect(spawn).toHaveBeenCalledTimes(1); + expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('fails a foreign or expired VLC context before spawning the player', async () => { + mockConsumeStalkerPlaybackContext.mockReturnValue(null); + const handler = getIpcMainHandler('OPEN_VLC_PLAYER'); + + await expect( + handler( + { sender: { id: 99 } }, + 'https://stream.example/live.m3u8', + 'Managed stream', + '', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'foreign-or-expired-context' + ) + ).rejects.toThrow('invalid-playback-context'); + + expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledWith({ + contextRef: 'foreign-or-expired-context', + senderId: 99, + streamUrl: 'https://stream.example/live.m3u8', + }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('keeps the legacy no-ref fallback while main-owned headers bypass renderer overrides', () => { + mockGetStalkerPlaybackContextHeaders.mockReturnValue({ + 'User-Agent': 'LegacyAgent/1.0', + }); + + expect( + resolveEffectiveExternalPlaybackRequest({ + headers: { 'X-Renderer': 'allowed-legacy-header' }, + url: 'https://stream.example/legacy.m3u8', + }).mergedHeaders + ).toEqual({ + 'User-Agent': 'LegacyAgent/1.0', + 'X-Renderer': 'allowed-legacy-header', + }); + + const managedOptions = { + headers: { + Authorization: 'Bearer renderer-token', + 'User-Agent': 'RendererAgent/1.0', + }, + mainOwnedHeaders: { + Authorization: 'Bearer main-token', + 'User-Agent': 'MainAgent/1.0', + }, + url: 'https://stream.example/managed.m3u8', + userAgent: 'RendererAgent/1.0', + }; + const managed = resolveEffectiveExternalPlaybackRequest(managedOptions); + + expect(managed.mergedHeaders).toEqual(managedOptions.mainOwnedHeaders); + expect(managed.effectiveUserAgent).toBe('MainAgent/1.0'); + }); +}); + describe('openVlcPlayer', () => { beforeEach(() => { (spawn as unknown as jest.Mock).mockReset(); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index d004ed2b4..f3fa82fd0 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import { CLOSE_EXTERNAL_PLAYER_SESSION, PlayerContentInfo, + StalkerPlaybackContextRef, } from '@iptvnator/shared/interfaces'; import { MPV_PLAYER_PATH, @@ -9,6 +10,7 @@ import { VLC_PLAYER_PATH, } from '../services/store.service'; import { normalizePlayerPathForStore } from './external-player-launch-context'; +import { stalkerPlaybackContextService } from '../services/stalker-playback-context.service'; import { externalPlayerSessions, traceExternalPlayer, @@ -41,7 +43,7 @@ export default class PlayerEvents { ipcMain.handle( 'OPEN_MPV_PLAYER', async ( - _event, + event, url: string, title: string, thumbnail?: string, @@ -50,9 +52,29 @@ ipcMain.handle( origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, - headers?: Record - ) => - openMpvPlayer({ + headers?: Record, + playbackContextRef?: StalkerPlaybackContextRef + ) => { + if (playbackContextRef !== undefined) { + const mainOwnedHeaders = stalkerPlaybackContextService.consume({ + contextRef: playbackContextRef, + senderId: event.sender.id, + streamUrl: url, + }); + if (!mainOwnedHeaders) { + throw new Error('invalid-playback-context'); + } + return openMpvPlayer({ + url, + title, + thumbnail, + contentInfo, + startTime, + mainOwnedHeaders, + }); + } + + return openMpvPlayer({ url, title, thumbnail, @@ -62,7 +84,8 @@ ipcMain.handle( contentInfo, startTime, headers, - }) + }); + } ); ipcMain.handle( @@ -83,7 +106,7 @@ ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { ipcMain.handle( 'OPEN_VLC_PLAYER', async ( - _event, + event, url: string, title: string, thumbnail?: string, @@ -92,9 +115,29 @@ ipcMain.handle( origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, - headers?: Record - ) => - openVlcPlayer({ + headers?: Record, + playbackContextRef?: StalkerPlaybackContextRef + ) => { + if (playbackContextRef !== undefined) { + const mainOwnedHeaders = stalkerPlaybackContextService.consume({ + contextRef: playbackContextRef, + senderId: event.sender.id, + streamUrl: url, + }); + if (!mainOwnedHeaders) { + throw new Error('invalid-playback-context'); + } + return openVlcPlayer({ + url, + title, + thumbnail, + contentInfo, + startTime, + mainOwnedHeaders, + }); + } + + return openVlcPlayer({ url, title, thumbnail, @@ -104,7 +147,8 @@ ipcMain.handle( contentInfo, startTime, headers, - }) + }); + } ); ipcMain.handle( diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index a8ad22e6a..a07633bfb 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -39,6 +39,7 @@ export interface OpenVlcPlayerRequest { contentInfo?: PlayerContentInfo; startTime?: number; headers?: Record; + mainOwnedHeaders?: Record; } let vlcProcess: ChildProcess | null = null; @@ -309,6 +310,7 @@ export async function openVlcPlayer({ contentInfo, startTime, headers, + mainOwnedHeaders, }: OpenVlcPlayerRequest) { const session = externalPlayerSessions.beginSession({ player: 'vlc', @@ -342,6 +344,7 @@ export async function openVlcPlayer({ referer, origin, headers, + mainOwnedHeaders, }); traceExternalPlayer('open vlc player', { path: vlcLaunchContext.playerPath, diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 8288a78cd..800f2d7c5 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -198,6 +198,34 @@ describe('ElectronService', () => { ); }); + it.each([ + ['OPEN_MPV_PLAYER', 'openInMpv'], + ['OPEN_VLC_PLAYER', 'openInVlc'], + ])( + 'forwards opaque playback contexts through %s', + async (event, method) => { + await service.sendIpcEvent(event, { + playbackContextRef: 'opaque-playback-context', + url: 'https://stream.example/live.m3u8', + }); + + expect( + electronBridge[method as 'openInMpv' | 'openInVlc'] + ).toHaveBeenCalledWith( + 'https://stream.example/live.m3u8', + '', + '', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'opaque-playback-context' + ); + } + ); + describe('startup playlist auto-refresh feedback', () => { function createPlaylist(id: string, title: string): Playlist { return { diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 688e80844..dab07c825 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -16,6 +16,7 @@ import { Playlist, PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE, + StalkerPlaybackContextRef, XTREAM_REQUEST, XTREAM_RESPONSE, XtreamCodeActions, @@ -38,6 +39,7 @@ interface PlayerLaunchPayload { readonly url: string; readonly ['user-agent']?: string; readonly contentInfo?: PlayerContentInfo; + readonly playbackContextRef?: StalkerPlaybackContextRef; } interface ErrorStatus { @@ -165,7 +167,7 @@ export class ElectronService extends DataService { if (type === 'OPEN_MPV_PLAYER') { const data = payload as PlayerLaunchPayload; try { - return (await window.electron.openInMpv( + const args = [ data.url, data.title ?? '', data.thumbnail ?? '', @@ -174,8 +176,14 @@ export class ElectronService extends DataService { data.origin ?? undefined, data.contentInfo, data.startTime, - data.headers ?? undefined - )) as T; + data.headers ?? undefined, + ] as const; + return (await (data.playbackContextRef === undefined + ? window.electron.openInMpv(...args) + : window.electron.openInMpv( + ...args, + data.playbackContextRef + ))) as T; } catch (error: unknown) { const errorMessage = this.getErrorDetails(error)?.message ?? String(error); @@ -194,7 +202,7 @@ export class ElectronService extends DataService { if (type === 'OPEN_VLC_PLAYER') { const data = payload as PlayerLaunchPayload; try { - return (await window.electron.openInVlc( + const args = [ data.url, data.title ?? '', data.thumbnail ?? '', @@ -203,8 +211,14 @@ export class ElectronService extends DataService { data.origin ?? undefined, data.contentInfo, data.startTime, - data.headers ?? undefined - )) as T; + data.headers ?? undefined, + ] as const; + return (await (data.playbackContextRef === undefined + ? window.electron.openInVlc(...args) + : window.electron.openInVlc( + ...args, + data.playbackContextRef + ))) as T; } catch (error: unknown) { const errorMessage = this.getErrorDetails(error)?.message ?? String(error); diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index 86ab12ca8..2642e0140 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -130,4 +130,31 @@ describe('PlayerService', () => { expect(dialog.open).not.toHaveBeenCalled(); expect(result).toEqual(session); }); + + it('forwards only the opaque context instead of renderer-controlled headers for managed playback', async () => { + settingsStore.player.mockReturnValue(VideoPlayer.MPV); + + await service.openResolvedPlayback({ + headers: { + Authorization: 'Bearer renderer-token', + Cookie: 'session=renderer-cookie', + }, + origin: 'https://renderer.example', + playbackContextRef: 'opaque-playback-context', + referer: 'https://renderer.example/referrer', + streamUrl: 'https://stream.example/live.m3u8', + title: 'Managed stream', + userAgent: 'RendererAgent/1.0', + }); + + const payload = dataService.sendIpcEvent.mock.calls[0][1]; + expect(payload).toMatchObject({ + playbackContextRef: 'opaque-playback-context', + url: 'https://stream.example/live.m3u8', + }); + expect(payload).not.toHaveProperty('headers'); + expect(payload).not.toHaveProperty('user-agent'); + expect(payload).not.toHaveProperty('referer'); + expect(payload).not.toHaveProperty('origin'); + }); }); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index a37e32b66..b262db01b 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -89,20 +89,30 @@ export class PlayerService { ): Promise { const ipcEvent = player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER; + const presentation = { + url: playback.streamUrl, + title: playback.title, + thumbnail: playback.thumbnail, + contentInfo: playback.contentInfo, + startTime: playback.startTime, + }; + const payload = + playback.playbackContextRef !== undefined + ? { + ...presentation, + playbackContextRef: playback.playbackContextRef, + } + : { + ...presentation, + 'user-agent': playback.userAgent, + referer: playback.referer, + origin: playback.origin, + headers: playback.headers, + }; return await this.dataService.sendIpcEvent( ipcEvent, - { - url: playback.streamUrl, - title: playback.title, - thumbnail: playback.thumbnail, - 'user-agent': playback.userAgent, - referer: playback.referer, - origin: playback.origin, - headers: playback.headers, - contentInfo: playback.contentInfo, - startTime: playback.startTime, - } + payload ); } } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index aa4ba74d8..7ae98eafe 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -40,6 +40,7 @@ import { StalkerSessionControlOutcome, StalkerSessionControlRequest, StalkerSessionOpenRequest, + StalkerPlaybackContextRef, StalkerSessionRequest, StalkerSessionRequestOutcome, } from './stalker-session.interface'; @@ -594,7 +595,8 @@ export interface ElectronBridgeApi { origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, - headers?: Record + headers?: Record, + playbackContextRef?: StalkerPlaybackContextRef ) => Promise; openInVlc: ( url: string, @@ -605,7 +607,8 @@ export interface ElectronBridgeApi { origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, - headers?: Record + headers?: Record, + playbackContextRef?: StalkerPlaybackContextRef ) => Promise; autoUpdatePlaylists: ( playlists: Playlist[],