fix(stalker): make persisted recipe authoritative

This commit is contained in:
4gray committed 2026-07-27 12:29:03 +02:00
1 parent 1961952f48
commit 79dcbba241
10 files changed
+133 -59

No files matched your search

@@ -606,7 +606,8 @@ describe('StreamResolverService', () => {
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
isFullStalkerPortal: true,
stalkerRequestRecipe: 'stateless-mac',
} satisfies Partial<Playlist>)
);
dataService.sendIpcEvent.mockResolvedValue({
@@ -668,7 +669,8 @@ describe('StreamResolverService', () => {
autoRefresh: false,
portalUrl: 'https://stalker.example.com/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
isFullStalkerPortal: false,
stalkerRequestRecipe: 'full-session',
userAgent: 'renderer-user-agent',
referrer: 'https://stalker.example.com/c/',
origin: 'https://stalker.example.com',
@@ -693,7 +695,8 @@ describe('StreamResolverService', () => {
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
isFullStalkerPortal: false,
stalkerRequestRecipe: 'full-session',
}),
STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink,
{
@@ -345,9 +345,6 @@ export class StreamResolverService {
...playlist,
portalUrl,
macAddress,
isFullStalkerPortal:
playlist?.isFullStalkerPortal ??
this.stalkerSession.isFullStalkerPortal(portalUrl),
};
const playbackLink = await fetchStalkerPlayback(
{
@@ -28,6 +28,7 @@ const PLAYLIST = {
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
stalkerRequestRecipe: 'stateless-mac',
} as PlaylistMeta;
const TestSeriesStore = signalStore(
@@ -254,7 +255,8 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
});
store.setCurrentPlaylist({
...PLAYLIST,
isFullStalkerPortal: true,
isFullStalkerPortal: false,
stalkerRequestRecipe: 'full-session',
});
store.setSelectedContentType('vod');
store.setSelectedItem({
@@ -271,8 +273,9 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: PLAYLIST._id,
isFullStalkerPortal: true,
isFullStalkerPortal: false,
lastUsage: '',
stalkerRequestRecipe: 'full-session',
}),
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
{
@@ -29,6 +29,7 @@ import {
sortVodSeriesSeasonsByNumber,
StalkerRequestDeps,
toStalkerSessionPlaylist,
usesTypedStalkerSession,
} from '../utils';
/**
@@ -83,10 +84,7 @@ async function fetchVodSeriesSeasons(
playlist: PlaylistMeta,
seriesId: string | number
): Promise<StalkerVodSeriesSeason[]> {
if (
playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
if (usesTypedStalkerSession(deps, playlist)) {
const result = await deps.stalkerSession.requestForPlaylist(
toStalkerSessionPlaylist(playlist),
STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons,
@@ -118,15 +116,6 @@ async function fetchVodSeriesSeasons(
return extractSeriesItems(response);
}
function supportsTypedSessions(stalkerSession: StalkerSessionService): boolean {
const probe = (
stalkerSession as Partial<
Pick<StalkerSessionService, 'supportsTypedSessions'>
>
).supportsTypedSessions;
return typeof probe !== 'function' || probe.call(stalkerSession);
}
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
@@ -22,11 +22,13 @@ const PLAYLIST = {
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
stalkerRequestRecipe: 'stateless-mac',
} as PlaylistMeta;
const FULL_PLAYLIST = {
...PLAYLIST,
isFullStalkerPortal: true,
stalkerRequestRecipe: 'full-session',
} as PlaylistMeta;
describe('stalker-player-request.utils', () => {
@@ -135,6 +137,57 @@ describe('stalker-player-request.utils', () => {
});
});
it('uses the full-session recipe when the legacy compatibility flag is stale', async () => {
stalkerSession.requestForPlaylist.mockResolvedValue({
streamUrl: '/media/video_77.mpg',
playbackContextRef: 'playback-context-1',
});
await fetchStalkerPlayback(
{
dataService: dataService as never,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
{
playlist: {
...FULL_PLAYLIST,
isFullStalkerPortal: false,
},
selectedContentType: 'itv',
cmd: '/media/source.mpg',
}
);
expect(stalkerSession.requestForPlaylist).toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('uses the stateless recipe when the legacy compatibility flag is stale', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: '/media/video_77.mpg' },
});
await fetchStalkerPlayback(
{
dataService: dataService as never,
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
{
playlist: {
...PLAYLIST,
isFullStalkerPortal: true,
},
selectedContentType: 'itv',
cmd: '/media/source.mpg',
}
);
expect(dataService.sendIpcEvent).toHaveBeenCalled();
expect(stalkerSession.requestForPlaylist).not.toHaveBeenCalled();
});
it('keeps a full-portal PWA request on the exact legacy IPC payload', async () => {
stalkerSession.supportsTypedSessions.mockReturnValue(false);
dataService.sendIpcEvent.mockResolvedValue({
@@ -12,6 +12,7 @@ import { StalkerContentType } from '../stalker-store.contracts';
import {
executeStalkerRequest,
toStalkerSessionPlaylist,
usesTypedStalkerSession,
} from './stalker-request.utils';
export interface StalkerPlayerResponse {
@@ -157,10 +158,7 @@ export async function fetchStalkerPlayback(
): Promise<StalkerPlaybackLink> {
const contentType =
options.forcedContentType ?? options.selectedContentType;
if (
options.playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
if (usesTypedStalkerSession(deps, options.playlist)) {
const isEpisode = options.series !== undefined;
const payload = await deps.stalkerSession.requestForPlaylist(
toStalkerSessionPlaylist(options.playlist),
@@ -231,24 +229,12 @@ function normalizePlaybackLink(
};
}
function supportsTypedSessions(stalkerSession: StalkerSessionService): boolean {
const probe = (
stalkerSession as Partial<
Pick<StalkerSessionService, 'supportsTypedSessions'>
>
).supportsTypedSessions;
return typeof probe !== 'function' || probe.call(stalkerSession);
}
export async function fetchStalkerMovieFileId(
deps: StalkerPlayerRequestDeps,
playlist: PlaylistMeta,
movieId: string
): Promise<string | null> {
if (
playlist.isFullStalkerPortal &&
supportsTypedSessions(deps.stalkerSession)
) {
if (usesTypedStalkerSession(deps, playlist)) {
const result = await deps.stalkerSession.requestForPlaylist(
toStalkerSessionPlaylist(playlist),
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
@@ -32,6 +32,7 @@ function fullPlaylist(): PlaylistMeta {
isFullStalkerPortal: true,
macAddress: 'has-mac-address',
portalUrl: 'https://portal.example.test/stalker_portal/server/load.php',
stalkerRequestRecipe: 'full-session',
stalkerDeviceId1: 'has-device-id-1',
stalkerDeviceId2: 'has-device-id-2',
stalkerSerialNumber: 'has-serial',
@@ -67,6 +68,7 @@ describe('executeStalkerRequest', () => {
isFullStalkerPortal: false,
macAddress: 'has-mac-address',
portalUrl: 'https://portal.example.test/load.php',
stalkerRequestRecipe: 'stateless-mac',
title: 'Basic Stalker Portal',
} as PlaylistMeta;
@@ -89,6 +91,52 @@ describe('executeStalkerRequest', () => {
).not.toHaveBeenCalled();
});
it('lets the verified full-session recipe override a stale false compatibility flag', async () => {
const deps = createDeps(true);
const playlist = {
...fullPlaylist(),
isFullStalkerPortal: false,
};
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
expect(
deps.stalkerSession.makeAuthenticatedRequest
).toHaveBeenCalled();
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('lets the verified stateless recipe override a stale true compatibility flag', async () => {
const deps = createDeps(true);
const playlist = {
...fullPlaylist(),
isFullStalkerPortal: true,
stalkerRequestRecipe: 'stateless-mac' as const,
};
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
expect(deps.dataService.sendIpcEvent).toHaveBeenCalled();
expect(
deps.stalkerSession.makeAuthenticatedRequest
).not.toHaveBeenCalled();
});
it('does not infer a typed runtime path from the legacy boolean when recipe migration is still pending', async () => {
const deps = createDeps(true);
const playlist = {
...fullPlaylist(),
stalkerRequestRecipe: undefined,
};
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
expect(deps.dataService.sendIpcEvent).toHaveBeenCalled();
expect(
deps.stalkerSession.makeAuthenticatedRequest
).not.toHaveBeenCalled();
});
it('keeps the PWA adapter byte-compatible on STALKER_REQUEST even for a persisted full-portal row', async () => {
const deps = createDeps(false);
const playlist = fullPlaylist();
@@ -42,7 +42,7 @@ export function usesTypedStalkerSession(
playlist: PlaylistMeta
): boolean {
return Boolean(
playlist.isFullStalkerPortal &&
playlist.stalkerRequestRecipe === 'full-session' &&
supportsTypedSessions(deps.stalkerSession)
);
}
@@ -32,17 +32,21 @@ function playlist(portalUrl: string): Playlist {
}
describe('executeStalkerSearchRequest', () => {
it('routes a detected full portal through the typed session facade', async () => {
it('routes a verified full-session recipe through the typed session facade despite a stale flag', async () => {
const dataService = {
sendIpcEvent: jest.fn(),
};
const stalkerSession = {
isFullStalkerPortal: jest.fn().mockReturnValue(true),
supportsTypedSessions: jest.fn().mockReturnValue(true),
makeAuthenticatedRequest: jest
.fn()
.mockResolvedValue({ js: { data: [] } }),
};
const fullPlaylist = {
...playlist('https://portal.example/custom/api.php'),
isFullStalkerPortal: false,
stalkerRequestRecipe: 'full-session' as const,
};
await executeStalkerSearchRequest(
{
@@ -50,14 +54,15 @@ describe('executeStalkerSearchRequest', () => {
stalkerSession:
stalkerSession as unknown as StalkerSessionService,
},
playlist('https://portal.example/server/load.php'),
fullPlaylist,
REQUEST_PARAMS
);
expect(stalkerSession.makeAuthenticatedRequest).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
isFullStalkerPortal: false,
stalkerRequestRecipe: 'full-session',
}),
REQUEST_PARAMS
);
@@ -69,11 +74,14 @@ describe('executeStalkerSearchRequest', () => {
sendIpcEvent: jest.fn().mockResolvedValue({ js: { data: [] } }),
};
const stalkerSession = {
isFullStalkerPortal: jest.fn().mockReturnValue(false),
supportsTypedSessions: jest.fn().mockReturnValue(true),
makeAuthenticatedRequest: jest.fn(),
};
const simplePlaylist = playlist('https://portal.example/portal.php');
const simplePlaylist = {
...playlist('https://portal.example/server/load.php'),
isFullStalkerPortal: true,
stalkerRequestRecipe: 'stateless-mac' as const,
};
await executeStalkerSearchRequest(
{
@@ -9,18 +9,5 @@ export async function executeStalkerSearchRequest<T>(
playlist: Playlist,
params: Record<string, string | number>
): Promise<T> {
const isFullPortal =
playlist.isFullStalkerPortal ??
deps.stalkerSession.isFullStalkerPortal(playlist.portalUrl ?? '');
return executeStalkerRequest<T>(
deps,
isFullPortal
? {
...playlist,
isFullStalkerPortal: true,
}
: playlist,
params
);
return executeStalkerRequest<T>(deps, playlist, params);
}