mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(playback): apply header override to Stalker radio, redact mock cookie log
Address Codex review feedback on #1335: - The radio branch of the Stalker live layout renders the dedicated audio player, never WebPlayerViewComponent, so the resolved portal headers were built but never applied — an auth-gated radio stream still 403'd. The override sync is extracted into ElectronStreamHeadersService (single owner of the scoped override slot, with clear-only-while-owning semantics so a destroyed consumer cannot wipe a newer consumer's override), applied by WebPlayerViewComponent for video players and by the radio branch before the audio element gets its URL. The service feature-detects the bridge method so partial bridges behave like the PWA instead of throwing. - The gated-stream mock no longer logs the raw Cookie header on 403 — presence only, matching the Authorization logging. - The gated scenario now serves an audio fixture for radio create_link and the Electron e2e covers the radio path end-to-end (bare request 403s, built-in audio player advances past the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f196abd963
commit
c2d1bff89d
11 files changed
+423
-85
No files matched your search
@@ -91,3 +91,58 @@ test('@electron @stalker built-in player plays an auth-gated portal stream', asy
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @stalker built-in audio player plays an auth-gated radio stream', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
// The radio branch renders the dedicated audio player instead of
|
||||
// WebPlayerViewComponent, so it exercises the Stalker live layout's own
|
||||
// header wiring — a gap the ITV test above cannot catch.
|
||||
const bareResponse = await request.get(
|
||||
`${stalkerMockServer}/stream/gated/audio.mp4`
|
||||
);
|
||||
expect(bareResponse.status()).toBe(403);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
macAddress: GATED_MAC,
|
||||
portalUrl: FULL_PORTAL_URL,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow.getByRole('link', { name: /radio/i }).click();
|
||||
await app.mainWindow.waitForURL(/stalker.*radio/);
|
||||
|
||||
const categories = app.mainWindow.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
await categories.first().click();
|
||||
|
||||
const channels = app.mainWindow.locator(
|
||||
'[data-test-id="channel-item"]'
|
||||
);
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
|
||||
// The bare <audio> element renders zero-size (its UI is custom), so
|
||||
// assert attachment rather than visibility.
|
||||
const audio = app.mainWindow.locator('app-audio-player audio').first();
|
||||
await expect(audio).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
audio.evaluate(
|
||||
(element: HTMLAudioElement) => element.currentTime
|
||||
),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -28,8 +28,12 @@ export function handleCreateLink(req: Request, res: Response): void {
|
||||
typeof req.get === 'function'
|
||||
? buildRequestOrigin(req)
|
||||
: `http://${req.headers['host'] ?? 'localhost:3210'}`;
|
||||
// Radio plays in an <audio> element, which needs a fixture with an audio
|
||||
// track; ITV/VOD get the video fixture.
|
||||
const gatedFile =
|
||||
req.query['type'] === 'radio' ? 'audio.mp4' : 'video.mp4';
|
||||
const streamUrl = getScenario(mac).gatedStream
|
||||
? `${requestOrigin}/stream/gated/video.mp4`
|
||||
? `${requestOrigin}/stream/gated/${gatedFile}`
|
||||
: resolveStreamUrl(cmd, itemIndex);
|
||||
|
||||
console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`);
|
||||
|
||||
@@ -177,28 +177,43 @@ app.get('/stalker', (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth-gated media endpoint for the `gated-stream` scenario. A real portal's
|
||||
* streamer sits behind the same session gate as the API, so this route
|
||||
* requires the mac cookie AND the MAC's Bearer token and answers 403
|
||||
* otherwise. It is the only automated proof that a player's actual media
|
||||
* Auth-gated media endpoints for the `gated-stream` scenario. A real portal's
|
||||
* streamer sits behind the same session gate as the API, so these routes
|
||||
* require the mac cookie AND the MAC's Bearer token and answer 403
|
||||
* otherwise. They are the only automated proof that a player's actual media
|
||||
* requests carry the portal credentials — a unit test cannot show that a
|
||||
* header reached the video element.
|
||||
* header reached the video (or audio) element.
|
||||
*
|
||||
* The body is the shared clear (non-DRM) fragmented-MP4 fixture from the
|
||||
* DASH e2e suite; `sendFile` supplies Range support for progressive playback.
|
||||
* The bodies are the shared clear (non-DRM) fragmented-MP4 fixtures from the
|
||||
* DASH e2e suite (video for ITV, audio-only for radio); `sendFile` supplies
|
||||
* Range support for progressive playback.
|
||||
*/
|
||||
const GATED_STREAM_FIXTURE = join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
|
||||
);
|
||||
const GATED_STREAM_FIXTURES: Record<string, string> = {
|
||||
'audio.mp4': join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-audio.mp4'
|
||||
),
|
||||
'video.mp4': join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
|
||||
),
|
||||
};
|
||||
|
||||
app.get('/stream/gated/:file', (req: Request, res: Response) => {
|
||||
const fixture = GATED_STREAM_FIXTURES[req.params['file'] ?? ''];
|
||||
if (!fixture) {
|
||||
res.status(404).type('text/plain').send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
app.get('/stream/gated/video.mp4', (req: Request, res: Response) => {
|
||||
const failure = checkRequestAuthorization(req, true);
|
||||
if (failure) {
|
||||
// Log only header PRESENCE: the cookie carries the mac session
|
||||
// credential and must never reach terminal/CI logs verbatim.
|
||||
console.log(
|
||||
`[gated-stream] 403 (${failure}) cookie=${String(
|
||||
req.headers['cookie'] ?? '<none>'
|
||||
)} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
|
||||
`[gated-stream] 403 (${failure}) cookie=${
|
||||
req.headers['cookie'] ? 'present' : '<none>'
|
||||
} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
|
||||
);
|
||||
res.status(403).type('text/plain').send(failure);
|
||||
return;
|
||||
@@ -207,7 +222,7 @@ app.get('/stream/gated/video.mp4', (req: Request, res: Response) => {
|
||||
// `dotfiles: 'allow'`: express refuses any path with a dot-segment by
|
||||
// default, and git worktrees live under `.claude/worktrees/…` — without
|
||||
// this the fixture 404s in every worktree checkout.
|
||||
res.sendFile(GATED_STREAM_FIXTURE, {
|
||||
res.sendFile(fixture, {
|
||||
dotfiles: 'allow',
|
||||
headers: { 'Content-Type': 'video/mp4' },
|
||||
});
|
||||
|
||||
@@ -132,11 +132,16 @@ The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders
|
||||
listener and updates layered in-memory overrides instead of stacking a new
|
||||
listener for every channel change.
|
||||
|
||||
`WebPlayerViewComponent` is the single renderer owner of the scoped override:
|
||||
it extracts the full header set from the resolved playback (including the
|
||||
Stalker mac cookie and Bearer token), configures the override **before**
|
||||
handing the source to any built-in player, and clears the scoped layer on
|
||||
destroy. Individual player components must not call the bridge themselves — a
|
||||
`ElectronStreamHeadersService` (`libs/ui/playback`) is the single renderer
|
||||
owner of the scoped override slot: it extracts the full header set from the
|
||||
resolved playback (including the Stalker mac cookie and Bearer token), and
|
||||
its `clear()` releases the slot only while the caller's stream still owns it,
|
||||
so a consumer being destroyed cannot wipe an override a newer consumer just
|
||||
configured. Two surfaces apply it: `WebPlayerViewComponent` for every
|
||||
built-in video player (configuring the override **before** handing the
|
||||
source over, clearing on destroy), and the Stalker live layout for the
|
||||
dedicated radio audio player, which never mounts a `WebPlayerViewComponent`.
|
||||
Individual player components must not call the bridge themselves — a
|
||||
narrower call would overwrite the credentialed override.
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -190,10 +190,12 @@ stream and attaches the same portal header set through
|
||||
`buildStalkerExternalPlaybackHeaders()`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts`).
|
||||
The resolved `ResolvedPortalPlayback.headers` feed both the external players
|
||||
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in web players (via
|
||||
the scoped Electron request-header override owned by `WebPlayerViewComponent`
|
||||
— see `docs/architecture/electron-security.md`, "Scoped Request Header
|
||||
Overrides").
|
||||
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
|
||||
scoped Electron request-header override (`ElectronStreamHeadersService`,
|
||||
applied by `WebPlayerViewComponent` for the video players and by the Stalker
|
||||
live layout for the radio audio player, which renders outside
|
||||
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
|
||||
"Scoped Request Header Overrides").
|
||||
|
||||
Two stream profiles exist, selected by one shared predicate:
|
||||
|
||||
|
||||
+41
@@ -279,6 +279,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
@@ -1127,6 +1128,46 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
expect(audioPlayer.channelName()).toBe('Jazz FM');
|
||||
expect(audioPlayer.dispatchAdjacentChannelAction()).toBe(false);
|
||||
});
|
||||
|
||||
it('configures the scoped Electron header override before radio playback starts', async () => {
|
||||
// The radio branch renders the dedicated audio player, not
|
||||
// WebPlayerViewComponent — without this wiring an auth-gated portal
|
||||
// radio stream 403s because its credentials never reach the request.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
Authorization: 'Bearer TOKEN99',
|
||||
},
|
||||
});
|
||||
|
||||
await component.playChannel(radioChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
'http://portal.example/radio_2.mpg',
|
||||
{
|
||||
authorization: 'Bearer TOKEN99',
|
||||
cookie: 'mac=00:1A:79:00:00:01',
|
||||
}
|
||||
);
|
||||
|
||||
const audioPlayer = fixture.debugElement.query(
|
||||
By.directive(StubAudioPlayerComponent)
|
||||
).componentInstance as StubAudioPlayerComponent;
|
||||
expect(audioPlayer.url()).toBe('http://portal.example/radio_2.mpg');
|
||||
});
|
||||
});
|
||||
|
||||
function buildProgram(channelId: string, title: string): EpgProgram {
|
||||
|
||||
+19
@@ -51,6 +51,7 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -116,6 +117,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly liveSidebarStateService = inject(
|
||||
@@ -360,6 +362,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private epgLoadRequestId = 0;
|
||||
private playbackRequestId = 0;
|
||||
/** Stream URL of the radio playback whose header override this layout configured. */
|
||||
private radioHeaderScopeUrl: string | null = null;
|
||||
private playbackResolution: {
|
||||
channelId: string;
|
||||
promise: Promise<ResolvedPortalPlayback>;
|
||||
@@ -556,6 +560,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
this.removeScrollListener();
|
||||
// Radio credentials must not outlive this layout; the service no-ops
|
||||
// when a newer playback already owns the override slot.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
}
|
||||
|
||||
isSelectedChannel(item: StalkerItvChannel): boolean {
|
||||
@@ -587,6 +594,18 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (isRadioMode) {
|
||||
// The radio branch renders the dedicated audio player, not
|
||||
// WebPlayerViewComponent, so the scoped Electron header
|
||||
// override (portal cookie/token for auth-gated streams) must
|
||||
// be configured here BEFORE the audio element gets the URL.
|
||||
await this.streamHeaders.apply(playback);
|
||||
if (
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.selectedChannelId() !== channelId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.radioHeaderScopeUrl = playback.streamUrl;
|
||||
this.activePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ export * from './lib/portal-inline-player/up-next-rail.util';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/vod-details/vod-details.component';
|
||||
export * from './lib/web-player-view/electron-stream-headers.service';
|
||||
export * from './lib/web-player-view/web-player-view.component';
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
|
||||
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const GATED_PLAYBACK = {
|
||||
streamUrl: STREAM_URL,
|
||||
title: 'Gated Channel',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
Authorization: 'Bearer TOKEN123',
|
||||
},
|
||||
};
|
||||
|
||||
describe('ElectronStreamHeadersService', () => {
|
||||
let service: ElectronStreamHeadersService;
|
||||
let setUserAgent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(window as unknown as { electron?: unknown }).electron = {
|
||||
setUserAgent,
|
||||
};
|
||||
service = TestBed.inject(ElectronStreamHeadersService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('returns null without an Electron bridge (PWA)', () => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
|
||||
expect(service.apply(GATED_PLAYBACK)).toBeNull();
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts the full header set and scopes it to the stream URL', async () => {
|
||||
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
{
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers explicit userAgent/referer fields over the headers map', async () => {
|
||||
await service.apply({
|
||||
...GATED_PLAYBACK,
|
||||
userAgent: 'ExplicitAgent/1.0',
|
||||
referer: 'http://explicit.example',
|
||||
});
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'ExplicitAgent/1.0',
|
||||
'http://explicit.example',
|
||||
STREAM_URL,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the credentials object when the playback carries none', async () => {
|
||||
await service.apply({
|
||||
streamUrl: 'https://example.com/live/plain.m3u8',
|
||||
title: 'Plain Channel',
|
||||
userAgent: 'PlainAgent/1.0',
|
||||
});
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'PlainAgent/1.0',
|
||||
undefined,
|
||||
'https://example.com/live/plain.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a superseded apply as not current', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
setUserAgent.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) =>
|
||||
resolvers.push(() => resolve(true))
|
||||
)
|
||||
);
|
||||
|
||||
const first = service.apply(GATED_PLAYBACK);
|
||||
const second = service.apply({
|
||||
...GATED_PLAYBACK,
|
||||
streamUrl: 'http://portal.example:8080/live/ch2.ts',
|
||||
});
|
||||
|
||||
resolvers[0]();
|
||||
resolvers[1]();
|
||||
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(second).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('clears the override only while the caller still owns the slot', async () => {
|
||||
await service.apply(GATED_PLAYBACK);
|
||||
setUserAgent.mockClear();
|
||||
|
||||
// A stale consumer (e.g. a destroyed component) must not wipe the
|
||||
// override a newer playback configured.
|
||||
service.clear('http://portal.example:8080/other-stream.ts');
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
|
||||
service.clear(STREAM_URL);
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
STREAM_URL
|
||||
);
|
||||
});
|
||||
|
||||
it('does not clear twice for the same stream', async () => {
|
||||
await service.apply(GATED_PLAYBACK);
|
||||
service.clear(STREAM_URL);
|
||||
setUserAgent.mockClear();
|
||||
|
||||
service.clear(STREAM_URL);
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves instead of throwing when the bridge rejects', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* silence */
|
||||
});
|
||||
setUserAgent.mockRejectedValue(new Error('ipc down'));
|
||||
|
||||
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchingKey = Object.keys(headers).find(
|
||||
(key) => key.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return matchingKey ? headers[matchingKey] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of the scoped Electron request-header override for built-in
|
||||
* playback. There is exactly one scoped override slot in the main process, so
|
||||
* every surface that plays a stream inline goes through this service:
|
||||
* `WebPlayerViewComponent` for the web video players, and the Stalker live
|
||||
* layout for the dedicated radio audio player, which never mounts a
|
||||
* `WebPlayerViewComponent` at all.
|
||||
*
|
||||
* `apply()` extracts the full header set from the resolved playback —
|
||||
* including the portal Cookie/Authorization that auth-gated streams require —
|
||||
* and hands it to the main process scoped to the stream's URL. A playback
|
||||
* without custom headers deliberately clears the previous scoped override so
|
||||
* stale headers never leak onto the next stream. `clear()` releases the slot
|
||||
* only while the caller's stream still owns it, so a consumer being destroyed
|
||||
* cannot wipe the override a newer consumer just configured.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronStreamHeadersService {
|
||||
/** Guards against a superseded apply resolving its header IPC late. */
|
||||
private syncSequence = 0;
|
||||
/** Stream URL the currently configured override belongs to. */
|
||||
private currentScopeUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Configures the scoped override for this playback. Resolves `true` when
|
||||
* this apply is still the newest one at the time the main process
|
||||
* acknowledged it — callers must only hand the source to a player on
|
||||
* `true`, so the first media request already carries the headers. Returns
|
||||
* null when there is no Electron bridge (PWA), where sources apply
|
||||
* synchronously and no override exists.
|
||||
*/
|
||||
apply(playback: ResolvedPortalPlayback): Promise<boolean> | null {
|
||||
// Feature-detect the bridge method rather than the bridge object:
|
||||
// partial bridges (tests, older preloads) must behave like the PWA
|
||||
// instead of throwing inside a playback flow.
|
||||
if (typeof window.electron?.setUserAgent !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sequence = ++this.syncSequence;
|
||||
const userAgent =
|
||||
playback.userAgent ??
|
||||
getHeaderValue(playback.headers, 'User-Agent');
|
||||
const referer =
|
||||
playback.referer ?? getHeaderValue(playback.headers, 'Referer');
|
||||
const cookie = getHeaderValue(playback.headers, 'Cookie');
|
||||
const authorization = getHeaderValue(playback.headers, 'Authorization');
|
||||
|
||||
this.currentScopeUrl = playback.streamUrl;
|
||||
return window.electron
|
||||
.setUserAgent(
|
||||
userAgent,
|
||||
referer,
|
||||
playback.streamUrl,
|
||||
cookie || authorization ? { authorization, cookie } : undefined
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[ElectronStreamHeaders] Failed to configure request headers:',
|
||||
error
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.then(() => sequence === this.syncSequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the scoped override — portal credentials must not outlive the
|
||||
* playback session that needed them. No-ops when another stream has taken
|
||||
* ownership of the slot since, and always in the PWA.
|
||||
*/
|
||||
clear(streamUrl: string | null): void {
|
||||
if (
|
||||
typeof window.electron?.setUserAgent !== 'function' ||
|
||||
streamUrl === null ||
|
||||
this.currentScopeUrl !== streamUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncSequence += 1;
|
||||
this.currentScopeUrl = null;
|
||||
void window.electron
|
||||
.setUserAgent(undefined, undefined, streamUrl)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
import {
|
||||
getDiagnosticCodecHint,
|
||||
getDiagnosticDescriptionKey,
|
||||
@@ -224,8 +225,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
|
||||
/** Stream URL the currently configured Electron header override belongs to. */
|
||||
private headerScopeStreamUrl: string | null = null;
|
||||
/** Guards against a superseded playback resolving its header IPC late. */
|
||||
private playbackSyncSequence = 0;
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -235,20 +235,16 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
const playback = this.resolvedPlayback();
|
||||
const isLive = this.resolvedIsLive();
|
||||
this.playbackDiagnostic.set(null);
|
||||
void this.applyPlayback(playback, isLive);
|
||||
this.applyPlayback(playback, isLive);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Portal credentials must not outlive the playback session that
|
||||
// needed them: dropping the scoped override here keeps only the
|
||||
// playlist-level (unscoped) User-Agent/Referer defaults active.
|
||||
this.playbackSyncSequence += 1;
|
||||
if (window.electron && this.headerScopeStreamUrl !== null) {
|
||||
void window.electron
|
||||
.setUserAgent(undefined, undefined, this.headerScopeStreamUrl)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
// playlist-level (unscoped) User-Agent/Referer defaults active. The
|
||||
// service no-ops if a newer consumer already owns the override slot.
|
||||
this.streamHeaders.clear(this.headerScopeStreamUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,8 +259,8 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
playback: ResolvedPortalPlayback,
|
||||
isLive: boolean
|
||||
): void {
|
||||
const sequence = ++this.playbackSyncSequence;
|
||||
const headerSync = this.syncElectronStreamHeaders(playback);
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.headerScopeStreamUrl = playback.streamUrl;
|
||||
const handOff = (): void => {
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, isLive);
|
||||
@@ -275,58 +271,13 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
void headerSync.then(() => {
|
||||
if (sequence === this.playbackSyncSequence) {
|
||||
void headerSync.then((stillCurrent) => {
|
||||
if (stillCurrent) {
|
||||
handOff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of the scoped header override for every built-in player.
|
||||
* Extracts the full header set from the resolved playback — including the
|
||||
* portal Cookie/Authorization that auth-gated Stalker streams require —
|
||||
* and hands it to the main process, scoped to this stream's URL. A
|
||||
* playback without custom headers deliberately clears the previous scoped
|
||||
* override so stale headers never leak onto the next stream. Returns null
|
||||
* when there is no Electron bridge (PWA).
|
||||
*/
|
||||
private syncElectronStreamHeaders(
|
||||
playback: ResolvedPortalPlayback
|
||||
): Promise<void> | null {
|
||||
if (!window.electron) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userAgent =
|
||||
playback.userAgent ??
|
||||
this.getHeaderValue(playback.headers, 'User-Agent');
|
||||
const referer =
|
||||
playback.referer ??
|
||||
this.getHeaderValue(playback.headers, 'Referer');
|
||||
const cookie = this.getHeaderValue(playback.headers, 'Cookie');
|
||||
const authorization = this.getHeaderValue(
|
||||
playback.headers,
|
||||
'Authorization'
|
||||
);
|
||||
|
||||
this.headerScopeStreamUrl = playback.streamUrl;
|
||||
return window.electron
|
||||
.setUserAgent(
|
||||
userAgent,
|
||||
referer,
|
||||
playback.streamUrl,
|
||||
cookie || authorization ? { authorization, cookie } : undefined
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[WebPlayerView] Failed to configure Electron request headers:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
setVjsOptions(streamUrl: string, isLive = true) {
|
||||
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
|
||||
const mimeType =
|
||||
|
||||
Reference in new issue
Block a user