From da49820e800c6171362857c13df34f7059af6f58 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Jul 2026 12:06:45 +0200 Subject: [PATCH] fix(stalker): restore playback command compatibility --- .changes/stalker-playback-command-paths.md | 6 ++++ .../stalker-http-session.spec.ts | 30 +++++++++++++++++++ .../stalker-session/stalker-http-session.ts | 29 ++++++++++++++++++ .../e2e/full-session-catalog-playback.json | 5 +++- docs/architecture/stalker-portal.md | 11 +++++++ .../lib/stalker-response-classifier.spec.ts | 15 ++++++++++ .../src/lib/stalker-response-classifier.ts | 10 ++++--- 7 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 .changes/stalker-playback-command-paths.md diff --git a/.changes/stalker-playback-command-paths.md b/.changes/stalker-playback-command-paths.md new file mode 100644 index 000000000..3c687132b --- /dev/null +++ b/.changes/stalker-playback-command-paths.md @@ -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. diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts index 1e714fa83..63ca2f44e 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts @@ -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 () => { diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts index 11c7793e9..e3e094a40 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts @@ -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( diff --git a/apps/stalker-mock-server/fixtures/replay/e2e/full-session-catalog-playback.json b/apps/stalker-mock-server/fixtures/replay/e2e/full-session-catalog-playback.json index 028432686..43502ed06 100644 --- a/apps/stalker-mock-server/fixtures/replay/e2e/full-session-catalog-playback.json +++ b/apps/stalker-mock-server/fixtures/replay/e2e/full-session-catalog-playback.json @@ -673,7 +673,10 @@ "kind": "json", "value": { "js": { - "cmd": "/stream/ready.m3u8" + "cmd": "/stream/ready.m3u8", + "error": "", + "load": "", + "streamer_id": "1" } } }, diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 76f09034c..e45aca355 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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` diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts index 5f4268c17..751b6dd62 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts @@ -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' }], diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts index 569141b99..c3d005e80 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts @@ -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;