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 ); } }