fix(stalker): restore playback command compatibility

This commit is contained in:
4gray committed 2026-07-29 12:06:45 +02:00
1 parent 9163f2d515
commit da49820e80
7 files changed
+101 -5

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker movies, series, live TV, and radio now start correctly when portals return empty compatibility error fields or require literal `/` separators in playback commands.
@@ -96,6 +96,36 @@ describe('StalkerHttpSession', () => {
expect(config.headers).not.toHaveProperty('Authorization');
expect(config.headers).not.toHaveProperty('Cookie');
expect(config.headers).not.toHaveProperty('Proxy-Authorization');
expect(config.paramsSerializer).toBeUndefined();
});
it('preserves only cmd slashes while encoding reserved query characters', async () => {
axiosMock.mockResolvedValueOnce({
data: Buffer.from('{}'),
headers: {},
status: 200,
});
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
params: {
type: 'vod',
action: 'create_link',
cmd: '/media/file 4?quality=hd&label=x=y#preview.mpg',
search: '/catalog value&mode=all#anchor',
page: 2,
},
url: 'http://192.168.1.20/server/load.php',
});
const config = axiosMock.mock.calls[0][0];
expect(axios.getUri(config)).toBe(
'http://192.168.1.20/server/load.php?type=vod&action=create_link&cmd=/media/file%204%3Fquality%3Dhd%26label%3Dx%3Dy%23preview.mpg&search=%2Fcatalog%20value%26mode%3Dall%23anchor&page=2'
);
});
it('blocks an anonymous public-to-private redirect before target contact', async () => {
@@ -118,6 +118,11 @@ export class StalkerHttpSession {
request.params === undefined
? undefined
: { ...request.params },
paramsSerializer: hasOwnCommandParameter(request.params)
? {
serialize: serializeStalkerHttpParameters,
}
: undefined,
redirectHooks: {
beforeValidatedRedirect: (redirect) => {
validateRedirectTarget(
@@ -194,6 +199,30 @@ export class StalkerHttpSession {
}
}
function serializeStalkerHttpParameters(
parameters: StalkerHttpParameters
): string {
return Object.entries(parameters)
.map(([key, value]) => {
const encodedValue = encodeURIComponent(String(value));
const compatibleValue =
key === 'cmd'
? encodedValue.replace(/%2F/gi, '/')
: encodedValue;
return `${encodeURIComponent(key)}=${compatibleValue}`;
})
.join('&');
}
function hasOwnCommandParameter(
parameters: StalkerHttpParameters | undefined
): boolean {
return (
parameters !== undefined &&
Object.prototype.hasOwnProperty.call(parameters, 'cmd')
);
}
function assertTransportConfig(config: StalkerTransportConfig): void {
if (
!isPositiveFiniteBound(
@@ -673,7 +673,10 @@
"kind": "json",
"value": {
"js": {
"cmd": "/stream/ready.m3u8"
"cmd": "/stream/ready.m3u8",
"error": "",
"load": "",
"streamer_id": "1"
}
}
},
+11
View File
@@ -65,6 +65,12 @@ Stalker has two deliberately separate request paths:
`STALKER_REQUEST`/HTTP adapter. They do not acquire a main-owned full
session.
Stalker query serialization is a compatibility contract: `cmd` values retain
literal forward slashes while query separators and all other parameter data
remain percent-encoded. Do not replace this behavior with Axios default
parameter serialization; some Ministra portals inspect the raw `create_link`
query and reject encoded playback-path separators.
The primary ownership points are:
- pure URL, response, identity, state-machine, and request-policy rules:
@@ -134,6 +140,11 @@ type remains fail-closed for ordinary candidates; only a persisted learned
endpoint may treat a body rejected solely by media type as a stale-hint miss
and continue bounded discovery.
Some successful Stalker responses, including `create_link`, carry an exact
empty `error` string alongside their payload. The response classifier treats
that compatibility sentinel as no error; non-empty error strings and all other
error indicators remain fail-closed.
Persisted compatibility fields are:
- `stalkerSourceUrl`
@@ -242,6 +242,21 @@ describe('Stalker response classifier', () => {
).toEqual({ kind: 'none' });
});
it('accepts the empty error field returned by successful create_link responses', () => {
expect(
classifyStalkerResponseFailure({
httpStatus: 200,
value: {
js: {
cmd: 'ffmpeg http://media.invalid/movie.ts',
error: '',
load: '',
},
},
})
).toEqual({ kind: 'none' });
});
it.each([
['Authorization failed', { kind: 'token-rejected' }],
['Access denied', { kind: 'failure', reason: 'account-access-denied' }],
@@ -344,11 +344,13 @@ function extractBodyError(
): string | undefined {
const envelope = asRecord(value);
const js = asRecord(envelope?.['js']);
if (typeof js?.['error'] === 'string') {
return js['error'];
const jsError = js?.['error'];
if (typeof jsError === 'string' && jsError !== '') {
return jsError;
}
if (typeof envelope?.['error'] === 'string') {
return envelope['error'];
const envelopeError = envelope?.['error'];
if (typeof envelopeError === 'string' && envelopeError !== '') {
return envelopeError;
}
if (value !== undefined) {
return undefined;