From e634cdea6e314ccd53049eddda341a7ab46b14a8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 23:49:49 +0200 Subject: [PATCH] fix(xtream): apply an account-info answer only to the panel it came from Review follow-up (Codex): an in-place edit keeps the playlist id while moving the source, so an answer already on the wire for the OLD panel passed the id-only guard and patched the new panel's status and clock into the store. One `answersFor(candidate, credentials)` predicate now gates the store patch, the error path and the persisted-row transform alike. Co-Authored-By: Claude Fable 5.1 --- .../features/with-portal.feature.spec.ts | 31 ++++++++++++ .../stores/features/with-portal.feature.ts | 49 ++++++++++++++----- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index fd3fd88ba..8aeab6eae 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -252,6 +252,37 @@ describe('withPortal', () => { expect(storedPlaylist.serverTimezone).toBe('Europe/London'); }); + it('does not patch the store with a late answer when the playlist was edited in place meanwhile', async () => { + let answer!: (value: unknown) => void; + apiService.getAccountInfo.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }) + ); + + const pending = store.checkPortalStatus(); + // Same id, new panel: the in-place edit flow. + const moved: XtreamPlaylistData = { + ...PLAYLIST, + serverUrl: 'https://moved.example.com', + }; + store.setCurrentPlaylist(moved); + storedPlaylist = { + ...storedPlaylist, + serverUrl: moved.serverUrl, + serverTimezone: undefined, + }; + answer({ + user_info: { auth: 1, exp_date: '0', status: 'Active' }, + server_info: { timezone: 'Europe/London' }, + }); + + await expect(pending).resolves.toBe('active'); + expect(store.currentPlaylist()).toEqual(moved); + expect(store.portalStatus()).toBe('unavailable'); + expect(storedPlaylist.serverTimezone).toBeUndefined(); + }); + it('does not persist a late answer onto a row whose connection was edited meanwhile', async () => { let answer!: (value: unknown) => void; apiService.getAccountInfo.mockReturnValue( diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index 960f16bc8..f9a078f44 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -58,6 +58,26 @@ export function withPortal() { const dataSource = inject(XTREAM_DATA_SOURCE); const playlistsService = inject(PlaylistsService); + /** + * Whether a playlist (the store's current one, or the stored + * row) still points at the panel an account-info answer came + * from. The playlist id alone is not enough: an in-place edit + * keeps the id while moving the source, and an answer already on + * the wire for the OLD panel must not describe the new one. + */ + const answersFor = ( + candidate: { + serverUrl?: string; + username?: string; + password?: string; + } | null, + credentials: XtreamCredentials + ): boolean => + !!candidate && + candidate.serverUrl === credentials.serverUrl && + candidate.username === credentials.username && + candidate.password === credentials.password; + /** * The Favorites / Recent catch-up resolver reads the STORED * playlist row, not this store, so a timezone learned here has @@ -79,9 +99,7 @@ export function withPortal() { playlistsService.transformPlaylistMeta( playlistId, (playlist) => - playlist.serverUrl !== credentials.serverUrl || - playlist.username !== credentials.username || - playlist.password !== credentials.password || + !answersFor(playlist, credentials) || playlist.serverTimezone === serverTimezone ? null : { ...playlist, serverTimezone } @@ -173,15 +191,18 @@ export function withPortal() { .map((format) => format.trim()) .filter(Boolean) : undefined; - // The answer belongs to the playlist whose - // credentials were sent: its timezone is persisted - // under THAT id regardless, while the store is - // patched only if that playlist is still the - // selected one — a source switch during the request - // must not hand playlist A's status or clock to - // playlist B. + // The answer belongs to the panel whose credentials + // were sent: its timezone is offered to THAT row + // regardless, while the store is patched only if the + // selected playlist is still that panel — neither a + // source switch nor an in-place edit during the + // request may hand the old panel's status or clock + // to the new one. const current = store.currentPlaylist(); - if (current?.id === playlist.id) { + if ( + current?.id === playlist.id && + answersFor(current, credentials) + ) { patchState(store, { portalStatus, currentPlaylist: { @@ -208,7 +229,11 @@ export function withPortal() { return portalStatus; } catch (error) { logger.error('Error checking portal status', error); - if (store.currentPlaylist()?.id === playlist.id) { + const current = store.currentPlaylist(); + if ( + current?.id === playlist.id && + answersFor(current, credentials) + ) { patchState(store, { portalStatus: 'unavailable' }); } return 'unavailable';