feat(playback): forward portal Cookie/Authorization to built-in players

The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).

- request-header-overrides.service: the scoped override now carries Cookie
  and Authorization, attached only to requests on the exact stream origin,
  in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
  drop credentials fail-closed; control characters in header values are
  rejected. Chosen over session.cookies.set(): jar cookies attach only to
  credentialed requests, which would force withCredentials into every engine
  and break against the Access-Control-Allow-Origin:* IPTV panels send, and
  jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
  every built-in player: it extracts the full header set from the resolved
  playback, configures the override BEFORE handing the source over (players
  render only once the source exists), and clears the scoped layer on
  destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
  would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
  set ITV already had (they previously carried no portal headers at all);
  same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
  (isStalkerStreamCredentialSafe): same-host port changes and scheme
  upgrades keep the portal profile (the #1158 class), a foreign host or
  https->http downgrade keeps the credential-free KSPlayer profile. The
  main-process fallback context uses the same predicate so
  isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
  ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
  create_link returns a local /stream/gated/video.mp4 that 403s without the
  mac cookie + current Bearer token; new Electron e2e proves a built-in
  player actually plays it (and that the gate refuses bare requests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-02 08:34:21 +02:00
1 parent 297e9fbef8
commit f196abd963
27 files changed
+1393 -136

No files matched your search

@@ -0,0 +1,93 @@
import {
addStalkerPortal,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
stalkerMockServer,
test,
waitForStalkerCatalog,
} from './electron-test-fixtures';
/**
* End-to-end proof that a BUILT-IN player's media requests carry the portal
* credentials (mac cookie + Bearer token) — the root of the long-running
* "only VLC works" cluster (#849, #910, #732): the web players used to
* receive only User-Agent/Referer/Origin, so any stream gated on the portal
* session could never play inline.
*
* The mock's `gated-stream` scenario makes `create_link` return this
* server's own `/stream/gated/video.mp4`, which answers 403 unless the
* request presents the mac cookie AND the MAC's current access token. A unit
* test cannot show that a header reached the video element; playback
* advancing past that gate can only happen when the scoped Electron header
* override attached the credentials to the actual media request.
*/
const GATED_MAC = '00:1A:79:00:00:09';
const GATED_STREAM_URL = `${stalkerMockServer}/stream/gated/video.mp4`;
// The full-portal URL shape: the app handshakes and holds a Bearer token,
// which is exactly what the gated stream endpoint demands.
const FULL_PORTAL_URL = `${stalkerMockServer}/stalker_portal/server/load.php`;
test('@electron @stalker built-in player plays an auth-gated portal stream', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
// First prove the gate is real: a credential-less request is refused, so
// a green playback assertion below cannot be a permissive-mock artifact.
const bareResponse = await request.get(GATED_STREAM_URL);
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);
// The portal lands on Movies; live playback lives in the ITV layout.
await app.mainWindow
.getByRole('link', { name: /live|itv/i })
.click();
await app.mainWindow.waitForURL(/stalker.*itv/);
// The ITV view renders channels only after a category is selected;
// index 0 is the "All channels" pseudo-category.
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();
const video = app.mainWindow
.locator('app-web-player-view video')
.first();
await expect(video).toBeVisible({ timeout: 15_000 });
// Advancing playback past the 403 gate is only possible when the
// media requests carried the portal cookie and Authorization header.
await expect
.poll(
() =>
video.evaluate(
(element: HTMLVideoElement) => element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(0.5);
await expect(
app.mainWindow.getByTestId('playback-diagnostic-banner')
).toBeHidden();
} finally {
await closeElectronApp(app);
}
});