diff --git a/.changes/stalker-endpoint-discovery.md b/.changes/stalker-endpoint-discovery.md index 18e2fe01f..ed93eb173 100644 --- a/.changes/stalker-endpoint-discovery.md +++ b/.changes/stalker-endpoint-discovery.md @@ -4,7 +4,8 @@ area: stalker issues: [850, 686, 755] --- -Stalker setup now accepts a host or `/c` address, finds the working API -endpoint and authentication mode, and rechecks changed connection details in -Edit. Misclassified portals from earlier versions still repair themselves on -first compatible failure without losing favorites or history. +Stalker portals are no longer classified by their URL shape: importing probes +the real API endpoint (`portal.php` vs `server/load.php`) and checks whether +the portal actually requires authentication. Canonical Ministra URLs finally +load content, `…/c` addresses resolve correctly, and misclassified existing +portals repair themselves on first failure — keeping favorites and history. diff --git a/.changes/stalker-smart-discovery.md b/.changes/stalker-smart-discovery.md new file mode 100644 index 000000000..49926d2be --- /dev/null +++ b/.changes/stalker-smart-discovery.md @@ -0,0 +1,8 @@ +--- +type: feature +area: stalker +--- + +Stalker setup now accepts a host or `/c` address, discovers the working API +endpoint and authentication mode, and rechecks connection details edited +later. Failed edits leave the saved playlist and active session unchanged. diff --git a/CLAUDE.md b/CLAUDE.md index 33aae3042..c63d364e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1167,7 +1167,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. -- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Failure writes nothing; success atomically replaces endpoint, mode, normalized identity and session metadata, then synchronizes the active `StalkerStore` snapshot and session/watchdog before another same-route request can use the old connection. 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 preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Failure writes nothing; 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. 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`. 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. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit. - 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 f2952dd85..2aca04ca4 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 @@ -18,10 +18,13 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { }; const portalRepair = { applyOverride: jest.fn((playlist: PlaylistMeta) => playlist), - retireForPlaylistEdit: jest.fn(), + fenceForPlaylistEdit: jest.fn(), + commitPlaylistEdit: jest.fn(), }; const stalkerSession = { - replaceSessionAfterEdit: jest.fn().mockResolvedValue(undefined), + replaceSessionAfterEdit: jest.fn( + async (playlist: PlaylistMeta) => playlist + ), }; let activePlaylist: PlaylistMeta | undefined; const stalkerStore = { @@ -212,9 +215,10 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { await service.applyResolvedConnection(resolvedPlaylist); - expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith( + expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith( draft._id ); + expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith(draft._id); expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith( expect.objectContaining({ portalUrl: 'https://new.example.com/server/load.php', @@ -236,6 +240,42 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { ); }); + it('replaces the active runtime playlist with the complete persisted row', async () => { + activePlaylist = { + ...draft, + portalUrl: 'https://old.example.com/portal.php', + isFullStalkerPortal: false, + referrer: 'https://portal.example.com/c/', + origin: 'https://portal.example.com', + }; + const resolvedPlaylist = { + ...draft, + portalUrl: 'https://new.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + }, + }; + stalkerSession.replaceSessionAfterEdit.mockImplementationOnce( + async (playlistWithSession: PlaylistMeta) => ({ + ...playlistWithSession, + referrer: 'https://portal.example.com/c/', + origin: 'https://portal.example.com', + }) + ); + + await service.applyResolvedConnection(resolvedPlaylist); + + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: 'https://new.example.com/server/load.php', + referrer: 'https://portal.example.com/c/', + origin: 'https://portal.example.com', + }) + ); + }); + it('clears the session and stops full-portal runtime behavior after a resolved simple edit', async () => { activePlaylist = { ...draft, @@ -251,9 +291,10 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { await service.applyResolvedConnection(resolvedPlaylist); - expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith( + expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith( draft._id ); + expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith(draft._id); expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith( expect.objectContaining({ portalUrl: 'https://new.example.com/portal.php', @@ -272,29 +313,29 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { ); }); - it('repoints the active snapshot before an old authentication finishes draining', async () => { + it('repoints the active snapshot only after old authentication finishes draining', async () => { activePlaylist = { ...draft, portalUrl: 'https://old.example.com/server/load.php', isFullStalkerPortal: true, }; - let finishReplacement: () => void = () => undefined; - stalkerSession.replaceSessionAfterEdit.mockReturnValueOnce( - new Promise((resolve) => { - finishReplacement = resolve; - }) - ); const resolvedPlaylist = { ...draft, portalUrl: 'https://new.example.com/portal.php', isFullStalkerPortal: false, stalkerSessionPatch: null, }; + let finishReplacement: () => void = () => undefined; + stalkerSession.replaceSessionAfterEdit.mockReturnValueOnce( + new Promise((resolve) => { + finishReplacement = () => resolve(resolvedPlaylist); + }) + ); const applying = service.applyResolvedConnection(resolvedPlaylist); expect(activePlaylist?.portalUrl).toBe( - 'https://new.example.com/portal.php' + 'https://old.example.com/server/load.php' ); let settled = false; void applying.then(() => (settled = true)); @@ -303,6 +344,39 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { finishReplacement(); await applying; + expect(activePlaylist?.portalUrl).toBe( + 'https://new.example.com/portal.php' + ); + }); + + it('keeps repair and active runtime state unchanged when persistence fails', async () => { + activePlaylist = { + ...draft, + portalUrl: 'https://old.example.com/server/load.php', + isFullStalkerPortal: true, + }; + const resolvedPlaylist = { + ...draft, + portalUrl: 'https://new.example.com/portal.php', + isFullStalkerPortal: false, + stalkerSessionPatch: null, + }; + stalkerSession.replaceSessionAfterEdit.mockRejectedValueOnce( + new Error('write failed') + ); + + await expect( + service.applyResolvedConnection(resolvedPlaylist) + ).rejects.toThrow('write failed'); + + expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + expect(portalRepair.commitPlaylistEdit).not.toHaveBeenCalled(); + expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled(); + expect(activePlaylist?.portalUrl).toBe( + 'https://old.example.com/server/load.php' + ); }); it.each([true, false])( @@ -336,7 +410,10 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { await service.applyResolvedConnection(resolvedPlaylist); expect(portalRepair.applyOverride).not.toHaveBeenCalled(); - expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith( + expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith( draft._id ); expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith( 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 473692511..2cbdc2929 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 @@ -46,20 +46,17 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis // the resolved row could turn a credential-only A→A edit back into // the repair's old A→B result. const runtimePlaylist = this.toRuntimePlaylist(playlist); - this.portalRepair.retireForPlaylistEdit(runtimePlaylist._id); - const sessionReplacement = - this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist); + this.portalRepair.fenceForPlaylistEdit(runtimePlaylist._id); + const persistedPlaylist = + await this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist); + this.portalRepair.commitPlaylistEdit(runtimePlaylist._id); - let activePlaylistUpdate = Promise.resolve(); if (this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id) { - // `setCurrentPlaylist` patches the signal-store snapshot before - // its optional SQLite sync awaits, and re-points/stops the active - // watchdog synchronously. The next catalog/playback request can - // therefore no longer use the pre-edit endpoint or identity. - activePlaylistUpdate = - this.stalkerStore.setCurrentPlaylist(runtimePlaylist); + // The persistence result is the complete row merged by + // PlaylistsService, so backup-restored playback headers and other + // metadata absent from the form survive the active replacement. + await this.stalkerStore.setCurrentPlaylist(persistedPlaylist); } - await Promise.all([sessionReplacement, activePlaylistUpdate]); } async resolveConnection( diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 4c122bf80..999181c75 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -202,9 +202,12 @@ service validates the draft. Auth rejection or an unreachable portal leaves the dialog open and writes nothing. Success atomically replaces the endpoint, mode and normalized identity together with session metadata: simple mode clears token/fingerprint/watchdog/account state, while full mode replaces it -with the confirmed authorization result. The app adapter also replaces the -active `StalkerStore` snapshot and session/watchdog state immediately, so the -next request in the same route cannot use the pre-edit connection. +with the confirmed authorization result. That awaited write returns the +complete merged playlist row before NgRx receives its state-only update and +before the app adapter replaces the active `StalkerStore` snapshot and +session/watchdog state, so persistence failure cannot expose a partial runtime +edit and metadata absent from the form (such as playback `Referer`/`Origin`) +survives the replacement. `PlaylistMetaUpdate` carries the persisted part as a transient `stalkerSessionPatch` (`undefined` preserves, `null` clears, an object fully replaces); `PlaylistsService` projects it onto the existing flat playlist diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 45703e9c5..cbd8c28c2 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -15,7 +15,14 @@ export const PlaylistActions = createActionGroup({ 'Add Playlist': props<{ playlist: Playlist }>(), 'Add Many Playlists': props<{ playlists: Playlist[] }>(), 'Remove Playlist': props<{ playlistId: string }>(), - 'Update Playlist Meta': props<{ playlist: PlaylistMetaUpdate }>(), + 'Update Playlist Meta': props<{ + playlist: PlaylistMetaUpdate; + /** + * False when an awaited owner already persisted this exact + * update and the action only synchronizes NgRx state. + */ + persist?: boolean; + }>(), 'Update Playlist': props<{ /** * Instrumentation-only; stripped before the DB invoke. diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 126b2f4c5..f82bda40e 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -376,6 +376,7 @@ export class PlaylistEffects { () => { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylistMeta), + filter((action) => action.persist !== false), switchMap((action) => this.playlistsService .updatePlaylistMeta(action.playlist) diff --git a/libs/m3u-state/src/lib/playlist-meta.effect.spec.ts b/libs/m3u-state/src/lib/playlist-meta.effect.spec.ts new file mode 100644 index 000000000..a945ebee4 --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-meta.effect.spec.ts @@ -0,0 +1,69 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { Router } from '@angular/router'; +import { Actions } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { PlaylistsService, SettingsStore } from '@iptvnator/services'; +import type { PlaylistMetaUpdate } from '@iptvnator/shared/interfaces'; +import { EMPTY, of, Subject } from 'rxjs'; +import { PlaylistActions } from './actions'; +import { PlaylistEffects } from './effects'; + +describe('PlaylistEffects updatePlaylistMeta$', () => { + const playlist = { + _id: 'stalker-1', + title: 'Portal', + count: 0, + importDate: '', + portalUrl: 'https://portal.example.com/server/load.php', + } as PlaylistMetaUpdate; + + let actions$: Subject; + let updatePlaylistMeta: jest.Mock; + let effects: PlaylistEffects; + + beforeEach(() => { + actions$ = new Subject(); + updatePlaylistMeta = jest.fn(() => of(undefined)); + const permissiveParent = { + get: () => ({}), + } as unknown as Injector; + const injector = Injector.create({ + parent: permissiveParent, + providers: [ + { provide: Actions, useValue: new Actions(actions$) }, + { provide: Store, useValue: { select: () => EMPTY } }, + { + provide: PlaylistsService, + useValue: { updatePlaylistMeta }, + }, + { provide: EpgService, useValue: { fetchEpg: jest.fn() } }, + { provide: Router, useValue: {} }, + { + provide: SettingsStore, + useValue: { getSettings: () => ({ epgUrl: [] }) }, + }, + ], + }); + + effects = runInInjectionContext(injector, () => new PlaylistEffects()); + effects.updatePlaylistMeta$.subscribe(); + }); + + it('skips persistence when an awaited owner already saved the update', () => { + actions$.next( + PlaylistActions.updatePlaylistMeta({ + playlist, + persist: false, + }) + ); + + expect(updatePlaylistMeta).not.toHaveBeenCalled(); + }); + + it('persists ordinary metadata updates', () => { + actions$.next(PlaylistActions.updatePlaylistMeta({ playlist })); + + expect(updatePlaylistMeta).toHaveBeenCalledWith(playlist); + }); +}); 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 e12cb52b4..d0cccbfcd 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 @@ -654,6 +654,7 @@ describe('PlaylistInfoComponent', () => { playlist: expect.objectContaining({ macAddress: '00:1A:79:AB:CD:EF', }) as PlaylistMeta, + persist: false, }) ); expect( @@ -753,6 +754,7 @@ describe('PlaylistInfoComponent', () => { expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ playlist: resolvedPlaylist, + persist: false, }) ); expect( @@ -761,6 +763,48 @@ describe('PlaylistInfoComponent', () => { expect(dialogRef.close).toHaveBeenCalledTimes(1); }); + it('does not update UI state or report success when resolved persistence fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + createStalkerComponent(); + const resolvedPlaylist = { + ...component.playlistDetails.getRawValue(), + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + }, + }; + stalkerConnectionEditor.resolveConnection.mockResolvedValue({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: resolvedPlaylist, + }); + stalkerConnectionEditor.applyResolvedConnection.mockRejectedValue( + new Error('write failed') + ); + component.playlistDetails + .get('username') + ?.setValue('subscriber'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED', + 'CLOSE', + { duration: 3000 } + ); + } finally { + consoleError.mockRestore(); + } + }); + it.each([ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED, STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE, 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 3c9fcae2b..1a0d06ee3 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 @@ -412,18 +412,22 @@ export class PlaylistInfoComponent { await this.updateXtreamPlaylist(normalizedPlaylist); } - // Dispatch store action to update UI - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: normalizedPlaylist, - }) - ); if (resolvedStalkerConnection) { await this.stalkerConnectionEditor.applyResolvedConnection( normalizedPlaylist ); } + // Resolved Stalker edits cross an awaited, atomic persistence + // boundary above. Their action updates NgRx only; every other + // metadata edit keeps the effect-owned persistence path. + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: normalizedPlaylist, + ...(resolvedStalkerConnection ? { persist: false } : {}), + }) + ); + this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' 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 187b70ad0..1cc383de4 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 @@ -38,7 +38,7 @@ export interface StalkerPlaylistConnectionEditor { resolveConnection( playlist: PlaylistMeta ): Promise; - /** Synchronizes in-run Stalker state after the resolved update dispatches. */ + /** Atomically persists a resolved edit, then synchronizes in-run state. */ applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise; } 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 371196d3c..1c7e2b4e2 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 @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import type { Playlist } from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from './stalker-session.service'; @@ -70,10 +70,15 @@ describe('Stalker edited-session coordination', () => { await expect(oldAuthentication).rejects.toThrow(/stale/i); await replacement; expect(service.getCachedToken(oldPlaylist._id)).toBe('NEW_TOKEN'); - expect(updateStalkerSession).toHaveBeenLastCalledWith( - oldPlaylist._id, - expect.objectContaining({ stalkerToken: 'NEW_TOKEN' }) + expect(updatePlaylistMeta).toHaveBeenLastCalledWith( + expect.objectContaining({ + portalUrl: 'https://new.example.com/server/load.php', + stalkerSessionPatch: expect.objectContaining({ + stalkerToken: 'NEW_TOKEN', + }), + }) ); + expect(updateStalkerSession).not.toHaveBeenCalled(); }); it('prevents late pre-edit auth from restoring a cleared simple session', async () => { @@ -104,4 +109,60 @@ describe('Stalker edited-session coordination', () => { }) ); }); + + it('persists a resolved full connection and session in one metadata write', async () => { + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + username: 'subscriber', + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + }; + + await service.replaceSessionAfterEdit(editedPlaylist); + + expect(updatePlaylistMeta).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: 'https://new.example.com/server/load.php', + username: 'subscriber', + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: expect.any(String), + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + stalkerAccountInfo: undefined, + }, + }) + ); + expect(updateStalkerSession).not.toHaveBeenCalled(); + }); + + it('does not adopt a resolved full session when its atomic write fails', async () => { + service.adoptDiscoveredSimplePortal(oldPlaylist); + updatePlaylistMeta.mockReturnValueOnce( + throwError(() => new Error('write failed')) + ); + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + }; + + await expect( + service.replaceSessionAfterEdit(editedPlaylist) + ).rejects.toThrow('write failed'); + + expect(service.getCachedToken(oldPlaylist._id)).toBeNull(); + + // A failed edit must release its pending authority as well: the + // still-persisted connection remains usable without an app restart. + service.setCachedToken(oldPlaylist._id, 'OLD_TOKEN', oldPlaylist); + await expect(service.ensureToken(oldPlaylist)).resolves.toEqual({ + token: 'OLD_TOKEN', + serialNumber: undefined, + }); + }); }); 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 c7557d826..10cabf445 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 @@ -12,7 +12,8 @@ import type { StalkerWatchdogController } from './stalker-watchdog.controller'; /** Serializes authoritative Edit results against pre-edit authentication. */ export class StalkerEditedSessionCoordinator { private readonly authoritativeFingerprints = new Map(); - private readonly replacements = new Map>(); + private readonly pendingFingerprints = new Map(); + private readonly replacements = new Map>(); constructor( private readonly tokens: StalkerTokenCache, @@ -38,15 +39,22 @@ export class StalkerEditedSessionCoordinator { } assertCurrent(playlistId: string, fingerprint: string): void { - const authoritative = this.authoritativeFingerprints.get(playlistId); + const authoritative = + this.pendingFingerprints.get(playlistId) ?? + this.authoritativeFingerprints.get(playlistId); if (authoritative && authoritative !== fingerprint) { throw new Error('Stale Stalker playlist configuration'); } } - replace(playlist: Playlist): Promise { + replace(playlist: Playlist): Promise { const playlistId = playlist._id; - const fingerprint = this.markAuthoritative(playlist); + const fingerprint = stalkerSessionFingerprint(playlist); + // Fence the old connection immediately, but do not make the edit + // authoritative until its single persistence write succeeds. A + // failed write therefore releases this fence and leaves the prior + // runtime session usable. + this.pendingFingerprints.set(playlistId, fingerprint); const previous = this.replacements.get(playlistId) ?? Promise.resolve(); const replacement = previous .catch(() => undefined) @@ -57,6 +65,9 @@ export class StalkerEditedSessionCoordinator { if (this.replacements.get(playlistId) === replacement) { this.replacements.delete(playlistId); } + if (this.pendingFingerprints.get(playlistId) === fingerprint) { + this.pendingFingerprints.delete(playlistId); + } }) .catch(() => undefined); return replacement; @@ -65,7 +76,7 @@ export class StalkerEditedSessionCoordinator { private async commit( playlist: Playlist, fingerprint: string - ): Promise { + ): Promise { const playlistId = playlist._id; this.assertCurrent(playlistId, fingerprint); await this.tokens @@ -74,35 +85,49 @@ export class StalkerEditedSessionCoordinator { this.assertCurrent(playlistId, fingerprint); const playlists = this.resolvePlaylistsService(); - if (!isFullStalkerPortalPlaylist(playlist)) { + const isFullPortal = isFullStalkerPortalPlaylist(playlist); + let sessionPatch: PlaylistMetaUpdate['stalkerSessionPatch'] = null; + if (isFullPortal) { + const token = playlist.stalkerToken; + if (!token) { + throw new Error( + 'Resolved full Stalker portal is missing a session token' + ); + } + sessionPatch = { + stalkerToken: token, + stalkerSessionIdentity: fingerprint, + stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout, + stalkerTimeslot: playlist.stalkerTimeslot, + stalkerAccountInfo: playlist.stalkerAccountInfo, + }; + } + + const persistedPlaylist = await firstValueFrom( + playlists.updatePlaylistMeta({ + ...playlist, + stalkerSessionPatch: sessionPatch, + } as PlaylistMetaUpdate) + ); + if (!persistedPlaylist) { + throw new Error('Resolved Stalker playlist could not be persisted'); + } + this.assertCurrent(playlistId, fingerprint); + this.authoritativeFingerprints.set(playlistId, fingerprint); + if (this.pendingFingerprints.get(playlistId) === fingerprint) { + this.pendingFingerprints.delete(playlistId); + } + + if (!sessionPatch) { this.tokens.clear(playlistId); - await firstValueFrom( - playlists.updatePlaylistMeta({ - ...playlist, - stalkerSessionPatch: null, - } as PlaylistMetaUpdate) - ); - return; + return persistedPlaylist; } - if (!playlist.stalkerToken) { - throw new Error( - 'Resolved full Stalker portal is missing a session token' - ); - } - - this.tokens.set(playlistId, playlist.stalkerToken, fingerprint); + this.tokens.set(playlistId, sessionPatch.stalkerToken, fingerprint); this.watchdog.applyProfileTiming(playlistId, { watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, timeslotSeconds: playlist.stalkerTimeslot, }); - await firstValueFrom( - playlists.updateStalkerSession(playlistId, { - stalkerToken: playlist.stalkerToken, - stalkerSessionIdentity: fingerprint, - stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout, - stalkerTimeslot: playlist.stalkerTimeslot, - }) - ); + return persistedPlaylist; } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index 7128c6e01..c75bacd6c 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -490,7 +490,7 @@ describe('StalkerPortalRepairService', () => { ); }); - it('retires a remembered endpoint override before explicit Edit installs its result', async () => { + it('fences an edit without changing runtime state until persistence commits', async () => { const wrongEndpoint = { ...MISCLASSIFIED, portalUrl: 'http://ministra.example/portal.php', @@ -503,14 +503,19 @@ describe('StalkerPortalRepairService', () => { token: 'TOKEN2', }); await service.repairPortal(wrongEndpoint); + clearCachedToken.mockClear(); + + service.fenceForPlaylistEdit(wrongEndpoint._id); + expect(service.applyOverride(wrongEndpoint).portalUrl).toBe( 'http://ministra.example/server/load.php' ); + expect(clearCachedToken).not.toHaveBeenCalled(); - service.retireForPlaylistEdit(wrongEndpoint._id); + service.commitPlaylistEdit(wrongEndpoint._id); expect(service.applyOverride(wrongEndpoint)).toBe(wrongEndpoint); - expect(clearCachedToken).toHaveBeenLastCalledWith('portal-1'); + expect(clearCachedToken).not.toHaveBeenCalled(); }); it('discards a repair verified before explicit Edit but completed after it', async () => { @@ -538,7 +543,7 @@ describe('StalkerPortalRepairService', () => { // The transform has verified A and computed B, but its async // persistence has not completed. Explicit Edit C must fence all // repair-side runtime/session effects that follow that await. - service.retireForPlaylistEdit(wrongEndpoint._id); + service.fenceForPlaylistEdit(wrongEndpoint._id); writeCompletion.next(writtenRow); await expect(repair).resolves.toBeNull(); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index b7f4b48eb..244e34eac 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -173,16 +173,25 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { } /** - * Retires a lazy-repair override before an explicit Edit result is - * installed. Discovery has just proved that exact endpoint and mode, so - * an override remembered for the pre-edit connection must not rewrite it. + * Invalidates every repair that started before an explicit Edit, without + * changing the working override or token yet. Persistence may still fail; + * in that case the previous runtime connection must remain untouched. */ - retireForPlaylistEdit(playlistId: string): void { + fenceForPlaylistEdit(playlistId: string): void { this.editGenerations.set( playlistId, (this.editGenerations.get(playlistId) ?? 0) + 1 ); - this.dropOverride(playlistId); + } + + /** + * Removes the pre-edit override after the resolved row and session have + * been committed. The session coordinator has already replaced or + * cleared the cached token, so clearing it here would discard the newly + * authoritative session. + */ + commitPlaylistEdit(playlistId: string): void { + this.overrides.delete(playlistId); } /** 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 c0ef13907..0781e7ca6 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 @@ -232,13 +232,13 @@ export class StalkerSessionService { /** * Installs the session produced by explicit Edit discovery. * - * The new fingerprint becomes authoritative synchronously, before an old - * authentication is awaited. That old request consequently fails its - * post-auth guard and cannot overwrite the resolved token (or restore a - * token after a full→simple edit). The final write is queued after it so - * the resolved session remains the last persisted session mutation. + * The new fingerprint fences old authentication synchronously, but only + * becomes authoritative after the atomic write succeeds. That old request + * consequently cannot overwrite the resolved token (or restore one after + * a full→simple edit), while a failed write releases the fence and keeps + * the previous runtime session usable. */ - replaceSessionAfterEdit(playlist: Playlist): Promise { + replaceSessionAfterEdit(playlist: Playlist): Promise { return this.editedSessions.replace(playlist); }