diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts
index 799e94191..e225197be 100644
--- a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts
+++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts
@@ -395,7 +395,12 @@ describe('StalkerEndpointResolver', () => {
action === 'handshake'
) {
return rawSuccess(
- '
Portal landing',
+ [
+ '',
+ 'stalker_portal',
+ '',
+ '',
+ ].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',
+ 'Account suspended',
+ 'text/html',
+ ],
+ [
+ 'an unknown XHTML device denial',
+ 'Device blocked',
+ 'application/xhtml+xml',
+ ],
+ [
+ 'an HTML denial with only a portal title marker',
+ 'stalker_portalAccount suspended',
+ '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'];
diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts
index 8d1b91fb1..0ec9d730e 100644
--- a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts
+++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts
@@ -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(']*>\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(
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 d47f8750d..1e714fa83 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
@@ -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) => {
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 68eb3a89c..11c7793e9 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
@@ -144,6 +144,7 @@ export class StalkerHttpSession {
validateStatus: () => true,
},
{
+ allowPrivateNetworkRedirects: identityBearing,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts
index 666524d35..955516184 100644
--- a/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts
+++ b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.spec.ts
@@ -16,6 +16,63 @@ function mapRemote(
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,
diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts
index 4c4754284..f274bb085 100644
--- a/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts
+++ b/apps/electron-backend/src/app/services/stalker-session/stalker-operation-adapter.ts
@@ -212,7 +212,9 @@ function mapShortEpg(
type: 'itv',
};
copyPositiveInteger(record, 'limit', wire, 'size');
- return remote(wire, (value) => mapEpg(value, channelId));
+ return remote(wire, (value) =>
+ mapEpg(value, { fallbackChannelId: channelId })
+ );
}
function mapEpgInfo(
@@ -227,7 +229,9 @@ function mapEpgInfo(
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(
@@ -257,7 +261,7 @@ function mapCreateLink(
}
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>,
+ 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>
+): 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) {
diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts
index 5d941aef3..34b5e4abd 100644
--- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts
+++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts
@@ -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);
diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts
index 7c3bd9408..0d3662c55 100644
--- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts
+++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts
@@ -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
+ ): Promise {
+ 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
): AuthenticationProgress {
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 8931b3b2d..d0962c655 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "إعادة المحاولة",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 3249e63e1..dc8befe5d 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "حاول مرة أخرى",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 33de874a0..c4b11f48f 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "Паўтарыць",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index 39eafd937..e483279eb 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index 346867fbb..3aceaa569 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "Επανάληψη",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index dba0f2414..026085d5c 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 69a0d13a6..6976e1bc2 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index f69a040fd..a21db2194 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index 2edd06f69..3e18fbdbe 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 19c310b20..762dcf467 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index 852239bba..da7ca90e8 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "再試行",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 56ded5ab1..090d8a683 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "다시 시도",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index a21d24e16..1eeafcc48 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index 58c22058c..bddce1a40 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index eeeb1e806..4975c1cd8 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 1439ffc4f..995f95258 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
"VALIDATE_CREDENTIALS": "Проверить данные",
"SAVE_AGAIN": "Сохранить снова",
+ "RETRY": "Повторить",
"USERNAME": "Имя пользователя",
"PASSWORD": "Пароль"
},
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index cd703b182..477e84061 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -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"
},
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index 56dda24dd..8cd77ecd6 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "重试",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index 2fcdf9873..c39c540ca 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
+ "RETRY": "重試",
"USERNAME": "Username",
"PASSWORD": "Password"
},
diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts
index eb9d608f5..abb976917 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts
@@ -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;
+ let snackBar: {
+ open: jest.Mock;
+ };
+ let snackRef: {
+ dismiss: jest.Mock;
+ onAction: () => Subject;
+ };
let queuedDialogResults: unknown[];
beforeEach(() => {
@@ -190,6 +198,14 @@ describe('StalkerConnectionFlowService', () => {
store = {
dispatch: jest.fn(),
};
+ snackAction = new Subject();
+ 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();
+ 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();
session.open.mockReturnValueOnce(deferredOpen.promise);
diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts
index fb144f497..37f5a4f73 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts
@@ -47,6 +47,7 @@ export class StalkerConnectionFlowService {
private readonly translate = inject(TranslateService);
private readonly readySubject = new Subject();
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 {
+ 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 {
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 {
@@ -125,6 +143,7 @@ export class StalkerConnectionFlowService {
async cancel(): Promise {
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 {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts
index f544275ba..952f7fdf0 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts
@@ -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();
diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts
index c62cccb61..0d473c40e 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts
@@ -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 {
- 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 {
const leaseRef = this.session.getLeaseRef(playlistId);
if (leaseRef === undefined) {
return;
}
+ await this.releaseLeaseSession(leaseRef);
+ }
+
+ private async releaseLeaseSession(
+ leaseRef: StalkerSessionLeaseRef
+ ): Promise {
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 {
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
);
}
}