fix(stalker): harden session compatibility

This commit is contained in:
4gray committed 2026-07-27 14:00:30 +02:00
1 parent 63ab2837a7
commit bdf353a3b8
31 files changed
+1067 -82

No files matched your search

@@ -395,7 +395,12 @@ describe('StalkerEndpointResolver', () => {
action === 'handshake'
) {
return rawSuccess(
'<!doctype html><html><body>Portal landing</body></html>',
[
'<!doctype html><html><head>',
'<title>stalker_portal</title>',
'<script src="../server/api/load_js.php"></script>',
'</head><body></body></html>',
].join(''),
{
contentType: 'text/html; charset=utf-8',
finalUrl: request.url,
@@ -438,6 +443,74 @@ describe('StalkerEndpointResolver', () => {
).toBe(false);
});
it.each([
[
'an unknown HTML account denial',
'<!doctype html><html><body>Account suspended</body></html>',
'text/html',
],
[
'an unknown XHTML device denial',
'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>Device blocked</body></html>',
'application/xhtml+xml',
],
[
'an HTML denial with only a portal title marker',
'<!doctype html><html><head><title>stalker_portal</title></head><body>Account suspended</body></html>',
'text/html',
],
])(
'treats %s as terminal after earlier stateless evidence',
async (_, denialBody, contentType) => {
const harness = createHarness((request) => {
const action = request.params?.['action'];
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
return success({}, { finalUrl: request.url });
}
if (
request.url.endsWith('/server/load.php') &&
action === 'handshake'
) {
return success(
{},
{ finalUrl: request.url, status: 404 }
);
}
if (
request.url.endsWith('/server/load.php') &&
action === 'get_genres'
) {
return success(
{ js: [{ id: '*', title: 'All' }] },
{ finalUrl: request.url }
);
}
if (
request.url.endsWith('/portal.php') &&
action === 'handshake'
) {
return rawSuccess(denialBody, {
contentType,
finalUrl: request.url,
});
}
throw new Error('Unexpected request');
});
const outcome = await harness.resolver.resolve({
descriptor: descriptor(),
transport,
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'incompatible-response',
retryable: false,
stage: 'resolving',
});
}
);
it('continues after early stateless evidence so a later full endpoint wins', async () => {
const harness = createHarness((request) => {
const action = request.params?.['action'];
@@ -563,15 +563,19 @@ function isBenignHtmlEndpointMiss(
?.split(';', 1)[0]
?.trim()
.toLowerCase();
if (mediaType !== 'text/html' && mediaType !== 'application/xhtml+xml') {
if (mediaType !== 'text/html' || rawBody === undefined) {
return false;
}
const prefix = rawBody?.trimStart().slice(0, 64).toLowerCase() ?? '';
return (
prefix.startsWith('<!doctype html') ||
prefix.startsWith('<html') ||
prefix.startsWith('<?xml')
);
// The public Stalker /c/ shell combines these bootstrap markers. Requiring
// two keeps branded denial pages from becoming a downgrade signal.
const normalized = rawBody.toLowerCase();
const markerCount = [
/<title[^>]*>\s*stalker_portal\s*<\/title>/i.test(rawBody),
normalized.includes('server/api/load_js.php'),
normalized.includes('window.loadrequiredfiles('),
normalized.includes('stb.init('),
].filter(Boolean).length;
return markerCount >= 2;
}
function isApiEnvelopeRejectedOnlyByMediaType(
@@ -98,6 +98,38 @@ describe('StalkerHttpSession', () => {
expect(config.headers).not.toHaveProperty('Proxy-Authorization');
});
it('blocks an anonymous public-to-private redirect before target contact', async () => {
axiosMock
.mockResolvedValueOnce({
data: Buffer.alloc(0),
headers: {
location: 'http://127.0.0.1/private-admin',
},
status: 302,
})
.mockResolvedValueOnce({
data: Buffer.from('private response'),
headers: {},
status: 200,
});
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://93.184.216.34/landing',
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'invalid-url',
retryable: false,
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it.each([200, 401, 404, 429, 500, 503])(
'returns HTTP %i as a successful transport result',
async (status) => {
@@ -144,6 +144,7 @@ export class StalkerHttpSession {
validateStatus: () => true,
},
{
allowPrivateNetworkRedirects: identityBearing,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
@@ -16,6 +16,63 @@ function mapRemote<Operation extends StalkerSessionApplicationOperation>(
return mapped;
}
const listResultMappers = [
{
name: 'categories',
emptyResult: { items: [] },
mapResult: (value: unknown) =>
mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
{ contentType: 'vod' }
).mapResult(value),
},
{
name: 'catalog',
emptyResult: { items: [] },
mapResult: (value: unknown) =>
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, {
contentType: 'vod',
}).mapResult(value),
},
{
name: 'seasons',
emptyResult: { seasons: [] },
mapResult: (value: unknown) =>
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons, {
seriesId: '42',
}).mapResult(value),
},
{
name: 'episodes',
emptyResult: { episodes: [] },
mapResult: (value: unknown) =>
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes, {
seasonId: '42:1',
seriesId: '42',
}).mapResult(value),
},
{
name: 'short EPG',
emptyResult: { programs: [] },
mapResult: (value: unknown) =>
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg, {
channelId: '11',
}).mapResult(value),
},
] as const;
const malformedListEnvelopes = [
{ name: 'object-without-data', value: { js: {} } },
{ name: 'direct-null', value: { js: null } },
{ name: 'direct-scalar', value: { js: 7 } },
{ name: 'direct-string', value: { js: 'not-a-list' } },
{ name: 'undefined', value: { js: { data: undefined } } },
{ name: 'null', value: { js: { data: null } } },
{ name: 'scalar', value: { js: { data: 7 } } },
{ name: 'object', value: { js: { data: {} } } },
{ name: 'string', value: { js: { data: 'not-a-list' } } },
] as const;
describe('stalker-operation-adapter', () => {
it.each([
{
@@ -302,6 +359,161 @@ describe('stalker-operation-adapter', () => {
});
});
it.each(listResultMappers)(
'keeps real empty arrays valid for $name direct and data-envelope shapes',
({ emptyResult, mapResult }) => {
expect(mapResult({ js: [] })).toEqual(emptyResult);
expect(mapResult({ js: { data: [] } })).toEqual(emptyResult);
}
);
it.each(
listResultMappers.flatMap((operation) =>
malformedListEnvelopes.map((envelope) => ({
envelope: envelope.name,
mapResult: operation.mapResult,
operation: operation.name,
value: envelope.value,
}))
)
)(
'rejects $envelope data for $operation',
({ mapResult, value }) => {
expect(() => mapResult(value)).toThrow(
'stalker-operation-invalid-response'
);
}
);
it('accepts the real data-wrapped channel map and empty bulk EPG shapes', () => {
const mapped = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
{}
);
const program = {
ch_id: '11',
name: 'News',
start_timestamp: 100,
stop_timestamp: 200,
};
expect(
mapped.mapResult({
js: {
data: {
'11': [program],
'12': [],
},
},
})
).toEqual({
programs: [
{
channelId: '11',
endsAt: 200,
startsAt: 100,
title: 'News',
},
],
});
expect(mapped.mapResult({ js: { data: {} } })).toEqual({
programs: [],
});
expect(mapped.mapResult({ js: [] })).toEqual({ programs: [] });
expect(mapped.mapResult({ js: { data: [] } })).toEqual({
programs: [],
});
});
it('preserves direct and nested channel-keyed bulk EPG variants', () => {
const mapped = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
{}
);
const program = {
ch_id: '11',
name: 'News',
start_timestamp: 100,
stop_timestamp: 200,
};
expect(
mapped.mapResult({
js: {
'11': [program],
'12': { data: [] },
'13': { epg: [] },
'14': { items: [] },
},
})
).toEqual({
programs: [
{
channelId: '11',
endsAt: 200,
startsAt: 100,
title: 'News',
},
],
});
expect(
mapped.mapResult({
js: {
data: {
'11': { data: [program] },
'12': { epg: [] },
'13': { items: [] },
},
},
})
).toEqual({
programs: [
{
channelId: '11',
endsAt: 200,
startsAt: 100,
title: 'News',
},
],
});
});
it.each([
{ name: 'object-without-data', value: { js: {} } },
{ name: 'direct-null', value: { js: null } },
{ name: 'direct-scalar', value: { js: 7 } },
{ name: 'direct-string', value: { js: 'not-an-epg-map' } },
{ name: 'undefined-data', value: { js: { data: undefined } } },
{ name: 'null-data', value: { js: { data: null } } },
{ name: 'scalar-data', value: { js: { data: 7 } } },
{ name: 'string-data', value: { js: { data: 'not-an-epg-map' } } },
{
name: 'scalar-channel-entry',
value: { js: { data: { '11': 7 } } },
},
{
name: 'unknown-channel-entry',
value: { js: { data: { '11': { unknown: [] } } } },
},
{
name: 'direct-scalar-channel-entry',
value: { js: { '11': 7 } },
},
{
name: 'direct-unknown-channel-entry',
value: { js: { '11': { unknown: [] } } },
},
] as const)('rejects malformed bulk EPG $name', ({ value }) => {
const mapped = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
{}
);
expect(() => mapped.mapResult(value)).toThrow(
'stalker-operation-invalid-response'
);
});
it('normalizes categories, seasons, episodes, EPG, links, and account summaries', () => {
const categories = mapRemote(
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
@@ -212,7 +212,9 @@ function mapShortEpg<Operation extends StalkerSessionApplicationOperation>(
type: 'itv',
};
copyPositiveInteger(record, 'limit', wire, 'size');
return remote(wire, (value) => mapEpg(value, channelId));
return remote(wire, (value) =>
mapEpg(value, { fallbackChannelId: channelId })
);
}
function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
@@ -227,7 +229,9 @@ function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
copyNonNegativeInteger(record, 'fromTimestamp', wire, 'from_timestamp');
copyPositiveInteger(record, 'periodHours', wire, 'period');
copyNonNegativeInteger(record, 'toTimestamp', wire, 'to_timestamp');
return remote(wire, (value) => mapEpg(value));
return remote(wire, (value) =>
mapEpg(value, { allowChannelMap: true })
);
}
function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
@@ -257,7 +261,7 @@ function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
}
function mapCategories(value: unknown): StalkerSessionCategoriesResult {
const items = readArray(unwrapJs(value)).flatMap((value) => {
const items = readDataArray(value).flatMap((value) => {
const record = asRecord(value);
if (!record) {
return [];
@@ -292,7 +296,7 @@ function mapCatalog(
): StalkerSessionCatalogResult {
const js = unwrapJs(value);
const envelope = asRecord(js);
const rawItems = envelope ? readArray(envelope['data']) : readArray(js);
const rawItems = readListPayload(js);
const items = rawItems.flatMap((value) => {
const mapped = mapCatalogItem(value, contentType);
return mapped ? [mapped] : [];
@@ -470,9 +474,18 @@ function mapEpisodeResult(
function mapEpg(
value: unknown,
fallbackChannelId?: StalkerSessionEntityId
{
allowChannelMap = false,
fallbackChannelId,
}: {
readonly allowChannelMap?: boolean;
readonly fallbackChannelId?: StalkerSessionEntityId;
} = {}
): StalkerSessionEpgResult {
const programs = flattenEpgEntries(unwrapJs(value)).flatMap((value) => {
const programs = flattenEpgEntries(
unwrapJs(value),
allowChannelMap
).flatMap((value) => {
const record = asRecord(value);
if (!record) {
return [];
@@ -561,43 +574,93 @@ function unwrapJs(value: unknown): unknown {
}
function readDataArray(value: unknown): readonly unknown[] {
const js = unwrapJs(value);
const record = asRecord(js);
return record ? readArray(record['data']) : readArray(js);
return readListPayload(unwrapJs(value));
}
function flattenEpgEntries(value: unknown): readonly unknown[] {
function readListPayload(value: unknown): readonly unknown[] {
if (Array.isArray(value)) {
return value;
}
const envelope = asRecord(value);
if (
!envelope ||
!Object.prototype.hasOwnProperty.call(envelope, 'data') ||
!Array.isArray(envelope['data'])
) {
throw invalidResponse();
}
return envelope['data'];
}
function flattenEpgEntries(
value: unknown,
allowChannelMap: boolean
): readonly unknown[] {
if (Array.isArray(value)) {
return value;
}
const record = asRecord(value);
if (!record) {
return [];
throw invalidResponse();
}
const direct = readArray(record['data']);
if (direct.length > 0) {
return direct;
if (Object.prototype.hasOwnProperty.call(record, 'data')) {
if (Array.isArray(record['data'])) {
return record['data'];
}
if (!allowChannelMap) {
throw invalidResponse();
}
const channelMap = asRecord(record['data']);
if (!channelMap) {
throw invalidResponse();
}
return flattenEpgChannelMap(channelMap, true);
}
if (!allowChannelMap) {
throw invalidResponse();
}
return flattenEpgChannelMap(record, false);
}
function flattenEpgChannelMap(
value: Readonly<Record<string, unknown>>,
allowEmpty: boolean
): readonly unknown[] {
const nestedValues = Object.values(value);
if (!allowEmpty && nestedValues.length === 0) {
throw invalidResponse();
}
const result: unknown[] = [];
for (const nested of Object.values(record)) {
for (const nested of nestedValues) {
if (Array.isArray(nested)) {
result.push(...nested);
continue;
}
const nestedRecord = asRecord(nested);
if (nestedRecord) {
result.push(
...readArray(
nestedRecord['data'] ??
nestedRecord['epg'] ??
nestedRecord['items']
)
);
if (!nestedRecord) {
throw invalidResponse();
}
result.push(...readNestedEpgArray(nestedRecord));
}
return result;
}
function readNestedEpgArray(
value: Readonly<Record<string, unknown>>
): readonly unknown[] {
for (const key of ['data', 'epg', 'items'] as const) {
if (!Object.prototype.hasOwnProperty.call(value, key)) {
continue;
}
if (!Array.isArray(value[key])) {
throw invalidResponse();
}
return value[key];
}
throw invalidResponse();
}
function normalizePlaybackCommand(value: string): string {
const trimmed = value.trim();
const separator = trimmed.indexOf(' ');
@@ -631,10 +694,6 @@ function asRecord(
: undefined;
}
function readArray(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
function readRequiredString(value: unknown): string {
const result = optionalString(value);
if (result === undefined) {
@@ -834,6 +834,87 @@ describe('StalkerSessionManager', () => {
});
});
it('caps one attempt at three credential submissions across auth replacements caused by alternating principals', async () => {
const targetAuths = [
new FakeAuth('target-user', false, [
{ attemptNumber: 1, kind: 'credentials-required' },
]),
new FakeAuth('target-user', false, [
{ attemptNumber: 2, kind: 'credentials-required' },
]),
new FakeAuth('target-user', false, [
{ attemptNumber: 2, kind: 'credentials-required' },
]),
new FakeAuth('target-user', false, [
{ attemptNumber: 2, kind: 'credentials-required' },
]),
];
const { manager } = harness({
auths: [
targetAuths[0],
new FakeAuth('principal-a'),
targetAuths[1],
new FakeAuth('principal-b'),
targetAuths[2],
new FakeAuth('principal-a'),
targetAuths[3],
],
resolverOutcomes: Array.from({ length: 7 }, () => full()),
});
let challenge = await manager.open(1, {
descriptor: descriptor('credential-budget-target'),
});
if (challenge.kind !== 'credentials-required') {
throw new Error('expected-credentials-challenge');
}
for (const [index, principal] of [
[0, 'principal-a'],
[1, 'principal-b'],
[2, 'principal-a'],
] as const) {
const interferer = await manager.open(index + 2, {
descriptor: descriptor(
`credential-budget-${principal}-${index}`
),
});
expectFullReady(interferer);
const outcome = await manager.continue(1, {
challengeRef: challenge.challengeRef,
response: {
kind: 'credentials',
password: `bad-password-${index}`,
username: 'target-user',
},
});
if (index === 2) {
expect(outcome).toMatchObject({
kind: 'failure',
reason: STALKER_SESSION_FAILURE_REASONS
.CredentialsAttemptLimit,
stage: 'do-auth',
});
continue;
}
expect(outcome).toMatchObject({
attemptNumber: index + 2,
kind: 'credentials-required',
});
if (outcome.kind !== 'credentials-required') {
throw new Error('expected-credentials-challenge');
}
challenge = outcome;
}
expect(
targetAuths.flatMap((auth) => auth.startedCredentials).filter(
(credentials) => credentials !== undefined
)
).toHaveLength(3);
});
it('resets the two-minute lifetime when a slow attempt becomes ready or issues a challenge', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
@@ -65,6 +65,7 @@ import {
} from './stalker-watchdog';
const ATTEMPT_TTL_MS = 120_000;
const MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT = 3;
const REFERENCE_BYTES = 32;
const MAX_REFERENCE_ATTEMPTS = 4;
const RECIPE_CLASSIFIER_VERSION = 1;
@@ -214,6 +215,7 @@ interface AttemptRecord {
challengeRef?: string;
coordinator: StalkerBaseIdentityCoordinator;
credentialCandidate?: StalkerAuthCredentials;
credentialSubmissions: number;
descriptor: StalkerSessionConnectionDescriptor;
expiresAt: number;
lastEndpoint?: string;
@@ -916,6 +918,7 @@ export class StalkerSessionManager {
validated.descriptor.sourceUrl,
validated.descriptor.macAddress
),
credentialSubmissions: 0,
descriptor: validated.descriptor,
expiresAt: this.#now() + ATTEMPT_TTL_MS,
previousSessions:
@@ -970,7 +973,16 @@ export class StalkerSessionManager {
attempt.descriptor
);
attempt.lastLandingUrl = resolved.landingUrl;
const authOutcome = await auth.start(credentials);
const authOutcome =
credentials === undefined
? this.#normalizeCredentialChallenge(
attempt,
await auth.start()
)
: await this.#runCredentialSubmission(
attempt,
() => auth.start(credentials)
);
if (authOutcome.kind !== 'ready') {
throw new ProgressSignal(
authProgress(authOutcome, auth, resolved, epoch)
@@ -1044,9 +1056,9 @@ export class StalkerSessionManager {
attempt.coordinator,
epoch
);
const outcome = await auth.submitCredentials(
credentials,
false
const outcome = await this.#runCredentialSubmission(
attempt,
() => auth.submitCredentials(credentials, false)
);
if (outcome.kind !== 'ready') {
throw new ProgressSignal(
@@ -1096,6 +1108,42 @@ export class StalkerSessionManager {
}
}
async #runCredentialSubmission(
attempt: AttemptRecord,
operation: () => Promise<StalkerAuthOutcome>
): Promise<StalkerAuthOutcome> {
if (
attempt.credentialSubmissions >=
MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT
) {
return credentialAttemptLimit();
}
attempt.credentialSubmissions += 1;
return this.#normalizeCredentialChallenge(
attempt,
await operation()
);
}
#normalizeCredentialChallenge(
attempt: AttemptRecord,
outcome: StalkerAuthOutcome
): StalkerAuthOutcome {
if (outcome.kind !== 'credentials-required') {
return outcome;
}
if (
attempt.credentialSubmissions >=
MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT
) {
return credentialAttemptLimit();
}
return {
...outcome,
attemptNumber: attempt.credentialSubmissions + 1,
};
}
async #publishProgress(
attempt: AttemptRecord,
progress: AuthenticationProgress,
@@ -1702,6 +1750,7 @@ export class StalkerSessionManager {
approvedOrigins: new Set(session.approvedOrigins),
coordinator: session.coordinator,
credentialCandidate: session.credentials,
credentialSubmissions: 0,
descriptor: session.descriptor,
expiresAt: this.#now() + ATTEMPT_TTL_MS,
lastIdentityRevision: session.identityRevision,
@@ -2273,6 +2322,18 @@ export class StalkerSessionManager {
}
}
function credentialAttemptLimit(): Extract<
StalkerAuthOutcome,
{ kind: 'failure' }
> {
return {
kind: 'failure',
reason: STALKER_SESSION_FAILURE_REASONS.CredentialsAttemptLimit,
retryable: false,
stage: 'do-auth',
};
}
function endpointProgress(
outcome: Exclude<StalkerEndpointResolverOutcome, { kind: 'full-session' }>
): AuthenticationProgress {
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "إعادة المحاولة",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "حاول مرة أخرى",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Паўтарыць",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser-User-Agent überschreiben (optional)",
"VALIDATE_CREDENTIALS": "Anmeldedaten prüfen",
"SAVE_AGAIN": "Erneut speichern",
"RETRY": "Erneut versuchen",
"USERNAME": "Benutzername",
"PASSWORD": "Passwort"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Επανάληψη",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Retry",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Reintentar",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Réessayer",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Újrapróbálkozás",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Riprova",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "再試行",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "다시 시도",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Opnieuw proberen",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Spróbuj ponownie",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Tentar novamente",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
"VALIDATE_CREDENTIALS": "Проверить данные",
"SAVE_AGAIN": "Сохранить снова",
"RETRY": "Повторить",
"USERNAME": "Имя пользователя",
"PASSWORD": "Пароль"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Tekrar dene",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "重试",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "重試",
"USERNAME": "Username",
"PASSWORD": "Password"
},
@@ -15,7 +15,7 @@ import {
StalkerSessionConnectionOutcome,
StalkerSessionFullReadyOutcome,
} from '@iptvnator/shared/interfaces';
import { EMPTY, Subject, of, throwError } from 'rxjs';
import { Subject, of, throwError } from 'rxjs';
import { StalkerCredentialsDialogComponent } from './stalker-credentials-dialog.component';
import { StalkerConnectionFlowService } from './stalker-connection-flow.service';
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
@@ -178,6 +178,14 @@ describe('StalkerConnectionFlowService', () => {
let dialog: {
open: jest.Mock;
};
let snackAction: Subject<void>;
let snackBar: {
open: jest.Mock;
};
let snackRef: {
dismiss: jest.Mock;
onAction: () => Subject<void>;
};
let queuedDialogResults: unknown[];
beforeEach(() => {
@@ -190,6 +198,14 @@ describe('StalkerConnectionFlowService', () => {
store = {
dispatch: jest.fn(),
};
snackAction = new Subject<void>();
snackRef = {
dismiss: jest.fn(),
onAction: () => snackAction,
};
snackBar = {
open: jest.fn(() => snackRef),
};
queuedDialogResults = [];
dialog = {
open: jest.fn(() => ({
@@ -219,11 +235,7 @@ describe('StalkerConnectionFlowService', () => {
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(() => ({
onAction: () => EMPTY,
})),
},
useValue: snackBar,
},
{
provide: TranslateService,
@@ -602,6 +614,138 @@ describe('StalkerConnectionFlowService', () => {
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
});
it('offers an actionable retry with the stable terminal reason', async () => {
session.open.mockResolvedValueOnce({
kind: 'failure',
reason: 'incompatible-response',
requestId: 'request-terminal-failure',
retryable: false,
stage: 'resolving',
});
session.forceRedetect.mockResolvedValueOnce(READY);
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenCalledWith(
expect.stringContaining('incompatible-response'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
snackAction.next();
snackAction.next();
await flushPromises();
expect(session.forceRedetect).toHaveBeenCalledWith('lease-route-1');
expect(session.forceRedetect).toHaveBeenCalledTimes(1);
});
it('invalidates a terminal retry action when navigation cancels the flow', async () => {
session.open.mockResolvedValueOnce({
kind: 'failure',
reason: 'request-timeout',
requestId: 'request-terminal-cancel',
retryable: true,
stage: 'handshaking',
});
await service.ensureConnected(LEGACY_PLAYLIST);
await service.cancel();
snackAction.next();
await flushPromises();
expect(snackRef.dismiss).toHaveBeenCalled();
expect(session.forceRedetect).not.toHaveBeenCalled();
});
it('invalidates an in-flight typed run when a new run resolves through the stateless path', async () => {
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
session.open.mockReturnValueOnce(deferredOpen.promise);
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
await flushPromises();
const statelessPlaylist = {
...LEGACY_PLAYLIST,
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'stateless-mac' as const,
};
await expect(
service.ensureConnected(statelessPlaylist)
).resolves.toBe(statelessPlaylist);
deferredOpen.resolve({
kind: 'failure',
reason: 'request-timeout',
requestId: 'request-stale-typed-failure',
retryable: true,
stage: 'handshaking',
});
await expect(connecting).resolves.toBeUndefined();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('turns an asynchronous force-redetect continuation rejection into an actionable failure', async () => {
session.forceRedetect.mockResolvedValueOnce({
attemptNumber: 1,
attemptRef: 'attempt-redetect-rejection',
challengeRef: 'challenge-redetect-rejection',
kind: 'credentials-required',
requestId: 'request-redetect-rejection',
});
session.continue.mockRejectedValueOnce(
new Error('redetect-continue-failed')
);
queuedDialogResults.push({
password: 'password',
username: 'user',
});
await expect(
service.forceRedetect(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('connection-redetect-failed'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('turns an asynchronous recovery continuation rejection into an actionable failure', async () => {
session.open.mockResolvedValueOnce({
attemptNumber: 1,
attemptRef: 'attempt-recovery-rejection',
challengeRef: 'challenge-recovery-rejection',
kind: 'credentials-required',
requestId: 'request-recovery-rejection',
});
session.continue.mockRejectedValueOnce(
new Error('recovery-continue-failed')
);
queuedDialogResults.push({
password: 'password',
username: 'user',
});
const handler = session.recoveryHandler();
expect(handler).toBeDefined();
await expect(
handler?.({
playlist: LEGACY_PLAYLIST,
trigger: 'endpoint-shape',
})
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('connection-recovery-failed'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('retains a ready attempt after a local write failure and retries the same draft', async () => {
session.open.mockResolvedValue(READY);
playlists.persistStalkerConnection.mockReturnValueOnce(
@@ -679,6 +823,44 @@ describe('StalkerConnectionFlowService', () => {
});
});
it('preserves the actionable terminal reason when promotion recovery returns a failure', async () => {
session.open.mockResolvedValueOnce(READY).mockResolvedValueOnce({
kind: 'failure',
reason: 'portal-unavailable',
requestId: 'request-promotion-reopen-failure',
retryable: true,
stage: 'handshaking',
});
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('portal-unavailable'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('offers an actionable retry when promotion recovery cannot reopen the persisted playlist', async () => {
session.open
.mockResolvedValueOnce(READY)
.mockRejectedValueOnce(new Error('reopen-failed'));
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('session-promotion-failed'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('discards a provisional ready outcome that arrives after cancellation of open', async () => {
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
session.open.mockReturnValueOnce(deferredOpen.promise);
@@ -47,6 +47,7 @@ export class StalkerConnectionFlowService {
private readonly translate = inject(TranslateService);
private readonly readySubject = new Subject<Playlist>();
private activeDialog: { close: () => void } | null = null;
private activeConnectionRetry: { dismiss: () => void } | null = null;
private activeAttemptRef: string | null = null;
private pendingPersistence: PendingPersistence | null = null;
private runId = 0;
@@ -65,10 +66,11 @@ export class StalkerConnectionFlowService {
}
async ensureConnected(playlist: Playlist): Promise<Playlist | undefined> {
const runId = ++this.runId;
this.dismissConnectionRetry();
if (!this.shouldUseTypedSession(playlist)) {
return playlist;
}
const runId = ++this.runId;
await this.discardPending();
const needsMigration = !this.hasVerifiedRecipe(playlist);
try {
@@ -83,33 +85,49 @@ export class StalkerConnectionFlowService {
);
return await this.handleOutcome(playlist, outcome, runId, false);
} catch {
this.offerConnectionRetry(
playlist,
'connection-open-failed',
runId
);
return undefined;
}
}
async forceRedetect(playlist: Playlist): Promise<Playlist | undefined> {
if (!this.session.supportsTypedSessions()) {
this.dismissConnectionRetry();
return playlist;
}
const leaseRef = this.session.getLeaseRef(playlist._id);
if (leaseRef === undefined) {
const runId = ++this.runId;
await this.discardPending();
try {
const runId = ++this.runId;
this.dismissConnectionRetry();
try {
if (leaseRef === undefined) {
await this.discardPending();
const outcome = await this.session.open(playlist, {
connectionMode: 'provisional',
provisionalReason: 'migration',
});
return this.handleOutcome(playlist, outcome, runId, true);
} catch {
return undefined;
return await this.handleOutcome(
playlist,
outcome,
runId,
true
);
}
const outcome = await this.session.forceRedetect(leaseRef);
return outcome.kind === 'success'
? playlist
: await this.handleOutcome(playlist, outcome, runId, true);
} catch {
this.offerConnectionRetry(
playlist,
'connection-redetect-failed',
runId
);
return undefined;
}
const runId = ++this.runId;
const outcome = await this.session.forceRedetect(leaseRef);
return outcome.kind === 'success'
? playlist
: this.handleOutcome(playlist, outcome, runId, true);
}
async retryPendingPersistence(): Promise<Playlist | undefined> {
@@ -125,6 +143,7 @@ export class StalkerConnectionFlowService {
async cancel(): Promise<void> {
this.runId += 1;
this.dismissConnectionRetry();
this.activeDialog?.close();
this.activeDialog = null;
await this.discardPending();
@@ -141,6 +160,7 @@ export class StalkerConnectionFlowService {
return undefined;
}
const runId = ++this.runId;
this.dismissConnectionRetry();
await this.discardPending();
try {
const outcome =
@@ -151,13 +171,18 @@ export class StalkerConnectionFlowService {
provisionalReason: 'migration',
})
: request.outcome;
return this.handleOutcome(
return await this.handleOutcome(
request.playlist,
outcome,
runId,
true
);
} catch {
this.offerConnectionRetry(
request.playlist,
'connection-recovery-failed',
runId
);
return undefined;
}
}
@@ -246,6 +271,9 @@ export class StalkerConnectionFlowService {
outcome.kind === 'ready' ? credentials : undefined;
continue;
}
if (outcome.kind === 'failure') {
this.offerConnectionRetry(playlist, outcome.reason, runId);
}
return undefined;
}
return undefined;
@@ -350,6 +378,7 @@ export class StalkerConnectionFlowService {
}
this.activeAttemptRef = null;
this.pendingPersistence = null;
this.dismissConnectionRetry();
if (pending.announceReady) {
this.readySubject.next(persisted);
}
@@ -371,23 +400,23 @@ export class StalkerConnectionFlowService {
try {
outcome = await this.session.open(persisted);
} catch {
this.notifyPromotionFailure();
this.offerConnectionRetry(
persisted,
'session-promotion-failed',
pending.runId
);
return undefined;
}
if (await this.discardOutcomeWhenStale(outcome, pending.runId)) {
return undefined;
}
if (outcome.kind !== 'ready') {
const recovered = await this.handleOutcome(
return this.handleOutcome(
persisted,
outcome,
pending.runId,
pending.announceReady
);
if (recovered === undefined && pending.runId === this.runId) {
this.notifyPromotionFailure();
}
return recovered;
}
if (pending.announceReady) {
@@ -442,6 +471,7 @@ export class StalkerConnectionFlowService {
}
private offerPersistenceRetry(): void {
this.dismissConnectionRetry();
const ref = this.snackBar.open(
this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
@@ -455,15 +485,50 @@ export class StalkerConnectionFlowService {
.subscribe(() => void this.retryPendingPersistence());
}
private notifyPromotionFailure(): void {
this.snackBar.open(
this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
{ reason: 'session-promotion-failed' }
),
undefined,
{ duration: 10_000 }
offerRetry(playlist: Playlist, reason: string): void {
this.offerConnectionRetry(playlist, reason, this.runId);
}
private offerConnectionRetry(
playlist: Playlist,
reason: string,
runId: number
): void {
if (runId !== this.runId) {
return;
}
this.dismissConnectionRetry();
const ref = this.snackBar.open(
this.connectionFailureMessage(reason),
this.translate.instant('HOME.STALKER_PORTAL.RETRY'),
{ duration: 120_000 }
);
this.activeConnectionRetry = ref;
ref.onAction()
.pipe(take(1))
.subscribe(() => {
if (
runId !== this.runId ||
this.activeConnectionRetry !== ref
) {
return;
}
this.activeConnectionRetry = null;
void this.forceRedetect(playlist);
});
}
private connectionFailureMessage(reason: string): string {
const message = this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
{ reason }
);
return message.includes(reason) ? message : `${message} ${reason}`;
}
private dismissConnectionRetry(): void {
this.activeConnectionRetry?.dismiss();
this.activeConnectionRetry = null;
}
private async discardPending(): Promise<void> {
@@ -25,6 +25,8 @@ const ACTIVE_PLAYLIST: PlaylistMeta = {
const FULL_STALKER_PLAYLIST: PlaylistMeta = {
...ACTIVE_PLAYLIST,
isFullStalkerPortal: true,
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'full-session',
stalkerSerialNumber: 'CUSTOMSN123',
stalkerDeviceId1: 'DEVICE-ID-1',
stalkerDeviceId2: 'DEVICE-ID-2',
@@ -89,6 +91,7 @@ describe('StalkerWorkspaceRouteSession', () => {
cancel: jest.fn().mockResolvedValue(undefined),
connectionReady$: connectionReady.asObservable(),
ensureConnected: jest.fn(async (playlist: PlaylistMeta) => playlist),
offerRetry: jest.fn(),
};
const session = {
@@ -142,12 +145,19 @@ describe('StalkerWorkspaceRouteSession', () => {
stalkerStore.setSelectedContentType.mockClear();
stalkerStore.setSearchPhrase.mockClear();
playlistsService.getPlaylistById.mockClear();
playlistsService.getPlaylistById.mockImplementation(() =>
of(ACTIVE_PLAYLIST)
);
connectionFlow.cancel.mockClear();
connectionFlow.ensureConnected.mockClear();
connectionFlow.offerRetry.mockClear();
session.activate.mockClear();
session.close.mockClear();
session.deactivate.mockClear();
session.getLeaseRef.mockClear();
session.getLeaseRef.mockImplementation(
(playlistId: string) => `lease-${playlistId}`
);
await TestBed.configureTestingModule({
providers: [
@@ -258,7 +268,7 @@ describe('StalkerWorkspaceRouteSession', () => {
);
});
it('uses active Stalker playlist metadata directly when the portal mode is explicit', async () => {
it('uses active Stalker playlist metadata directly when its recipe is current', async () => {
activePlaylist.set(FULL_STALKER_PLAYLIST);
TestBed.inject(StalkerWorkspaceRouteSession);
@@ -270,6 +280,30 @@ describe('StalkerWorkspaceRouteSession', () => {
);
});
it('does not let a legacy boolean on temporary route metadata shadow the full stored playlist', async () => {
activePlaylist.set({
...ACTIVE_PLAYLIST,
isFullStalkerPortal: false,
title: 'Untitled playlist',
});
playlistsService.getPlaylistById.mockReturnValue(
of(FULL_STALKER_PLAYLIST)
);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
PLAYLIST_ID
);
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
});
it('does not expose a playlist to catalog resources when connection flow is cancelled', async () => {
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
@@ -279,6 +313,92 @@ describe('StalkerWorkspaceRouteSession', () => {
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
});
it('offers a retry instead of exposing a playlist when lease activation fails', async () => {
session.activate.mockResolvedValueOnce({
kind: 'failure',
reason: 'portal-unavailable',
requestId: 'activate-failure',
retryable: true,
stage: 'ready',
});
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(connectionFlow.offerRetry).toHaveBeenCalledWith(
expect.objectContaining({ _id: PLAYLIST_ID }),
'portal-unavailable'
);
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
});
it('does not let a stale activation failure close or retry a newer lease for the same playlist', async () => {
const staleActivation = createDeferred<{
kind: 'failure';
reason: string;
requestId: string;
retryable: boolean;
stage: 'ready';
}>();
let currentLeaseRef = 'lease-stale';
session.getLeaseRef.mockImplementation(() => currentLeaseRef);
session.activate
.mockReturnValueOnce(staleActivation.promise)
.mockResolvedValueOnce({
action: 'activate',
kind: 'success',
requestId: 'activate-current',
});
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(session.activate).toHaveBeenCalledWith('lease-stale');
router.url = '/workspace/m3u/playlist-2';
playlistContext.syncFromUrl.mockReturnValueOnce({
inWorkspace: true,
playlistId: 'playlist-2',
provider: 'm3u',
section: null,
});
routerEvents.next(
new NavigationEnd(
2,
`/workspace/stalker/${PLAYLIST_ID}/vod`,
router.url
)
);
await flushEffects();
currentLeaseRef = 'lease-current';
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
routerEvents.next(
new NavigationEnd(
3,
'/workspace/m3u/playlist-2',
router.url
)
);
await flushEffects();
expect(session.activate).toHaveBeenLastCalledWith('lease-current');
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
expect.objectContaining({ _id: PLAYLIST_ID })
);
staleActivation.resolve({
kind: 'failure',
reason: 'stale-portal-unavailable',
requestId: 'activate-stale-failure',
retryable: true,
stage: 'ready',
});
await flushEffects();
expect(session.deactivate).not.toHaveBeenCalledWith('lease-current');
expect(session.close).not.toHaveBeenCalledWith('lease-current');
expect(connectionFlow.offerRetry).not.toHaveBeenCalled();
});
it('cancels a provisional route attempt before leaving the Stalker workspace', async () => {
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
@@ -16,8 +16,13 @@ import {
StalkerSessionService,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol';
import { PlaylistsService } from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
Playlist,
PlaylistMeta,
type StalkerSessionLeaseRef,
} from '@iptvnator/shared/interfaces';
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
@Injectable()
@@ -146,7 +151,8 @@ export class StalkerWorkspaceRouteSession {
private async activateAndSetCurrentPlaylist(
playlist: Playlist
): Promise<void> {
if (playlist._id !== this.currentPlaylistId) {
const activationGeneration = this.syncGeneration;
if (!this.isCurrentActivation(playlist, activationGeneration)) {
return;
}
const leaseRef = this.session.getLeaseRef(playlist._id);
@@ -154,28 +160,96 @@ export class StalkerWorkspaceRouteSession {
try {
const activation = await this.session.activate(leaseRef);
if (activation.kind !== 'success') {
await this.releasePlaylistSession(playlist._id);
const retryEligible = this.isCurrentActivation(
playlist,
activationGeneration,
leaseRef
);
await this.releaseLeaseSession(leaseRef);
this.offerActivationRetry(
playlist,
activation.kind === 'failure'
? activation.reason
: `session-activation-${activation.kind}`,
activationGeneration,
leaseRef,
retryEligible
);
return;
}
} catch {
await this.releasePlaylistSession(playlist._id);
const retryEligible = this.isCurrentActivation(
playlist,
activationGeneration,
leaseRef
);
await this.releaseLeaseSession(leaseRef);
this.offerActivationRetry(
playlist,
'session-activation-failed',
activationGeneration,
leaseRef,
retryEligible
);
return;
}
}
if (playlist._id === this.currentPlaylistId) {
if (
this.isCurrentActivation(
playlist,
activationGeneration,
leaseRef
)
) {
await this.stalkerStore.setCurrentPlaylist(playlist);
}
}
private offerActivationRetry(
playlist: Playlist,
reason: string,
activationGeneration: number,
leaseRef: StalkerSessionLeaseRef,
retryEligible: boolean
): void {
const currentLeaseRef = this.session.getLeaseRef(playlist._id);
if (
retryEligible &&
this.isCurrentActivation(playlist, activationGeneration) &&
(currentLeaseRef === undefined || currentLeaseRef === leaseRef)
) {
this.connectionFlow.offerRetry(playlist, reason);
}
}
private async releasePlaylistSession(playlistId: string): Promise<void> {
const leaseRef = this.session.getLeaseRef(playlistId);
if (leaseRef === undefined) {
return;
}
await this.releaseLeaseSession(leaseRef);
}
private async releaseLeaseSession(
leaseRef: StalkerSessionLeaseRef
): Promise<void> {
await this.session.deactivate(leaseRef).catch(() => undefined);
await this.session.close(leaseRef).catch(() => undefined);
}
private isCurrentActivation(
playlist: Playlist,
activationGeneration: number,
leaseRef?: StalkerSessionLeaseRef
): boolean {
return (
activationGeneration === this.syncGeneration &&
playlist._id === this.currentPlaylistId &&
(leaseRef === undefined ||
this.session.getLeaseRef(playlist._id) === leaseRef)
);
}
private syncRouteState(section: PortalRailSection | null): void {
if (!section) {
return;
@@ -213,7 +287,7 @@ export class StalkerWorkspaceRouteSession {
): Promise<PlaylistMeta | undefined> {
const activePlaylist = this.playlistContext.activePlaylist();
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
if (this.hasCurrentStalkerRecipe(playlistId, activePlaylist)) {
return activePlaylist;
}
@@ -225,13 +299,15 @@ export class StalkerWorkspaceRouteSession {
return storedPlaylist ?? activePlaylist ?? undefined;
}
private hasExplicitStalkerPortalMode(
private hasCurrentStalkerRecipe(
playlistId: string,
playlist: PlaylistMeta | null
): playlist is PlaylistMeta {
return (
playlist?._id === playlistId &&
playlist.isFullStalkerPortal !== undefined
playlist.stalkerRequestRecipe !== undefined &&
playlist.stalkerRecipeClassifierVersion ===
STALKER_RECIPE_CLASSIFIER_VERSION
);
}
}