From 3ebe31f041bbf532d435a4a0e58483093f9e2301 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 11:57:46 +0200 Subject: [PATCH] fix(stalker): close route session races --- .../stalker-connection-flow.service.spec.ts | 199 +++++++++++++-- .../stalker-connection-flow.service.ts | 97 +++++++- ...er-workspace-route-session.service.spec.ts | 230 +++++++++++++++++- ...stalker-workspace-route-session.service.ts | 80 +++++- .../src/lib/playlists.service.spec.ts | 69 ++++++ libs/services/src/lib/playlists.service.ts | 58 ++++- 6 files changed, 682 insertions(+), 51 deletions(-) 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 4de152642..9e83ddb06 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, of, throwError } from 'rxjs'; +import { EMPTY, 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'; @@ -63,6 +63,57 @@ const CONTROL_SUCCESS = { requestId: 'request-commit-route-1', }; +const PERSISTED_READY: StalkerSessionFullReadyOutcome = { + capabilities: { + authenticatedSession: true, + playbackContext: true, + }, + connectionMode: 'persisted-open', + endpoint: 'https://portal.example/stalker_portal/server/load.php', + kind: 'ready', + landingUrl: 'https://portal.example/c/', + leaseRef: 'lease-route-reopened', + persistenceDraft: { + isFullStalkerPortal: true, + portalUrl: 'https://portal.example/stalker_portal/server/load.php', + stalkerLandingUrl: 'https://portal.example/c/', + stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z', + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'full-session', + stalkerSourceUrl: 'https://source.example/c/', + }, + recipe: 'full-session', + requestId: 'request-ready-route-reopened', +}; + +function createDeferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function flushPromises(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +} + +function createClosableDialogRef() { + const closed = new Subject(); + return { + afterClosed: () => closed.asObservable(), + close: jest.fn(() => { + closed.next(undefined); + closed.complete(); + }), + }; +} + function createSessionMock() { let recoveryHandler: StalkerSessionRecoveryHandler | undefined; return { @@ -175,8 +226,7 @@ describe('StalkerConnectionFlowService', () => { .calls[0][0] as Playlist; expect(persistedDraft).toMatchObject({ isFullStalkerPortal: true, - portalUrl: - 'https://portal.example/stalker_portal/server/load.php', + portalUrl: 'https://portal.example/stalker_portal/server/load.php', stalkerRequestRecipe: 'full-session', stalkerSourceUrl: 'https://source.example/c/', }); @@ -201,15 +251,16 @@ describe('StalkerConnectionFlowService', () => { await expect(service.ensureConnected(simple)).resolves.toBe(simple); session.supportsTypedSessions.mockReturnValueOnce(false); - await expect( - service.ensureConnected(LEGACY_PLAYLIST) - ).resolves.toBe(LEGACY_PLAYLIST); + await expect(service.ensureConnected(LEGACY_PLAYLIST)).resolves.toBe( + LEGACY_PLAYLIST + ); expect(session.open).not.toHaveBeenCalled(); }); it('shows exact origin and credential challenges and persists only accepted credentials', async () => { const origin: StalkerSessionConnectionOutcome = { + attemptRef: 'attempt-route-1', challengeRef: 'challenge-origin-route-1', finalOrigin: 'https://portal.example', kind: 'origin-approval-required', @@ -218,6 +269,7 @@ describe('StalkerConnectionFlowService', () => { }; const credentials: StalkerSessionConnectionOutcome = { attemptNumber: 2, + attemptRef: 'attempt-route-1', challengeRef: 'challenge-credentials-route-1', kind: 'credentials-required', requestId: 'request-credentials-route-1', @@ -291,6 +343,7 @@ describe('StalkerConnectionFlowService', () => { it('consumes a rejected origin challenge without persisting anything', async () => { const origin: StalkerSessionConnectionOutcome = { + attemptRef: 'attempt-origin-rejected', challengeRef: 'challenge-origin-rejected', finalOrigin: 'https://other.example', kind: 'origin-approval-required', @@ -311,16 +364,13 @@ describe('StalkerConnectionFlowService', () => { service.ensureConnected(LEGACY_PLAYLIST) ).resolves.toBeUndefined(); - expect(session.continue).toHaveBeenCalledWith( - LEGACY_PLAYLIST._id, - { - challengeRef: 'challenge-origin-rejected', - response: { - approved: false, - kind: 'origin-approval', - }, - } - ); + expect(session.continue).toHaveBeenCalledWith(LEGACY_PLAYLIST._id, { + challengeRef: 'challenge-origin-rejected', + response: { + approved: false, + kind: 'origin-approval', + }, + }); expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); }); @@ -359,15 +409,121 @@ describe('StalkerConnectionFlowService', () => { expect(session.discard).toHaveBeenCalledWith('attempt-route-1'); }); - it('turns a rejected promotion into a discarded runtime attempt', async () => { - session.open.mockResolvedValue(READY); + it('reopens the persisted connection after discarding a rejected promotion', async () => { + session.open + .mockResolvedValueOnce(READY) + .mockResolvedValueOnce(PERSISTED_READY); session.commit.mockRejectedValueOnce(new Error('promotion-failed')); - await expect( - service.ensureConnected(LEGACY_PLAYLIST) - ).resolves.toBeUndefined(); + const connected = await service.ensureConnected(LEGACY_PLAYLIST); expect(session.discard).toHaveBeenCalledWith('attempt-route-1'); + expect(session.open).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + portalUrl: + 'https://portal.example/stalker_portal/server/load.php', + stalkerRequestRecipe: 'full-session', + }) + ); + expect(connected).toMatchObject({ + portalUrl: 'https://portal.example/stalker_portal/server/load.php', + stalkerRequestRecipe: 'full-session', + }); + }); + + it('discards a provisional ready outcome that arrives after cancellation of open', async () => { + const deferredOpen = createDeferred(); + session.open.mockReturnValueOnce(deferredOpen.promise); + + const connecting = service.ensureConnected(LEGACY_PLAYLIST); + await flushPromises(); + expect(session.open).toHaveBeenCalledTimes(1); + + await service.cancel(); + deferredOpen.resolve(READY); + + await expect(connecting).resolves.toBeUndefined(); + expect(session.discard).toHaveBeenCalledWith('attempt-route-1'); + expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); + }); + + it('discards a provisional ready outcome that arrives after cancellation of continue', async () => { + const credentials: StalkerSessionConnectionOutcome = { + attemptNumber: 1, + attemptRef: 'attempt-route-1', + challengeRef: 'challenge-late-ready', + kind: 'credentials-required', + requestId: 'request-late-ready', + }; + const deferredContinue = + createDeferred(); + session.open.mockResolvedValueOnce(credentials); + session.continue.mockReturnValueOnce(deferredContinue.promise); + queuedDialogResults.push({ + password: 'password', + username: 'user', + }); + + const connecting = service.ensureConnected(LEGACY_PLAYLIST); + await flushPromises(); + expect(session.continue).toHaveBeenCalledTimes(1); + + await service.cancel(); + deferredContinue.resolve(READY); + + await expect(connecting).resolves.toBeUndefined(); + expect(session.discard).toHaveBeenCalledWith('attempt-route-1'); + expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); + }); + + it('discards an origin challenge attempt when cancellation closes its active dialog', async () => { + const origin: StalkerSessionConnectionOutcome = { + attemptRef: 'attempt-origin-dialog', + challengeRef: 'challenge-origin-dialog', + finalOrigin: 'https://portal.example', + kind: 'origin-approval-required', + requestId: 'request-origin-dialog', + sourceOrigin: 'https://source.example', + }; + const dialogRef = createClosableDialogRef(); + session.open.mockResolvedValueOnce(origin); + dialog.open.mockReturnValueOnce(dialogRef); + + const connecting = service.ensureConnected(LEGACY_PLAYLIST); + await flushPromises(); + expect(dialog.open).toHaveBeenCalledTimes(1); + + await service.cancel(); + await expect(connecting).resolves.toBeUndefined(); + + expect(dialogRef.close).toHaveBeenCalledTimes(1); + expect(session.discard).toHaveBeenCalledWith('attempt-origin-dialog'); + }); + + it('discards a credential challenge attempt when cancellation closes its active dialog', async () => { + const credentials: StalkerSessionConnectionOutcome = { + attemptNumber: 1, + attemptRef: 'attempt-credentials-dialog', + challengeRef: 'challenge-credentials-dialog', + kind: 'credentials-required', + requestId: 'request-credentials-dialog', + }; + const dialogRef = createClosableDialogRef(); + session.open.mockResolvedValueOnce(credentials); + dialog.open.mockReturnValueOnce(dialogRef); + + const connecting = service.ensureConnected(LEGACY_PLAYLIST); + await flushPromises(); + expect(dialog.open).toHaveBeenCalledTimes(1); + + await service.cancel(); + await expect(connecting).resolves.toBeUndefined(); + + expect(dialogRef.close).toHaveBeenCalledTimes(1); + expect(session.discard).toHaveBeenCalledWith( + 'attempt-credentials-dialog' + ); }); it('opens a provisional replacement for a principal transition and enables one facade reissue', async () => { @@ -399,6 +555,7 @@ describe('StalkerConnectionFlowService', () => { it('routes force-redetect through the active lease and handles its challenge', async () => { const credentials: StalkerSessionConnectionOutcome = { attemptNumber: 1, + attemptRef: 'attempt-force-redetect', challengeRef: 'challenge-force-redetect', kind: 'credentials-required', requestId: 'request-force-redetect', 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 3c881b6f1..4dca685f2 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 @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- route connection recovery state is intentionally kept in one owner */ import { DestroyRef, Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -88,7 +89,7 @@ export class StalkerConnectionFlowService { if (!this.session.supportsTypedSessions()) { return playlist; } - let leaseRef = this.session.getLeaseRef(playlist._id); + const leaseRef = this.session.getLeaseRef(playlist._id); if (leaseRef === undefined) { const runId = ++this.runId; await this.discardPending(); @@ -164,7 +165,12 @@ export class StalkerConnectionFlowService { ): Promise { let outcome = initialOutcome; let credentials: StalkerCredentialsDialogResult | undefined; - while (runId === this.runId) { + while (true) { + if (await this.discardOutcomeWhenStale(outcome, runId)) { + return undefined; + } + this.activeAttemptRef = + this.getOutcomeAttemptRef(outcome) ?? this.activeAttemptRef; if (outcome.kind === 'ready') { const draft = this.applyReadyOutcome( playlist, @@ -190,6 +196,9 @@ export class StalkerConnectionFlowService { kind: 'origin-approval', }, }); + if (await this.discardOutcomeWhenStale(outcome, runId)) { + return undefined; + } if (!approved) { return undefined; } @@ -293,12 +302,16 @@ export class StalkerConnectionFlowService { pending.outcome.attemptRef ); } catch { - await this.discardAttempt(pending.outcome.attemptRef); - return undefined; + return this.reopenPersistedAfterPromotionFailure( + pending, + persisted + ); } if (promotion.kind !== 'success') { - await this.discardAttempt(pending.outcome.attemptRef); - return undefined; + return this.reopenPersistedAfterPromotionFailure( + pending, + persisted + ); } } this.activeAttemptRef = null; @@ -309,6 +322,63 @@ export class StalkerConnectionFlowService { return persisted; } + private async reopenPersistedAfterPromotionFailure( + pending: PendingPersistence, + persisted: Playlist + ): Promise { + await this.discardAttempt(pending.outcome.attemptRef); + this.activeAttemptRef = null; + this.pendingPersistence = null; + if (pending.runId !== this.runId) { + return undefined; + } + + let outcome: StalkerSessionConnectionOutcome; + try { + outcome = await this.session.open(persisted); + } catch { + this.notifyPromotionFailure(); + return undefined; + } + if (await this.discardOutcomeWhenStale(outcome, pending.runId)) { + return undefined; + } + if (outcome.kind !== 'ready') { + const recovered = await this.handleOutcome( + persisted, + outcome, + pending.runId, + pending.announceReady + ); + if (recovered === undefined && pending.runId === this.runId) { + this.notifyPromotionFailure(); + } + return recovered; + } + + if (pending.announceReady) { + this.readySubject.next(persisted); + } + return persisted; + } + + private async discardOutcomeWhenStale( + outcome: StalkerSessionConnectionOutcome, + runId: number + ): Promise { + if (runId === this.runId) { + return false; + } + await this.discardAttempt(this.getOutcomeAttemptRef(outcome)); + return true; + } + + private getOutcomeAttemptRef( + outcome: StalkerSessionConnectionOutcome + ): string | undefined { + return 'attemptRef' in outcome ? outcome.attemptRef : undefined; + } + private applyReadyOutcome( playlist: Playlist, outcome: ReadyOutcome, @@ -338,6 +408,17 @@ 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 } + ); + } + private async discardPending(): Promise { const attemptRef = this.pendingPersistence?.outcome.attemptRef ?? @@ -348,7 +429,9 @@ export class StalkerConnectionFlowService { await this.discardAttempt(attemptRef); } - private async discardAttempt(attemptRef: string | undefined): Promise { + private async discardAttempt( + attemptRef: string | undefined + ): Promise { if (attemptRef !== undefined) { await this.session.discard(attemptRef).catch(() => undefined); } 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 77f1d4e01..f544275ba 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 @@ -1,9 +1,13 @@ +/* eslint-disable max-lines -- route lifecycle and navigation race matrix is kept together */ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; import { EMPTY, Subject, of } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerSessionService, + StalkerStore, +} from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service'; @@ -29,10 +33,20 @@ const FULL_STALKER_PLAYLIST: PlaylistMeta = { } as PlaylistMeta; async function flushEffects(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + for (let index = 0; index < 12; index += 1) { + await Promise.resolve(); + } +} + +function createDeferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; } function getStalkerSectionFromUrl(url: string): string | null { @@ -74,9 +88,26 @@ describe('StalkerWorkspaceRouteSession', () => { const connectionFlow = { cancel: jest.fn().mockResolvedValue(undefined), connectionReady$: connectionReady.asObservable(), - ensureConnected: jest.fn( - async (playlist: PlaylistMeta) => playlist - ), + ensureConnected: jest.fn(async (playlist: PlaylistMeta) => playlist), + }; + + const session = { + activate: jest.fn().mockResolvedValue({ + action: 'activate', + kind: 'success', + requestId: 'activate-success', + }), + close: jest.fn().mockResolvedValue({ + action: 'close', + kind: 'success', + requestId: 'close-success', + }), + deactivate: jest.fn().mockResolvedValue({ + action: 'deactivate', + kind: 'success', + requestId: 'deactivate-success', + }), + getLeaseRef: jest.fn((playlistId: string) => `lease-${playlistId}`), }; const router = { @@ -113,6 +144,10 @@ describe('StalkerWorkspaceRouteSession', () => { playlistsService.getPlaylistById.mockClear(); connectionFlow.cancel.mockClear(); connectionFlow.ensureConnected.mockClear(); + session.activate.mockClear(); + session.close.mockClear(); + session.deactivate.mockClear(); + session.getLeaseRef.mockClear(); await TestBed.configureTestingModule({ providers: [ @@ -137,6 +172,10 @@ describe('StalkerWorkspaceRouteSession', () => { provide: StalkerConnectionFlowService, useValue: connectionFlow, }, + { + provide: StalkerSessionService, + useValue: session, + }, ], }); }); @@ -159,6 +198,10 @@ describe('StalkerWorkspaceRouteSession', () => { ).toBeLessThan( stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0] ); + expect(session.activate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + expect(session.activate.mock.invocationCallOrder[0]).toBeLessThan( + stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0] + ); expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv'); expect(selectedContentType()).toBe('itv'); expect( @@ -257,6 +300,11 @@ describe('StalkerWorkspaceRouteSession', () => { await flushEffects(); expect(connectionFlow.cancel).toHaveBeenCalledTimes(1); + expect(session.deactivate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + expect(session.close).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + expect(session.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + session.close.mock.invocationCallOrder[0] + ); expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith( undefined ); @@ -274,4 +322,170 @@ describe('StalkerWorkspaceRouteSession', () => { FULL_STALKER_PLAYLIST ); }); + + it('does not connect a stale playlist after its lookup resolves behind a newer route', async () => { + const staleLookup = new Subject(); + const nextPlaylist = { + ...FULL_STALKER_PLAYLIST, + _id: 'stalker-2', + title: 'Second Stalker', + }; + playlistsService.getPlaylistById.mockImplementation( + (playlistId: string) => + playlistId === PLAYLIST_ID + ? staleLookup.asObservable() + : of(nextPlaylist) + ); + playlistContext.syncFromUrl.mockImplementation((url: string) => { + const match = url.match( + /^\/workspace\/stalker\/([^/]+)\/([^/?]+)(?:\/|$)/ + ); + return { + inWorkspace: true, + provider: match ? 'stalker' : null, + playlistId: match?.[1] ?? null, + section: (match?.[2] ?? null) as + | 'favorites' + | 'itv' + | 'radio' + | 'recent' + | 'search' + | 'series' + | 'vod' + | null, + }; + }); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + PLAYLIST_ID + ); + + router.url = '/workspace/stalker/stalker-2/series'; + routerEvents.next( + new NavigationEnd( + 2, + `/workspace/stalker/${PLAYLIST_ID}/vod`, + router.url + ) + ); + await flushEffects(); + + expect(connectionFlow.ensureConnected).toHaveBeenCalledWith( + nextPlaylist + ); + + staleLookup.next(FULL_STALKER_PLAYLIST); + staleLookup.complete(); + await flushEffects(); + + expect(connectionFlow.ensureConnected).not.toHaveBeenCalledWith( + FULL_STALKER_PLAYLIST + ); + expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalledWith( + FULL_STALKER_PLAYLIST + ); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + nextPlaylist + ); + }); + + it('keeps a newer section while the same playlist connection is still completing', async () => { + const deferredConnection = createDeferred(); + connectionFlow.ensureConnected.mockReturnValueOnce( + deferredConnection.promise + ); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + expect(connectionFlow.ensureConnected).toHaveBeenCalledTimes(1); + + router.url = `/workspace/stalker/${PLAYLIST_ID}/itv`; + routerEvents.next( + new NavigationEnd( + 2, + `/workspace/stalker/${PLAYLIST_ID}/vod`, + router.url + ) + ); + await flushEffects(); + expect(selectedContentType()).toBe('itv'); + + deferredConnection.resolve(FULL_STALKER_PLAYLIST); + await flushEffects(); + + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + FULL_STALKER_PLAYLIST + ); + expect(selectedContentType()).toBe('itv'); + }); + + it('closes the old lease before activating a switched Stalker playlist', async () => { + const nextPlaylist = { + ...FULL_STALKER_PLAYLIST, + _id: 'stalker-2', + title: 'Second Stalker', + }; + playlistsService.getPlaylistById.mockImplementation( + (playlistId: string) => + of( + playlistId === PLAYLIST_ID + ? FULL_STALKER_PLAYLIST + : nextPlaylist + ) + ); + playlistContext.syncFromUrl.mockImplementation((url: string) => { + const match = url.match( + /^\/workspace\/stalker\/([^/]+)\/([^/?]+)(?:\/|$)/ + ); + return { + inWorkspace: true, + provider: match ? 'stalker' : null, + playlistId: match?.[1] ?? null, + section: (match?.[2] ?? null) as + | 'favorites' + | 'itv' + | 'radio' + | 'recent' + | 'search' + | 'series' + | 'vod' + | null, + }; + }); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + router.url = '/workspace/stalker/stalker-2/vod'; + routerEvents.next( + new NavigationEnd( + 2, + `/workspace/stalker/${PLAYLIST_ID}/vod`, + router.url + ) + ); + await flushEffects(); + + expect(session.deactivate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + expect(session.close).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + expect(session.activate).toHaveBeenCalledWith('lease-stalker-2'); + expect(session.close.mock.invocationCallOrder[0]).toBeLessThan( + session.activate.mock.invocationCallOrder.at(-1) as number + ); + }); + + it('deactivates and closes the current lease when the route provider is destroyed', async () => { + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + session.deactivate.mockClear(); + session.close.mockClear(); + + TestBed.resetTestingModule(); + await flushEffects(); + + expect(session.deactivate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + expect(session.close).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`); + }); }); 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 60a253a51..c62cccb61 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 @@ -13,6 +13,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PortalRailSection } from '@iptvnator/portal/shared/util'; import { StalkerContentType, + StalkerSessionService, StalkerStore, } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from '@iptvnator/services'; @@ -26,10 +27,12 @@ export class StalkerWorkspaceRouteSession { private readonly playlistContext = inject(PlaylistContextFacade); private readonly playlistsService = inject(PlaylistsService); private readonly router = inject(Router); + private readonly session = inject(StalkerSessionService); private readonly stalkerStore = inject(StalkerStore); private currentPlaylistId: string | null = null; private readonly currentSection = signal(null); + private routeSyncGeneration = 0; private syncGeneration = 0; constructor() { @@ -48,13 +51,21 @@ export class StalkerWorkspaceRouteSession { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((playlist) => { if (playlist._id === this.currentPlaylistId) { - void this.stalkerStore.setCurrentPlaylist(playlist); + void this.activateAndSetCurrentPlaylist(playlist); } }); void this.syncRouteContext(); this.destroyRef.onDestroy(() => { + const playlistId = this.currentPlaylistId; + this.currentPlaylistId = null; + this.routeSyncGeneration += 1; + this.syncGeneration += 1; + void this.connectionFlow.cancel(); + if (playlistId !== null) { + void this.releasePlaylistSession(playlistId); + } this.stalkerStore.resetCategories(); this.stalkerStore.setSelectedCategory(null); this.stalkerStore.clearSelectedItem(); @@ -62,16 +73,22 @@ export class StalkerWorkspaceRouteSession { } private async syncRouteContext(): Promise { - const generation = ++this.syncGeneration; + const routeGeneration = ++this.routeSyncGeneration; const routeContext = this.playlistContext.syncFromUrl(this.router.url); const playlistId = routeContext.provider === 'stalker' ? routeContext.playlistId : null; + const generation = + playlistId === this.currentPlaylistId + ? this.syncGeneration + : ++this.syncGeneration; if (!playlistId && this.currentPlaylistId !== null) { + const previousPlaylistId = this.currentPlaylistId; this.currentPlaylistId = null; await this.connectionFlow.cancel(); + await this.releasePlaylistSession(previousPlaylistId); if (generation !== this.syncGeneration) { return; } @@ -80,16 +97,30 @@ export class StalkerWorkspaceRouteSession { this.stalkerStore.clearSelectedItem(); await this.stalkerStore.setCurrentPlaylist(undefined); } else if (playlistId && this.currentPlaylistId !== playlistId) { - if (this.currentPlaylistId !== null) { - await this.connectionFlow.cancel(); - } + const previousPlaylistId = this.currentPlaylistId; this.currentPlaylistId = playlistId; + if (previousPlaylistId !== null) { + await this.connectionFlow.cancel(); + await this.releasePlaylistSession(previousPlaylistId); + if ( + generation !== this.syncGeneration || + playlistId !== this.currentPlaylistId + ) { + return; + } + } this.stalkerStore.resetCategories(); this.stalkerStore.setSelectedCategory(null); this.stalkerStore.clearSelectedItem(); const playlist = await this.resolveStalkerPlaylist(playlistId); + if ( + generation !== this.syncGeneration || + playlistId !== this.currentPlaylistId + ) { + return; + } const connected = playlist === undefined ? undefined @@ -103,11 +134,46 @@ export class StalkerWorkspaceRouteSession { return; } if (connected !== undefined) { - await this.stalkerStore.setCurrentPlaylist(connected); + await this.activateAndSetCurrentPlaylist(connected); } } - this.syncRouteState(routeContext.section); + if (routeGeneration === this.routeSyncGeneration) { + this.syncRouteState(routeContext.section); + } + } + + private async activateAndSetCurrentPlaylist( + playlist: Playlist + ): Promise { + if (playlist._id !== this.currentPlaylistId) { + return; + } + const leaseRef = this.session.getLeaseRef(playlist._id); + if (leaseRef !== undefined) { + try { + const activation = await this.session.activate(leaseRef); + if (activation.kind !== 'success') { + await this.releasePlaylistSession(playlist._id); + return; + } + } catch { + await this.releasePlaylistSession(playlist._id); + return; + } + } + if (playlist._id === this.currentPlaylistId) { + await this.stalkerStore.setCurrentPlaylist(playlist); + } + } + + private async releasePlaylistSession(playlistId: string): Promise { + const leaseRef = this.session.getLeaseRef(playlistId); + if (leaseRef === undefined) { + return; + } + await this.session.deactivate(leaseRef).catch(() => undefined); + await this.session.close(leaseRef).catch(() => undefined); } private syncRouteState(section: PortalRailSection | null): void { diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 59d9e03a6..c516cdf0a 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1748,9 +1748,11 @@ describe('PlaylistsService', () => { importDate: '2026-07-27T00:00:00.000Z', lastUsage: '2026-07-27T00:00:00.000Z', macAddress: '00:1A:79:AA:BB:CC', + password: undefined, portalUrl: 'https://portal.example/portal.php', stalkerRequestRecipe: 'stateless-mac', title: 'Updated Portal', + username: undefined, }) ); @@ -1758,6 +1760,73 @@ describe('PlaylistsService', () => { expect(store.current.password).toBe('saved-password'); }); + it('merges only connection-owned fields from a stale Stalker authentication snapshot', async () => { + const staleSnapshot = { + ...createBasePlaylist('stalker-concurrent-metadata'), + autoRefresh: false, + favorites: [ + { stream_id: 1, title: 'Favorite before authentication' }, + ], + macAddress: '00:1A:79:AA:BB:CC', + password: 'old-password', + portalUrl: 'https://old.example/server/load.php', + recentlyViewed: [], + title: 'Title before authentication', + username: 'old-user', + } as Playlist; + const { store, electron } = + createStatefulElectronStore(staleSnapshot); + store.current = { + ...staleSnapshot, + autoRefresh: true, + favorites: [{ stream_id: 9, title: 'Concurrent Favorite' }], + hiddenGroupTitles: ['Hidden concurrently'], + position: 7, + recentlyViewed: [ + { + category_id: 'vod', + id: 'concurrent-recent', + title: 'Concurrent Recent', + }, + ], + title: 'Renamed while authentication was running', + } as Playlist; + testWindow.electron = electron; + const service = createService(); + + await firstValueFrom( + service.persistStalkerConnection({ + ...staleSnapshot, + password: 'new-password', + portalUrl: 'https://new.example/server/load.php', + stalkerLandingUrl: 'https://new.example/c/', + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'full-session', + stalkerSourceUrl: 'https://new.example/c/', + username: 'new-user', + }) + ); + + expect(store.current).toEqual( + expect.objectContaining({ + autoRefresh: true, + favorites: [{ stream_id: 9, title: 'Concurrent Favorite' }], + hiddenGroupTitles: ['Hidden concurrently'], + password: 'new-password', + portalUrl: 'https://new.example/server/load.php', + position: 7, + recentlyViewed: [ + expect.objectContaining({ + id: 'concurrent-recent', + }), + ], + stalkerRequestRecipe: 'full-session', + title: 'Renamed while authentication was running', + username: 'new-user', + }) + ); + }); + it('does not mutate the stored Stalker row when persistence fails', async () => { const initial = { ...createBasePlaylist('stalker-write-failure'), diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 572ccade0..162d94d1c 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -74,6 +74,31 @@ type PlaylistParserModule = Partial & { default?: Partial; }; +const STALKER_CONNECTION_FIELDS = [ + 'isFullStalkerPortal', + 'macAddress', + 'origin', + 'password', + 'portalUrl', + 'referrer', + 'stalkerAccountInfo', + 'stalkerDeviceId1', + 'stalkerDeviceId2', + 'stalkerIdentityOverrides', + 'stalkerLandingUrl', + 'stalkerLastVerifiedAt', + 'stalkerProfilePreset', + 'stalkerRecipeClassifierVersion', + 'stalkerRequestRecipe', + 'stalkerSerialNumber', + 'stalkerSignature1', + 'stalkerSignature2', + 'stalkerSourceUrl', + 'stalkerTransportConfiguration', + 'userAgent', + 'username', +] as const satisfies readonly (keyof Playlist)[]; + export function resolvePlaylistParser(parserModule: PlaylistParserModule) { const parse = parserModule.parse ?? parserModule.default?.parse; @@ -463,9 +488,10 @@ export class PlaylistsService { } /** - * Persists the complete, already verified Stalker connection in one - * serialized row write. This is the promotion boundary used before the - * main-process provisional session is committed. + * Persists an already verified Stalker connection in one serialized row + * write. Existing rows merge only connection-owned fields from the + * pre-authentication draft, so concurrent title, collection, playback, + * refresh, and display-setting changes survive this promotion boundary. * * The legacy bearer token is always removed. Omitted credentials preserve * an existing accepted pair, which lets saved-credential reconnects update @@ -484,11 +510,14 @@ export class PlaylistsService { ); const safeDraft = { ...draft }; delete safeDraft.stalkerToken; - const nextPlaylist = { - ...(currentPlaylist ?? {}), - ...safeDraft, - _id: playlistId, - } as Playlist; + const nextPlaylist = + currentPlaylist === undefined + ? safeDraft + : { + ...currentPlaylist, + ...this.pickStalkerConnectionFields(safeDraft), + _id: playlistId, + }; delete nextPlaylist.stalkerToken; await this.persistPlaylistMutation(nextPlaylist); @@ -496,6 +525,19 @@ export class PlaylistsService { }); } + private pickStalkerConnectionFields(draft: Playlist): Partial { + const patch: Partial = {}; + for (const field of STALKER_CONNECTION_FIELDS) { + if ( + Object.prototype.hasOwnProperty.call(draft, field) && + draft[field] !== undefined + ) { + Object.assign(patch, { [field]: draft[field] }); + } + } + return patch; + } + getPlaylist(id: string) { if (id === 'global-favorites') { return this.getPlaylistWithGlobalFavorites();