fix(stalker): let media transports control seek byte ranges (#1544)

This commit is contained in:
4gray authored and GitHub committed 2026-09-05 20:51:32 +02:00
1 parent d9d6f49757
commit eb3cf89f62
7 files changed
+100 -16

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Seeking in Stalker movies and series now reaches the selected position instead of jumping forward or skipping to the next episode after resuming playback.
@@ -7,6 +7,31 @@ import {
describe('stalker playback context', () => {
const macAddress = '00:1A:79:AA:BB:CC';
it.each([
['http://range-portal.test/c/', 'http://range-cdn.test/episode.mkv'],
[
'https://range-downgrade.test/c/',
'http://range-downgrade.test/episode.mkv',
],
['http://range-owned.test/c/', 'http://range-owned.test/episode.mkv'],
])(
'leaves seek byte ranges to the player for %s → %s',
(portalUrl, streamUrl) => {
rememberStalkerPlaybackContext({
streamUrl,
portalUrl,
macAddress,
});
const headers = getStalkerPlaybackContextHeaders(streamUrl);
expect(headers).not.toBeNull();
expect(
Object.keys(headers ?? {}).map((name) => name.toLowerCase())
).not.toContain('range');
}
);
function rememberSameOriginContext(
name: string,
serialNumber?: string
@@ -135,7 +160,8 @@ describe('stalker playback context', () => {
// token; the lookup keys on origin+path so the second link still
// finds its portal headers instead of playing bare.
const firstLink = 'http://tmp-link2.example.test/ch/7?token=first';
const secondLink = 'http://tmp-link2.example.test/ch/7?token=second';
const secondLink =
'http://tmp-link2.example.test/ch/7?token=second';
rememberStalkerPlaybackContext({
streamUrl: firstLink,
portalUrl:
@@ -6,7 +6,6 @@ import {
} from '@iptvnator/shared/interfaces';
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
const CONTEXT_TTL_MS = 15 * 60 * 1000;
@@ -111,10 +110,10 @@ export function rememberStalkerPlaybackContext(input: {
const headers: Record<string, string> = crossOriginStream
? {
// Align with known working clients for direct tokenized stream URLs.
// Match the renderer's direct profile; the player owns Range
// so each seek can request its own byte offset.
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: STALKER_STREAM_RANGE_HEADER,
'Icy-MetaData': '1',
Connection: 'keep-alive',
}
+13 -1
View File
@@ -1184,10 +1184,18 @@ Two stream profiles exist, selected by one shared predicate:
`Origin`/`Referer` set to the portal origin.
- **Foreign / direct** (different host, or an https→http downgrade): the
credential-free `KSPlayer` direct-stream profile (`User-Agent: KSPlayer`,
`Accept`, `Range`, `Icy-MetaData`, `Connection`). Portal credentials must
`Accept`, `Icy-MetaData`, `Connection`). Portal credentials must
never reach a third-party host; direct stream URLs carry their access token
in the URL minted by `create_link`.
Neither playback profile sets `Range`; byte ranges belong to the media
transport and must change with each seek. A static `Range: bytes=0-` overrides
mpv's requested offset, making the server return the beginning again. After
resuming a movie or episode this can send playback forward or to EOF instead
of the selected time. Regression coverage in
`stalker-live-playback.utils.spec.ts` checks both profiles and TLS downgrades;
`with-stalker-player.feature.spec.ts` verifies the resumed CDN episode path.
**The token is bound to the endpoint the headers claim.** Both header inputs —
the portal coordinates and the Bearer token — must describe the same portal, or
a session negotiated for one host is presented to another. In
@@ -1220,6 +1228,10 @@ same shared predicate — if the two ever diverged,
renderer's credentialed headers for streams the main process misread as
direct.
The fallback also leaves `Range` unset, covered by
`stalker-playback-context.service.spec.ts`, so external launches cannot
reintroduce a fixed byte offset when renderer headers are absent.
The mock server's `gated-stream` scenario (MAC `00:1A:79:00:00:09`) makes
`create_link` return a local `/stream/gated/video.mp4` that answers 403
without the mac cookie and current Bearer token;
@@ -7,9 +7,7 @@ import {
} from './stalker-live-playback.utils';
import { STALKER_SERIAL_NUMBER } from './stalker-session.service';
function createPlaylist(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
function createPlaylist(overrides: Partial<PlaylistMeta> = {}): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Playlist',
@@ -36,6 +34,27 @@ function createPlaylist(
}
describe('buildStalkerExternalPlaybackHeaders', () => {
it.each([
['http://portal.test/c/', 'http://cdn.other.test/episode.mkv'],
['https://portal.test/c/', 'http://portal.test/episode.mkv'],
['http://portal.test/c/', 'http://portal.test/episode.mkv'],
])(
'leaves HTTP byte ranges to the player for %s → %s',
(portalUrl, streamUrl) => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({ portalUrl }),
'TOKEN',
streamUrl
);
// A static bytes=0- overrides mpv's per-seek byte offset. The server
// then returns the beginning again instead of the requested segment.
expect(
Object.keys(headers).map((name) => name.toLowerCase())
).not.toContain('range');
}
);
it('treats the legacy default serial as absent', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({ stalkerSerialNumber: STALKER_SERIAL_NUMBER }),
@@ -59,11 +59,11 @@ export function buildStalkerExternalPlaybackHeaders(
// KSPlayer direct-stream profile: their access token travels in the URL
// that create_link minted, and the portal's mac cookie/Bearer token must
// never reach a third-party host.
// Leave Range to the player: a fixed bytes=0- overrides its seek offset.
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
return {
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: 'bytes=0-',
Connection: 'keep-alive',
'Icy-MetaData': '1',
};
@@ -6,7 +6,10 @@ import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { of } from 'rxjs';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerPlayer } from './with-stalker-player.feature';
@@ -304,9 +307,7 @@ describe('withStalkerPlayer', () => {
);
expect(session.getCachedToken).toHaveBeenCalledWith(PLAYLIST._id);
expect(playback.headers?.['Cookie']).toContain(
'mac=00:1A:79:00:00:01'
);
expect(playback.headers?.['Cookie']).toContain('mac=00:1A:79:00:00:01');
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
expect(playback.headers?.['X-User-Agent']).toBeDefined();
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
@@ -338,6 +339,29 @@ describe('withStalkerPlayer', () => {
expect(playback.origin).toBeUndefined();
});
it('resumes a CDN episode without fixing subsequent media requests at byte zero', async () => {
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'ffmpeg http://cdn.example/episode_3.mkv' },
});
const playback = await store.resolveVodPlayback(
'ffmpeg http://cdn.example/episode_3.mkv',
'Episode 3',
undefined,
3,
123,
780
);
expect(playback.startTime).toBe(780);
expect(playback.contentInfo).toMatchObject({
contentType: 'episode',
contentXtreamId: 123,
});
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
expect(playback.headers).not.toHaveProperty('Range');
});
describe('temporary-link semantics', () => {
const CHANNEL = {
id: '10001',
@@ -536,9 +560,7 @@ describe('withStalkerPlayer', () => {
const playback = await store.resolveRadioPlayback(radioItem);
expect(playback.headers?.['Cookie']).toContain(
'mac=00:1A:79:00:00:01'
);
expect(playback.headers?.['Cookie']).toContain('mac=00:1A:79:00:00:01');
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
expect(playback.referer).toBe('http://demo.example');
expect(playback.origin).toBe('http://demo.example');