mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(stalker): let media transports control seek byte ranges (#1544)
This commit is contained in:
1 parent
d9d6f49757
commit
eb3cf89f62
7 files changed
+100
-16
No files matched your search
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
+29
-7
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user