From 37575c8d884d76c895d703b5a7ecb8ed3ea55c71 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 9 Aug 2026 18:01:13 +0200 Subject: [PATCH] fix(stalker): merge late connection saves --- CLAUDE.md | 2 +- ...playlist-connection-editor.service.spec.ts | 33 +++++++++++ ...lker-playlist-connection-editor.service.ts | 22 +++++-- docs/architecture/stalker-portal.md | 7 ++- .../playlist-info.component.spec.ts | 32 +++++++++-- .../playlist-info/playlist-info.component.ts | 14 ++++- ...talker-playlist-connection-editor.token.ts | 10 +++- ...stalker-edited-session-coordinator.spec.ts | 49 ++++++++++++++++ .../lib/stalker-edited-session-coordinator.ts | 57 ++++++++++++++++--- .../src/lib/stalker-session.service.ts | 5 +- 10 files changed, 202 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c4a7559b..664c53b3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1263,7 +1263,7 @@ engine` (restart required) or - Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one. - Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `/portal.php`, while canonical-shaped unreachable addresses still abort. If bounded discovery returns while abandoned authentication remains on the wire, the refusal is shown immediately but Add and every form field stay disabled until its settlement promise resolves. -- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. It preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled, so the atomic persistence and state-only update complete while success UI is suppressed. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape. +- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. It preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled. That late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only connection/session fields into the current row, so newer title/EPG/metadata edits win; its returned row feeds the state-only update while success UI is suppressed. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape. - `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair calls discovery, it verifies that the persisted row still owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or backup restore with the same playlist ID but different connection metadata retires the override and token only after the persisted row confirms ownership and only if no explicit Edit took ownership during that read, so a delayed stale request cannot remove valid runtime state or a token negotiated by the overlapping Edit. Each repair installs a session-level authentication fence synchronously, drains the existing token slot before probing, and keeps request routing ahead of effective-connection selection until repair finishes; an abandoned transport keeps both the repair and session fences until it actually settles. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. - Explicit Edit advances the repair generation before installing its resolved session. Lazy repair captures that generation before any probe-history row read and rechecks it with the active Edit fence before reserving discovery. A repair that started earlier is therefore discarded even if it was restoring a `discarded` history record or had already verified its row, so it cannot probe alongside Edit or restore an older endpoint, mode or token afterwards. - Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them. diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts index 808ecbb89..47f01976a 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts @@ -358,6 +358,39 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { ); }); + it('returns an atomic connection-only merge for a post-navigation save', async () => { + const resolvedPlaylist = { + ...draft, + title: 'Stale form title', + portalUrl: 'https://new.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + }, + }; + const persistedPlaylist = { + ...resolvedPlaylist, + title: 'Newer title', + epgUrls: ['https://new.example.com/epg.xml'], + }; + stalkerSession.replaceSessionAfterEdit.mockResolvedValueOnce( + persistedPlaylist + ); + + await expect( + service.applyResolvedConnection(resolvedPlaylist, { + preserveCurrentMetadata: true, + }) + ).resolves.toEqual(persistedPlaylist); + + expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Stale form title' }), + expect.objectContaining({ playlistId: draft._id }), + { preserveCurrentMetadata: true } + ); + }); + it('clears the session and stops full-portal runtime behavior after a resolved simple edit', async () => { activePlaylist = { ...draft, diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts index c5e944281..b8e5b0dd4 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts @@ -14,6 +14,7 @@ import { STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS, type StalkerPlaylistConnectionEditor, type StalkerPlaylistConnectionResult, + type StalkerResolvedConnectionApplyOptions, } from '@iptvnator/playlist/shared/ui'; import { normalizeStalkerPortalIdentity, @@ -45,7 +46,10 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis private readonly translate = inject(TranslateService); private readonly editFences = new Map(); - async applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise { + async applyResolvedConnection( + playlist: PlaylistMetaUpdate, + options: StalkerResolvedConnectionApplyOptions = {} + ): Promise { // Edit discovery is newer and more authoritative than a lazy repair // remembered for the previous connection. Applying that override to // the resolved row could turn a credential-only A→A edit back into @@ -55,11 +59,16 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis this.editFences.get(runtimePlaylist._id) ?? (await this.beginEditFence(runtimePlaylist)); try { - const persistedPlaylist = - await this.stalkerSession.replaceSessionAfterEdit( - runtimePlaylist, - fence - ); + const persistedPlaylist = options.preserveCurrentMetadata + ? await this.stalkerSession.replaceSessionAfterEdit( + runtimePlaylist, + fence, + options + ) + : await this.stalkerSession.replaceSessionAfterEdit( + runtimePlaylist, + fence + ); this.portalRepair.commitPlaylistEdit(runtimePlaylist._id); this.editFences.delete(runtimePlaylist._id); @@ -71,6 +80,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis // other metadata absent from the form survive replacement. await this.stalkerStore.setCurrentPlaylist(persistedPlaylist); } + return persistedPlaylist; } catch (error) { this.releaseEditFence(runtimePlaylist._id, fence); throw error; diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 8d38c2c67..dd84bf534 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -208,8 +208,11 @@ validation window. If navigation or another owner starts closing/destroys the dialog while discovery is in flight, a successful result still crosses the atomic persistence boundary: the submitted `get_profile` may already have pinned the new serial/device identity remotely and cannot be recalled. The -state-only update also completes, while dialog close and success UI are -suppressed after destruction. Before discovery starts, Edit reserves the playlist ID; an +late commit uses `transformPlaylistMeta()` inside the per-playlist write queue +to merge only the resolved connection/session fields into the current row, so +a newer title, EPG, or other metadata edit wins. The returned merged row feeds +the state-only update, while dialog close and success UI are suppressed after +destruction. Before discovery starts, Edit reserves the playlist ID; an overlapping Edit cannot replace that owner. The reservation blocks every new authentication (including a URL edit with the same normalized fingerprint) and repair, and drains any work already in flight. Ownership is rechecked after diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts index eb13b35fe..6237cd25a 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -115,7 +115,9 @@ describe('PlaylistInfoComponent', () => { disableClose: false, }; stalkerConnectionEditor = { - applyResolvedConnection: jest.fn().mockResolvedValue(undefined), + applyResolvedConnection: jest.fn( + async (playlist: PlaylistMeta) => playlist + ), resolveConnection: jest.fn( async (updatedPlaylist: PlaylistMeta) => ({ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, @@ -841,6 +843,14 @@ describe('PlaylistInfoComponent', () => { stalkerSessionIdentity: 'new-fingerprint', }, }; + const currentPlaylist = { + ...resolvedPlaylist, + title: 'Newer title', + epgUrls: ['https://new.example.com/epg.xml'], + }; + stalkerConnectionEditor.applyResolvedConnection.mockResolvedValueOnce( + currentPlaylist + ); let finishDiscovery: | ((value: { status: 'resolved'; @@ -869,10 +879,12 @@ describe('PlaylistInfoComponent', () => { expect( stalkerConnectionEditor.applyResolvedConnection - ).toHaveBeenCalledWith(resolvedPlaylist); + ).toHaveBeenCalledWith(resolvedPlaylist, { + preserveCurrentMetadata: true, + }); expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ - playlist: resolvedPlaylist, + playlist: currentPlaylist, persist: false, }) ); @@ -891,6 +903,14 @@ describe('PlaylistInfoComponent', () => { stalkerSessionIdentity: 'new-fingerprint', }, }; + const currentPlaylist = { + ...resolvedPlaylist, + title: 'Newer title', + epgUrls: ['https://new.example.com/epg.xml'], + }; + stalkerConnectionEditor.applyResolvedConnection.mockResolvedValueOnce( + currentPlaylist + ); let finishDiscovery: | ((value: { status: 'resolved'; @@ -919,10 +939,12 @@ describe('PlaylistInfoComponent', () => { expect( stalkerConnectionEditor.applyResolvedConnection - ).toHaveBeenCalledWith(resolvedPlaylist); + ).toHaveBeenCalledWith(resolvedPlaylist, { + preserveCurrentMetadata: true, + }); expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ - playlist: resolvedPlaylist, + playlist: currentPlaylist, persist: false, }) ); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 3d2966138..5c543a300 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -397,6 +397,7 @@ export class PlaylistInfoComponent { } let resolvedStalkerConnection = false; + let preserveCurrentMetadata = false; let normalizedPlaylist: PlaylistMetaUpdate = this.normalizeStalkerPlaylistMeta( this.normalizeXtreamPlaylistMeta(submittedPlaylist) @@ -425,6 +426,8 @@ export class PlaylistInfoComponent { } normalizedPlaylist = result.playlist; resolvedStalkerConnection = true; + preserveCurrentMetadata = + this.dialogClosing || this.destroyRef.destroyed; } else { normalizedPlaylist = preserveStalkerConnection( this.playlist, @@ -443,9 +446,14 @@ export class PlaylistInfoComponent { } if (resolvedStalkerConnection) { - await this.stalkerConnectionEditor.applyResolvedConnection( - normalizedPlaylist - ); + normalizedPlaylist = preserveCurrentMetadata + ? await this.stalkerConnectionEditor.applyResolvedConnection( + normalizedPlaylist, + { preserveCurrentMetadata: true } + ) + : await this.stalkerConnectionEditor.applyResolvedConnection( + normalizedPlaylist + ); } // Resolved Stalker edits cross an awaited, atomic persistence diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts index 1cc383de4..95c9be7d9 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts @@ -34,12 +34,20 @@ interface StalkerPlaylistConnectionResultMap { export type StalkerPlaylistConnectionResult = StalkerPlaylistConnectionResultMap[keyof StalkerPlaylistConnectionResultMap]; +export interface StalkerResolvedConnectionApplyOptions { + /** Merge only connection/session fields into the current persisted row. */ + preserveCurrentMetadata?: boolean; +} + export interface StalkerPlaylistConnectionEditor { resolveConnection( playlist: PlaylistMeta ): Promise; /** Atomically persists a resolved edit, then synchronizes in-run state. */ - applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise; + applyResolvedConnection( + playlist: PlaylistMetaUpdate, + options?: StalkerResolvedConnectionApplyOptions + ): Promise; } export const STALKER_PLAYLIST_CONNECTION_EDITOR = diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts index 27c7b38cd..3dfb0cfd5 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts @@ -23,11 +23,15 @@ describe('Stalker edited-session coordination', () => { let service: StalkerSessionService; let getPlaylistById: jest.Mock; let updatePlaylistMeta: jest.Mock; + let transformPlaylistMeta: jest.Mock; let updateStalkerSession: jest.Mock; beforeEach(() => { getPlaylistById = jest.fn(() => of(oldPlaylist)); updatePlaylistMeta = jest.fn(() => of(oldPlaylist)); + transformPlaylistMeta = jest.fn((_id, transform) => + of(transform(oldPlaylist)) + ); updateStalkerSession = jest.fn(() => of(oldPlaylist)); sendIpcEvent = jest.fn(); TestBed.configureTestingModule({ @@ -42,6 +46,7 @@ describe('Stalker edited-session coordination', () => { useValue: { getPlaylistById, updatePlaylistMeta, + transformPlaylistMeta, updateStalkerSession, }, }, @@ -318,6 +323,50 @@ describe('Stalker edited-session coordination', () => { expect(updateStalkerSession).not.toHaveBeenCalled(); }); + it('atomically merges only connection fields into a newer row after navigation', async () => { + const currentPlaylist = { + ...oldPlaylist, + title: 'Newer title', + epgUrls: ['https://new.example.com/epg.xml'], + } as Playlist; + transformPlaylistMeta.mockImplementationOnce((_id, transform) => + of(transform(currentPlaylist)) + ); + const editedPlaylist = { + ...oldPlaylist, + title: 'Stale form title', + portalUrl: 'https://new.example.com/server/load.php', + username: 'subscriber', + stalkerToken: 'NEW_TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + } as Playlist; + const fence = await service.beginEditDiscovery(editedPlaylist); + + const persisted = await service.replaceSessionAfterEdit( + editedPlaylist, + fence, + { preserveCurrentMetadata: true } + ); + + expect(updatePlaylistMeta).not.toHaveBeenCalled(); + expect(transformPlaylistMeta).toHaveBeenCalledWith( + oldPlaylist._id, + expect.any(Function) + ); + expect(persisted).toEqual( + expect.objectContaining({ + title: 'Newer title', + epgUrls: ['https://new.example.com/epg.xml'], + portalUrl: 'https://new.example.com/server/load.php', + username: 'subscriber', + stalkerToken: 'NEW_TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + }) + ); + }); + it('does not adopt a resolved full session when its atomic write fails', async () => { service.adoptDiscoveredSimplePortal(oldPlaylist); updatePlaylistMeta.mockReturnValueOnce( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts index d6733a7fb..a2ba6e917 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts @@ -77,8 +77,7 @@ export class StalkerEditedSessionCoordinator { if (stalkerSessionFingerprint(playlist) !== sessionFingerprint) { throw new Error('Stale Stalker playlist configuration'); } - const authoritative = - this.authoritativeConfigurations.get(playlistId); + const authoritative = this.authoritativeConfigurations.get(playlistId); if ( authoritative && authoritative !== @@ -121,7 +120,11 @@ export class StalkerEditedSessionCoordinator { } } - replace(playlist: Playlist, fence?: StalkerEditFence): Promise { + replace( + playlist: Playlist, + fence?: StalkerEditFence, + options: { preserveCurrentMetadata?: boolean } = {} + ): Promise { const playlistId = playlist._id; const sessionFingerprint = stalkerSessionFingerprint(playlist); const configurationFingerprint = stalkerConfigurationFingerprint( @@ -152,7 +155,8 @@ export class StalkerEditedSessionCoordinator { playlist, sessionFingerprint, configurationFingerprint, - owner + owner, + options ) ); this.replacements.set(playlistId, replacement); @@ -173,7 +177,8 @@ export class StalkerEditedSessionCoordinator { playlist: Playlist, sessionFingerprint: string, configurationFingerprint: string, - owner: symbol + owner: symbol, + options: { preserveCurrentMetadata?: boolean } ): Promise { const playlistId = playlist._id; this.assertFenceCurrent( @@ -208,10 +213,18 @@ export class StalkerEditedSessionCoordinator { } const persistedPlaylist = await firstValueFrom( - playlists.updatePlaylistMeta({ - ...playlist, - stalkerSessionPatch: sessionPatch, - } as PlaylistMetaUpdate) + options.preserveCurrentMetadata + ? playlists.transformPlaylistMeta(playlistId, (current) => + mergeResolvedStalkerConnection( + current, + playlist, + sessionPatch + ) + ) + : playlists.updatePlaylistMeta({ + ...playlist, + stalkerSessionPatch: sessionPatch, + } as PlaylistMetaUpdate) ); if (!persistedPlaylist) { throw new Error('Resolved Stalker playlist could not be persisted'); @@ -292,6 +305,32 @@ export class StalkerEditedSessionCoordinator { } } +/** Applies remote connection authority without replaying stale form metadata. */ +function mergeResolvedStalkerConnection( + current: Playlist, + resolved: Playlist, + sessionPatch: PlaylistMetaUpdate['stalkerSessionPatch'] +): Playlist { + return { + ...current, + portalUrl: resolved.portalUrl, + isFullStalkerPortal: resolved.isFullStalkerPortal, + macAddress: resolved.macAddress, + username: resolved.username, + password: resolved.password, + stalkerSerialNumber: resolved.stalkerSerialNumber, + stalkerDeviceId1: resolved.stalkerDeviceId1, + stalkerDeviceId2: resolved.stalkerDeviceId2, + stalkerSignature1: resolved.stalkerSignature1, + stalkerSignature2: resolved.stalkerSignature2, + stalkerToken: sessionPatch?.stalkerToken, + stalkerSessionIdentity: sessionPatch?.stalkerSessionIdentity, + stalkerWatchdogTimeout: sessionPatch?.stalkerWatchdogTimeout, + stalkerTimeslot: sessionPatch?.stalkerTimeslot, + stalkerAccountInfo: sessionPatch?.stalkerAccountInfo, + }; +} + /** In-run Edit authority also owns the observed full/simple routing mode. */ function stalkerConfigurationFingerprint( playlist: Playlist, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 3846625ec..000e1ed5f 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -286,9 +286,10 @@ export class StalkerSessionService { */ replaceSessionAfterEdit( playlist: Playlist, - fence?: StalkerEditFence + fence?: StalkerEditFence, + options: { preserveCurrentMetadata?: boolean } = {} ): Promise { - return this.editedSessions.replace(playlist, fence); + return this.editedSessions.replace(playlist, fence, options); } /**