diff --git a/.changes/stalker-smart-discovery.md b/.changes/stalker-smart-discovery.md new file mode 100644 index 000000000..356e131eb --- /dev/null +++ b/.changes/stalker-smart-discovery.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker setup now accepts hosts or `/c`, discovers the working API endpoint and +authentication mode, and rechecks edited connection details. Canceled or failed +edits leave saved and active sessions unchanged. Completed edits reject old +configuration requests and late responses. Timed-out authentication stays +fenced until its transport settles. diff --git a/CLAUDE.md b/CLAUDE.md index 37cfb3de6..78d8c9cc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1261,20 +1261,23 @@ engine` (restart required) or **Stalker Portal Mode and Endpoint Discovery**: +- Every resolved Edit commit is guarded by the source connection authority captured when Edit began. Electron checks it inside the per-playlist write queue; PWA performs the read, predicate, and cursor update in one IndexedDB readwrite transaction, so another tab cannot interleave a replacement. The one-time legacy mode-flag migration also scans and updates rows through one readwrite cursor transaction and never replays a pre-transaction snapshot. Delete/restore or replacement under the same playlist ID aborts both ordinary and post-navigation writes; the latter still merge concurrent title/EPG metadata when authority matches. - 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 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 the proven endpoint and mode. -- `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. +- 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. A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays byte-identical even if the dialog hydrated before a concurrent discovery committed; it skips discovery. A persisted `portalUrl` keeps the row on the Stalker save path even if legacy Xtream fields remain. 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, PWA acquires a shared playlist-authority barrier plus an exclusive origin-wide per-playlist Web Lock and verifies the persisted source authority while holding both. Add/delete, backup restore, and bulk replacement take the same row lock, while Delete All takes the barrier exclusively, so authority cannot change between preflight and the identity-bearing request. A concurrent Edit or stale dialog fails before remote discovery; a replacement waits for the current owner. Same-tab Save first publishes its local authentication owner, drains an existing lazy repair through actual Web Lock request completion, and only then asks for the conflicting row lock; repair callers already queued behind that owner observe the Edit block and do not reserve again. PWA fails closed if Web Locks are unavailable, while Electron relies on its single-instance local 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 together with discovery's transient session patch, so NgRx replaces or clears its session fields 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 reads the persisted source or calls discovery, PWA takes the same playlist-authority barrier and row reservation as explicit Edit; contention or unavailable Web Locks declines repair without a remote request, and ownership is held through the conditional transform. This prevents repair in another tab from authenticating alongside Edit or crossing delete/restore. The persisted-row preflight still verifies that the caller 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. - Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream. - Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding"). **Stalker Session Authentication**: -- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a thin facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-token-cache.ts` (in-run token + pending-auth state, tagged with the identity fingerprint), `stalker-session-store.ts` (the session persisted on the playlist row), `stalker-portal-error.ts` and `stalker-response-classification.ts`. +- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a thin facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-authenticated-request-client.ts`, `stalker-edited-session-coordinator.ts` (authoritative Edit/session serialization), `stalker-watchdog.controller.ts`, `stalker-token-cache.ts` (in-run token + pending-auth state, tagged with the identity fingerprint), `stalker-session-store.ts` (the session persisted on the playlist row), `stalker-portal-error.ts` and `stalker-response-classification.ts`. - `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. - Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text. - Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. -- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The _effective_ cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. +- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin, path, and URL Basic-auth userinfo) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session; URL parsers omit `user:pass@` from `origin`, so userinfo is tracked separately while endpoints without it retain their previous fingerprint across upgrades). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The _effective_ cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. - Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting. - Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle"). diff --git a/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts b/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts index 6578220e3..820d6b0fb 100644 --- a/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts +++ b/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts @@ -4,12 +4,15 @@ import { closeElectronApp, expect, launchElectronApp, + openSourceEditor, openSources, resetMockServers, restartElectronApp, sourceRowByTitle, stalkerMockServer, test, + updateSourceDialog, + saveSourceDialog, waitForStalkerCatalog, } from './electron-test-fixtures'; @@ -34,6 +37,7 @@ import { const CANONICAL_IMPORT_MAC = '00:1A:79:00:00:21'; const MINISTRA_IMPORT_MAC = '00:1A:79:00:00:22'; const RESELLER_IMPORT_MAC = '00:1A:79:00:00:23'; +const BARE_HOST_IMPORT_MAC = '00:1A:79:00:00:27'; const REPAIR_FLAG_MAC = '00:1A:79:00:00:24'; const REPAIR_ENDPOINT_MAC = '00:1A:79:00:00:25'; const HEALTHY_RESELLER_MAC = '00:1A:79:00:00:26'; @@ -82,22 +86,26 @@ async function seedStalkerPlaylist( } ): Promise { const nowIso = new Date().toISOString(); - await page.evaluate(async (playlist) => { - const electron = (window as unknown as PlaylistStorageWindow).electron; - await electron.dbUpsertAppPlaylist(playlist); - }, { - _id: row.id, - title: row.title, - macAddress: row.macAddress, - portalUrl: row.portalUrl, - isFullStalkerPortal: row.isFullStalkerPortal, - count: 0, - autoRefresh: false, - importDate: nowIso, - lastUsage: nowIso, - favorites: [], - recentlyViewed: [], - }); + await page.evaluate( + async (playlist) => { + const electron = (window as unknown as PlaylistStorageWindow) + .electron; + await electron.dbUpsertAppPlaylist(playlist); + }, + { + _id: row.id, + title: row.title, + macAddress: row.macAddress, + portalUrl: row.portalUrl, + isFullStalkerPortal: row.isFullStalkerPortal, + count: 0, + autoRefresh: false, + importDate: nowIso, + lastUsage: nowIso, + favorites: [], + recentlyViewed: [], + } + ); } async function openSeededPortal(page: Page, title: string): Promise { @@ -106,7 +114,7 @@ async function openSeededPortal(page: Page, title: string): Promise { await waitForStalkerCatalog(page); } -test('@electron @stalker import discovery resolves canonical, ministra-/c and reseller URLs', async ({ +test('@electron @stalker Add and Edit discover bare, canonical, Ministra and reseller URLs', async ({ dataDir, request, }) => { @@ -167,6 +175,37 @@ test('@electron @stalker import discovery resolves canonical, ministra-/c and re portalUrl: `${stalkerMockServer}/portal.php`, isFullStalkerPortal: false, }); + + // 4) A bare host is valid input. The reseller endpoint answers first, + // so Add persists the resolved API handler rather than root HTML. + await openSources(app.mainWindow); + await addStalkerPortal(app.mainWindow, { + name: 'Bare Host', + macAddress: BARE_HOST_IMPORT_MAC, + portalUrl: stalkerMockServer, + }); + await waitForStalkerCatalog(app.mainWindow); + expect( + await readStoredPortalConfig(app.mainWindow, 'Bare Host') + ).toEqual({ + portalUrl: `${stalkerMockServer}/portal.php`, + isFullStalkerPortal: false, + }); + + // Edit uses the same discovery path as Add. Moving the source to a + // genuine Ministra tenant must replace endpoint and mode together. + await openSources(app.mainWindow); + const editDialog = await openSourceEditor(app.mainWindow, 'Bare Host'); + await updateSourceDialog(editDialog, { + portalUrl: `${stalkerMockServer}/ministra/c`, + }); + await saveSourceDialog(app.mainWindow, editDialog); + await expect + .poll(() => readStoredPortalConfig(app.mainWindow, 'Bare Host')) + .toEqual({ + portalUrl: `${stalkerMockServer}/ministra/server/load.php`, + isFullStalkerPortal: true, + }); } finally { await closeElectronApp(app); } @@ -219,7 +258,10 @@ test('@electron @stalker lazy repair fixes misclassified stored portals and neve await openSeededPortal(app.mainWindow, 'Misclassified Canonical'); await expect .poll(() => - readStoredPortalConfig(app.mainWindow, 'Misclassified Canonical') + readStoredPortalConfig( + app.mainWindow, + 'Misclassified Canonical' + ) ) .toEqual({ portalUrl: `${stalkerMockServer}/server/load.php`, diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 73c53d1bd..d6835ea71 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -26,6 +26,7 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; import { DataService } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; @@ -37,6 +38,7 @@ import { PlayerService } from './services/player.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; import { shouldEnableServiceWorker } from './services/runtime-config'; +import { AppStalkerPlaylistConnectionEditorService } from './services/stalker-playlist-connection-editor.service'; import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service'; // AoT requires an exported function for factories @@ -142,6 +144,10 @@ export const appConfig: ApplicationConfig = { useExisting: ExternalPlaybackService, }, ...providePortalPlaybackPositions(), + { + provide: STALKER_PLAYLIST_CONNECTION_EDITOR, + useExisting: AppStalkerPlaylistConnectionEditorService, + }, ...provideWorkspaceShellActions(), ...provideXtreamDataSource(), { 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 new file mode 100644 index 000000000..91b89bd86 --- /dev/null +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts @@ -0,0 +1,572 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { + StalkerPortalDiscoveryService, + StalkerPortalError, + StalkerPortalRepairService, + StalkerSessionService, + StalkerStore, + stalkerSessionFingerprint, +} from '@iptvnator/portal/stalker/data-access'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS } from '@iptvnator/playlist/shared/ui'; +import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { AppStalkerPlaylistConnectionEditorService } from './stalker-playlist-connection-editor.service'; + +describe('AppStalkerPlaylistConnectionEditorService', () => { + const discovery = { + discover: jest.fn(), + }; + const portalRepair = { + applyOverride: jest.fn((playlist: PlaylistMeta) => playlist), + fenceForPlaylistEdit: jest.fn(() => Promise.resolve()), + releasePlaylistEdit: jest.fn(), + commitPlaylistEdit: jest.fn(), + }; + const stalkerSession = { + beginEditDiscovery: jest.fn(async (playlist: PlaylistMeta) => ({ + playlistId: playlist._id, + owner: Symbol('edit-fence'), + })), + cancelEditDiscovery: jest.fn(), + replaceSessionAfterEdit: jest.fn( + async (playlist: PlaylistMeta) => playlist + ), + }; + let activePlaylist: PlaylistMeta | undefined; + const stalkerStore = { + currentPlaylist: jest.fn(() => activePlaylist), + setCurrentPlaylist: jest.fn((playlist: PlaylistMeta) => { + activePlaylist = playlist; + }), + }; + const translate = { + instant: jest.fn((key: string) => key), + }; + + const draft: PlaylistMeta = { + _id: 'stalker-1', + title: 'Stalker Portal', + count: 0, + importDate: '2026-08-08T00:00:00.000Z', + portalUrl: 'https://portal.example.com/c', + macAddress: '00:1A:79:AA:BB:CC', + username: 'subscriber', + password: 'secret', + stalkerSerialNumber: ' SERIAL ', + stalkerDeviceId1: ' DEVICE-1 ', + stalkerDeviceId2: '', + stalkerSignature1: ' SIGNATURE-1 ', + stalkerSignature2: '', + }; + + let service: AppStalkerPlaylistConnectionEditorService; + + beforeEach(() => { + jest.clearAllMocks(); + activePlaylist = undefined; + TestBed.configureTestingModule({ + providers: [ + AppStalkerPlaylistConnectionEditorService, + { + provide: StalkerPortalDiscoveryService, + useValue: discovery, + }, + { + provide: StalkerPortalRepairService, + useValue: portalRepair, + }, + { + provide: StalkerSessionService, + useValue: stalkerSession, + }, + { provide: StalkerStore, useValue: stalkerStore }, + { provide: TranslateService, useValue: translate }, + ], + }); + service = TestBed.inject(AppStalkerPlaylistConnectionEditorService); + }); + + it('resolves a simple portal and clears the previous full session', async () => { + const sourcePlaylist = { + ...draft, + portalUrl: 'https://old.example.com/server/load.php', + }; + discovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/portal.php', + isFullStalkerPortal: false, + }); + + const result = await service.resolveConnection(draft, sourcePlaylist); + + expect(stalkerSession.beginEditDiscovery).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: draft.portalUrl, + stalkerSerialNumber: 'SERIAL', + }), + expect.objectContaining({ + portalUrl: sourcePlaylist.portalUrl, + stalkerSerialNumber: sourcePlaylist.stalkerSerialNumber, + }), + expect.any(Promise) + ); + + expect(discovery.discover).toHaveBeenCalledWith( + draft.portalUrl, + draft.macAddress, + { + serialNumber: 'SERIAL', + deviceId1: 'DEVICE-1', + signature1: 'SIGNATURE-1', + }, + { + credentials: { + username: 'subscriber', + password: 'secret', + }, + } + ); + expect(result).toEqual({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: { + ...draft, + portalUrl: 'https://portal.example.com/portal.php', + isFullStalkerPortal: false, + stalkerSerialNumber: 'SERIAL', + stalkerDeviceId1: 'DEVICE-1', + stalkerDeviceId2: '', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: '', + stalkerSessionPatch: null, + }, + }); + }); + + it('replaces a full-portal session with the confirmed authorization result', async () => { + discovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + token: 'NEW_TOKEN', + watchdogTimeoutSeconds: 75, + timeslotSeconds: 8, + accountInfo: { + login: 'subscriber', + expire_date: 1800000000, + tariff_plan_name: 'Premium', + status: 0, + }, + }); + + const result = await service.resolveConnection(draft); + const resolvedPlaylist = { + ...draft, + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSerialNumber: 'SERIAL', + stalkerDeviceId1: 'DEVICE-1', + stalkerDeviceId2: '', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: '', + }; + + expect(result).toEqual({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: { + ...resolvedPlaylist, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: + stalkerSessionFingerprint(resolvedPlaylist), + stalkerWatchdogTimeout: 75, + stalkerTimeslot: 8, + stalkerAccountInfo: { + login: 'subscriber', + expireDate: 1800000000, + tariffPlanName: 'Premium', + status: 0, + }, + }, + }, + }); + }); + + it('returns a user-facing portal refusal without producing an update', async () => { + discovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: 'https://portal.example.com/server/load.php', + error: new StalkerPortalError( + 'login-rejected', + 'Subscription expired' + ), + }); + + await expect(service.resolveConnection(draft)).resolves.toEqual({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED, + message: + 'HOME.STALKER_PORTAL.LOGIN_REJECTED HOME.STALKER_PORTAL.PORTAL_MESSAGE', + }); + }); + + it('keeps an abandoned authentication fenced until it settles', async () => { + let settleAuthentication: () => void = () => undefined; + const abandonedAuthenticationSettled = new Promise((resolve) => { + settleAuthentication = resolve; + }); + discovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: 'https://portal.example.com/server/load.php', + abandonedInFlight: true, + abandonedAuthenticationSettled, + }); + + await expect(service.resolveConnection(draft)).resolves.toMatchObject({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED, + }); + + expect(stalkerSession.cancelEditDiscovery).not.toHaveBeenCalled(); + expect(portalRepair.releasePlaylistEdit).not.toHaveBeenCalled(); + + settleAuthentication(); + await abandonedAuthenticationSettled; + await Promise.resolve(); + + expect(stalkerSession.cancelEditDiscovery).toHaveBeenCalledWith( + expect.objectContaining({ playlistId: draft._id }) + ); + expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + }); + + it('returns a dedicated error when no endpoint can be reached', async () => { + discovery.discover.mockResolvedValue({ status: 'unreachable' }); + + await expect(service.resolveConnection(draft)).resolves.toEqual({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE, + message: 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE', + }); + expect(stalkerSession.cancelEditDiscovery).toHaveBeenCalled(); + expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + }); + + it('does not start discovery until old authentication and repair work drain', async () => { + let finishSessionFence: (fence: { + playlistId: string; + owner: symbol; + }) => void = () => undefined; + let finishRepairFence: () => void = () => undefined; + stalkerSession.beginEditDiscovery.mockReturnValueOnce( + new Promise((resolve) => { + finishSessionFence = resolve; + }) + ); + portalRepair.fenceForPlaylistEdit.mockReturnValueOnce( + new Promise((resolve) => { + finishRepairFence = resolve; + }) + ); + discovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/portal.php', + isFullStalkerPortal: false, + }); + + const resolving = service.resolveConnection(draft); + await Promise.resolve(); + + expect(discovery.discover).not.toHaveBeenCalled(); + + finishSessionFence({ + playlistId: draft._id, + owner: Symbol('edit-fence'), + }); + await Promise.resolve(); + expect(discovery.discover).not.toHaveBeenCalled(); + + finishRepairFence(); + await resolving; + + expect(discovery.discover).toHaveBeenCalledTimes(1); + }); + + it('replaces the active runtime playlist and session after a resolved full-portal edit', async () => { + activePlaylist = { + ...draft, + portalUrl: 'https://old.example.com/portal.php', + isFullStalkerPortal: false, + }; + const resolvedPlaylist = { + ...draft, + portalUrl: 'https://new.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + }, + }; + + await service.applyResolvedConnection(resolvedPlaylist); + + 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', + isFullStalkerPortal: true, + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + }), + expect.objectContaining({ playlistId: draft._id }) + ); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: 'https://new.example.com/server/load.php', + isFullStalkerPortal: true, + }) + ); + expect(activePlaylist?.portalUrl).toBe( + 'https://new.example.com/server/load.php' + ); + }); + + 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('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, + portalUrl: 'https://old.example.com/server/load.php', + isFullStalkerPortal: true, + }; + const resolvedPlaylist = { + ...draft, + portalUrl: 'https://new.example.com/portal.php', + isFullStalkerPortal: false, + stalkerSessionPatch: null, + }; + + await service.applyResolvedConnection(resolvedPlaylist); + + 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', + isFullStalkerPortal: false, + stalkerToken: undefined, + stalkerSessionIdentity: undefined, + }), + expect.objectContaining({ playlistId: draft._id }) + ); + expect(activePlaylist).toEqual( + expect.objectContaining({ + portalUrl: 'https://new.example.com/portal.php', + isFullStalkerPortal: false, + stalkerToken: undefined, + stalkerSessionIdentity: undefined, + }) + ); + }); + + it('repoints the active snapshot only after old authentication finishes draining', 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, + }; + let finishReplacement: () => void = () => undefined; + stalkerSession.replaceSessionAfterEdit.mockReturnValueOnce( + new Promise((resolve) => { + finishReplacement = () => resolve(resolvedPlaylist); + }) + ); + + const applying = service.applyResolvedConnection(resolvedPlaylist); + + expect(activePlaylist?.portalUrl).toBe( + 'https://old.example.com/server/load.php' + ); + let settled = false; + void applying.then(() => (settled = true)); + await Promise.resolve(); + expect(settled).toBe(false); + + 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(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled(); + expect(activePlaylist?.portalUrl).toBe( + 'https://old.example.com/server/load.php' + ); + }); + + it.each([true, false])( + 'keeps the exact credential-only Edit result when a prior endpoint repair exists (active=%s)', + async (isActive) => { + const resolvedPlaylist = { + ...draft, + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + username: 'new-user', + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + }, + }; + // This is the remembered A→B repair that used to rewrite the + // newly proven A endpoint when only credentials changed. + portalRepair.applyOverride.mockReturnValue({ + ...resolvedPlaylist, + portalUrl: 'https://portal.example.com/portal.php', + }); + activePlaylist = isActive + ? { + ...draft, + portalUrl: 'https://portal.example.com/portal.php', + } + : undefined; + + await service.applyResolvedConnection(resolvedPlaylist); + + expect(portalRepair.applyOverride).not.toHaveBeenCalled(); + expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith( + draft._id + ); + expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: 'https://portal.example.com/server/load.php', + username: 'new-user', + stalkerToken: 'NEW_TOKEN', + }), + expect.objectContaining({ playlistId: draft._id }) + ); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes( + isActive ? 1 : 0 + ); + if (isActive) { + expect(activePlaylist?.portalUrl).toBe( + 'https://portal.example.com/server/load.php' + ); + } + } + ); +}); 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 new file mode 100644 index 000000000..ed50c4526 --- /dev/null +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts @@ -0,0 +1,310 @@ +import { inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + asStalkerPortalError, + StalkerPortalDiscoveryService, + StalkerPortalRepairService, + StalkerSessionService, + StalkerStore, + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + stalkerSessionFingerprint, + type StalkerPortalErrorKind, +} from '@iptvnator/portal/stalker/data-access'; +import { + STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS, + type StalkerPlaylistConnectionEditor, + type StalkerPlaylistConnectionResult, + type StalkerResolvedConnectionApplyOptions, +} from '@iptvnator/playlist/shared/ui'; +import { + normalizeStalkerPortalIdentity, + type Playlist, + type PlaylistMeta, + type PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; + +const STALKER_EDIT_ERROR_KEY_BY_KIND: Readonly< + Record +> = { + 'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED', + 'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED', + 'device-conflict': 'HOME.STALKER_PORTAL.DEVICE_CONFLICT', + blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED', + 'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED', +}; + +type StalkerEditFence = Awaited< + ReturnType +>; + +@Injectable({ providedIn: 'root' }) +export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylistConnectionEditor { + private readonly discovery = inject(StalkerPortalDiscoveryService); + private readonly portalRepair = inject(StalkerPortalRepairService); + private readonly stalkerSession = inject(StalkerSessionService); + private readonly stalkerStore = inject(StalkerStore); + private readonly translate = inject(TranslateService); + private readonly editFences = new Map(); + + 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 + // the repair's old A→B result. + const runtimePlaylist = this.toRuntimePlaylist(playlist); + const fence = + this.editFences.get(runtimePlaylist._id) ?? + (await this.beginEditFence(runtimePlaylist)); + try { + 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); + + if ( + this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id + ) { + // The persistence result is the complete row merged by + // PlaylistsService, so backup-restored playback headers and + // other metadata absent from the form survive replacement. + await this.stalkerStore.setCurrentPlaylist(persistedPlaylist); + } + return persistedPlaylist; + } catch (error) { + this.releaseEditFence(runtimePlaylist._id, fence); + throw error; + } + } + + async resolveConnection( + playlist: PlaylistMeta, + sourcePlaylist: PlaylistMeta = playlist + ): Promise { + const identity = normalizeStalkerPortalIdentity({ + serialNumber: playlist.stalkerSerialNumber, + deviceId1: playlist.stalkerDeviceId1, + deviceId2: playlist.stalkerDeviceId2, + signature1: playlist.stalkerSignature1, + signature2: playlist.stalkerSignature2, + }); + const normalizedPlaylist: PlaylistMetaUpdate = { + ...playlist, + stalkerSerialNumber: identity.serialNumber ?? '', + stalkerDeviceId1: identity.deviceId1 ?? '', + stalkerDeviceId2: identity.deviceId2 ?? '', + stalkerSignature1: identity.signature1 ?? '', + stalkerSignature2: identity.signature2 ?? '', + }; + const fence = await this.beginEditFence( + this.toRuntimePlaylist(normalizedPlaylist), + this.toRuntimePlaylist(sourcePlaylist) + ); + let outcome: Awaited< + ReturnType + >; + try { + outcome = await this.discovery.discover( + playlist.portalUrl ?? '', + playlist.macAddress ?? '', + identity, + { + credentials: { + username: playlist.username ?? '', + password: playlist.password ?? '', + }, + } + ); + } catch (error) { + this.releaseEditFence(playlist._id, fence); + throw error; + } + + if (outcome.status === 'unreachable') { + this.releaseEditFence(playlist._id, fence); + return { + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE, + message: this.translate.instant( + 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE' + ), + }; + } + + if (outcome.status === 'auth-rejected') { + if (outcome.abandonedInFlight) { + this.releaseEditFenceAfterAuthenticationSettles( + playlist._id, + fence, + outcome.abandonedAuthenticationSettled + ); + } else { + this.releaseEditFence(playlist._id, fence); + } + return { + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED, + message: this.buildAuthErrorMessage(outcome.error), + }; + } + + const resolvedPlaylist: PlaylistMetaUpdate = { + ...normalizedPlaylist, + portalUrl: outcome.portalUrl, + isFullStalkerPortal: outcome.isFullStalkerPortal, + }; + + if (!outcome.isFullStalkerPortal) { + return { + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: { + ...resolvedPlaylist, + stalkerSessionPatch: null, + }, + }; + } + + if (!outcome.token) { + this.releaseEditFence(playlist._id, fence); + return { + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED, + message: this.translate.instant( + 'HOME.STALKER_PORTAL.AUTH_FAILED' + ), + }; + } + + return { + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: { + ...resolvedPlaylist, + stalkerSessionPatch: { + stalkerToken: outcome.token, + stalkerSessionIdentity: + stalkerSessionFingerprint(resolvedPlaylist), + stalkerWatchdogTimeout: + outcome.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + stalkerTimeslot: outcome.timeslotSeconds ?? 0, + stalkerAccountInfo: outcome.accountInfo + ? { + login: outcome.accountInfo.login, + expireDate: outcome.accountInfo.expire_date, + tariffPlanName: + outcome.accountInfo.tariff_plan_name, + status: outcome.accountInfo.status, + } + : undefined, + }, + }, + }; + } + + private async beginEditFence( + playlist: Playlist, + sourcePlaylist: Playlist = playlist + ): Promise { + // Both fences are installed synchronously before either drain is + // awaited. No new authentication or lazy repair can start while the + // work that predates this Edit is settling. + const repairDrain = this.portalRepair.fenceForPlaylistEdit( + playlist._id + ); + let fence: StalkerEditFence | undefined; + try { + fence = await this.stalkerSession.beginEditDiscovery( + playlist, + sourcePlaylist, + repairDrain + ); + await repairDrain; + this.editFences.set(playlist._id, fence); + return fence; + } catch (error) { + if (fence) { + this.stalkerSession.cancelEditDiscovery(fence); + } + this.portalRepair.releasePlaylistEdit(playlist._id); + throw error; + } + } + + private releaseEditFence( + playlistId: string, + fence: StalkerEditFence + ): void { + if (this.editFences.get(playlistId) === fence) { + this.editFences.delete(playlistId); + } + this.stalkerSession.cancelEditDiscovery(fence); + this.portalRepair.releasePlaylistEdit(playlistId); + } + + private releaseEditFenceAfterAuthenticationSettles( + playlistId: string, + fence: StalkerEditFence, + authenticationSettled: Promise | undefined + ): void { + // Fail closed if a producer ever reports an abandoned request without + // its lifetime. Releasing here would let a fresh session authenticate + // while the old get_profile can still land and invalidate its token. + if (!authenticationSettled) { + return; + } + + void authenticationSettled.then(() => { + // The dialog may have been closed or a later owner may already + // have retired this exact reservation. Never release a different + // edit's counted repair fence from this late continuation. + if (this.editFences.get(playlistId) === fence) { + this.releaseEditFence(playlistId, fence); + } + }); + } + + private buildAuthErrorMessage(error: unknown): string { + const portalError = asStalkerPortalError(error); + const headline = this.translate.instant( + portalError + ? STALKER_EDIT_ERROR_KEY_BY_KIND[portalError.kind] + : 'HOME.STALKER_PORTAL.AUTH_FAILED' + ); + + if (!portalError?.portalText) { + return headline; + } + + return `${headline} ${this.translate.instant( + 'HOME.STALKER_PORTAL.PORTAL_MESSAGE', + { message: portalError.portalText } + )}`; + } + + private toRuntimePlaylist(update: PlaylistMetaUpdate): Playlist { + const { stalkerSessionPatch, ...metadata } = update; + return { + lastUsage: '', + ...metadata, + ...(stalkerSessionPatch !== undefined + ? { + stalkerToken: stalkerSessionPatch?.stalkerToken, + stalkerSessionIdentity: + stalkerSessionPatch?.stalkerSessionIdentity, + stalkerWatchdogTimeout: + stalkerSessionPatch?.stalkerWatchdogTimeout, + stalkerTimeslot: stalkerSessionPatch?.stalkerTimeslot, + stalkerAccountInfo: + stalkerSessionPatch?.stalkerAccountInfo, + } + : {}), + } as Playlist; + } +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2c534b2eb..ce6b49cbb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "التوقيع 2 (اختياري)", "SIGNATURE_HINT": "64 حرفًا سداسيًا عشريًا - استخدمه إذا كان المزود يتطلب توقيعات للتحقق من الجهاز", "SERVER_URL": "رابط الخادم", - "URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)", + "URL_HINT": "أدخل اسم مضيف أو عنوانًا ينتهي بـ /c أو portal.php أو server/load.php.", + "URL_VALIDATION_ERROR": "أدخل رابطًا صالحًا يبدأ بـ http:// أو https://.", "ADD": "إضافة", "VALIDATING": "جارٍ التحقق من البوابة...", "CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور", "AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.", + "EDIT_UNREACHABLE": "لم تستجب البوابة. لم يتم حفظ أي تغييرات. تحقق من الرابط وحاول مرة أخرى.", "LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.", "LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.", "PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f3c638c5c..2e5cc5a32 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "التوقيع 2 (اختياري)", "SIGNATURE_HINT": "64 حرف hex - استعملو إذا المزود كيتطلب توقيعات التحقق من الجهاز", "SERVER_URL": "رابط الخادم", - "URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)", + "URL_HINT": "دخل الهوست ولا عنوان كيسالي بـ /c ولا portal.php ولا server/load.php.", + "URL_VALIDATION_ERROR": "دخل رابط صحيح كيبدا بـ http:// ولا https://.", "ADD": "زيد", "VALIDATING": "جاري التحقق من البوابة...", "CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور", "AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.", + "EDIT_UNREACHABLE": "البوابة ما جاوباتش. ما تحفظ حتى تغيير. تأكد من الرابط وعاود المحاولة.", "LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.", "LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.", "PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 96f173d40..7f2cc8fd1 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Подпіс 2 (неабавязкова)", "SIGNATURE_HINT": "64 шаснаццатковыя сімвалы — выкарыстоўвайце, калі правайдэр патрабуе подпісы для верыфікацыі прылады", "SERVER_URL": "URL сервера", - "URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).", + "URL_HINT": "Увядзіце хост або адрас, які заканчваецца на /c, portal.php ці server/load.php.", + "URL_VALIDATION_ERROR": "Увядзіце правільны URL, які пачынаецца з http:// або https://.", "ADD": "Дадаць", "VALIDATING": "Праверка партала...", "CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль", "AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.", + "EDIT_UNREACHABLE": "Партал не адказаў. Змены не захаваны. Праверце URL і паўтарыце спробу.", "LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.", "LOGIN_REJECTED": "Партал адхіліў лагін і пароль.", "PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 5a54a010d..eb249ca1b 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Signatur 2 (optional)", "SIGNATURE_HINT": "64 Hex-Zeichen – verwenden, wenn der Anbieter Geräte-Verifizierungssignaturen verlangt", "SERVER_URL": "Server-URL", - "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)", + "URL_HINT": "Geben Sie einen Host oder eine Adresse mit /c, portal.php oder server/load.php ein.", + "URL_VALIDATION_ERROR": "Geben Sie eine gültige URL mit http:// oder https:// ein.", "ADD": "Hinzufügen", "VALIDATING": "Portal wird überprüft …", "CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt", "AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.", + "EDIT_UNREACHABLE": "Das Portal hat nicht geantwortet. Es wurden keine Änderungen gespeichert. Prüfen Sie die URL und versuchen Sie es erneut.", "LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.", "LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.", "PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2cefa7898..a8e7327a3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Υπογραφή 2 (Προαιρετικό)", "SIGNATURE_HINT": "64 δεκαεξαδικοί χαρακτήρες - χρησιμοποιήστε εάν ο πάροχος απαιτεί υπογραφές επαλήθευσης συσκευής", "SERVER_URL": "Διεύθηνση URL σέρβερ", - "URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)", + "URL_HINT": "Εισαγάγετε έναν host ή μια διεύθυνση που τελειώνει σε /c, portal.php ή server/load.php.", + "URL_VALIDATION_ERROR": "Εισαγάγετε μια έγκυρη διεύθυνση URL που αρχίζει με http:// ή https://.", "ADD": "Προσθήκη", "VALIDATING": "Επικύρωση πύλης...", "CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης", "AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.", + "EDIT_UNREACHABLE": "Η πύλη δεν απάντησε. Δεν αποθηκεύτηκαν αλλαγές. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.", "LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.", "LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.", "PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f35662d4f..3695ede59 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Signature 2 (Optional)", "SIGNATURE_HINT": "64 hex characters - use if provider requires device verification signatures", "SERVER_URL": "Server URL", - "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)", + "URL_HINT": "Enter a host or an address ending in /c, portal.php, or server/load.php.", + "URL_VALIDATION_ERROR": "Enter a valid http:// or https:// URL.", "ADD": "Add", "VALIDATING": "Validating portal...", "CREDENTIALS_HINT": "Only needed when the portal asks for a login and password", "AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.", + "EDIT_UNREACHABLE": "The portal did not respond. No changes were saved. Check the URL and try again.", "LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.", "LOGIN_REJECTED": "The portal rejected the login and password.", "PORTAL_REFUSED": "The portal refused access for this device.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 691d75580..207523bf6 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Firma 2 (opcional)", "SIGNATURE_HINT": "64 caracteres hexadecimales — úsalo si el proveedor requiere firmas de verificación del dispositivo", "SERVER_URL": "URL del servidor", - "URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).", + "URL_HINT": "Introduce un host o una dirección que termine en /c, portal.php o server/load.php.", + "URL_VALIDATION_ERROR": "Introduce una URL válida que empiece por http:// o https://.", "ADD": "Agregar", "VALIDATING": "Validando portal…", "CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña", "AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.", + "EDIT_UNREACHABLE": "El portal no respondió. No se guardaron los cambios. Comprueba la URL e inténtalo de nuevo.", "LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.", "LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.", "PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index cae45cc6e..478033a26 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Signature 2 (optionnelle)", "SIGNATURE_HINT": "64 caractères hexadécimaux — à utiliser si le fournisseur exige des signatures de vérification d'appareil", "SERVER_URL": "URL du serveur", - "URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)", + "URL_HINT": "Saisissez un hôte ou une adresse se terminant par /c, portal.php ou server/load.php.", + "URL_VALIDATION_ERROR": "Saisissez une URL valide commençant par http:// ou https://.", "ADD": "Ajouter", "VALIDATING": "Validation du portail…", "CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe", "AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.", + "EDIT_UNREACHABLE": "Le portail n'a pas répondu. Aucune modification n'a été enregistrée. Vérifiez l'URL et réessayez.", "LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.", "LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.", "PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 15602430b..52fa198b2 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "2. aláírás (nem kötelező)", "SIGNATURE_HINT": "64 hexadecimális karakter – csak akkor adja meg, ha a szolgáltató eszközellenőrzési aláírást kér", "SERVER_URL": "Kiszolgáló URL-címe", - "URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)", + "URL_HINT": "Adjon meg egy gazdagépet vagy /c, portal.php vagy server/load.php végű címet.", + "URL_VALIDATION_ERROR": "Adjon meg egy http:// vagy https:// kezdetű érvényes URL-címet.", "ADD": "Hozzáadás", "VALIDATING": "Portál ellenőrzése…", "CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér", "AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.", + "EDIT_UNREACHABLE": "A portál nem válaszolt. A módosítások nem lettek mentve. Ellenőrizze az URL-címet, és próbálja újra.", "LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.", "LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.", "PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index d1c5ae2b7..76fd6f92c 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Firma 2 (opzionale)", "SIGNATURE_HINT": "64 caratteri esadecimali — usa se il fornitore richiede firme di verifica del dispositivo", "SERVER_URL": "URL server", - "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)", + "URL_HINT": "Inserisci un host o un indirizzo che termini con /c, portal.php o server/load.php.", + "URL_VALIDATION_ERROR": "Inserisci un URL valido che inizi con http:// o https://.", "ADD": "Aggiungi", "VALIDATING": "Validazione del portale...", "CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password", "AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.", + "EDIT_UNREACHABLE": "Il portale non ha risposto. Nessuna modifica è stata salvata. Controlla l'URL e riprova.", "LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.", "LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.", "PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 6f687b8c2..fc8013ccc 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "署名 2(任意)", "SIGNATURE_HINT": "16進64文字。プロバイダーがデバイス検証署名を要求する場合に使用", "SERVER_URL": "サーバーURL", - "URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)", + "URL_HINT": "ホスト、または /c、portal.php、server/load.php で終わるアドレスを入力してください。", + "URL_VALIDATION_ERROR": "http:// または https:// で始まる有効なURLを入力してください。", "ADD": "追加", "VALIDATING": "ポータルを検証中...", "CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要", "AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。", + "EDIT_UNREACHABLE": "ポータルが応答しませんでした。変更は保存されていません。URLを確認して再試行してください。", "LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。", "LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。", "PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 23d63b858..76e5e7cd2 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "서명 2 (선택 사항)", "SIGNATURE_HINT": "16진수 64자 - 공급자가 기기 검증 서명을 요구할 때 사용", "SERVER_URL": "서버 URL", - "URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).", + "URL_HINT": "호스트 또는 /c, portal.php, server/load.php로 끝나는 주소를 입력하세요.", + "URL_VALIDATION_ERROR": "http:// 또는 https://로 시작하는 올바른 URL을 입력하세요.", "ADD": "추가하다", "VALIDATING": "포털을 검증하는 중...", "CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요", "AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.", + "EDIT_UNREACHABLE": "포털이 응답하지 않았습니다. 변경 사항이 저장되지 않았습니다. URL을 확인하고 다시 시도하세요.", "LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.", "LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.", "PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2b80527b1..bdde36e4f 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Handtekening 2 (optioneel)", "SIGNATURE_HINT": "64 hex-tekens — gebruiken als de provider apparaatverificatiehandtekeningen vereist", "SERVER_URL": "Server URL", - "URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)", + "URL_HINT": "Voer een host of adres in dat eindigt op /c, portal.php of server/load.php.", + "URL_VALIDATION_ERROR": "Voer een geldige URL in die begint met http:// of https://.", "ADD": "Toevoegen", "VALIDATING": "Portaal valideren...", "CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt", "AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.", + "EDIT_UNREACHABLE": "Het portaal reageerde niet. Er zijn geen wijzigingen opgeslagen. Controleer de URL en probeer het opnieuw.", "LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.", "LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.", "PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 36d1b3dfd..1ed4a1a73 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Sygnatura 2 (opcjonalnie)", "SIGNATURE_HINT": "64 znaki szesnastkowe – użyj, jeśli dostawca wymaga sygnatur weryfikacji urządzenia", "SERVER_URL": "URL serwera", - "URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)", + "URL_HINT": "Wprowadź host lub adres zakończony /c, portal.php albo server/load.php.", + "URL_VALIDATION_ERROR": "Wprowadź poprawny URL zaczynający się od http:// lub https://.", "ADD": "Dodaj", "VALIDATING": "Walidacja portalu...", "CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła", "AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.", + "EDIT_UNREACHABLE": "Portal nie odpowiedział. Nie zapisano żadnych zmian. Sprawdź URL i spróbuj ponownie.", "LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.", "LOGIN_REJECTED": "Portal odrzucił login i hasło.", "PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 79d89b736..8ad9c698c 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Assinatura 2 (opcional)", "SIGNATURE_HINT": "64 caracteres hexadecimais - use se o provedor exigir assinaturas de verificação do dispositivo", "SERVER_URL": "URL do servidor", - "URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)", + "URL_HINT": "Introduza um host ou endereço terminado em /c, portal.php ou server/load.php.", + "URL_VALIDATION_ERROR": "Introduza um URL válido que comece por http:// ou https://.", "ADD": "Adicionar", "VALIDATING": "Validando portal...", "CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha", "AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.", + "EDIT_UNREACHABLE": "O portal não respondeu. Nenhuma alteração foi guardada. Verifique o URL e tente novamente.", "LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.", "LOGIN_REJECTED": "O portal rejeitou o login e a senha.", "PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 9cf9ba21b..c32c5af69 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "Подпись 2 (необязательно)", "SIGNATURE_HINT": "64 шестнадцатеричных символа — используйте, если провайдер требует подписи проверки устройства", "SERVER_URL": "URL сервера", - "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).", + "URL_HINT": "Введите хост или адрес, оканчивающийся на /c, portal.php или server/load.php.", + "URL_VALIDATION_ERROR": "Введите корректный URL, начинающийся с http:// или https://.", "ADD": "Добавить", "VALIDATING": "Проверка портала…", "CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль", "AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.", + "EDIT_UNREACHABLE": "Портал не ответил. Изменения не сохранены. Проверьте URL и повторите попытку.", "LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.", "LOGIN_REJECTED": "Портал отклонил логин и пароль.", "PORTAL_REFUSED": "Портал отказал в доступе этому устройству.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d37b02cf6..1c2b6ee82 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "İmza 2 (İsteğe Bağlı)", "SIGNATURE_HINT": "64 onaltılık karakter - sağlayıcı cihaz doğrulama imzaları gerektiriyorsa kullanın", "SERVER_URL": "Sunucu URL'si", - "URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)", + "URL_HINT": "/c, portal.php veya server/load.php ile biten bir ana makine ya da adres girin.", + "URL_VALIDATION_ERROR": "http:// veya https:// ile başlayan geçerli bir URL girin.", "ADD": "Ekle", "VALIDATING": "Portal doğrulanıyor...", "CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir", "AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.", + "EDIT_UNREACHABLE": "Portal yanıt vermedi. Hiçbir değişiklik kaydedilmedi. URL'yi kontrol edip tekrar deneyin.", "LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.", "LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.", "PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 855bda9e6..fa9691c4b 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "签名 2(可选)", "SIGNATURE_HINT": "64 位十六进制字符 — 如提供商需要设备验证签名时使用", "SERVER_URL": "服务器 URL", - "URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)", + "URL_HINT": "请输入主机或以 /c、portal.php、server/load.php 结尾的地址。", + "URL_VALIDATION_ERROR": "请输入以 http:// 或 https:// 开头的有效 URL。", "ADD": "添加", "VALIDATING": "正在验证门户…", "CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写", "AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。", + "EDIT_UNREACHABLE": "门户没有响应。未保存任何更改。请检查 URL 后重试。", "LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。", "LOGIN_REJECTED": "门户拒绝了该登录名和密码。", "PORTAL_REFUSED": "门户拒绝了此设备的访问。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index aae2dd65a..3b290bf9e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -199,11 +199,13 @@ "SIGNATURE_2": "簽章 2(選填)", "SIGNATURE_HINT": "64 個十六進位字元——若供應商要求裝置驗證簽章時請填寫", "SERVER_URL": "伺服器網址", - "URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)", + "URL_HINT": "請輸入主機或以 /c、portal.php、server/load.php 結尾的位址。", + "URL_VALIDATION_ERROR": "請輸入以 http:// 或 https:// 開頭的有效網址。", "ADD": "新增", "VALIDATING": "正在驗證入口網站...", "CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫", "AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。", + "EDIT_UNREACHABLE": "入口網站沒有回應。未儲存任何變更。請檢查網址後再試一次。", "LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。", "LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。", "PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。", diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index e5dd7b246..5d0be80f9 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -60,41 +60,42 @@ below is reached as `/workspace/stalker/:id/…`. `DataService.sendIpcEvent(STALKER_REQUEST, ...)` directly. It also hooks the lazy portal repair (see "Portal Mode and Endpoint Discovery"). - Four callers deliberately sit outside it and issue `STALKER_REQUEST` - themselves, because each one runs *below* or *before* what it routes on: + Four callers deliberately sit outside it and issue `STALKER_REQUEST` + themselves, because each one runs _below_ or _before_ what it routes on: - - `StalkerAuthApi` — `handshake` / `get_profile` / `do_auth` are what the - full-portal branch is implemented in terms of, so routing them back - through it would recurse. - - `StalkerPortalDiscoveryService` — probes run before a mode exists; the - mode is what they are determining. - - `StalkerAccountInfoService.fetchViaProfile()` — the full-mode refresh is - a profile request, so it takes the same exemption as the auth layer. - - `StreamResolverService`, for a collection item carrying its own portal - coordinates with **no playlist row** — there is no meta to route or - repair with. The playlist-backed branch beside it does use - `executeStalkerRequest()`, and wins when a row exists, so a repaired - endpoint beats a stale favorite's snapshot. + - `StalkerAuthApi` — `handshake` / `get_profile` / `do_auth` are what the + full-portal branch is implemented in terms of, so routing them back + through it would recurse. + - `StalkerPortalDiscoveryService` — probes run before a mode exists; the + mode is what they are determining. + - `StalkerAccountInfoService.fetchViaProfile()` — the full-mode refresh is + a profile request, so it takes the same exemption as the auth layer. + - `StreamResolverService`, for a collection item carrying its own portal + coordinates with **no playlist row** — there is no meta to route or + repair with. The playlist-backed branch beside it does use + `executeStalkerRequest()`, and wins when a row exists, so a repaired + endpoint beats a stale favorite's snapshot. - The exemption is from the routing, not from the repair it hooks — but only - **one** of the four wires `StalkerPortalRepairService` itself, and the - asymmetry is worth knowing before adding a fifth: + The exemption is from the routing, not from the repair it hooks — but only + **one** of the four wires `StalkerPortalRepairService` itself, and the + asymmetry is worth knowing before adding a fifth: - - `StalkerAccountInfoService.fetchViaProfile()` wires it explicitly, - because opening the account dialog on a playlist with a stale endpoint - must be able to fix it instead of waiting for an unrelated catalog - request. - - `StalkerPortalDiscoveryService` is what repair *drives*, so it cannot - repair itself. - - The auth layer wires nothing. It does not need to: a terminal handshake - failure propagates out of the full-portal branch and is caught by - whichever `executeStalkerRequest()` call triggered the authentication, - which is exactly why "terminal handshake failures" is one of the repair - triggers listed above. - - `StreamResolverService`'s row-less branch has no playlist to repair. + - `StalkerAccountInfoService.fetchViaProfile()` wires it explicitly, + because opening the account dialog on a playlist with a stale endpoint + must be able to fix it instead of waiting for an unrelated catalog + request. + - `StalkerPortalDiscoveryService` is what repair _drives_, so it cannot + repair itself. + - The auth layer wires nothing. It does not need to: a terminal handshake + failure propagates out of the full-portal branch and is caught by + whichever `executeStalkerRequest()` call triggered the authentication, + which is exactly why "terminal handshake failures" is one of the repair + triggers listed above. + - `StreamResolverService`'s row-less branch has no playlist to repair. + + Anything new that is not auth or discovery belongs on + `executeStalkerRequest()`. - Anything new that is not auth or discovery belongs on - `executeStalkerRequest()`. 4. Electron main process handles `STALKER_REQUEST` in `apps/electron-backend/src/app/events/stalker.events.ts`. 5. Axios calls the portal's persisted API endpoint (`portal.php` on @@ -135,20 +136,20 @@ Two portal modes exist, persisted per playlist as `Accept`/`Accept-Language`/`Connection` set (see "Request Transport and `cmd` Encoding"). - What it does **not** get is anything carried by the session. The direct - branch of `dispatchStalkerRequest()` forwards only `url`, `macAddress` and - `params`, so the builder never sees a token *or* a serial: no - `Authorization: Bearer` (there is none to send), and no `SN` header or - serial-derived `__cfduid` cookie **even when the playlist stores a serial**. - The full-portal branch forwards both, because `makeAuthenticatedRequest()` - passes `token` and `serialNumber`. This covers portal API requests only — - playback headers are built from the playlist row by a different helper and - are NOT mode-gated, so the same simple-mode playlist does send its serial - with a portal-owned stream (see "Stalker Identity Policy"). - Whether a simple panel ought to receive - the serial is unproven: no reference portal is known to require it, and the - `sn` *parameter* only ever travels on `get_profile`, which a simple portal - never calls. Treat it as an open question rather than a bug to fix blind. + What it does **not** get is anything carried by the session. The direct + branch of `dispatchStalkerRequest()` forwards only `url`, `macAddress` and + `params`, so the builder never sees a token _or_ a serial: no + `Authorization: Bearer` (there is none to send), and no `SN` header or + serial-derived `__cfduid` cookie **even when the playlist stores a serial**. + The full-portal branch forwards both, because `makeAuthenticatedRequest()` + passes `token` and `serialNumber`. This covers portal API requests only — + playback headers are built from the playlist row by a different helper and + are NOT mode-gated, so the same simple-mode playlist does send its serial + with a portal-owned stream (see "Stalker Identity Policy"). + Whether a simple panel ought to receive + the serial is unproven: no reference portal is known to require it, and the + `sn` _parameter_ only ever travels on `get_profile`, which a simple portal + never calls. Treat it as an open question rather than a bug to fix blind. Neither label is tied to a URL shape: mode follows OBSERVED behavior, so a `portal.php` panel that enforces the token is classified — and treated @@ -164,31 +165,124 @@ copies of this rule existed (import, session service, legacy-flag migration) and their drift shipped broken configurations (#850, #686, #755); no new consumer may re-implement the rule. -**Endpoint discovery (import).** `portal.php` does not exist in official -Stalker/Ministra — it is a reseller-panel alias; the canonical endpoint -derived from a `…/c` URL is `/server/load.php`. Instead of guessing -from the URL shape, `StalkerPortalDiscoveryService` -(`libs/portal/stalker/data-access`) probes candidates in order — the pasted -URL itself when it already names a `.php` endpoint, then `/portal.php` -→ `/server/load.php` → `/stalker_portal/server/load.php` — and -classifies each endpoint by observed behavior: a token-less +**Endpoint discovery (import and edit).** The address field requires an +explicit `http://` or `https://` scheme, but accepts a bare host, a browser +entry point such as `…/c`, or a concrete `.php` API endpoint. `portal.php` +does not exist in official Stalker/Ministra — it is a reseller-panel alias; +the canonical endpoint derived from a `…/c` URL is +`/server/load.php`. Instead of guessing from the URL shape, +`StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) probes +candidates in order — the pasted URL itself when it already names a `.php` +endpoint, then `/portal.php` → `/server/load.php` → +`/stalker_portal/server/load.php` — and classifies each endpoint by +observed behavior: a token-less `itv/get_genres` that returns data proves a token-free panel; the plain-text auth failure proves the endpoint enforces the token, which is confirmed by running the real handshake + `get_profile`. The import dialog persists the -proven endpoint and mode. When no candidate answers at all, panel-style URLs -fall back to the pre-discovery behavior (legacy `…/c` → `portal.php` rewrite, -simple mode, import succeeds with a warning) so temporarily offline panels -can still be added; canonical-shaped URLs abort like the old mandatory -handshake did (both classifications run on the normalized -`origin + pathname` form). Probe failure sequencing: ANY resolvable HTTP -status moves to the next candidate — 4xx means the endpoint is absent, a -5xx can be one broken handler beside a healthy sibling — and 401/403 -specifically classify as auth-required (the handshake is attempted, for -middlewares that answer HTTP auth codes instead of the stock 200 + +proven endpoint and mode, and that resolved API endpoint is what Edit shows. +When no candidate answers at all, panel-style URLs fall back to the +pre-discovery behavior (a bare host becomes `/portal.php`; legacy +`…/c` becomes the matching `portal.php`; simple mode, import succeeds with a +warning) so temporarily offline panels can still be added. Canonical-shaped +URLs abort like the old mandatory handshake did (both classifications run on +the normalized `origin + pathname` form). Probe failure sequencing: ANY +resolvable HTTP status moves to the next candidate — 4xx means the endpoint +is absent, a 5xx can be one broken handler beside a healthy sibling — and +401/403 specifically classify as auth-required (the handshake is attempted, +for middlewares that answer HTTP auth codes instead of the stock 200 + plain-text body). Status-less TIMEOUTS also continue (a single handler can hang); only connection-level failures (refused, unresolvable host) abort discovery, since every candidate shares the host. +The playlist-info Edit dialog compares URL, MAC, username/password, serial, +device IDs and signatures as one connection identity. A metadata-only edit +does not run discovery; its queued write omits every connection/mode field so +the current stored connection stays byte-for-byte, including when the dialog +was hydrated before an older discovery result committed. +Before enabling a Stalker form, the dialog loads the complete persisted +playlist row by ID. Electron's startup metadata projection omits payload-only +identity fields, so editing that summary directly could otherwise display and +then persist empty serial, device ID, signature, or mode values. +Changing any connection field disables the form while the same discovery +service validates the draft. Auth rejection or an unreachable portal leaves +the dialog open and writes nothing. Escape/backdrop closure is disabled for the +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 +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. Both the ordinary resolved metadata update and this late transform +require the storage-current row to retain the source connection authority +captured when Edit began. Electron performs the check inside its per-playlist +write queue; PWA performs the read, predicate, and cursor update in one +IndexedDB readwrite transaction so another tab cannot interleave a replacement. +The one-time legacy mode-flag migration likewise scans and updates rows through +one readwrite cursor transaction; it never replays a snapshot collected before +another tab's delete/restore or replacement. +Delete/restore or replacement under the same ID therefore aborts instead of +receiving a portal/session write. A row identified by its persisted `portalUrl` +stays on the Stalker save path even if legacy Xtream fields remain, so an +unrelated Xtream write cannot strand the Edit reservation. Before discovery +starts, PWA Edit acquires a shared playlist-authority barrier plus an exclusive +origin-wide Web Lock keyed by playlist ID and, while holding both, verifies that +the persisted row still has the source connection shown when Edit began. +Add/delete, backup restore, and bulk replacement paths take the same row lock; +Delete All takes the barrier exclusively. The checked authority therefore +cannot be replaced between that preflight and the identity-bearing discovery +request. Another tab fails before overlapping discovery, while a replacement +waits for the existing Edit owner; a stale dialog also fails before it can touch +the remote session. PWA fails closed when Web Locks are unavailable, while +Electron relies on its single-instance local owner. Lazy repair tries the same +barrier and row reservation before its persisted-source preflight; contention +or unavailable PWA locking declines repair without discovery, while an acquired +reservation stays held through its conditional row transform. The reservation +blocks every new authentication (including a URL edit with the same normalized +fingerprint) and repair, and drains any work already in flight. +When Save follows a lazy repair in the same tab, Edit publishes its local +authentication owner first, drains that repair through the actual Web Lock +request completion, and only then requests the row reservation itself. Repair +callers already queued behind that owner observe the Edit block and return +without trying to reserve the row again. +Ownership is rechecked after every asynchronous drain or authority rebase. +Ordinary failure releases that reservation with the previous runtime untouched; +if a bounded discovery result still has an abandoned authentication on the +wire, Edit keeps both reservations until the transport operation actually +settles. +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. 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. The state-only update reattaches the transient +session patch from discovery, because the persisted flat row deliberately does +not contain that field; this lets NgRx replace or clear its session projection. +Runtime configuration authority combines the session fingerprint with the +observed full/simple mode. Both authenticated calls and direct simple-mode +requests cross that guard before dispatch and again after transport, so a +same-endpoint mode-only Edit rejects stale playlist objects in either direction +before they can authenticate or issue a token-free portal request, and discards +an older portal response that completes after Edit commits. A different +authority may rebase only after the current persisted row proves that it owns +the same playlist ID; this keeps delete/restore and backup merge flows usable +without letting an in-flight stale request overrule Edit. +`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 +fields, so the patch itself never enters SQLite, IndexedDB or a backup and no +schema or backup-version migration is required. + +The shared playlist UI exposes only +`STALKER_PLAYLIST_CONNECTION_EDITOR` and its +`resolved | auth-rejected | unreachable` result contract. The web composition +layer implements the token with Stalker data-access; the UI library must not +import Stalker discovery directly. + **Lazy repair (existing playlists).** The flag is frozen in the DB, so records persisted by the old guess stay broken without repair — but a large share of users are on working reseller panels, and only probing can tell the @@ -199,17 +293,38 @@ plain-text auth bodies AND their JSON envelopes (`js.error`/`js.msg`), HTTP 404 (endpoint absent), HTTP 401/403 (endpoint behind an HTTP auth gate), and terminal handshake/profile errors — never timeouts or other network failures), at most once per SOURCE CONFIGURATION (endpoint + mode + MAC + -identity fingerprint) per playlist per session — an edited configuration +identity fingerprint + credentials) per playlist per session — an edited configuration may probe when it fails, while every already-probed one stays latched for -the session — and persists only a configuration discovery has proven to -answer, and only when it differs from the failing one. A repaired configuration is applied immediately via an +the session. Before an unrecorded probe makes any portal request, it verifies +that the persisted row still carries the failing source; a request that failed +late after Edit committed is declined before discovery can authenticate against +the old portal and invalidate the edited session. A repair installs a +per-playlist authentication fence before probing, drains authentication that +already owns the runtime token slot, and makes request routing wait before it +chooses the effective connection. The fence normally ends with the repair; an +abandoned authentication keeps both it and `pendingRepairs` alive until the +transport settles. Repair persists only a +configuration discovery has proven to answer, and only when it differs from the +failing one. A repaired configuration is applied immediately via an in-session override inside `executeStalkerRequest()` (stale store snapshots -keep working) and persisted through `PlaylistsService.transformPlaylistMeta` +keep working). The override is bound to that source's endpoint, mode, device +identity, and credentials; an Edit or backup restore under the same playlist +ID retires it when any connection field differs, but only after the persisted +row confirms ownership and only if no explicit Edit took ownership during +that read — a delayed stale request cannot globally remove the current +override or a token negotiated by the overlapping Edit. The repair is persisted +through `PlaylistsService.transformPlaylistMeta` — the verification and the patch run in ONE slot of the per-playlist write queue, so a user edit that is queued but not yet committed wins over the repair instead of being overwritten; the transform patches the freshly read row (`portalUrl` + `isFullStalkerPortal` only, so user state can never be -clobbered) and returns null to abort. Deletion runs through the same queue, +clobbered) and returns null to abort. Explicit Edit also advances an in-run +generation before replacing the session. Repair captures it before any +history-path row read and rechecks it together with the active Edit fence +before reserving discovery, so restoring a discarded configuration cannot +start a probe alongside Edit; a repair that already verified its row but +finishes later likewise cannot install its older override or token. +Deletion runs through the same queue, so a repair can never resurrect a playlist deleted mid-probe. Portals that work are never probed, let alone rewritten. E2E coverage: `apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts` against the @@ -298,8 +413,8 @@ describes the emulated box, not the account — see "Reported device profile".) and reused for initial auth, token refresh, retry auth, normal API requests, and same-origin playback headers. - Those last two reach the wire by different routes, and **only one is - mode-gated** — the asymmetry is easy to get backwards: + Those last two reach the wire by different routes, and **only one is + mode-gated** — the asymmetry is easy to get backwards: - **Portal API requests** receive the serial through `makeAuthenticatedRequest()`, which only the full-portal branch of @@ -309,9 +424,10 @@ describes the emulated box, not the account — see "Reported device profile".) - **Same-origin playback headers** are built by `buildStalkerExternalPlaybackHeaders()`, which reads `playlist.stalkerSerialNumber` straight off the row with no mode check - at all. So a simple-mode playlist holding a real serial *does* send `SN` + at all. So a simple-mode playlist holding a real serial _does_ send `SN` and the serial-derived `__cfduid` on portal-owned streams — while its API calls do not. + - Empty optional identity fields remain absent. IPTVnator must never generate a device ID behind the user's back, and must never duplicate `device_id2` from `device_id1` on its own. @@ -353,7 +469,7 @@ never rewritten on read: - rewriting them at the transport would move `stalkerSessionFingerprint` for every existing playlist at once, with no user action and no way to opt out. -An edit therefore *does* move the fingerprint and force a re-authentication. +An edit therefore _does_ move the fingerprint and force a re-authentication. That is intended: the user changed the identity, and the field shows exactly what will be sent. @@ -367,7 +483,7 @@ MAC. Refusing one would stop those users adding or editing a portal that works for them. The mock encodes the same split (`enforceMacFormat` is set only on the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in `stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict -endpoint and be refused *there*, not in the form. +endpoint and be refused _there_, not in the form. In the edit dialog **both** passes — blur and submit — normalize only a MAC the user actually changed, compared against the value the dialog was opened with @@ -435,10 +551,11 @@ later empty value as a permanent lockout. Therefore: deliberately emptied, and the import would pin IDs the user opted out of. - All three are mutation-verified. Note the tests have to control when the - digest settles (hold it behind a gate, or delay the older invocation): - Node resolves digests this small in start order, so the naive versions pass - with the guards removed; + All three are mutation-verified. Note the tests have to control when the + digest settles (hold it behind a gate, or delay the older invocation): + Node resolves digests this small in start order, so the naive versions pass + with the guards removed; + - **the MAC and its device IDs travel as one value.** `settleMacAddressIdentity()` reads the MAC once, before it awaits, and returns it together with the IDs derived from exactly it; `addPlaylist()` @@ -469,17 +586,17 @@ later empty value as a permanent lockout. Therefore: out" would be false and would discourage them from fixing an ID that was never pinned. - **Known imprecision, deliberately not closed here.** The gate proves that - *some* device ID reached the portal, not that the currently stored one did: - a user who edits the ID after import keeps `isFullStalkerPortal` true while - the new value has never seen `get_profile`. The copy is hedged for exactly - that ("a device ID", not "this one") and the fields stay editable, so the - remedy — restoring the ID that was pinned — is never blocked. Making it - exact needs a per-identity confirmation signal; - `Playlist.stalkerSessionIdentity` already carries a session fingerprint that - would serve, but reading it here would make a `type:ui` playlist library - depend on the Stalker data-access lib, which the Nx boundaries forbid. Worth - revisiting behind a shared contract, not worth a boundary exception. + **Known imprecision, deliberately not closed here.** The gate proves that + _some_ device ID reached the portal, not that the currently stored one did: + a user who edits the ID after import keeps `isFullStalkerPortal` true while + the new value has never seen `get_profile`. The copy is hedged for exactly + that ("a device ID", not "this one") and the fields stay editable, so the + remedy — restoring the ID that was pinned — is never blocked. Making it + exact needs a per-identity confirmation signal; + `Playlist.stalkerSessionIdentity` already carries a session fingerprint that + would serve, but reading it here would make a `type:ui` playlist library + depend on the Stalker data-access lib, which the Nx boundaries forbid. Worth + revisiting behind a shared contract, not worth a boundary exception. The trade-off the option exists for is interoperability, not obfuscation: a user who reaches the same account from StbEmu already has this exact value @@ -537,20 +654,31 @@ The handshake's `not_valid` flag is propagated into the follow-up `get_profile` as `not_valid_token`. Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering -the **portal endpoint (origin AND path), the device identity and the account -credentials**, stored +the **portal endpoint (origin, path, and URL Basic-auth userinfo), the device +identity and the account credentials**, stored next to the token as `Playlist.stalkerSessionIdentity` and used for the in-run cache as well, so an edit applies without a restart. All three halves are load-bearing: `ensureToken()` re-presents tokens in a handshake, so an endpoint edit would otherwise disclose the previous portal's bearer token to another portal — and origin alone is not enough, since discovery deliberately preserves tenant base paths, so `/tenant-a/…` and `/tenant-b/…` on one host -are different portals; an identity edit must not inherit the old session; and for a -status-2 portal the login decides which account the token represents. A token +are different portals. The URL parser omits `user:pass@` from `origin`, so that +userinfo is fingerprinted separately; changing a reverse proxy's Basic-auth +identity must not reuse a bearer token negotiated through the previous one. +Endpoints without userinfo retain their previous fingerprint across upgrades. +An identity edit must not inherit the old session; and for a status-2 portal +the login decides which account the token represents. A token with no recorded fingerprint (written before this existed) counts as unverified and is never re-presented — such a row owes a full profile anyway, and the write-back then records the fingerprint. +The Edit coordinator deliberately keeps a separate, in-run configuration +authority key containing that session fingerprint plus the observed portal +mode. The mode is not added to `stalkerSessionIdentity`, so existing persisted +sessions remain compatible, but a full↔simple Edit at the same endpoint still +retires stale runtime snapshots. The token-free dispatch path calls +`ensureToken()` as a network-free authority guard before its direct IPC request. + Because that reuse skips the only response carrying the watchdog cadence, the cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` / `stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema @@ -563,7 +691,7 @@ imported before the cadence was persisted has a reusable token and no cadence, and skipping would strand it on the 120 s default permanently, since the profile is the only thing that could teach it. Such a playlist runs one profile, persists what it learns, and skips from then on. What gets persisted -is the *effective* cadence (the 120 s default when the portal advertises +is the _effective_ cadence (the 120 s default when the portal advertises none), so stored absence keeps meaning exactly one thing — never profiled — rather than sending a portal that advertises nothing back through a profile on every start. @@ -682,11 +810,17 @@ plus the 15 s drain is one no transport can recall — the PWA `fetch()` takes n signal at all, and the Electron main process runs its HTTP request to completion. Advancing anyway would stake the next candidate's freshly issued session on that request never landing. So the rejection carries -`abandonedInFlight` and the candidate loop returns instead of probing on, +`abandonedInFlight` plus a settlement promise and the candidate loop returns +instead of probing on, preferring an honest "could not confirm this portal" the user can retry over a session that looks established and dies later. It costs nothing in the normal case: an aborted attempt settles as soon as its in-flight request errors out, -so the drain returns at once and the loop continues. +so the drain returns at once and the loop continues. Edit may return that +bounded error to the dialog, but its session and repair fences remain installed +until the settlement promise resolves. Import reports the refusal immediately +but keeps Add and the form disabled for the same lifetime. Lazy repair retains +its pending/runtime-authentication fences. Catalog, watchdog, repair, Add and +retry authentication therefore cannot race the abandoned `get_profile`. The budget itself covers the longest real flow: a status-2 portal costs four sequential requests (handshake, profile, `do_auth`, profile retry) and the @@ -795,15 +929,15 @@ portal has to resolve the command. `null` is deliberately wider than the flag check alone; the extra guards can only push a row back onto the `create_link` path, so they cannot regress a portal that works today: -| Input | Verdict | Why | -| --- | --- | --- | -| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. | -| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. | -| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. | -| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. | -| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. | -| Portal-local host — `localhost` and any `*.localhost` name (RFC 6761 §6.3 reserves the whole suffix for loopback), `localhost.localdomain`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`); a terminal DNS root dot is stripped first | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. | -| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. | +| Input | Verdict | Why | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. | +| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. | +| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. | +| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. | +| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. | +| Portal-local host — `localhost` and any `*.localhost` name (RFC 6761 §6.3 reserves the whole suffix for loopback), `localhost.localdomain`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`); a terminal DNS root dot is stripped first | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. | +| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. | `fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and short-circuits before the request. It never applies it when `series` is set: @@ -860,8 +994,8 @@ Every static return therefore warms first, through one primitive — `ensureToken` performs handshake + `get_profile` with no link minted, and validates the identity the cached token was negotiated for — which the raw `getCachedToken()` cannot. It is cheap where it is not needed: a simple portal -returns immediately and a warm cache with a matching fingerprint resolves -without a request. +runs only the in-memory configuration-authority guard and returns immediately, +while a warm cache with a matching fingerprint resolves without a request. The store's player feature reads `getCachedToken()` for its header set, which is safe there because it runs immediately after the warm above populated the @@ -925,14 +1059,14 @@ A temporary link lives about 5 seconds (`tv_tmp_link_ttl` / so the rule is to resolve immediately before playback and never persist, cache or replay the result. What each persisting path actually stores: -| Path | Stores | Verdict | -| --- | --- | --- | -| Recently Viewed | the raw row including `cmd` (`buildStalkerRecentlyViewedPayload` spreads the item) | Re-resolves on replay. | -| Favorites | the raw row including `cmd` (`addStalkerFavorite`, `toggleFavorite`) | Re-resolves on replay. | -| Playback positions | `playlist_id` + `content_xtream_id` + content type only — no URL column | Not applicable. | -| Main-process playback context (`stalker-playback-context.service.ts`) | header sets keyed by the stream URL's origin + path, 15 min TTL | Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare. | -| ITV full-list cache (`StalkerItvCacheService`) | catalog rows | Rows, not links. | -| Downloads | the resolved `url` on the `downloads` row | **The one exception** — see below. | +| Path | Stores | Verdict | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Recently Viewed | the raw row including `cmd` (`buildStalkerRecentlyViewedPayload` spreads the item) | Re-resolves on replay. | +| Favorites | the raw row including `cmd` (`addStalkerFavorite`, `toggleFavorite`) | Re-resolves on replay. | +| Playback positions | `playlist_id` + `content_xtream_id` + content type only — no URL column | Not applicable. | +| Main-process playback context (`stalker-playback-context.service.ts`) | header sets keyed by the stream URL's origin + path, 15 min TTL | Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare. | +| ITV full-list cache (`StalkerItvCacheService`) | catalog rows | Rows, not links. | +| Downloads | the resolved `url` on the `downloads` row | **The one exception** — see below. | The download row is the only place a resolved URL outlives the playback that produced it, because the main-process downloader needs a URL it can retry and diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 3f6008509..cbd8c28c2 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -4,6 +4,7 @@ import { EpgProgram, Playlist, PlaylistMeta, + PlaylistMetaUpdate, } from '@iptvnator/shared/interfaces'; export const PlaylistActions = createActionGroup({ @@ -14,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: PlaylistMeta }>(), + '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/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts index 89b9e61a0..394182ed6 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -46,6 +46,125 @@ describe('playlistReducers', () => { ).toEqual(['Movies', 'News']); }); + it('updates a resolved Stalker connection and projects the transient session patch', () => { + const existingPlaylist = { + _id: 'stalker-1', + title: 'Stalker Portal', + count: 0, + importDate: '2026-08-08T00:00:00.000Z', + portalUrl: 'https://old.example.com/portal.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: false, + stalkerSerialNumber: 'OLD-SERIAL', + } as PlaylistMeta; + const state = { + ...initialState, + playlists: playlistsAdapter.addOne( + existingPlaylist, + initialState.playlists + ), + }; + + const nextState = reducer( + state, + PlaylistActions.updatePlaylistMeta({ + playlist: { + ...existingPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + isFullStalkerPortal: true, + macAddress: '00:1A:79:DD:EE:FF', + username: 'subscriber', + password: 'secret', + stalkerSerialNumber: 'NEW-SERIAL', + stalkerDeviceId1: 'DEVICE-1', + stalkerDeviceId2: 'DEVICE-2', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: 'SIGNATURE-2', + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 3, + stalkerAccountInfo: { + login: 'subscriber', + status: 'active', + }, + }, + }, + }) + ); + const stored = nextState.playlists.entities['stalker-1']; + + expect(stored).toEqual( + expect.objectContaining({ + portalUrl: 'https://new.example.com/server/load.php', + isFullStalkerPortal: true, + macAddress: '00:1A:79:DD:EE:FF', + username: 'subscriber', + password: 'secret', + stalkerSerialNumber: 'NEW-SERIAL', + stalkerDeviceId1: 'DEVICE-1', + stalkerDeviceId2: 'DEVICE-2', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: 'SIGNATURE-2', + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 3, + stalkerAccountInfo: { + login: 'subscriber', + status: 'active', + }, + }) + ); + expect(stored).not.toHaveProperty('stalkerSessionPatch'); + }); + + it('clears the active Stalker session when the transient patch is null', () => { + const existingPlaylist = { + _id: 'stalker-1', + title: 'Stalker Portal', + count: 0, + importDate: '2026-08-08T00:00:00.000Z', + portalUrl: 'https://old.example.com/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + stalkerToken: 'OLD_TOKEN', + stalkerSessionIdentity: 'old-fingerprint', + stalkerWatchdogTimeout: 120, + stalkerTimeslot: 7, + stalkerAccountInfo: { login: 'old-user' }, + } as Playlist; + const state = { + ...initialState, + playlists: playlistsAdapter.addOne( + existingPlaylist, + initialState.playlists + ), + }; + + const nextState = reducer( + state, + PlaylistActions.updatePlaylistMeta({ + playlist: { + ...existingPlaylist, + stalkerSessionPatch: null, + }, + }) + ); + const stored = nextState.playlists.entities['stalker-1']; + + expect(stored).toEqual( + expect.objectContaining({ + stalkerToken: undefined, + stalkerSessionIdentity: undefined, + stalkerWatchdogTimeout: undefined, + stalkerTimeslot: undefined, + stalkerAccountInfo: undefined, + }) + ); + expect(stored).not.toHaveProperty('stalkerSessionPatch'); + }); + it('updates the active playlist channel cache and clears loading on playlist refresh', () => { const refreshedChannel = { epgParams: '', @@ -160,9 +279,9 @@ describe('playlistReducers', () => { }) ); - expect( - nextState.playlists.entities['playlist-1']?.autoRefresh - ).toBe(true); + expect(nextState.playlists.entities['playlist-1']?.autoRefresh).toBe( + true + ); }); it('keeps autoRefresh disabled on playlist refresh when the existing playlist has it disabled', () => { @@ -194,8 +313,8 @@ describe('playlistReducers', () => { }) ); - expect( - nextState.playlists.entities['playlist-1']?.autoRefresh - ).toBe(false); + expect(nextState.playlists.entities['playlist-1']?.autoRefresh).toBe( + false + ); }); }); diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index fb22c6be4..1d92e88bb 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -39,11 +39,9 @@ export const playlistReducers = [ detectedEpgUrls: action.playlist.detectedEpgUrls ?? currentPlaylist?.detectedEpgUrls, - enabledEpgUrls: - action.playlist.epgUrls ?? currentPlaylist?.epgUrls, + enabledEpgUrls: action.playlist.epgUrls ?? currentPlaylist?.epgUrls, manualEpgUrls: - action.playlist.manualEpgUrls ?? - currentPlaylist?.manualEpgUrls, + action.playlist.manualEpgUrls ?? currentPlaylist?.manualEpgUrls, disabledEpgUrls: action.playlist.disabledEpgUrls ?? currentPlaylist?.disabledEpgUrls, @@ -151,6 +149,39 @@ export const playlistReducers = [ isFullStalkerPortal: p.isFullStalkerPortal, } : {}), + ...(p.stalkerSerialNumber !== undefined + ? { + stalkerSerialNumber: p.stalkerSerialNumber, + } + : {}), + ...(p.stalkerDeviceId1 !== undefined + ? { stalkerDeviceId1: p.stalkerDeviceId1 } + : {}), + ...(p.stalkerDeviceId2 !== undefined + ? { stalkerDeviceId2: p.stalkerDeviceId2 } + : {}), + ...(p.stalkerSignature1 !== undefined + ? { stalkerSignature1: p.stalkerSignature1 } + : {}), + ...(p.stalkerSignature2 !== undefined + ? { stalkerSignature2: p.stalkerSignature2 } + : {}), + ...(p.stalkerSessionPatch !== undefined + ? { + stalkerToken: + p.stalkerSessionPatch?.stalkerToken, + stalkerSessionIdentity: + p.stalkerSessionPatch + ?.stalkerSessionIdentity, + stalkerWatchdogTimeout: + p.stalkerSessionPatch + ?.stalkerWatchdogTimeout, + stalkerTimeslot: + p.stalkerSessionPatch?.stalkerTimeslot, + stalkerAccountInfo: + p.stalkerSessionPatch?.stalkerAccountInfo, + } + : {}), ...(p.favorites != null ? { favorites: p.favorites } : {}), diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html index c2491114f..4dc53d6a7 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html @@ -15,10 +15,10 @@ id="portalUrl" formControlName="portalUrl" /> - {{ + {{ 'HOME.STALKER_PORTAL.URL_HINT' | translate }} + {{ 'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate - }} - {{ 'SETTINGS.EPG_URL_ERROR' | translate }} + }} {{ diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index 899c7e992..fd3844e1a 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -72,6 +72,19 @@ describe('StalkerPortalImportComponent identity handling', () => { ); }); + it('accepts HTTP(S) bare hosts and rejects URLs without a web protocol', () => { + const control = component.form.controls.portalUrl; + + control.setValue('portal.example.com'); + expect(control.valid).toBe(false); + + control.setValue('file:///tmp/portal.php'); + expect(control.valid).toBe(false); + + control.setValue('https://portal.example.com'); + expect(control.valid).toBe(true); + }); + it('passes trimmed SN, device IDs, and signatures into initial authentication and persisted playlist metadata', async () => { component.form.patchValue({ _id: 'playlist-1', @@ -189,7 +202,8 @@ describe('StalkerPortalImportComponent identity handling', () => { it('persists the cadence and the identity the token was negotiated for', async () => { portalDiscovery.discover.mockResolvedValue({ status: 'resolved', - portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', isFullStalkerPortal: true, token: 'token-1', watchdogTimeoutSeconds: 90, @@ -220,7 +234,8 @@ describe('StalkerPortalImportComponent identity handling', () => { // exactly the login portals this flow exists for. portalDiscovery.discover.mockResolvedValue({ status: 'resolved', - portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', isFullStalkerPortal: true, token: 'token-1', }); @@ -251,7 +266,8 @@ describe('StalkerPortalImportComponent identity handling', () => { it('records the effective cadence when the portal advertises none', async () => { portalDiscovery.discover.mockResolvedValue({ status: 'resolved', - portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', isFullStalkerPortal: true, token: 'token-1', }); @@ -275,7 +291,8 @@ describe('StalkerPortalImportComponent identity handling', () => { it("relays the portal's own refusal instead of a generic error", async () => { portalDiscovery.discover.mockResolvedValue({ status: 'auth-rejected', - portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', error: new StalkerPortalError('login-required'), }); component.form.patchValue({ @@ -296,10 +313,52 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); + it('keeps Add disabled until an abandoned authentication settles', async () => { + let settleAuthentication: () => void = () => undefined; + const abandonedAuthenticationSettled = new Promise((resolve) => { + settleAuthentication = resolve; + }); + portalDiscovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + abandonedInFlight: true, + abandonedAuthenticationSettled, + }); + component.form.patchValue({ + _id: 'playlist-abandoned', + title: 'Slow Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + const importing = component.addPlaylist(); + while (portalDiscovery.discover.mock.calls.length === 0) { + await Promise.resolve(); + } + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + + expect(component.isLoading()).toBe(true); + expect(component.form.controls.portalUrl.disabled).toBe(true); + await component.addPlaylist(); + expect(portalDiscovery.discover).toHaveBeenCalledTimes(1); + expect(store.dispatch).not.toHaveBeenCalled(); + + settleAuthentication(); + await importing; + + expect(component.isLoading()).toBe(false); + expect(component.form.controls.portalUrl.enabled).toBe(true); + }); + it("appends the portal's explanation to a blocked refusal", async () => { portalDiscovery.discover.mockResolvedValue({ status: 'auth-rejected', - portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', error: new StalkerPortalError( 'blocked', 'device conflict - device_id mismatch' @@ -517,7 +576,7 @@ describe('StalkerPortalImportComponent identity handling', () => { ); }); - it('does not submit a MAC paired with the previous MAC\'s IDs', async () => { + it("does not submit a MAC paired with the previous MAC's IDs", async () => { // Clicking Add blurs the MAC field, so the blur's SHA-256 is // still in flight when the click handler runs. Snapshotting the // form there pairs the corrected MAC with the old MAC's device @@ -568,9 +627,7 @@ describe('StalkerPortalImportComponent identity handling', () => { const delayed = call++ < 2; const result = await realDigest(algorithm, data); if (delayed) { - await new Promise((resolve) => - setTimeout(resolve, 20) - ); + await new Promise((resolve) => setTimeout(resolve, 20)); } return result; }) as typeof globalThis.crypto.subtle.digest); @@ -727,7 +784,7 @@ describe('StalkerPortalImportComponent identity handling', () => { ); }); - it('never pairs one MAC with another MAC\'s device IDs', async () => { + it("never pairs one MAC with another MAC's device IDs", async () => { // The submit-time digest is asynchronous, so a MAC edit can land // while it runs. Reading the MAC from the form afterwards would // ship the new address with the old address's IDs — a mismatch @@ -860,7 +917,8 @@ describe('StalkerPortalImportComponent identity handling', () => { it('gives a device conflict its own headline', async () => { portalDiscovery.discover.mockResolvedValue({ status: 'auth-rejected', - portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', error: new StalkerPortalError( 'device-conflict', 'device conflict - device_id mismatch' @@ -907,4 +965,24 @@ describe('StalkerPortalImportComponent identity handling', () => { }) ); }); + + it('persists portal.php instead of the root page for an unreachable bare host', async () => { + portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' }); + component.form.patchValue({ + _id: 'playlist-bare-host', + title: 'Offline Panel', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://panel.example.com', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(store.dispatch.mock.calls[0][0].playlist).toEqual( + expect.objectContaining({ + portalUrl: 'https://panel.example.com/portal.php', + isFullStalkerPortal: false, + }) + ); + }); }); diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index f3634a28c..bcfb41477 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -92,7 +92,7 @@ interface StalkerSettledIdentity { }) export class StalkerPortalImportComponent { readonly addClicked = output(); - readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; + readonly URL_REGEX = /^https?:\/\/[^ "\s]+$/i; readonly form = new FormGroup({ _id: new FormControl(createRandomId()), @@ -136,8 +136,10 @@ export class StalkerPortalImportComponent { */ get showsForeignOuiHint(): boolean { const value = this.form.controls.macAddress.value; - return Boolean(normalizeStalkerMacAddress(value)) && - !hasInfomirMacOui(value); + return ( + Boolean(normalizeStalkerMacAddress(value)) && + !hasInfomirMacOui(value) + ); } /** @@ -161,7 +163,7 @@ export class StalkerPortalImportComponent { !this.derivesDeviceIds() && Boolean( this.form.controls.deviceId1.value || - this.form.controls.deviceId2.value + this.form.controls.deviceId2.value ) ); } @@ -403,8 +405,7 @@ export class StalkerPortalImportComponent { stalkerAccountInfo = { login: discovery.accountInfo.login, expireDate: discovery.accountInfo.expire_date, - tariffPlanName: - discovery.accountInfo.tariff_plan_name, + tariffPlanName: discovery.accountInfo.tariff_plan_name, status: discovery.accountInfo.status, }; } @@ -432,6 +433,14 @@ export class StalkerPortalImportComponent { undefined, { duration: 8000 } ); + if (discovery.abandonedInFlight) { + // The bounded error may arrive while get_profile is still + // on the wire. Keep Add and every identity field locked + // until it leaves the transport, or an immediate retry + // could establish a token that this late attempt revokes. + await (discovery.abandonedAuthenticationSettled ?? + new Promise(() => undefined)); + } return; } else if ( isFullStalkerPortalUrl( diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts index 112ec10dc..f7373af82 100644 --- a/libs/playlist/shared/ui/src/index.ts +++ b/libs/playlist/shared/ui/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/add-playlist-menu/playlist-type'; export * from './lib/playlist-switcher/playlist-switcher.component'; export * from './lib/recent-playlists/playlist-info/playlist-info.component'; +export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token'; export * from './lib/recent-playlists/recent-playlists.component'; export * from './lib/recent-playlists/empty-state/empty-state.component'; export * from './lib/playlist-refresh-action.service'; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html index 60b840f47..e87383744 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html @@ -5,249 +5,214 @@

{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}

- - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate - }} - - - @if (playlist.portalUrl) { +
+ {{ - 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate + 'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate }} - + - } - @if (playlist.macAddress) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate - }} - - {{ - 'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate - }} - - } - @if (playlist.portalUrl) { - @if (hasStoredStalkerDeviceIds) { -

- {{ - 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING' - | translate - }} -

+ @if (playlist.portalUrl) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate + }} + + {{ + 'HOME.STALKER_PORTAL.URL_HINT' | translate + }} + {{ + 'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate + }} + } - - {{ - 'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate - }} - - - - {{ - 'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate - }} - - - - {{ - 'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate - }} - - - - {{ - 'HOME.STALKER_PORTAL.SIGNATURE_1' | translate - }} - - - - {{ - 'HOME.STALKER_PORTAL.SIGNATURE_2' | translate - }} - - - } - @if (playlist.serverUrl) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate - }} - - - } - @if (playlist.username) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate - }} - - - } - @if (playlist.password) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate - }} - - - } - @if (playlist.url) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate - }} - - - - } - @if (playlist.filename) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate - }} - - - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate - }} - - - } - @if (playlist.count) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate - }} - - - } - @if (isDesktop) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate - }} - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate - }} - - } - @if (playlist.filePath) { - - - {{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }} - - - @if (playlist.updateState === 2) { - + @if (playlist.macAddress) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate + }} + + {{ + 'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate + }} + + } + @if (playlist.portalUrl) { + @if (hasStoredStalkerDeviceIds) { +

{{ - 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' + 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING' | translate }} - +

} -
- } - @if (canManagePlaylistEpgSources) { -
-
- event_note -
-

- {{ 'SETTINGS.EPG_SOURCES' | translate }} -

-

- @if (hiddenDetectedPlaylistEpgSourceCount > 0) { - {{ - 'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED' - | translate - : { - enabled: - playlistEpgUrls.length, - detected: - playlistDetectedEpgUrls.length, - } - }} - } @else { - {{ - 'SETTINGS.EPG_SOURCES_DESCRIPTION' - | translate - }} - } -

-
-
- @for (epgUrl of playlistEpgUrls; track epgUrl) { -
- - {{ - 'SETTINGS.EPG_URL_LABEL' | translate - }} - - - - - -
- } -
+ {{ + 'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.SIGNATURE_1' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.SIGNATURE_2' | translate + }} + + + } + @if (playlist.serverUrl) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate + }} + + + } + @if (playlist.portalUrl || playlist.username) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate + }} + + + } + @if (playlist.portalUrl || playlist.password) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate + }} + + + } + @if (playlist.url) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate + }} + + + + } + @if (playlist.filename) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' + | translate + }} + + + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate + }} + + + } + @if (playlist.count) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate + }} + + + } + @if (isDesktop) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate + }} + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' + | translate + }} + + } + @if (playlist.filePath) { + + + {{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }} + + + @if (playlist.updateState === 2) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' + | translate + }} + + } + + } + @if (canManagePlaylistEpgSources) { +
- @for ( - control of playlistEpgSourceInputs.controls; - track $index - ) { +
+ event_note +
+

+ {{ 'SETTINGS.EPG_SOURCES' | translate }} +

+

+ @if (hiddenDetectedPlaylistEpgSourceCount > 0) { + {{ + 'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED' + | translate + : { + enabled: + playlistEpgUrls.length, + detected: + playlistDetectedEpgUrls.length, + } + }} + } @else { + {{ + 'SETTINGS.EPG_SOURCES_DESCRIPTION' + | translate + }} + } +

+
+
+ @for (epgUrl of playlistEpgUrls; track epgUrl) {
{{ 'SETTINGS.EPG_URL_LABEL' | translate }} - - @if (control.invalid && control.touched) { - {{ - 'SETTINGS.EPG_URL_ERROR' | translate - }} - } + + +
} -
- - +
+ @for ( + control of playlistEpgSourceInputs.controls; + track $index + ) { +
+ + {{ + 'SETTINGS.EPG_URL_LABEL' | translate + }} + + @if (control.invalid && control.touched) { + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + } + + +
+ } +
+ + +
-
-
- } - @if (isDesktop && (playlist.url || playlist.filePath)) { - - {{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }} - -

- {{ - 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION' - | translate - }} -

- } - - - @if (!playlist.serverUrl && !playlist.portalUrl) { +
+ } + @if (isDesktop && (playlist.url || playlist.filePath)) { + + {{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }} + +

+ {{ + 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION' + | translate + }} +

+ } +
+ + @if (!playlist.serverUrl && !playlist.portalUrl) { + + } + + - } - - - - + +
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 6e8fec1b2..917768e00 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 @@ -3,7 +3,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { @@ -14,11 +14,16 @@ import { } from '@iptvnator/services'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { PlaylistInfoComponent } from './playlist-info.component'; +import { + STALKER_PLAYLIST_CONNECTION_EDITOR, + STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS, +} from './stalker-playlist-connection-editor.token'; describe('PlaylistInfoComponent', () => { let component: PlaylistInfoComponent; let fixture: ComponentFixture; let playlistsService: { + getPlaylistById: jest.Mock; getRawPlaylistById: jest.Mock; }; let databaseService: { @@ -46,7 +51,14 @@ describe('PlaylistInfoComponent', () => { dispatch: jest.Mock; }; let dialogRef: { + beforeClosed: jest.Mock; close: jest.Mock; + disableClose: boolean; + }; + let dialogBeforeClosed: Subject; + let stalkerConnectionEditor: { + applyResolvedConnection: jest.Mock; + resolveConnection: jest.Mock; }; const originalElectron = window.electron; @@ -62,6 +74,7 @@ describe('PlaylistInfoComponent', () => { beforeEach(async () => { playlistsService = { + getPlaylistById: jest.fn(), getRawPlaylistById: jest.fn(() => of('#EXTM3U\n')), }; databaseService = { @@ -95,8 +108,22 @@ describe('PlaylistInfoComponent', () => { store = { dispatch: jest.fn(), }; + dialogBeforeClosed = new Subject(); dialogRef = { + beforeClosed: jest.fn(() => dialogBeforeClosed), close: jest.fn(), + disableClose: false, + }; + stalkerConnectionEditor = { + applyResolvedConnection: jest.fn( + async (playlist: PlaylistMeta) => playlist + ), + resolveConnection: jest.fn( + async (updatedPlaylist: PlaylistMeta) => ({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: updatedPlaylist, + }) + ), }; await TestBed.configureTestingModule({ @@ -155,6 +182,10 @@ describe('PlaylistInfoComponent', () => { provide: RuntimeCapabilitiesService, useValue: runtime, }, + { + provide: STALKER_PLAYLIST_CONNECTION_EDITOR, + useValue: stalkerConnectionEditor, + }, ], }).compileComponents(); }); @@ -567,25 +598,87 @@ describe('PlaylistInfoComponent', () => { }); describe('Stalker identity fields', () => { - function createStalkerComponent( + async function createStalkerComponent( overrides: Partial = {} - ): void { + ): Promise { + const stalkerPlaylist = { + ...playlist, + url: undefined, + portalUrl: 'https://portal.example.com/c', + macAddress: '00:1a:79:aa:bb:cc', + isFullStalkerPortal: true, + ...overrides, + } as Playlist & { id: string }; + playlistsService.getPlaylistById.mockReturnValue( + of(stalkerPlaylist) + ); TestBed.overrideProvider(MAT_DIALOG_DATA, { - useValue: { - ...playlist, - url: undefined, - portalUrl: 'https://portal.example.com/c', - macAddress: '00:1a:79:aa:bb:cc', - isFullStalkerPortal: true, - ...overrides, - } as Playlist & { id: string }, + useValue: stalkerPlaylist, }); createComponent(); fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); } - it('canonicalizes an edited MAC on blur', () => { - createStalkerComponent(); + it('hydrates the complete stored row before editing a summarized Stalker playlist', async () => { + const summary = { + ...playlist, + url: undefined, + portalUrl: 'https://portal.example.com/c', + macAddress: '00:1a:79:aa:bb:cc', + } as Playlist & { id: string }; + const storedPlaylist = { + ...summary, + isFullStalkerPortal: true, + stalkerSerialNumber: 'STORED-SERIAL', + stalkerDeviceId1: 'STORED-DEVICE-1', + stalkerDeviceId2: 'STORED-DEVICE-2', + stalkerSignature1: 'STORED-SIGNATURE-1', + stalkerSignature2: 'STORED-SIGNATURE-2', + }; + const storedPlaylist$ = new Subject(); + playlistsService.getPlaylistById.mockReturnValue(storedPlaylist$); + TestBed.overrideProvider(MAT_DIALOG_DATA, { useValue: summary }); + createComponent(); + fixture.detectChanges(); + + expect(component.isHydratingStalkerPlaylist()).toBe(true); + + storedPlaylist$.next(storedPlaylist); + storedPlaylist$.complete(); + await fixture.whenStable(); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://new.example.com'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + 'playlist-1' + ); + expect( + stalkerConnectionEditor.resolveConnection + ).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: 'https://new.example.com', + stalkerSerialNumber: 'STORED-SERIAL', + stalkerDeviceId1: 'STORED-DEVICE-1', + stalkerDeviceId2: 'STORED-DEVICE-2', + stalkerSignature1: 'STORED-SIGNATURE-1', + stalkerSignature2: 'STORED-SIGNATURE-2', + }), + expect.objectContaining({ + portalUrl: 'https://portal.example.com/c', + stalkerSerialNumber: 'STORED-SERIAL', + }) + ); + }); + + it('canonicalizes an edited MAC on blur', async () => { + await createStalkerComponent(); const control = component.playlistDetails.get('macAddress'); control?.setValue('00-1a-79-ab-cd-ef'); @@ -596,10 +689,10 @@ describe('PlaylistInfoComponent', () => { expect(control?.dirty).toBe(true); }); - it('leaves a stored MAC untouched until it is edited', () => { + it('leaves a stored MAC untouched until it is edited', async () => { // Loading the dialog must not move the session fingerprint: the // stored lowercase MAC is what the portal already accepted. - createStalkerComponent(); + await createStalkerComponent(); expect(component.playlistDetails.get('macAddress')?.value).toBe( '00:1a:79:aa:bb:cc' @@ -607,8 +700,8 @@ describe('PlaylistInfoComponent', () => { expect(component.playlistDetails.pristine).toBe(true); }); - it('refuses to save a malformed MAC', () => { - createStalkerComponent(); + it('refuses to save a malformed MAC', async () => { + await createStalkerComponent(); const control = component.playlistDetails.get('macAddress'); control?.setValue('00:1A:79:AA:BB'); @@ -620,10 +713,10 @@ describe('PlaylistInfoComponent', () => { it('canonicalizes the MAC on submit when no blur fired', async () => { // Pressing Enter inside the field submits without the field losing // focus, so `onMacAddressBlur` never runs. - createStalkerComponent(); - component.playlistDetails.get('macAddress')?.setValue( - '00-1a-79-ab-cd-ef' - ); + await createStalkerComponent(); + component.playlistDetails + .get('macAddress') + ?.setValue('00-1a-79-ab-cd-ef'); await component.saveChanges( component.playlistDetails.value as PlaylistMeta @@ -634,24 +727,464 @@ describe('PlaylistInfoComponent', () => { playlist: expect.objectContaining({ macAddress: '00:1A:79:AB:CD:EF', }) as PlaylistMeta, + persist: false, + }) + ); + expect( + stalkerConnectionEditor.resolveConnection + ).toHaveBeenCalledTimes(1); + }); + + it('saves metadata without discovery when connection fields are unchanged', async () => { + await createStalkerComponent({ + username: 'subscriber', + password: 'secret', + stalkerSerialNumber: 'SERIAL', + }); + component.playlistDetails.get('title')?.setValue('Renamed'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect( + stalkerConnectionEditor.resolveConnection + ).not.toHaveBeenCalled(); + expect( + stalkerConnectionEditor.applyResolvedConnection + ).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: expect.objectContaining({ + title: 'Renamed', + }) as PlaylistMeta, + }) + ); + const dispatchedPlaylist = store.dispatch.mock.calls[0][0] + .playlist as PlaylistMeta; + expect(dispatchedPlaylist).not.toHaveProperty('portalUrl'); + expect(dispatchedPlaylist).not.toHaveProperty( + 'isFullStalkerPortal' + ); + expect(dispatchedPlaylist).not.toHaveProperty('macAddress'); + expect(dispatchedPlaylist).not.toHaveProperty('username'); + expect(dispatchedPlaylist).not.toHaveProperty('password'); + expect(dispatchedPlaylist).not.toHaveProperty( + 'stalkerSerialNumber' + ); + expect(dispatchedPlaylist).not.toHaveProperty('stalkerDeviceId1'); + expect(dispatchedPlaylist).not.toHaveProperty('stalkerDeviceId2'); + expect(dispatchedPlaylist).not.toHaveProperty('stalkerSignature1'); + expect(dispatchedPlaylist).not.toHaveProperty('stalkerSignature2'); + }); + + it.each([ + ['portalUrl', 'https://new.example.com'], + ['macAddress', '00:1A:79:AB:CD:EF'], + ['username', 'new-user'], + ['password', 'new-password'], + ['stalkerSerialNumber', 'NEW-SERIAL'], + ['stalkerDeviceId1', 'NEW-DEVICE-1'], + ['stalkerDeviceId2', 'NEW-DEVICE-2'], + ['stalkerSignature1', 'NEW-SIGNATURE-1'], + ['stalkerSignature2', 'NEW-SIGNATURE-2'], + ])('runs discovery when %s changes', async (field, value) => { + await createStalkerComponent({ + username: 'subscriber', + password: 'secret', + stalkerSerialNumber: 'SERIAL', + stalkerDeviceId1: 'DEVICE-1', + stalkerDeviceId2: 'DEVICE-2', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: 'SIGNATURE-2', + }); + component.playlistDetails.get(field)?.setValue(value); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect( + stalkerConnectionEditor.resolveConnection + ).toHaveBeenCalledTimes(1); + }); + + it('dispatches the resolved endpoint, mode and session patch together', async () => { + await createStalkerComponent(); + const resolvedPlaylist = { + ...component.playlistDetails.getRawValue(), + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 4, + }, + }; + const { + stalkerSessionPatch, + ...persistedPlaylistWithoutSessionPatch + } = resolvedPlaylist; + stalkerConnectionEditor.resolveConnection.mockResolvedValue({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: resolvedPlaylist, + }); + stalkerConnectionEditor.applyResolvedConnection.mockResolvedValueOnce( + persistedPlaylistWithoutSessionPatch + ); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://portal.example.com/c'); + component.playlistDetails.get('username')?.setValue('subscriber'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + ...persistedPlaylistWithoutSessionPatch, + stalkerSessionPatch, + }, + persist: false, + }) + ); + expect( + stalkerConnectionEditor.applyResolvedConnection + ).toHaveBeenCalledWith(resolvedPlaylist); + expect(dialogRef.close).toHaveBeenCalledTimes(1); + }); + + it('retains a simple-portal session clear in the state-only update', async () => { + await createStalkerComponent(); + const resolvedPlaylist = { + ...component.playlistDetails.getRawValue(), + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: false, + stalkerSessionPatch: null, + }; + const persistedPlaylist = { + ...resolvedPlaylist, + stalkerSessionPatch: undefined, + }; + stalkerConnectionEditor.resolveConnection.mockResolvedValue({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: resolvedPlaylist, + }); + stalkerConnectionEditor.applyResolvedConnection.mockResolvedValueOnce( + persistedPlaylist + ); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://portal.example.com/server/load.php'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + ...persistedPlaylist, + stalkerSessionPatch: null, + }, + persist: false, }) ); }); + it('does not route a Stalker edit through stale Xtream metadata', async () => { + runtime.supportsXtreamSqliteDataSource = true; + await createStalkerComponent({ + serverUrl: 'https://old-xtream.example.com', + username: 'subscriber', + password: 'secret', + }); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://new.example.com'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect( + databaseService.updateXtreamPlaylistDetails + ).not.toHaveBeenCalled(); + expect( + stalkerConnectionEditor.applyResolvedConnection + ).toHaveBeenCalledTimes(1); + }); + + it('does not normalize Stalker credentials through stale Xtream metadata', async () => { + await createStalkerComponent({ + serverUrl: 'https://old-xtream.example.com', + username: ' subscriber ', + password: ' secret ', + }); + component.playlistDetails.get('title')?.setValue('Renamed'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect( + stalkerConnectionEditor.resolveConnection + ).not.toHaveBeenCalled(); + const dispatchedPlaylist = store.dispatch.mock.calls[0][0] + .playlist as PlaylistMeta; + expect(dispatchedPlaylist).not.toHaveProperty('username'); + expect(dispatchedPlaylist).not.toHaveProperty('password'); + }); + + it('persists a resolved edit when the component is destroyed during discovery', async () => { + await createStalkerComponent(); + const resolvedPlaylist = { + ...component.playlistDetails.getRawValue(), + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + 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'; + playlist: typeof resolvedPlaylist; + }) => void) + | undefined; + stalkerConnectionEditor.resolveConnection.mockReturnValueOnce( + new Promise((resolve) => { + finishDiscovery = resolve; + }) + ); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://new.example.com'); + const saving = component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + await Promise.resolve(); + + fixture.destroy(); + finishDiscovery?.({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: resolvedPlaylist, + }); + await saving; + + expect( + stalkerConnectionEditor.applyResolvedConnection + ).toHaveBeenCalledWith(resolvedPlaylist, { + preserveCurrentMetadata: true, + }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: currentPlaylist, + persist: false, + }) + ); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('persists a resolved edit while the dialog close animation is pending', async () => { + await createStalkerComponent(); + const resolvedPlaylist = { + ...component.playlistDetails.getRawValue(), + portalUrl: 'https://portal.example.com/server/load.php', + isFullStalkerPortal: true, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + 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'; + playlist: typeof resolvedPlaylist; + }) => void) + | undefined; + stalkerConnectionEditor.resolveConnection.mockReturnValueOnce( + new Promise((resolve) => { + finishDiscovery = resolve; + }) + ); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://new.example.com'); + const saving = component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + await Promise.resolve(); + + dialogBeforeClosed.next(); + finishDiscovery?.({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED, + playlist: resolvedPlaylist, + }); + await saving; + + expect( + stalkerConnectionEditor.applyResolvedConnection + ).toHaveBeenCalledWith(resolvedPlaylist, { + preserveCurrentMetadata: true, + }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: currentPlaylist, + persist: false, + }) + ); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('does not update UI state or report success when resolved persistence fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + await 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, + ])('keeps the dialog open and saves nothing on %s', async (status) => { + await createStalkerComponent(); + stalkerConnectionEditor.resolveConnection.mockResolvedValue({ + status, + message: `error:${status}`, + }); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://new.example.com'); + + await component.saveChanges( + component.playlistDetails.getRawValue() as PlaylistMeta + ); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + `error:${status}`, + 'CLOSE', + { duration: 8000 } + ); + expect(component.isSaving()).toBe(false); + }); + + it('ignores a second Save while discovery is pending', async () => { + await createStalkerComponent(); + let resolveDiscovery: + | ((value: { status: 'unreachable'; message: string }) => void) + | undefined; + stalkerConnectionEditor.resolveConnection.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve; + }) + ); + component.playlistDetails + .get('portalUrl') + ?.setValue('https://new.example.com'); + const value = + component.playlistDetails.getRawValue() as PlaylistMeta; + + const firstSave = component.saveChanges(value); + const secondSave = component.saveChanges(value); + + expect(component.isSaving()).toBe(true); + expect(dialogRef.disableClose).toBe(true); + expect( + stalkerConnectionEditor.resolveConnection + ).toHaveBeenCalledTimes(1); + resolveDiscovery?.({ + status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE, + message: 'offline', + }); + await Promise.all([firstSave, secondSave]); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.disableClose).toBe(false); + }); + + it('accepts a bare HTTP host but rejects an address without a protocol', async () => { + await createStalkerComponent(); + const control = component.playlistDetails.get('portalUrl'); + + control?.setValue('portal.example.com'); + expect(control?.valid).toBe(false); + + control?.setValue('https://portal.example.com'); + expect(control?.valid).toBe(true); + + control?.setValue('HTTP://portal.example.com/c'); + expect(control?.valid).toBe(true); + }); + it('persists a grandfathered MAC untouched on submit', async () => { - createStalkerComponent({ macAddress: 'legacy-device-42' }); + await createStalkerComponent({ macAddress: 'legacy-device-42' }); await component.saveChanges( component.playlistDetails.value as PlaylistMeta ); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.updatePlaylistMeta({ - playlist: expect.objectContaining({ - macAddress: 'legacy-device-42', - }) as PlaylistMeta, - }) - ); + const dispatchedPlaylist = store.dispatch.mock.calls[0][0] + .playlist as PlaylistMeta; + expect(dispatchedPlaylist).not.toHaveProperty('macAddress'); }); it('leaves a non-canonical MAC alone when focus passes through it', async () => { @@ -659,7 +1192,9 @@ describe('PlaylistInfoComponent', () => { // there would mark the form dirty AND make the value differ from // the stored one, which is what the submit guard reads — so a // later title-only save would carry the rewritten identity. - createStalkerComponent({ macAddress: '00-1a-79-aa-bb-cc' }); + await createStalkerComponent({ + macAddress: '00-1a-79-aa-bb-cc', + }); const control = component.playlistDetails.get('macAddress'); component.onMacAddressBlur(); @@ -672,13 +1207,9 @@ describe('PlaylistInfoComponent', () => { component.playlistDetails.value as PlaylistMeta ); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.updatePlaylistMeta({ - playlist: expect.objectContaining({ - macAddress: '00-1a-79-aa-bb-cc', - }) as PlaylistMeta, - }) - ); + const dispatchedPlaylist = store.dispatch.mock.calls[0][0] + .playlist as PlaylistMeta; + expect(dispatchedPlaylist).not.toHaveProperty('macAddress'); }); it('leaves an untouched non-canonical MAC alone on an unrelated save', async () => { @@ -686,7 +1217,9 @@ describe('PlaylistInfoComponent', () => { // what a permissive portal registered, and changing them moves // the session fingerprint and re-authenticates under a spelling // the portal never saw. - createStalkerComponent({ macAddress: '00-1a-79-aa-bb-cc' }); + await createStalkerComponent({ + macAddress: '00-1a-79-aa-bb-cc', + }); component.playlistDetails.get('title')?.setValue('Renamed'); await component.saveChanges( @@ -696,18 +1229,20 @@ describe('PlaylistInfoComponent', () => { expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ playlist: expect.objectContaining({ - macAddress: '00-1a-79-aa-bb-cc', title: 'Renamed', }) as PlaylistMeta, }) ); + const dispatchedPlaylist = store.dispatch.mock.calls[0][0] + .playlist as PlaylistMeta; + expect(dispatchedPlaylist).not.toHaveProperty('macAddress'); }); - it('does not claim a simple portal has pinned its device IDs', () => { + it('does not claim a simple portal has pinned its device IDs', async () => { // device_id travels only on get_profile/do_auth, which a // panel-style portal never runs — so nothing was pinned and the // lockout warning would be false. - createStalkerComponent({ + await createStalkerComponent({ isFullStalkerPortal: false, stalkerDeviceId1: 'ABCDEF', }); @@ -718,23 +1253,25 @@ describe('PlaylistInfoComponent', () => { ); }); - it('keeps a MAC outside the Infomir range saveable', () => { + it('keeps a MAC outside the Infomir range saveable', async () => { // Most reseller panels do not run the stock OUI filter, so this is // a working configuration — the import hint explains the risk, the // form must not block it. - createStalkerComponent({ macAddress: 'AA:BB:CC:DD:EE:01' }); + await createStalkerComponent({ + macAddress: 'AA:BB:CC:DD:EE:01', + }); expect(component.playlistDetails.get('macAddress')?.valid).toBe( true ); }); - it('grandfathers a stored MAC it would now reject', () => { + it('grandfathers a stored MAC it would now reject', async () => { // Before this validation existed the field accepted anything, and // on a panel that ignores the MAC such a playlist works. Blocking // Save would also strand the title, URL and EPG edits in the same // dialog. - createStalkerComponent({ macAddress: 'legacy-device-42' }); + await createStalkerComponent({ macAddress: 'legacy-device-42' }); expect(component.playlistDetails.get('macAddress')?.valid).toBe( true @@ -742,8 +1279,8 @@ describe('PlaylistInfoComponent', () => { expect(component.playlistDetails.valid).toBe(true); }); - it('still refuses a newly typed malformed MAC on a grandfathered playlist', () => { - createStalkerComponent({ macAddress: 'legacy-device-42' }); + it('still refuses a newly typed malformed MAC on a grandfathered playlist', async () => { + await createStalkerComponent({ macAddress: 'legacy-device-42' }); const control = component.playlistDetails.get('macAddress'); control?.setValue('legacy-device-43'); @@ -751,8 +1288,8 @@ describe('PlaylistInfoComponent', () => { expect(control?.valid).toBe(false); }); - it('does not warn about a pinning that has not happened', () => { - createStalkerComponent(); + it('does not warn about a pinning that has not happened', async () => { + await createStalkerComponent(); expect(component.hasStoredStalkerDeviceIds).toBe(false); expect(fixture.nativeElement.textContent).not.toContain( @@ -760,14 +1297,14 @@ describe('PlaylistInfoComponent', () => { ); }); - it('treats a blank stored device ID as never sent', () => { - createStalkerComponent({ stalkerDeviceId2: ' ' }); + it('treats a blank stored device ID as never sent', async () => { + await createStalkerComponent({ stalkerDeviceId2: ' ' }); expect(component.hasStoredStalkerDeviceIds).toBe(false); }); - it('warns once a device ID has been pinned', () => { - createStalkerComponent({ stalkerDeviceId1: 'ABCDEF' }); + it('warns once a device ID has been pinned', async () => { + await createStalkerComponent({ stalkerDeviceId1: 'ABCDEF' }); expect(component.hasStoredStalkerDeviceIds).toBe(true); expect(fixture.nativeElement.textContent).toContain( @@ -775,8 +1312,8 @@ describe('PlaylistInfoComponent', () => { ); }); - it('warns when only the second device ID is pinned', () => { - createStalkerComponent({ stalkerDeviceId2: 'FEDCBA' }); + it('warns when only the second device ID is pinned', async () => { + await createStalkerComponent({ stalkerDeviceId2: 'FEDCBA' }); expect(component.hasStoredStalkerDeviceIds).toBe(true); }); 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 e4431b2ed..49d1b7a00 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 @@ -1,6 +1,6 @@ import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { DatePipe } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { Component, DestroyRef, inject, signal } from '@angular/core'; import { FormControl, ReactiveFormsModule, @@ -38,11 +38,21 @@ import { normalizeXtreamServerUrl, Playlist, PlaylistMeta, + PlaylistMetaUpdate, } from '@iptvnator/shared/interfaces'; import { normalizeEpgUrls, resolvePlaylistEpgSourceState, } from '@iptvnator/shared/m3u-utils'; +import { + hasStalkerConnectionChanged, + omitStalkerConnection, + STALKER_PORTAL_URL_PATTERN, +} from './stalker-playlist-edit.utils'; +import { + STALKER_PLAYLIST_CONNECTION_EDITOR, + STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS, +} from './stalker-playlist-connection-editor.token'; type DesktopFileSaveBridge = Pick< typeof window.electron, @@ -60,6 +70,13 @@ const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/; flex: 1 1 auto; } + .playlist-info-fields { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + mat-dialog-content { display: flex; flex-direction: column; @@ -167,10 +184,19 @@ export class PlaylistInfoComponent { private runtime = inject(RuntimeCapabilitiesService); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly settingsStore = inject(SettingsStore); + private readonly destroyRef = inject(DestroyRef); + private readonly stalkerConnectionEditor = inject( + STALKER_PLAYLIST_CONNECTION_EDITOR + ); private dialogRef = inject(MatDialogRef, { optional: true, }); public playlistData = inject(MAT_DIALOG_DATA); + readonly isSaving = signal(false); + readonly isHydratingStalkerPlaylist = signal(false); + readonly stalkerPlaylistHydrationFailed = signal(false); + private dialogClosing = false; + private readonly stalkerPlaylistHydration: Promise; get isDesktop(): boolean { return this.runtime.supportsDesktopFileSave; @@ -224,7 +250,7 @@ export class PlaylistInfoComponent { return Boolean( normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId1) ?? - normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId2) + normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId2) ); } @@ -272,8 +298,18 @@ export class PlaylistInfoComponent { playlistDetails!: UntypedFormGroup; constructor() { + this.dialogRef?.beforeClosed().subscribe(() => { + this.dialogClosing = true; + }); this.playlist = this.playlistData; this.createForm(); + if (this.playlist.portalUrl) { + this.isHydratingStalkerPlaylist.set(true); + this.stalkerPlaylistHydration = + this.hydrateCompleteStalkerPlaylist(); + } else { + this.stalkerPlaylistHydration = Promise.resolve(); + } } /** @@ -316,7 +352,15 @@ export class PlaylistInfoComponent { // title/URL/EPG edits too. createStalkerMacAddressValidator(this.playlist.macAddress) ), - portalUrl: new FormControl(this.playlist.portalUrl), + portalUrl: new FormControl( + this.playlist.portalUrl, + this.playlist.portalUrl + ? [ + Validators.required, + Validators.pattern(STALKER_PORTAL_URL_PATTERN), + ] + : [] + ), stalkerSerialNumber: new FormControl( this.playlist.stalkerSerialNumber ), @@ -331,12 +375,71 @@ export class PlaylistInfoComponent { } async saveChanges(playlist: PlaylistMeta): Promise { + if (this.isSaving()) { + return; + } + + this.isSaving.set(true); + if (this.dialogRef) { + this.dialogRef.disableClose = true; + } try { - const normalizedPlaylist = this.normalizeStalkerPlaylistMeta( - this.normalizeXtreamPlaylistMeta(playlist) - ); + let submittedPlaylist = playlist; + if (this.isHydratingStalkerPlaylist()) { + await this.stalkerPlaylistHydration; + submittedPlaylist = this.playlistDetails.value as PlaylistMeta; + } + if ( + this.stalkerPlaylistHydrationFailed() || + this.playlistDetails.invalid + ) { + return; + } + + let resolvedStalkerConnection = false; + let resolvedStalkerSessionPatch: + PlaylistMetaUpdate['stalkerSessionPatch'] | undefined; + let preserveCurrentMetadata = false; + let normalizedPlaylist: PlaylistMetaUpdate = + this.normalizeStalkerPlaylistMeta( + this.normalizeXtreamPlaylistMeta(submittedPlaylist) + ); + if (this.playlist.portalUrl) { + if ( + hasStalkerConnectionChanged( + this.playlist, + normalizedPlaylist + ) + ) { + const result = + await this.stalkerConnectionEditor.resolveConnection( + normalizedPlaylist, + this.playlist + ); + if ( + result.status !== + STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED + ) { + this.snackBar.open( + result.message, + this.translate.instant('CLOSE'), + { duration: 8000 } + ); + return; + } + normalizedPlaylist = result.playlist; + resolvedStalkerSessionPatch = + result.playlist.stalkerSessionPatch; + resolvedStalkerConnection = true; + preserveCurrentMetadata = + this.dialogClosing || this.destroyRef.destroyed; + } else { + normalizedPlaylist = + omitStalkerConnection(normalizedPlaylist); + } + } const isXtream = - this.playlist && + !this.playlist.portalUrl && this.playlist.username && this.playlist.password && this.playlist.serverUrl; @@ -345,13 +448,41 @@ export class PlaylistInfoComponent { await this.updateXtreamPlaylist(normalizedPlaylist); } - // Dispatch store action to update UI + if (resolvedStalkerConnection) { + normalizedPlaylist = preserveCurrentMetadata + ? await this.stalkerConnectionEditor.applyResolvedConnection( + normalizedPlaylist, + { preserveCurrentMetadata: true } + ) + : await this.stalkerConnectionEditor.applyResolvedConnection( + normalizedPlaylist + ); + normalizedPlaylist = { + ...normalizedPlaylist, + stalkerSessionPatch: resolvedStalkerSessionPatch, + }; + } + + // 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 } : {}), }) ); + // Save already authorized this connection change, and a full + // portal's completed get_profile may have pinned the submitted + // serial/device identity remotely. Navigation cannot recall that + // request, so the resolved identity/session is persisted above + // even when the dialog disappears. Only view-side completion is + // suppressed after destruction/close animation begins. + if (this.dialogClosing || this.destroyRef.destroyed) { + return; + } + this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' @@ -369,6 +500,47 @@ export class PlaylistInfoComponent { duration: 3000, } ); + } finally { + if (this.dialogRef) { + this.dialogRef.disableClose = false; + } + this.isSaving.set(false); + } + } + + /** + * Electron's startup metadata projection deliberately excludes the + * payload-only Stalker identity and session fields. Editing that summary + * directly would render the identity controls empty and could clear a + * portal-pinned serial/device identity on the next discovery. Hydrate the + * authoritative row before enabling the form in every runtime so Edit + * always starts from the same persisted connection that playback uses. + */ + private async hydrateCompleteStalkerPlaylist(): Promise { + try { + const persistedPlaylist = await firstValueFrom( + this.playlistsService.getPlaylistById(this.playlist._id) + ); + if (!persistedPlaylist) { + throw new Error('Stored Stalker playlist was not found'); + } + + this.playlist = { + ...this.playlistData, + ...persistedPlaylist, + id: this.playlistData.id ?? persistedPlaylist._id, + }; + this.createForm(); + } catch (error) { + console.error('Failed to load complete Stalker playlist:', error); + this.stalkerPlaylistHydrationFailed.set(true); + this.snackBar.open( + this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED'), + this.translate.instant('CLOSE'), + { duration: 3000 } + ); + } finally { + this.isHydratingStalkerPlaylist.set(false); } } @@ -405,7 +577,12 @@ export class PlaylistInfoComponent { } private normalizeXtreamPlaylistMeta(playlist: PlaylistMeta): PlaylistMeta { - if (!playlist.serverUrl || !playlist.username || !playlist.password) { + if ( + this.playlist.portalUrl || + !playlist.serverUrl || + !playlist.username || + !playlist.password + ) { return playlist; } 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 new file mode 100644 index 000000000..1bf26782c --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts @@ -0,0 +1,57 @@ +import { InjectionToken } from '@angular/core'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; + +export const STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS = { + RESOLVED: 'resolved', + AUTH_REJECTED: 'auth-rejected', + UNREACHABLE: 'unreachable', +} as const; + +export interface StalkerPlaylistConnectionResolved { + status: typeof STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED; + playlist: PlaylistMetaUpdate; +} + +export interface StalkerPlaylistConnectionAuthRejected { + status: typeof STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED; + message: string; +} + +export interface StalkerPlaylistConnectionUnreachable { + status: typeof STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE; + message: string; +} + +interface StalkerPlaylistConnectionResultMap { + resolved: StalkerPlaylistConnectionResolved; + 'auth-rejected': StalkerPlaylistConnectionAuthRejected; + unreachable: StalkerPlaylistConnectionUnreachable; +} + +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, + sourcePlaylist?: PlaylistMeta + ): Promise; + /** Atomically persists a resolved edit, then synchronizes in-run state. */ + applyResolvedConnection( + playlist: PlaylistMetaUpdate, + options?: StalkerResolvedConnectionApplyOptions + ): Promise; +} + +export const STALKER_PLAYLIST_CONNECTION_EDITOR = + new InjectionToken( + 'STALKER_PLAYLIST_CONNECTION_EDITOR' + ); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts new file mode 100644 index 000000000..0f1239dc8 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts @@ -0,0 +1,59 @@ +import { + normalizeStalkerIdentityValue, + normalizeStalkerSerialNumber, + type Playlist, + type PlaylistMeta, +} from '@iptvnator/shared/interfaces'; + +export const STALKER_PORTAL_URL_PATTERN = /^\s*https?:\/\/[^ "\s]+\s*$/i; + +const STALKER_CONNECTION_FIELDS = [ + 'portalUrl', + 'macAddress', + 'username', + 'password', + 'stalkerSerialNumber', + 'stalkerDeviceId1', + 'stalkerDeviceId2', + 'stalkerSignature1', + 'stalkerSignature2', +] as const; + +type StalkerConnectionField = (typeof STALKER_CONNECTION_FIELDS)[number]; + +export function hasStalkerConnectionChanged( + current: Playlist, + update: PlaylistMeta +): boolean { + return STALKER_CONNECTION_FIELDS.some( + (field) => + comparableConnectionValue(field, current[field]) !== + comparableConnectionValue(field, update[field]) + ); +} + +/** Lets the queued persistence boundary preserve its current connection. */ +export function omitStalkerConnection(update: PlaylistMeta): PlaylistMeta { + const metadata = { ...update }; + for (const field of STALKER_CONNECTION_FIELDS) { + delete metadata[field]; + } + delete metadata.isFullStalkerPortal; + return metadata; +} + +function comparableConnectionValue( + field: StalkerConnectionField, + value: string | undefined +): string { + if (field === 'portalUrl') { + return value?.trim() ?? ''; + } + if (field === 'stalkerSerialNumber') { + return normalizeStalkerSerialNumber(value) ?? ''; + } + if (field.startsWith('stalker')) { + return normalizeStalkerIdentityValue(value) ?? ''; + } + return value ?? ''; +} diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index a5451750f..32209a282 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -995,7 +995,7 @@ describe('StreamResolverService', () => { expect(playback.headers?.['Authorization']).toBeUndefined(); }); - it('does not authenticate a simple portal for playback headers', async () => { + it('guards a simple portal without requiring playback auth headers', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'stalker-2', @@ -1006,7 +1006,7 @@ describe('StreamResolverService', () => { ); stalkerSession.getCachedToken.mockReturnValue(null); - await service.resolvePlayback({ + const playback = await service.resolvePlayback({ uid: 'stalker::stalker-2::99', name: 'Simple Radio', contentType: 'live', @@ -1018,7 +1018,13 @@ describe('StreamResolverService', () => { stalkerCmd: 'https://simple.example.com/radio/99.mp3', } satisfies UnifiedCollectionItem); - expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'stalker-2', + isFullStalkerPortal: false, + }) + ); + expect(playback.headers?.['Authorization']).toBeUndefined(); }); it('keeps Stalker collection playback from a foreign CDN credential-free', async () => { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts index 22bf0de47..1b0d9d94e 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts @@ -15,8 +15,10 @@ describe('StalkerAccountInfoService', () => { let stalkerSession: { refreshAccountProfile: jest.Mock; makeAuthenticatedRequest: jest.Mock; + ensureToken: jest.Mock; }; let portalRepair: { + waitForPendingRepair: jest.Mock; applyOverride: jest.Mock; shouldAttemptRepair: jest.Mock; repairPortal: jest.Mock; @@ -44,8 +46,10 @@ describe('StalkerAccountInfoService', () => { stalkerSession = { refreshAccountProfile: jest.fn(), makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }; portalRepair = { + waitForPendingRepair: jest.fn().mockResolvedValue(undefined), applyOverride: jest.fn((playlist) => playlist), shouldAttemptRepair: jest.fn().mockReturnValue(false), repairPortal: jest.fn().mockResolvedValue(null), @@ -121,9 +125,7 @@ describe('StalkerAccountInfoService', () => { expect(portalRepair.repairPortal).toHaveBeenCalledWith( fullPortalPlaylist ); - expect( - stalkerSession.refreshAccountProfile - ).toHaveBeenLastCalledWith( + expect(stalkerSession.refreshAccountProfile).toHaveBeenLastCalledWith( expect.objectContaining({ portalUrl: repaired.portalUrl }) ); expect(snapshot).toMatchObject({ login: 'user-1' }); @@ -274,9 +276,9 @@ describe('StalkerAccountInfoService', () => { const boom = new Error('timeout of 15000ms exceeded'); stalkerSession.refreshAccountProfile.mockRejectedValue(boom); - await expect( - service.fetchAccountInfo(fullPortalPlaylist) - ).rejects.toBe(boom); + await expect(service.fetchAccountInfo(fullPortalPlaylist)).rejects.toBe( + boom + ); expect(portalRepair.repairPortal).not.toHaveBeenCalled(); }); @@ -406,9 +408,9 @@ describe('StalkerAccountInfoService', () => { it('propagates portal errors so the dialog can fall back to cached data', async () => { dataService.sendIpcEvent.mockRejectedValue(new Error('offline')); - await expect( - service.fetchAccountInfo(portalPlaylist) - ).rejects.toThrow('offline'); + await expect(service.fetchAccountInfo(portalPlaylist)).rejects.toThrow( + 'offline' + ); }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts index 215cd8171..fa32b8f82 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts @@ -76,6 +76,7 @@ export class StalkerAccountInfoService { return null; } + await this.portalRepair.waitForPendingRepair(playlist._id); const effectivePlaylist = this.portalRepair.applyOverride(playlist); if (isFullStalkerPortalPlaylist(effectivePlaylist)) { return this.fetchViaProfile(effectivePlaylist); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts b/libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts new file mode 100644 index 000000000..6ad57b7d9 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts @@ -0,0 +1,100 @@ +import { + extractStalkerAuthFailureBody, + isFullStalkerPortalPlaylist, + type Playlist, + STALKER_REQUEST, +} from '@iptvnator/shared/interfaces'; +import type { DataService } from '@iptvnator/services'; +import { StalkerPortalError } from './stalker-portal-error'; +import { isStalkerAuthorizationFailure } from './stalker-response-classification'; +import { stalkerSessionFingerprint } from './stalker-session-store'; +import type { StalkerTokenCache } from './stalker-token-cache'; + +/** Authenticated request + one-shot session renewal shared by all callers. */ +export class StalkerAuthenticatedRequestClient { + constructor( + private readonly dataService: DataService, + private readonly tokens: StalkerTokenCache, + private readonly ensureToken: ( + playlist: Playlist + ) => Promise<{ token: string | null; serialNumber?: string }>, + private readonly assertCurrent: ( + playlist: Playlist, + sessionFingerprint: string + ) => void + ) {} + + async request( + playlist: Playlist, + params: Record, + retryOnAuthFailure = true + ): Promise { + // Bind the whole request to one immutable connection snapshot. An + // Edit can replace the authoritative endpoint/mode while transport is + // in flight; its old response must never reach a store or player. + const configuration = { ...playlist } as Playlist; + const sessionFingerprint = + stalkerSessionFingerprint(configuration); + const { token, serialNumber } = + await this.ensureToken(configuration); + + try { + const response = await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: configuration.portalUrl, + macAddress: configuration.macAddress, + params, + token, + ...(serialNumber ? { serialNumber } : {}), + } + ); + this.assertCurrent(configuration, sessionFingerprint); + if (isStalkerAuthorizationFailure(response)) { + return this.handleAuthorizationFailure( + configuration, + params, + token, + response, + retryOnAuthFailure + ); + } + return response; + } catch (error) { + // A classified response above is already wrapped with its + // redacted portal body. Re-classifying that app-owned error would + // discard the body and replace it with an empty failure. + if (error instanceof StalkerPortalError) { + throw error; + } + if (isStalkerAuthorizationFailure(error)) { + this.tokens.retireFailed(configuration._id, token); + if ( + retryOnAuthFailure && + isFullStalkerPortalPlaylist(configuration) + ) { + return this.request(configuration, params, false); + } + } + // Transport evidence (especially HTTP 401/403) is consumed by + // lazy repair. Preserve it exactly after retry exhaustion. + throw error; + } + } + + private handleAuthorizationFailure( + playlist: Playlist, + params: Record, + failedToken: string | null, + failure: unknown, + retryOnAuthFailure: boolean + ): Promise { + this.tokens.retireFailed(playlist._id, failedToken); + if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) { + return this.request(playlist, params, false); + } + + const failureBody = extractStalkerAuthFailureBody(failure) ?? undefined; + throw new StalkerPortalError('auth-failed', failureBody, failureBody); + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edit-cross-context-reservation.ts b/libs/portal/stalker/data-access/src/lib/stalker-edit-cross-context-reservation.ts new file mode 100644 index 000000000..d99bda1ad --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-edit-cross-context-reservation.ts @@ -0,0 +1 @@ +export { acquirePlaylistAuthorityEditReservation as acquireCrossContextEditReservation } from '@iptvnator/services'; 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 new file mode 100644 index 000000000..b1f7f46c5 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts @@ -0,0 +1,698 @@ +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { DataService, PlaylistsService } from '@iptvnator/services'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { StalkerEditedSessionCoordinator } from './stalker-edited-session-coordinator'; +import { StalkerSessionService } from './stalker-session.service'; +import { stalkerSessionFingerprint } from './stalker-session-store'; + +describe('Stalker edited-session coordination', () => { + const originalLockManager = globalThis.navigator?.locks; + const oldPlaylist = { + _id: 'portal-edit', + title: 'Portal', + portalUrl: 'https://old.example.com/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + lastUsage: '', + } as Playlist; + + let authenticate: jest.SpyInstance; + let sendIpcEvent: jest.Mock; + let resolveOldAuthentication: ( + value: Awaited> + ) => void = () => undefined; + let service: StalkerSessionService; + let getPlaylistById: jest.Mock; + let updatePlaylistMetaIfCurrent: jest.Mock; + let transformPlaylistMeta: jest.Mock; + let updateStalkerSession: jest.Mock; + + beforeEach(() => { + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { + request: jest.fn( + async ( + name: string, + _options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => + callback({ + name, + mode: 'exclusive', + } as Lock) + ), + }, + }); + getPlaylistById = jest.fn(() => of(oldPlaylist)); + updatePlaylistMetaIfCurrent = jest.fn((_playlist, isCurrent) => + of(isCurrent(oldPlaylist) ? oldPlaylist : null) + ); + transformPlaylistMeta = jest.fn((_id, transform) => + of(transform(oldPlaylist)) + ); + updateStalkerSession = jest.fn(() => of(oldPlaylist)); + sendIpcEvent = jest.fn(); + TestBed.configureTestingModule({ + providers: [ + StalkerSessionService, + { + provide: DataService, + useValue: { sendIpcEvent }, + }, + { + provide: PlaylistsService, + useValue: { + getPlaylistById, + updatePlaylistMetaIfCurrent, + transformPlaylistMeta, + updateStalkerSession, + }, + }, + ], + }); + service = TestBed.inject(StalkerSessionService); + authenticate = jest.spyOn(service, 'authenticate').mockReturnValueOnce( + new Promise((resolve) => { + resolveOldAuthentication = resolve; + }) + ); + }); + + afterEach(() => { + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: originalLockManager, + }); + }); + + it('prevents late pre-edit auth from replacing a resolved full session', async () => { + const oldAuthentication = service.ensureToken(oldPlaylist); + while (authenticate.mock.calls.length === 0) { + await Promise.resolve(); + } + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 5, + }; + const fencePromise = service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + + resolveOldAuthentication({ token: 'OLD_TOKEN' }); + + await expect(oldAuthentication).rejects.toThrow(/stale/i); + const fence = await fencePromise; + await service.replaceSessionAfterEdit(editedPlaylist, fence); + expect(service.getCachedToken(oldPlaylist._id)).toBe('NEW_TOKEN'); + expect(updatePlaylistMetaIfCurrent).toHaveBeenLastCalledWith( + expect.objectContaining({ + portalUrl: 'https://new.example.com/server/load.php', + stalkerSessionPatch: expect.objectContaining({ + stalkerToken: 'NEW_TOKEN', + }), + }), + expect.any(Function) + ); + expect(updateStalkerSession).not.toHaveBeenCalled(); + }); + + it('fences and drains pre-edit authentication before discovery may start', async () => { + const oldAuthentication = service.ensureToken(oldPlaylist); + while (authenticate.mock.calls.length === 0) { + await Promise.resolve(); + } + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + }; + + let fenceSettled = false; + const fencePromise = service + .beginEditDiscovery(editedPlaylist, oldPlaylist) + .then((fence) => { + fenceSettled = true; + return fence; + }); + await Promise.resolve(); + expect(fenceSettled).toBe(false); + + resolveOldAuthentication({ token: 'OLD_TOKEN' }); + + await expect(oldAuthentication).rejects.toThrow(/stale/i); + const fence = await fencePromise; + service.cancelEditDiscovery(fence); + }); + + it('publishes Edit ownership while draining local repair before requesting the row lock', async () => { + let finishRepairDrain: () => void = () => undefined; + const repairDrain = new Promise((resolve) => { + finishRepairDrain = resolve; + }); + const request = globalThis.navigator?.locks?.request as jest.Mock; + + const fencePromise = service.beginEditDiscovery( + oldPlaylist, + oldPlaylist, + repairDrain + ); + await Promise.resolve(); + + expect(request).not.toHaveBeenCalled(); + await expect(service.beginEditDiscovery(oldPlaylist)).rejects.toThrow( + /already in progress/i + ); + + finishRepairDrain(); + const fence = await fencePromise; + expect(request).toHaveBeenCalledWith( + 'iptvnator:playlist-authority', + { mode: 'shared' }, + expect.any(Function) + ); + service.cancelEditDiscovery(fence); + }); + + it('blocks new authentication while a same-fingerprint edit owns the playlist', async () => { + const textOnlyEdit = { + ...oldPlaylist, + portalUrl: `${oldPlaylist.portalUrl}/`, + }; + expect(stalkerSessionFingerprint(textOnlyEdit)).toBe( + stalkerSessionFingerprint(oldPlaylist) + ); + const fence = await service.beginEditDiscovery(textOnlyEdit); + + await expect(service.ensureToken(oldPlaylist)).rejects.toThrow( + /stale/i + ); + expect(authenticate).not.toHaveBeenCalled(); + + service.cancelEditDiscovery(fence); + }); + + it('blocks runtime authentication while portal repair discovery owns the playlist', async () => { + const repairFence = service.beginPortalRepairDiscovery(oldPlaylist._id); + await repairFence.drained; + + const authentication = service.ensureToken(oldPlaylist); + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + expect(authenticate).not.toHaveBeenCalled(); + + service.completePortalRepairDiscovery(repairFence); + while (authenticate.mock.calls.length === 0) { + await Promise.resolve(); + } + resolveOldAuthentication({ token: 'REPAIRED_TOKEN' }); + + await expect(authentication).resolves.toMatchObject({ + token: 'REPAIRED_TOKEN', + }); + }); + + it('drains authentication that started before portal repair discovery', async () => { + const authentication = service.ensureToken(oldPlaylist); + while (authenticate.mock.calls.length === 0) { + await Promise.resolve(); + } + + const repairFence = service.beginPortalRepairDiscovery(oldPlaylist._id); + let drained = false; + void repairFence.drained.then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); + + resolveOldAuthentication({ token: 'PRE_REPAIR_TOKEN' }); + await expect(authentication).rejects.toThrow(/stale/i); + await repairFence.drained; + expect(drained).toBe(true); + + service.completePortalRepairDiscovery(repairFence); + }); + + it('rejects an overlapping edit without retiring the first owner', async () => { + const firstFence = await service.beginEditDiscovery(oldPlaylist); + + await expect( + service.beginEditDiscovery({ + ...oldPlaylist, + username: 'second-edit', + }) + ).rejects.toThrow(/already in progress/i); + + await expect( + service.replaceSessionAfterEdit( + { ...oldPlaylist, stalkerToken: 'FIRST_EDIT_TOKEN' }, + firstFence + ) + ).resolves.toEqual(oldPlaylist); + }); + + it('reserves a playlist across PWA tabs before discovery starts', async () => { + let held = false; + const request = jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => { + if (name === 'iptvnator:playlist-authority') { + await callback({ + name, + mode: 'shared', + } as Lock); + return; + } + if (held) { + await callback(null); + return; + } + held = true; + try { + await callback({ + name, + mode: options.mode ?? 'exclusive', + } as Lock); + } finally { + held = false; + } + } + ); + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { request }, + }); + let persistedPlaylist = oldPlaylist; + const createCoordinator = () => + new StalkerEditedSessionCoordinator( + { getPending: jest.fn(() => undefined) } as never, + {} as never, + () => + ({ + getPlaylistById: jest.fn(() => of(persistedPlaylist)), + }) as unknown as PlaylistsService + ); + const firstTab = createCoordinator(); + const secondTab = createCoordinator(); + + const firstFence = await firstTab.beginEdit(oldPlaylist); + + await expect(secondTab.beginEdit(oldPlaylist)).rejects.toThrow( + /already in progress/i + ); + expect(request).toHaveBeenCalledWith( + 'iptvnator:playlist-authority', + { mode: 'shared' }, + expect.any(Function) + ); + expect(request).toHaveBeenCalledWith( + `iptvnator:playlist-authority:${oldPlaylist._id}`, + { ifAvailable: true, mode: 'exclusive' }, + expect.any(Function) + ); + + firstTab.cancelEdit(firstFence); + for (let i = 0; i < 5 && held; i += 1) { + await Promise.resolve(); + } + + persistedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://first-tab.example.com/portal.php', + }; + await expect( + secondTab.beginEdit(oldPlaylist, oldPlaylist) + ).rejects.toThrow(/stale/i); + for (let i = 0; i < 5 && held; i += 1) { + await Promise.resolve(); + } + + persistedPlaylist = oldPlaylist; + const secondFence = await secondTab.beginEdit(oldPlaylist); + secondTab.cancelEdit(secondFence); + }); + + it('fails closed before PWA discovery when cross-context locks are unavailable', async () => { + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: undefined, + }); + + await expect(service.beginEditDiscovery(oldPlaylist)).rejects.toThrow( + /cross-context playlist edit locking is unavailable/i + ); + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('prevents late pre-edit auth from restoring a cleared simple session', async () => { + const oldAuthentication = service.ensureToken(oldPlaylist); + while (authenticate.mock.calls.length === 0) { + await Promise.resolve(); + } + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/portal.php', + isFullStalkerPortal: false, + stalkerToken: undefined, + stalkerSessionIdentity: undefined, + stalkerWatchdogTimeout: undefined, + stalkerTimeslot: undefined, + }; + const fencePromise = service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + + resolveOldAuthentication({ token: 'OLD_TOKEN' }); + + await expect(oldAuthentication).rejects.toThrow(/stale/i); + const fence = await fencePromise; + await service.replaceSessionAfterEdit(editedPlaylist, fence); + expect(service.getCachedToken(oldPlaylist._id)).toBeNull(); + expect(updatePlaylistMetaIfCurrent).toHaveBeenLastCalledWith( + expect.objectContaining({ + _id: oldPlaylist._id, + stalkerSessionPatch: null, + }), + expect.any(Function) + ); + }); + + it('rejects a stale full snapshot after a mode-only edit to simple', async () => { + const simplePlaylist = { + ...oldPlaylist, + isFullStalkerPortal: false, + stalkerToken: undefined, + stalkerSessionIdentity: undefined, + } as Playlist; + updatePlaylistMetaIfCurrent.mockReturnValueOnce(of(simplePlaylist)); + authenticate.mockReset().mockResolvedValue({ + token: 'STALE_FULL_TOKEN', + }); + + const fence = await service.beginEditDiscovery( + simplePlaylist, + oldPlaylist + ); + await service.replaceSessionAfterEdit(simplePlaylist, fence); + getPlaylistById.mockReturnValueOnce(of(simplePlaylist)); + + await expect(service.ensureToken(oldPlaylist)).rejects.toThrow( + /stale/i + ); + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('rejects a stale simple snapshot after a mode-only edit to full', async () => { + const simplePlaylist = { + ...oldPlaylist, + isFullStalkerPortal: false, + } as Playlist; + const resolvedFullPlaylist = { + ...oldPlaylist, + stalkerToken: 'NEW_FULL_TOKEN', + } as Playlist; + updatePlaylistMetaIfCurrent.mockReturnValueOnce( + of(resolvedFullPlaylist) + ); + getPlaylistById + .mockReturnValueOnce(of(simplePlaylist)) + .mockReturnValueOnce(of(resolvedFullPlaylist)); + + const fence = await service.beginEditDiscovery( + resolvedFullPlaylist, + simplePlaylist + ); + await service.replaceSessionAfterEdit(resolvedFullPlaylist, fence); + + await expect(service.ensureToken(simplePlaylist)).rejects.toThrow( + /stale/i + ); + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('rejects an authenticated response completed after Edit commits', async () => { + service.setCachedToken(oldPlaylist._id, 'OLD_TOKEN', oldPlaylist); + let resolveOldResponse!: (value: unknown) => void; + sendIpcEvent.mockReturnValueOnce( + new Promise((resolve) => (resolveOldResponse = resolve)) + ); + const oldRequest = service.makeAuthenticatedRequest(oldPlaylist, { + type: 'itv', + action: 'get_genres', + }); + while (sendIpcEvent.mock.calls.length === 0) { + await Promise.resolve(); + } + + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + } as Playlist; + updatePlaylistMetaIfCurrent.mockReturnValueOnce(of(editedPlaylist)); + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + await service.replaceSessionAfterEdit(editedPlaylist, fence); + resolveOldResponse({ js: { data: ['STALE_CATEGORY'] } }); + + await expect(oldRequest).rejects.toThrow(/stale/i); + }); + + 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, + }; + + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + await service.replaceSessionAfterEdit(editedPlaylist, fence); + + expect(updatePlaylistMetaIfCurrent).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.any(Function) + ); + 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, + oldPlaylist + ); + + const persisted = await service.replaceSessionAfterEdit( + editedPlaylist, + fence, + { preserveCurrentMetadata: true } + ); + + expect(updatePlaylistMetaIfCurrent).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('rejects a late merge after another connection replaces the playlist ID', async () => { + const replacementPlaylist = { + ...oldPlaylist, + portalUrl: 'https://restored.example.com/portal.php', + macAddress: '00:1A:79:11:22:33', + } as Playlist; + transformPlaylistMeta.mockImplementationOnce((_id, transform) => + of(transform(replacementPlaylist)) + ); + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + } as Playlist; + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + + await expect( + service.replaceSessionAfterEdit(editedPlaylist, fence, { + preserveCurrentMetadata: true, + }) + ).rejects.toThrow(/could not be persisted/i); + + expect(service.getCachedToken(oldPlaylist._id)).toBeNull(); + expect(updatePlaylistMetaIfCurrent).not.toHaveBeenCalled(); + }); + + it('rejects an ordinary resolved write after another connection replaces the playlist ID', async () => { + const replacementPlaylist = { + ...oldPlaylist, + portalUrl: 'https://restored.example.com/portal.php', + macAddress: '00:1A:79:11:22:33', + } as Playlist; + updatePlaylistMetaIfCurrent.mockImplementationOnce( + (_playlist, isCurrent) => + of(isCurrent(replacementPlaylist) ? replacementPlaylist : null) + ); + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + } as Playlist; + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + + await expect( + service.replaceSessionAfterEdit(editedPlaylist, fence) + ).rejects.toThrow(/could not be persisted/i); + + expect(service.getCachedToken(oldPlaylist._id)).toBeNull(); + expect(transformPlaylistMeta).not.toHaveBeenCalled(); + }); + + it('does not adopt a resolved full session when its atomic write fails', async () => { + service.adoptDiscoveredSimplePortal(oldPlaylist); + updatePlaylistMetaIfCurrent.mockReturnValueOnce( + throwError(() => new Error('write failed')) + ); + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + }; + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + + await expect( + service.replaceSessionAfterEdit(editedPlaylist, fence) + ).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, + }); + }); + + it('rebases stale authority when a restored persisted row owns the playlist id', async () => { + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + }; + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + await service.replaceSessionAfterEdit(editedPlaylist, fence); + + // Simulate delete + backup restore of the original row and ID. + service.setCachedToken(oldPlaylist._id, 'RESTORED_TOKEN', oldPlaylist); + + await expect(service.ensureToken(oldPlaylist)).resolves.toEqual({ + token: 'RESTORED_TOKEN', + serialNumber: undefined, + }); + }); + + it('rechecks edit ownership after an asynchronous authority rebase', async () => { + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + }; + const committedFence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + await service.replaceSessionAfterEdit(editedPlaylist, committedFence); + + const persistedRow = new Subject(); + getPlaylistById.mockReturnValueOnce(persistedRow); + const restoredRequest = service.ensureToken(oldPlaylist); + while (getPlaylistById.mock.calls.length === 0) { + await Promise.resolve(); + } + + const fence = await service.beginEditDiscovery( + { + ...oldPlaylist, + username: 'new-user', + }, + oldPlaylist + ); + persistedRow.next(oldPlaylist); + persistedRow.complete(); + + await expect(restoredRequest).rejects.toThrow(/stale/i); + expect(authenticate).not.toHaveBeenCalled(); + + service.cancelEditDiscovery(fence); + }); +}); 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 new file mode 100644 index 000000000..ce5ee895c --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts @@ -0,0 +1,414 @@ +import { firstValueFrom } from 'rxjs'; +import { + isFullStalkerPortalPlaylist, + type Playlist, + type PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; +import type { PlaylistsService } from '@iptvnator/services'; +import { acquireCrossContextEditReservation } from './stalker-edit-cross-context-reservation'; +import { stalkerSessionFingerprint } from './stalker-session-store'; +import type { StalkerTokenCache } from './stalker-token-cache'; +import type { StalkerWatchdogController } from './stalker-watchdog.controller'; + +/** Opaque ownership proof for one discovery-to-persistence Edit attempt. */ +export interface StalkerEditFence { + readonly playlistId: string; + readonly owner: symbol; +} + +interface PendingEdit { + readonly owner: symbol; + readonly configurationFingerprint: string; + readonly sourceConfigurationFingerprint: string; + readonly releaseCrossContextReservation: () => void; +} + +/** Serializes authoritative Edit results against pre-edit authentication. */ +export class StalkerEditedSessionCoordinator { + private readonly authoritativeConfigurations = new Map(); + private readonly pendingEdits = new Map(); + private readonly replacements = new Map>(); + + constructor( + private readonly tokens: StalkerTokenCache, + private readonly watchdog: StalkerWatchdogController, + private readonly resolvePlaylistsService: () => PlaylistsService + ) {} + + markAuthoritative(playlist: Playlist): string { + const sessionFingerprint = stalkerSessionFingerprint(playlist); + this.authoritativeConfigurations.set( + playlist._id, + stalkerConfigurationFingerprint(playlist, sessionFingerprint) + ); + return sessionFingerprint; + } + + async guard(playlist: Playlist): Promise { + const sessionFingerprint = stalkerSessionFingerprint(playlist); + const configurationFingerprint = stalkerConfigurationFingerprint( + playlist, + sessionFingerprint + ); + this.assertNoPendingEdit(playlist._id); + const authoritative = this.authoritativeConfigurations.get( + playlist._id + ); + if (authoritative && authoritative !== configurationFingerprint) { + await this.rebaseFromPersistedRow( + playlist, + configurationFingerprint + ); + } + // The persisted-row lookup yields. An Edit that acquired the ID while + // it was in flight must still fence this request, even if the lookup + // proved that the caller owned the row before Edit began. + this.assertCurrent(playlist, sessionFingerprint); + const replacement = this.replacements.get(playlist._id); + if (replacement) { + await replacement; + this.assertCurrent(playlist, sessionFingerprint); + } + return sessionFingerprint; + } + + assertCurrent(playlist: Playlist, sessionFingerprint: string): void { + const playlistId = playlist._id; + // A reservation blocks every new authentication, including a + // text-only URL edit whose normalized session fingerprint is equal. + this.assertNoPendingEdit(playlistId); + if (stalkerSessionFingerprint(playlist) !== sessionFingerprint) { + throw new Error('Stale Stalker playlist configuration'); + } + const authoritative = this.authoritativeConfigurations.get(playlistId); + if ( + authoritative && + authoritative !== + stalkerConfigurationFingerprint(playlist, sessionFingerprint) + ) { + throw new Error('Stale Stalker playlist configuration'); + } + } + + async beginEdit( + playlist: Playlist, + sourcePlaylist: Playlist = playlist, + beforeCrossContextReservation: Promise = Promise.resolve() + ): Promise { + const playlistId = playlist._id; + if (sourcePlaylist._id !== playlistId) { + throw new Error('Stale Stalker playlist configuration'); + } + if (this.pendingEdits.has(playlistId)) { + throw new Error('Stalker playlist edit already in progress'); + } + const configurationFingerprint = + stalkerConfigurationFingerprint(playlist); + const fence = { playlistId, owner: Symbol('stalker-edit') }; + this.pendingEdits.set(playlistId, { + owner: fence.owner, + configurationFingerprint, + sourceConfigurationFingerprint: + stalkerConfigurationFingerprint(sourcePlaylist), + releaseCrossContextReservation: () => undefined, + }); + + try { + // The local Edit owner is already published, blocking new auth. + // A same-tab repair may still hold the cross-context row lock; + // drain it before requesting that lock instead of failing Save. + await beforeCrossContextReservation; + this.assertFenceCurrent(fence, configurationFingerprint); + const releaseCrossContextReservation = + await acquireCrossContextEditReservation(playlistId); + const pending = this.pendingEdits.get(playlistId); + if ( + pending?.owner !== fence.owner || + pending.configurationFingerprint !== configurationFingerprint + ) { + releaseCrossContextReservation(); + throw new Error('Stale Stalker playlist configuration'); + } + this.pendingEdits.set(playlistId, { + owner: fence.owner, + configurationFingerprint, + sourceConfigurationFingerprint: + stalkerConfigurationFingerprint(sourcePlaylist), + releaseCrossContextReservation, + }); + await this.assertPersistedSourceCurrent(sourcePlaylist); + this.assertFenceCurrent(fence, configurationFingerprint); + await this.replacements.get(playlistId)?.catch(() => undefined); + this.assertFenceCurrent(fence, configurationFingerprint); + await this.tokens + .getPending(playlistId) + ?.promise.catch(() => undefined); + this.assertFenceCurrent(fence, configurationFingerprint); + return fence; + } catch (error) { + this.cancelEdit(fence); + throw error; + } + } + + cancelEdit(fence: StalkerEditFence): void { + const pending = this.pendingEdits.get(fence.playlistId); + if (pending?.owner === fence.owner) { + this.pendingEdits.delete(fence.playlistId); + pending.releaseCrossContextReservation(); + } + } + + replace( + playlist: Playlist, + fence: StalkerEditFence, + options: { preserveCurrentMetadata?: boolean } = {} + ): Promise { + const playlistId = playlist._id; + const sessionFingerprint = stalkerSessionFingerprint(playlist); + const configurationFingerprint = stalkerConfigurationFingerprint( + playlist, + sessionFingerprint + ); + const owner = fence.owner; + const pending = this.pendingEdits.get(playlistId); + if (fence.playlistId !== playlistId || pending?.owner !== fence.owner) { + return Promise.reject( + new Error('Stale Stalker playlist configuration') + ); + } + const sourceConfigurationFingerprint = + pending.sourceConfigurationFingerprint; + // Keep the same owner while discovery replaces its input-shaped + // fingerprint with the resolved endpoint/mode fingerprint. + this.pendingEdits.set(playlistId, { + owner, + configurationFingerprint, + sourceConfigurationFingerprint, + releaseCrossContextReservation: + pending.releaseCrossContextReservation, + }); + const previous = this.replacements.get(playlistId) ?? Promise.resolve(); + const replacement = previous + .catch(() => undefined) + .then(() => + this.commit( + playlist, + sessionFingerprint, + configurationFingerprint, + owner, + sourceConfigurationFingerprint, + options + ) + ); + this.replacements.set(playlistId, replacement); + void replacement + .finally(() => { + if (this.replacements.get(playlistId) === replacement) { + this.replacements.delete(playlistId); + } + const currentPending = this.pendingEdits.get(playlistId); + if (currentPending?.owner === owner) { + this.pendingEdits.delete(playlistId); + currentPending.releaseCrossContextReservation(); + } + }) + .catch(() => undefined); + return replacement; + } + + private async commit( + playlist: Playlist, + sessionFingerprint: string, + configurationFingerprint: string, + owner: symbol, + sourceConfigurationFingerprint: string, + options: { preserveCurrentMetadata?: boolean } + ): Promise { + const playlistId = playlist._id; + this.assertFenceCurrent( + { playlistId, owner }, + configurationFingerprint + ); + await this.tokens + .getPending(playlistId) + ?.promise.catch(() => undefined); + this.assertFenceCurrent( + { playlistId, owner }, + configurationFingerprint + ); + + const playlists = this.resolvePlaylistsService(); + 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: sessionFingerprint, + stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout, + stalkerTimeslot: playlist.stalkerTimeslot, + stalkerAccountInfo: playlist.stalkerAccountInfo, + }; + } + + const persistedPlaylist = await firstValueFrom( + options.preserveCurrentMetadata + ? playlists.transformPlaylistMeta(playlistId, (current) => + stalkerConfigurationFingerprint(current) === + sourceConfigurationFingerprint + ? mergeResolvedStalkerConnection( + current, + playlist, + sessionPatch + ) + : null + ) + : playlists.updatePlaylistMetaIfCurrent( + { + ...playlist, + stalkerSessionPatch: sessionPatch, + } as PlaylistMetaUpdate, + (current) => + stalkerConfigurationFingerprint(current) === + sourceConfigurationFingerprint + ) + ); + if (!persistedPlaylist) { + throw new Error('Resolved Stalker playlist could not be persisted'); + } + this.assertFenceCurrent( + { playlistId, owner }, + configurationFingerprint + ); + this.authoritativeConfigurations.set( + playlistId, + configurationFingerprint + ); + if (!sessionPatch) { + this.tokens.clear(playlistId); + return persistedPlaylist; + } + + this.tokens.set( + playlistId, + sessionPatch.stalkerToken, + sessionFingerprint + ); + this.watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, + timeslotSeconds: playlist.stalkerTimeslot, + }); + return persistedPlaylist; + } + + private assertFenceCurrent( + fence: StalkerEditFence, + configurationFingerprint: string + ): void { + const pending = this.pendingEdits.get(fence.playlistId); + if ( + !pending || + pending.owner !== fence.owner || + pending.configurationFingerprint !== configurationFingerprint + ) { + throw new Error('Stale Stalker playlist configuration'); + } + } + + private assertNoPendingEdit(playlistId: string): void { + if (this.pendingEdits.has(playlistId)) { + throw new Error('Stale Stalker playlist configuration'); + } + } + + private async assertPersistedSourceCurrent( + sourcePlaylist: Playlist + ): Promise { + try { + const persisted = await firstValueFrom( + this.resolvePlaylistsService().getPlaylistById( + sourcePlaylist._id + ) + ); + if ( + persisted && + stalkerConfigurationFingerprint(persisted) === + stalkerConfigurationFingerprint(sourcePlaylist) + ) { + return; + } + } catch { + // Preserve the stable stale-configuration error below. + } + throw new Error('Stale Stalker playlist configuration'); + } + + private async rebaseFromPersistedRow( + playlist: Playlist, + configurationFingerprint: string + ): Promise { + try { + const persisted = await firstValueFrom( + this.resolvePlaylistsService().getPlaylistById(playlist._id) + ); + if ( + persisted && + stalkerConfigurationFingerprint(persisted) === + configurationFingerprint + ) { + this.authoritativeConfigurations.set( + playlist._id, + configurationFingerprint + ); + return; + } + } catch { + // Preserve the stable stale-configuration error below. A failed + // row read cannot prove that an external replacement owns the ID. + } + throw new Error('Stale Stalker playlist configuration'); + } +} + +/** 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, + sessionFingerprint = stalkerSessionFingerprint(playlist) +): string { + return JSON.stringify([ + sessionFingerprint, + isFullStalkerPortalPlaylist(playlist), + ]); +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts index ad042ba62..9f0434fbf 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts @@ -101,7 +101,12 @@ describe('StalkerItvCacheService', () => { { provide: DataService, useValue: { sendIpcEvent } }, { provide: StalkerSessionService, - useValue: { makeAuthenticatedRequest: jest.fn() }, + useValue: { + makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), + }, }, ], }); @@ -184,7 +189,12 @@ describe('StalkerItvCacheService', () => { }); const load = service.ensureLoaded(PLAYLIST); - await flushMicrotasks(); + // The second request starts only after page-one progress is recorded. + // Wait for that observable boundary instead of a fixed microtask + // count: request guards may add network-free async hops. + while (callsFor('get_ordered_list') < 2) { + await Promise.resolve(); + } expect(service.isLoading(PLAYLIST)).toBe(true); expect(service.progressOf(PLAYLIST)).toEqual({ loaded: 14, total: 28 }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts index a03d54a1d..32964a386 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts @@ -27,9 +27,7 @@ describe('StalkerPortalDiscoveryService', () => { const { url } = payload as { url: string }; const handler = handlers[url]; if (!handler) { - return Promise.reject( - new Error(`unexpected probe for ${url}`) - ); + return Promise.reject(new Error(`unexpected probe for ${url}`)); } if ('reject' in handler) { return Promise.reject(handler.reject); @@ -180,7 +178,10 @@ describe('StalkerPortalDiscoveryService', () => { // for portals that previously authenticated directly. mockProbes({ 'http://gated.example/portal.php': { - reject: { message: 'HTTP Error 401: Unauthorized', status: 401 }, + reject: { + message: 'HTTP Error 401: Unauthorized', + status: 401, + }, }, }); authenticate.mockResolvedValue({ token: 'TOKEN4' }); @@ -214,7 +215,9 @@ describe('StalkerPortalDiscoveryService', () => { reject: { message: 'HTTP Error 404: Not Found', status: 404 }, }, }); - authenticate.mockRejectedValue(new Error('Handshake failed: No token received')); + authenticate.mockRejectedValue( + new Error('Handshake failed: No token received') + ); const outcome = await service.discover('http://gated.example/c', MAC); @@ -370,10 +373,7 @@ describe('StalkerPortalDiscoveryService', () => { } ); - const discovery = service.discover( - 'http://slow.example/c', - MAC - ); + const discovery = service.discover('http://slow.example/c', MAC); // Let the probe resolve so the auth attempt actually starts. await Promise.resolve(); await Promise.resolve(); @@ -454,6 +454,47 @@ describe('StalkerPortalDiscoveryService', () => { } }); + it('preserves a later abandoned attempt over an earlier ordinary rejection', async () => { + jest.useFakeTimers(); + try { + mockProbes({ + 'http://mixed.example/portal.php': { + resolve: 'Authorization failed.', + }, + 'http://mixed.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + authenticate + .mockRejectedValueOnce(new Error('first refused')) + .mockImplementationOnce(() => new Promise(() => undefined)); + + const discovery = service.discover('http://mixed.example/c', MAC); + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } + expect(authenticate).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(65_001); + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } + jest.advanceTimersByTime(15_001); + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } + + await expect(discovery).resolves.toMatchObject({ + status: 'auth-rejected', + portalUrl: 'http://mixed.example/server/load.php', + abandonedInFlight: true, + abandonedAuthenticationSettled: expect.any(Promise), + }); + } finally { + jest.useRealTimers(); + } + }); + it('stops discovery when the drain deadline expires with the attempt still live', async () => { jest.useFakeTimers(); try { @@ -471,9 +512,15 @@ describe('StalkerPortalDiscoveryService', () => { }, }); - // Never settles — not even after the drain. + let settleAuthentication: () => void = () => undefined; authenticate - .mockImplementationOnce(() => new Promise(() => undefined)) + .mockImplementationOnce( + () => + new Promise((resolve) => { + settleAuthentication = () => + resolve({ token: 'ABANDONED' }); + }) + ) .mockResolvedValue({ token: 'SECOND' }); const discovery = service.discover('http://hung.example/c', MAC); @@ -490,12 +537,28 @@ describe('StalkerPortalDiscoveryService', () => { await Promise.resolve(); } - await expect(discovery).resolves.toMatchObject({ + const outcome = await discovery; + expect(outcome).toMatchObject({ status: 'auth-rejected', abandonedInFlight: true, }); // The second candidate was never authenticated. expect(authenticate).toHaveBeenCalledTimes(1); + + if (outcome.status !== 'auth-rejected') { + throw new Error('Expected an authentication rejection'); + } + expect(outcome.abandonedAuthenticationSettled).toBeDefined(); + let abandonedSettled = false; + void outcome.abandonedAuthenticationSettled?.then(() => { + abandonedSettled = true; + }); + await Promise.resolve(); + expect(abandonedSettled).toBe(false); + + settleAuthentication(); + await outcome.abandonedAuthenticationSettled; + expect(abandonedSettled).toBe(true); } finally { jest.useRealTimers(); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts index 009c1c8e5..d3903e685 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts @@ -55,6 +55,13 @@ export interface StalkerPortalDiscoveryRejection { * invalidate the session a later candidate had just established. */ abandonedInFlight?: boolean; + /** + * Resolves once the abandoned authentication has actually left the + * transport. Callers that reserve the playlist during discovery must + * keep that reservation until this resolves: returning the user-facing + * rejection is bounded, but the request on the wire is not. + */ + abandonedAuthenticationSettled?: Promise; } /** No candidate answered like a Stalker portal (host down or not a portal). */ @@ -167,7 +174,10 @@ export class StalkerPortalDiscoveryService { return outcome; } if (outcome.abandonedInFlight) { - return authRejection ?? outcome; + // This attempt's transport lifetime must reach the + // caller. Returning an earlier ordinary refusal would + // hide the live request and let Edit release its fence. + return outcome; } authRejection = authRejection ?? outcome; continue; @@ -217,7 +227,7 @@ export class StalkerPortalDiscoveryService { // An attempt still on the wire outranks further probing: // see `abandonedInFlight`. if (outcome.abandonedInFlight) { - return authRejection ?? outcome; + return outcome; } // The endpoint is real but refused our credentials; // remember the first such endpoint in case no later @@ -294,11 +304,12 @@ export class StalkerPortalDiscoveryService { // would stake a working candidate's session on a request nobody // can recall. const DRAINED = Symbol('drained'); + const abandonedAuthenticationSettled = pending.then( + () => undefined, + () => undefined + ); const outcome = await Promise.race([ - pending.then( - () => DRAINED, - () => DRAINED - ), + abandonedAuthenticationSettled.then(() => DRAINED), new Promise((resolve) => setTimeout(resolve, ABANDONED_DRAIN_MS) ), @@ -312,7 +323,12 @@ export class StalkerPortalDiscoveryService { status: 'auth-rejected', portalUrl: candidate, error, - ...(outcome === DRAINED ? {} : { abandonedInFlight: true }), + ...(outcome === DRAINED + ? {} + : { + abandonedInFlight: true, + abandonedAuthenticationSettled, + }), }; } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts index 8b99a9e2d..4c430faa7 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts @@ -54,7 +54,9 @@ describe('buildStalkerEndpointCandidates', () => { it('keeps a nonstandard pasted endpoint as the first candidate', () => { expect( - buildStalkerEndpointCandidates('http://portal.example/cp/portal.php') + buildStalkerEndpointCandidates( + 'http://portal.example/cp/portal.php' + ) ).toEqual([ 'http://portal.example/cp/portal.php', 'http://portal.example/cp/server/load.php', @@ -62,7 +64,7 @@ describe('buildStalkerEndpointCandidates', () => { ]); }); - it('derives standard fallbacks from a nonstandard endpoint\'s directory', () => { + it("derives standard fallbacks from a nonstandard endpoint's directory", () => { // The pasted endpoint keeps the first shot, but recovery candidates // must be its SIBLINGS — not paths appended to the file itself. expect( @@ -319,9 +321,7 @@ describe('classifyStalkerProbeResponse', () => { expect(classifyStalkerProbeResponse({ js: false })).toBe( 'not-a-portal' ); - expect(classifyStalkerProbeResponse({ js: null })).toBe( - 'not-a-portal' - ); + expect(classifyStalkerProbeResponse({ js: null })).toBe('not-a-portal'); expect(classifyStalkerProbeResponse({ js: {} })).toBe('not-a-portal'); }); @@ -368,6 +368,12 @@ describe('getStalkerRequestErrorStatus', () => { }); describe('legacyTransformStalkerPortalUrl', () => { + it('uses portal.php for an offline bare host', () => { + expect(legacyTransformStalkerPortalUrl('http://x.example')).toBe( + 'http://x.example/portal.php' + ); + }); + it('keeps the historical rewrites for the unreachable-host fallback', () => { expect(legacyTransformStalkerPortalUrl('http://x.example/c')).toBe( 'http://x.example/portal.php' diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts index c7797dd4f..b5e793438 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts @@ -94,7 +94,9 @@ export function buildStalkerEndpointCandidates(rawUrl: string): string[] { // ends in /stalker_portal — nesting it again would probe a path no // server has. if (!/\/stalker_portal(\/|$)/i.test(base)) { - candidates.push(candidateFrom(`${base}/stalker_portal/server/load.php`)); + candidates.push( + candidateFrom(`${base}/stalker_portal/server/load.php`) + ); } return [...new Set(candidates)]; @@ -112,7 +114,8 @@ export { isStalkerAuthFailureResponse, } from '@iptvnator/shared/interfaces'; -export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal'; +export type StalkerProbeClassification = + 'data' | 'auth-required' | 'not-a-portal'; /** * Classifies what a token-less content request got back from a candidate @@ -208,6 +211,15 @@ export function isStalkerProbeTimeout(error: unknown): boolean { export function legacyTransformStalkerPortalUrl(url: string): string { url = url.replace(/\/+$/, ''); + try { + if (new URL(url).pathname === '/') { + return `${url}/portal.php`; + } + } catch { + // The import form validates URLs before this fallback runs. Preserve + // the historical best-effort behavior for direct helper callers. + } + if (url.endsWith('/c')) { if (url.includes('/stalker_portal')) { return url.replace( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts new file mode 100644 index 000000000..7721a82b8 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts @@ -0,0 +1,79 @@ +import type { StalkerTokenCache } from './stalker-token-cache'; + +/** Opaque ownership proof for one lazy-repair authentication fence. */ +export interface StalkerPortalRepairDiscoveryFence { + readonly playlistId: string; + readonly owner: symbol; + /** Existing runtime authentication that repair must drain first. */ + readonly drained: Promise; +} + +interface PendingPortalRepairDiscovery { + readonly owner: symbol; + readonly settled: Promise; + readonly release: () => void; +} + +/** Serializes runtime authentication against lazy endpoint repair. */ +export class StalkerPortalRepairDiscoveryCoordinator { + private readonly pending = new Map(); + + constructor(private readonly tokens: StalkerTokenCache) {} + + begin(playlistId: string): StalkerPortalRepairDiscoveryFence { + if (this.pending.has(playlistId)) { + throw new Error('Stalker portal repair already in progress'); + } + + const owner = Symbol('stalker-portal-repair'); + let release: () => void = () => undefined; + const settled = new Promise((resolve) => { + release = resolve; + }); + this.pending.set(playlistId, { owner, settled, release }); + const pendingAuthentication = this.tokens.getPending(playlistId); + const drained = ( + pendingAuthentication?.promise.catch(() => undefined) ?? + Promise.resolve() + ).then(() => { + if (this.pending.get(playlistId)?.owner !== owner) { + throw new Error('Stale Stalker portal repair fence'); + } + }); + + return { playlistId, owner, drained }; + } + + complete(fence: StalkerPortalRepairDiscoveryFence): void { + const pending = this.pending.get(fence.playlistId); + if (pending?.owner !== fence.owner) { + return; + } + this.pending.delete(fence.playlistId); + pending.release(); + } + + /** Returns synchronously when no repair owns the playlist. */ + waitIfPending(playlistId: string): Promise | null { + if (!this.pending.has(playlistId)) { + return null; + } + return this.waitUntilAvailable(playlistId); + } + + assertAvailable(playlistId: string): void { + if (this.pending.has(playlistId)) { + throw new Error('Stale Stalker playlist configuration'); + } + } + + private async waitUntilAvailable(playlistId: string): Promise { + for (;;) { + const pending = this.pending.get(playlistId); + if (!pending) { + return; + } + await pending.settled; + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts new file mode 100644 index 000000000..7c3980793 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts @@ -0,0 +1,32 @@ +import { + isFullStalkerPortalPlaylist, + type PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { stalkerIdentityFingerprint } from './stalker-identity.utils'; + +/** What a probe of one source configuration concluded this session. */ +export type StalkerProbeRecord = + StalkerPortalModeOverride | 'no-change' | 'discarded'; + +export interface StalkerPortalModeOverride { + sourcePortalUrl?: string; + sourceIsFullStalkerPortal: boolean; + identityFingerprint: string; + credentialsFingerprint: string; + portalUrl: string; + isFullStalkerPortal: boolean; +} + +export function stalkerCredentialsFingerprint(playlist: PlaylistMeta): string { + return JSON.stringify([playlist.username ?? '', playlist.password ?? '']); +} + +export function stalkerRepairSourceFingerprint(playlist: PlaylistMeta): string { + return JSON.stringify([ + playlist.portalUrl ?? '', + isFullStalkerPortalPlaylist(playlist), + stalkerIdentityFingerprint(playlist), + playlist.username ?? '', + playlist.password ?? '', + ]); +} 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 27e066a7f..849ddce26 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 @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import type { Playlist } from '@iptvnator/shared/interfaces'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; @@ -24,10 +24,18 @@ const MISCLASSIFIED = { isFullStalkerPortal: false, } as PlaylistMeta; +async function flushMicrotasks(): Promise { + for (let i = 0; i < 4; i += 1) { + await Promise.resolve(); + } +} + describe('StalkerPortalRepairService', () => { + const originalLockManager = globalThis.navigator?.locks; let service: StalkerPortalRepairService; let discover: jest.Mock; let transformPlaylistMeta: jest.Mock; + let getPlaylistById: jest.Mock; /** What the persisted row looks like when the repair re-verifies it. */ let persistedRow: Playlist | undefined; /** The row the atomic transform actually wrote, if any. */ @@ -36,10 +44,29 @@ describe('StalkerPortalRepairService', () => { let persistError: Error | null; let setCachedToken: jest.Mock; let adoptDiscoveredSession: jest.Mock; + let adoptDiscoveredSimplePortal: jest.Mock; let clearCachedToken: jest.Mock; let refreshActiveWatchdogPlaylist: jest.Mock; + let beginPortalRepairDiscovery: jest.Mock; + let completePortalRepairDiscovery: jest.Mock; beforeEach(() => { + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { + request: jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => + callback({ + name, + mode: options.mode ?? 'exclusive', + } as Lock) + ), + }, + }); discover = jest.fn(); persistedRow = MISCLASSIFIED as Playlist; writtenRow = null; @@ -61,10 +88,18 @@ describe('StalkerPortalRepairService', () => { writtenRow = next; return of(next); }); + getPlaylistById = jest.fn(() => of(persistedRow)); setCachedToken = jest.fn(); adoptDiscoveredSession = jest.fn(); + adoptDiscoveredSimplePortal = jest.fn(); clearCachedToken = jest.fn(); refreshActiveWatchdogPlaylist = jest.fn(); + beginPortalRepairDiscovery = jest.fn((playlistId: string) => ({ + playlistId, + owner: Symbol('portal-repair'), + drained: Promise.resolve(), + })); + completePortalRepairDiscovery = jest.fn(); TestBed.configureTestingModule({ providers: [ @@ -76,7 +111,7 @@ describe('StalkerPortalRepairService', () => { provide: PlaylistsService, useValue: { transformPlaylistMeta, - getPlaylistById: jest.fn(() => of(persistedRow)), + getPlaylistById, }, }, { @@ -84,8 +119,11 @@ describe('StalkerPortalRepairService', () => { useValue: { setCachedToken, adoptDiscoveredSession, + adoptDiscoveredSimplePortal, clearCachedToken, refreshActiveWatchdogPlaylist, + beginPortalRepairDiscovery, + completePortalRepairDiscovery, }, }, ], @@ -94,10 +132,20 @@ describe('StalkerPortalRepairService', () => { service = TestBed.inject(StalkerPortalRepairService); }); + afterEach(() => { + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: originalLockManager, + }); + }); + describe('shouldAttemptRepair', () => { it('triggers on the middleware plain-text auth bodies', () => { expect( - service.shouldAttemptRepair(MISCLASSIFIED, 'Authorization failed.') + service.shouldAttemptRepair( + MISCLASSIFIED, + 'Authorization failed.' + ) ).toBe(true); expect( service.shouldAttemptRepair( @@ -175,9 +223,9 @@ describe('StalkerPortalRepairService', () => { status: 500, }) ).toBe(false); - expect( - service.shouldAttemptRepair(MISCLASSIFIED, { js: [] }) - ).toBe(false); + expect(service.shouldAttemptRepair(MISCLASSIFIED, { js: [] })).toBe( + false + ); }); it('never triggers for playlists without portal coordinates', () => { @@ -191,6 +239,123 @@ describe('StalkerPortalRepairService', () => { }); describe('repairPortal', () => { + it('does not preflight or discover while another tab owns the playlist authority', async () => { + const request = jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => { + if (name === 'iptvnator:playlist-authority') { + return callback({ + name, + mode: options.mode ?? 'shared', + } as Lock); + } + return callback(null); + } + ); + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { request }, + }); + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + + await expect( + service.repairPortal(MISCLASSIFIED) + ).resolves.toBeNull(); + + expect(getPlaylistById).not.toHaveBeenCalled(); + expect(beginPortalRepairDiscovery).not.toHaveBeenCalled(); + expect(discover).not.toHaveBeenCalled(); + }); + + it('holds playlist authority from persisted preflight through the conditional commit', async () => { + let rowAuthorityHeld = false; + let finishPhysicalRelease: () => void = () => undefined; + const physicalRelease = new Promise((resolve) => { + finishPhysicalRelease = resolve; + }); + const request = jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => { + if (name === 'iptvnator:playlist-authority') { + return callback({ name, mode: 'shared' } as Lock); + } + rowAuthorityHeld = true; + try { + const result = await callback({ + name, + mode: options.mode ?? 'exclusive', + } as Lock); + await physicalRelease; + return result; + } finally { + rowAuthorityHeld = false; + } + } + ); + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { request }, + }); + getPlaylistById.mockImplementation(() => { + expect(rowAuthorityHeld).toBe(true); + return of(persistedRow); + }); + discover.mockImplementation(async () => { + expect(rowAuthorityHeld).toBe(true); + return { + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + token: 'LOCKED_REPAIR_TOKEN', + }; + }); + transformPlaylistMeta.mockImplementation((_id, transform) => { + expect(rowAuthorityHeld).toBe(true); + const next = transform(persistedRow) as Playlist; + writtenRow = next; + return of(next); + }); + + let repairSettled = false; + const repair = service.repairPortal(MISCLASSIFIED); + void repair.then(() => { + repairSettled = true; + }); + while (transformPlaylistMeta.mock.calls.length === 0) { + await Promise.resolve(); + } + await flushMicrotasks(); + expect(repairSettled).toBe(false); + expect(rowAuthorityHeld).toBe(true); + + finishPhysicalRelease(); + await expect(repair).resolves.toMatchObject({ + isFullStalkerPortal: true, + }); + + expect(rowAuthorityHeld).toBe(false); + expect(request).toHaveBeenCalledWith( + 'iptvnator:playlist-authority', + { mode: 'shared' }, + expect.any(Function) + ); + expect(request).toHaveBeenCalledWith( + `iptvnator:playlist-authority:${MISCLASSIFIED._id}`, + { ifAvailable: true, mode: 'exclusive' }, + expect.any(Function) + ); + }); + it('persists a proven different mode and returns the patched playlist', async () => { discover.mockResolvedValue({ status: 'resolved', @@ -286,6 +451,49 @@ describe('StalkerPortalRepairService', () => { expect(writtenRow).toBeNull(); }); + it('keeps repair authentication fenced until an abandoned attempt settles', async () => { + let settleAuthentication: () => void = () => undefined; + const abandonedAuthenticationSettled = new Promise( + (resolve) => { + settleAuthentication = resolve; + } + ); + discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: MISCLASSIFIED.portalUrl, + abandonedInFlight: true, + abandonedAuthenticationSettled, + }); + + const repair = service.repairPortal(MISCLASSIFIED); + while (discover.mock.calls.length === 0) { + await Promise.resolve(); + } + let repairSettled = false; + void repair.then(() => { + repairSettled = true; + }); + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + + expect(repairSettled).toBe(false); + expect(beginPortalRepairDiscovery).toHaveBeenCalledWith( + MISCLASSIFIED._id + ); + expect(completePortalRepairDiscovery).not.toHaveBeenCalled(); + + const waitingForRepair = service.waitForPendingRepair( + MISCLASSIFIED._id + ); + settleAuthentication(); + await expect(repair).resolves.toBeNull(); + await expect(waitingForRepair).resolves.toBeUndefined(); + expect(completePortalRepairDiscovery).toHaveBeenCalledWith( + expect.objectContaining({ playlistId: MISCLASSIFIED._id }) + ); + }); + it('discards a repair whose credentials changed while probing', async () => { // Discovery can run for tens of seconds; a login saved meanwhile // means the outcome was negotiated for an account the row no @@ -347,12 +555,14 @@ describe('StalkerPortalRepairService', () => { // without the credentials the probe reports `login-required` and // the source could never be repaired. discover.mockResolvedValue({ status: 'unreachable' }); - - await service.repairPortal({ + const playlistWithCredentials = { ...MISCLASSIFIED, username: 'user', password: 'secret', - }); + } as PlaylistMeta; + persistedRow = playlistWithCredentials as Playlist; + + await service.repairPortal(playlistWithCredentials); expect(discover).toHaveBeenCalledWith( expect.any(String), @@ -371,21 +581,17 @@ describe('StalkerPortalRepairService', () => { expect(discover).toHaveBeenCalledTimes(1); }); - it('re-enters for an edited configuration after awaiting a pending probe', async () => { - // A's probe is in flight when a request from the EDITED config B - // fails: after A settles (and is discarded by the row guard), B - // must get its own probe instead of inheriting A's outcome. + it('re-enters for an edited configuration after declining a pending stale source', async () => { + // A's persisted-row preflight is in flight when a request from + // the EDITED config B fails: after stale A is declined, B must + // get its own probe instead of inheriting A's outcome. const edited = { ...MISCLASSIFIED, portalUrl: 'http://edited.example/portal.php', } as PlaylistMeta; persistedRow = edited as Playlist; - let resolveFirstDiscovery!: (value: unknown) => void; - discover.mockReturnValueOnce( - new Promise((resolve) => (resolveFirstDiscovery = resolve)) - ); - discover.mockResolvedValueOnce({ + discover.mockResolvedValue({ status: 'resolved', portalUrl: 'http://edited.example/server/load.php', isFullStalkerPortal: true, @@ -394,15 +600,9 @@ describe('StalkerPortalRepairService', () => { const oldRepair = service.repairPortal(MISCLASSIFIED); const editedRepair = service.repairPortal(edited); - resolveFirstDiscovery({ - status: 'resolved', - portalUrl: MISCLASSIFIED.portalUrl, - isFullStalkerPortal: true, - }); - expect(await oldRepair).toBeNull(); const repaired = await editedRepair; - expect(discover).toHaveBeenCalledTimes(2); + expect(discover).toHaveBeenCalledTimes(1); expect(repaired?.portalUrl).toBe( 'http://edited.example/server/load.php' ); @@ -467,7 +667,9 @@ describe('StalkerPortalRepairService', () => { ...MISCLASSIFIED, portalUrl: 'http://other.example/portal.php', } as PlaylistMeta; + persistedRow = edited as Playlist; expect(service.applyOverride(edited)).toBe(edited); + await flushMicrotasks(); // …and the once-per-session latch re-arms so the EDITED // configuration may probe if it fails too. @@ -476,7 +678,6 @@ describe('StalkerPortalRepairService', () => { portalUrl: 'http://other.example/server/load.php', isFullStalkerPortal: true, }); - persistedRow = edited as Playlist; const repairedAgain = await service.repairPortal(edited); expect(discover).toHaveBeenCalledTimes(2); expect(repairedAgain?.portalUrl).toBe( @@ -484,6 +685,139 @@ describe('StalkerPortalRepairService', () => { ); }); + it('fences an edit without changing runtime state until persistence commits', async () => { + const wrongEndpoint = { + ...MISCLASSIFIED, + portalUrl: 'http://ministra.example/portal.php', + } as PlaylistMeta; + persistedRow = wrongEndpoint as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + 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.commitPlaylistEdit(wrongEndpoint._id); + + expect(service.applyOverride(wrongEndpoint)).toBe(wrongEndpoint); + expect(clearCachedToken).not.toHaveBeenCalled(); + }); + + it('does not start another repair while explicit Edit discovery owns the playlist', async () => { + const fence = service.fenceForPlaylistEdit(MISCLASSIFIED._id); + discover.mockClear(); + + await expect( + service.repairPortal(MISCLASSIFIED) + ).resolves.toBeNull(); + expect(discover).not.toHaveBeenCalled(); + + service.releasePlaylistEdit(MISCLASSIFIED._id); + await fence; + }); + + it('blocks an already-queued same-tab repair from reserving after Edit starts', async () => { + let finishDiscovery: (outcome: { + status: 'resolved'; + portalUrl: string; + isFullStalkerPortal: boolean; + }) => void = () => undefined; + discover.mockReturnValueOnce( + new Promise((resolve) => { + finishDiscovery = resolve; + }) + ); + const request = globalThis.navigator?.locks?.request as jest.Mock; + + const firstRepair = service.repairPortal(MISCLASSIFIED); + while (discover.mock.calls.length === 0) { + await Promise.resolve(); + } + const queuedRepair = service.repairPortal(MISCLASSIFIED); + const editDrain = service.fenceForPlaylistEdit(MISCLASSIFIED._id); + + finishDiscovery({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + + await expect(firstRepair).resolves.toBeNull(); + await expect(queuedRepair).resolves.toBeNull(); + await editDrain; + expect(discover).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); + + service.releasePlaylistEdit(MISCLASSIFIED._id); + }); + + it('does not probe a stale source after explicit Edit has committed', async () => { + const edited = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/server/load.php', + isFullStalkerPortal: true, + } as Playlist; + await service.fenceForPlaylistEdit(MISCLASSIFIED._id); + persistedRow = edited; + service.commitPlaylistEdit(MISCLASSIFIED._id); + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + token: 'STALE_REPAIR_TOKEN', + }); + + await expect( + service.repairPortal(MISCLASSIFIED) + ).resolves.toBeNull(); + expect(discover).not.toHaveBeenCalled(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); + }); + + it('discards a repair verified before explicit Edit but completed after it', async () => { + const wrongEndpoint = { + ...MISCLASSIFIED, + portalUrl: 'http://ministra.example/portal.php', + } as PlaylistMeta; + persistedRow = wrongEndpoint as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + token: 'REPAIR_TOKEN', + }); + const writeCompletion = new Subject(); + transformPlaylistMeta.mockImplementation((_id, transform) => { + writtenRow = transform(persistedRow) as Playlist | null; + return writeCompletion; + }); + + const repair = service.repairPortal(wrongEndpoint); + while (transformPlaylistMeta.mock.calls.length === 0) { + await Promise.resolve(); + } + // 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.fenceForPlaylistEdit(wrongEndpoint._id); + writeCompletion.next(writtenRow); + + await expect(repair).resolves.toBeNull(); + expect(service.applyOverride(wrongEndpoint)).toBe(wrongEndpoint); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); + expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); + }); + it('discards an in-flight repair when the row was edited during the probe', async () => { // The probe can run for tens of seconds; a user who saves a new // portal URL meanwhile must win over the repair of the old one. @@ -544,11 +878,13 @@ describe('StalkerPortalRepairService', () => { stalkerSerialNumber: 'a', stalkerDeviceId1: 'b', } as PlaylistMeta; + persistedRow = shiftedIdentity as Playlist; // A DIFFERENT identity must invalidate, not inherit. expect(service.applyOverride(shiftedIdentity)).toBe( shiftedIdentity ); + await flushMicrotasks(); expect(clearCachedToken).toHaveBeenCalledWith('portal-1'); }); @@ -569,10 +905,13 @@ describe('StalkerPortalRepairService', () => { macAddress: '00:1A:79:00:44:44', } as PlaylistMeta; // The edit drops the active override… + persistedRow = editedIdentity as Playlist; expect(service.applyOverride(editedIdentity)).toBe(editedIdentity); + await flushMicrotasks(); expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED); // …and the restored configuration reinstalls it on failure. + persistedRow = MISCLASSIFIED as Playlist; refreshActiveWatchdogPlaylist.mockClear(); const restored = await service.repairPortal(MISCLASSIFIED); expect(discover).toHaveBeenCalledTimes(1); @@ -603,8 +942,9 @@ describe('StalkerPortalRepairService', () => { portalUrl: 'http://c.example/portal.php', } as PlaylistMeta; // The edit drops the active override… - expect(service.applyOverride(otherConfig)).toBe(otherConfig); persistedRow = otherConfig as Playlist; + expect(service.applyOverride(otherConfig)).toBe(otherConfig); + await flushMicrotasks(); refreshActiveWatchdogPlaylist.mockClear(); // …and a stale A request does not bring it back. @@ -632,20 +972,129 @@ describe('StalkerPortalRepairService', () => { macAddress: '00:1A:79:00:88:88', } as PlaylistMeta; + persistedRow = editedIdentity as Playlist; expect(service.applyOverride(editedIdentity)).toBe(editedIdentity); + await flushMicrotasks(); expect(clearCachedToken).toHaveBeenCalledWith('portal-1'); // The latch is re-armed for the edited identity. discover.mockClear(); discover.mockResolvedValue({ status: 'unreachable' }); - persistedRow = editedIdentity as Playlist; await service.repairPortal(editedIdentity); expect(discover).toHaveBeenCalledTimes(1); }); + it('drops a repaired override when a restored row has new credentials', async () => { + const original = { + ...MISCLASSIFIED, + username: 'user-1', + password: 'password-1', + } as PlaylistMeta; + persistedRow = original as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: original.portalUrl, + isFullStalkerPortal: true, + }); + await service.repairPortal(original); + clearCachedToken.mockClear(); + + const restored = { + ...original, + username: 'user-2', + password: 'password-2', + } as PlaylistMeta; + persistedRow = restored as Playlist; + + expect(service.applyOverride(restored)).toBe(restored); + await flushMicrotasks(); + expect(clearCachedToken).toHaveBeenCalledWith(original._id); + + discover.mockResolvedValue({ status: 'unreachable' }); + await service.repairPortal(restored); + expect(discover).toHaveBeenCalledTimes(2); + }); + + it('keeps a valid override when only a delayed snapshot has stale credentials', async () => { + const current = { + ...MISCLASSIFIED, + username: 'current-user', + password: 'current-password', + } as PlaylistMeta; + persistedRow = current as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: current.portalUrl, + isFullStalkerPortal: true, + }); + await service.repairPortal(current); + persistedRow = writtenRow as Playlist; + clearCachedToken.mockClear(); + + const delayed = { + ...current, + username: 'stale-user', + password: 'stale-password', + } as PlaylistMeta; + expect(service.applyOverride(delayed)).toBe(delayed); + await flushMicrotasks(); + + expect(clearCachedToken).not.toHaveBeenCalled(); + expect(service.applyOverride(current)).toMatchObject({ + isFullStalkerPortal: true, + }); + }); + + it.each(['before', 'after'] as const)( + 'does not retire a token when its row read starts %s an explicit edit takes ownership', + async (readOrder) => { + const original = { + ...MISCLASSIFIED, + username: 'repair-user', + password: 'repair-password', + } as PlaylistMeta; + persistedRow = original as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: original.portalUrl, + isFullStalkerPortal: true, + token: 'REPAIR_TOKEN', + }); + await service.repairPortal(original); + clearCachedToken.mockClear(); + + const edited = { + ...original, + username: 'edited-user', + password: 'edited-password', + } as PlaylistMeta; + const rowRead = new Subject(); + getPlaylistById.mockReturnValueOnce(rowRead); + + if (readOrder === 'before') { + expect(service.applyOverride(edited)).toBe(edited); + } + // A confirmation may have started just before Edit, or a + // delayed caller may start it while Edit owns the ID. + await service.fenceForPlaylistEdit(original._id); + if (readOrder === 'after') { + expect(service.applyOverride(edited)).toBe(edited); + } + + setCachedToken(original._id, 'EDIT_TOKEN', edited); + rowRead.next(edited as Playlist); + rowRead.complete(); + await flushMicrotasks(); + + expect(clearCachedToken).not.toHaveBeenCalled(); + service.commitPlaylistEdit(original._id); + expect(service.applyOverride(original)).toBe(original); + } + ); + it('re-probes a DISCARDED configuration once the row is restored to it', async () => { - // A's probe was discarded because the row moved to B mid-probe; - // after the user restores the row to A, A's next failure must - // probe again instead of staying dead for the session. + // A's probe was discarded by the persisted-row preflight because + // the row had moved to B; after the user restores the row to A, + // A's next failure must probe instead of staying dead. discover.mockResolvedValue({ status: 'resolved', portalUrl: MISCLASSIFIED.portalUrl, @@ -656,22 +1105,68 @@ describe('StalkerPortalRepairService', () => { portalUrl: 'http://edited.example/portal.php', } as Playlist; expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); - expect(discover).toHaveBeenCalledTimes(1); + expect(discover).not.toHaveBeenCalled(); // Row restored to A → the discarded marker yields to a new probe. persistedRow = MISCLASSIFIED as Playlist; const repaired = await service.repairPortal(MISCLASSIFIED); - expect(discover).toHaveBeenCalledTimes(2); + expect(discover).toHaveBeenCalledTimes(1); expect(repaired).toMatchObject({ isFullStalkerPortal: true }); }); + it('does not start discovery when Edit takes ownership during a DISCARDED history read', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/portal.php', + } as Playlist; + await service.repairPortal(MISCLASSIFIED); + expect(discover).not.toHaveBeenCalled(); + + persistedRow = MISCLASSIFIED as Playlist; + const historyRead = new Subject(); + getPlaylistById.mockClear(); + getPlaylistById.mockReturnValueOnce(historyRead); + beginPortalRepairDiscovery.mockClear(); + completePortalRepairDiscovery.mockClear(); + + const repair = service.repairPortal(MISCLASSIFIED); + while (getPlaylistById.mock.calls.length === 0) { + await Promise.resolve(); + } + expect(getPlaylistById).toHaveBeenCalledTimes(1); + // Edit installs its generation fence immediately, then drains + // the published repair owner while the row read is still live. + const editDrain = service.fenceForPlaylistEdit(MISCLASSIFIED._id); + historyRead.next(MISCLASSIFIED as Playlist); + historyRead.complete(); + + await expect(repair).resolves.toBeNull(); + await editDrain; + expect(beginPortalRepairDiscovery).not.toHaveBeenCalled(); + expect(discover).not.toHaveBeenCalled(); + + // A failed/cancelled Edit releases the fence without consuming + // the discarded history record; the restored source can retry. + service.releasePlaylistEdit(MISCLASSIFIED._id); + await expect( + service.repairPortal(MISCLASSIFIED) + ).resolves.toMatchObject({ isFullStalkerPortal: true }); + expect(discover).toHaveBeenCalledTimes(1); + }); + it('re-arms the EDITED configuration after a mid-probe edit discarded a repair', async () => { const edited = { ...MISCLASSIFIED, portalUrl: 'http://edited.example/portal.php', } as PlaylistMeta; - // Probe of the OLD config lands after the user edit → discarded. + // The OLD config is declined before discovery because the row + // already carries the user edit. discover.mockResolvedValue({ status: 'resolved', portalUrl: MISCLASSIFIED.portalUrl, @@ -679,12 +1174,12 @@ describe('StalkerPortalRepairService', () => { }); persistedRow = edited as Playlist; expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); - expect(discover).toHaveBeenCalledTimes(1); + expect(discover).not.toHaveBeenCalled(); // A stale snapshot of the already-probed config must NOT loop // the probe… expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); - expect(discover).toHaveBeenCalledTimes(1); + expect(discover).not.toHaveBeenCalled(); // …but the EDITED configuration failing later must be allowed // to repair without an application restart. @@ -694,7 +1189,7 @@ describe('StalkerPortalRepairService', () => { isFullStalkerPortal: true, }); const repaired = await service.repairPortal(edited); - expect(discover).toHaveBeenCalledTimes(2); + expect(discover).toHaveBeenCalledTimes(1); expect(repaired?.portalUrl).toBe( 'http://edited.example/server/load.php' ); @@ -724,9 +1219,9 @@ describe('StalkerPortalRepairService', () => { const repaired = await service.repairPortal(MISCLASSIFIED); expect(repaired?.isFullStalkerPortal).toBe(true); - expect(service.applyOverride(MISCLASSIFIED).isFullStalkerPortal).toBe( - true - ); + expect( + service.applyOverride(MISCLASSIFIED).isFullStalkerPortal + ).toBe(true); }); it('clears a stale cached token when a portal turns out token-free', async () => { @@ -745,7 +1240,13 @@ describe('StalkerPortalRepairService', () => { const repaired = await service.repairPortal(wronglyFull); expect(repaired?.isFullStalkerPortal).toBe(false); - expect(clearCachedToken).toHaveBeenCalledWith('portal-1'); + expect(adoptDiscoveredSimplePortal).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'portal-1', + portalUrl: 'http://panel.example/portal.php', + isFullStalkerPortal: false, + }) + ); }); }); }); 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 617bddf77..50487f50c 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 @@ -16,33 +16,19 @@ import { getStalkerPortalIdentityFromPlaylist, stalkerIdentityFingerprint, } from './stalker-identity.utils'; +import { + stalkerCredentialsFingerprint, + stalkerRepairSourceFingerprint, + type StalkerPortalModeOverride, + type StalkerProbeRecord, +} from './stalker-portal-repair-state'; +import { StalkerRepairAuthorityCoordinator } from './stalker-repair-authority-coordinator'; import { StalkerSessionService } from './stalker-session.service'; import { type StalkerPortalRepairApi, toStalkerSessionPlaylist, } from './stores/utils/stalker-request.utils'; -/** - * What a probe of one source configuration concluded this session: - * an override to (re)install, 'no-change' (probed; the stored configuration - * is what probing proves, or nothing answered), or 'discarded' (the row - * moved on mid-probe, so the outcome never applied to any persisted state). - */ -type StalkerProbeRecord = StalkerPortalModeOverride | 'no-change' | 'discarded'; - -interface StalkerPortalModeOverride { - /** The failing configuration this repair replaced. */ - sourcePortalUrl?: string; - sourceIsFullStalkerPortal: boolean; - /** MAC + Stalker identity the repair probe authenticated as. */ - identityFingerprint: string; - /** The proven-working configuration. */ - portalUrl: string; - isFullStalkerPortal: boolean; -} - - - /** * Lazy repair for playlists whose persisted portal endpoint or mode is * wrong. The flag used to be a URL-shape guess frozen at import, so a @@ -95,10 +81,10 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { string, Map >(); - private readonly pendingRepairs = new Map< - string, - Promise - >(); + private readonly repairAuthority = new StalkerRepairAuthorityCoordinator(); + /** Explicit Edit invalidates every repair that started before it. */ + private readonly editGenerations = new Map(); + private readonly editFenceCounts = new Map(); constructor() { // The watchdog resolves its playlist from the persisted row; while a @@ -134,13 +120,15 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { } if ( - stalkerIdentityFingerprint(playlist) !== override.identityFingerprint + stalkerIdentityFingerprint(playlist) !== + override.identityFingerprint || + stalkerCredentialsFingerprint(playlist) !== + override.credentialsFingerprint ) { - // The MAC or Stalker identity was edited after the repair. The - // override AND the token the repair authenticated for the - // PREVIOUS identity must go — otherwise requests and watchdog - // pings would pair the edited identity with a foreign session. - this.dropOverride(playlist._id); + // Do not let one delayed request globally retire current state. + // The snapshot itself stays untouched; persistence confirms in + // the background whether it really owns this playlist ID. + this.retireOverrideIfPersisted(playlist, override); return playlist; } @@ -164,13 +152,70 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { }; } - // The portal URL or mode was edited to something else entirely — - // same story: the edited configuration is used verbatim and the - // repair session state is retired. - this.dropOverride(playlist._id); + // Another endpoint/mode may likewise be a delayed request rather + // than the current row. Use it verbatim, but mutate shared repair + // state only after persistence confirms it. + this.retireOverrideIfPersisted(playlist, override); return playlist; } + private retireOverrideIfPersisted( + playlist: PlaylistMeta, + override: StalkerPortalModeOverride + ): void { + const editGeneration = this.editGenerations.get(playlist._id) ?? 0; + void this.rowCurrentlyMatches(playlist).then((matches) => { + if ( + matches && + !this.editGenerationChanged(playlist._id, editGeneration) && + !this.editFenceCounts.has(playlist._id) && + this.overrides.get(playlist._id) === override + ) { + this.dropOverride(playlist._id); + } + }); + } + + /** + * 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. + */ + fenceForPlaylistEdit(playlistId: string): Promise { + this.editFenceCounts.set( + playlistId, + (this.editFenceCounts.get(playlistId) ?? 0) + 1 + ); + this.editGenerations.set( + playlistId, + (this.editGenerations.get(playlistId) ?? 0) + 1 + ); + this.repairAuthority.block(playlistId); + return this.repairAuthority.wait(playlistId).then(() => undefined); + } + + /** Releases the repair fence when discovery or persistence fails. */ + releasePlaylistEdit(playlistId: string): void { + this.repairAuthority.unblock(playlistId); + const remaining = (this.editFenceCounts.get(playlistId) ?? 1) - 1; + if (remaining > 0) { + this.editFenceCounts.set(playlistId, remaining); + } else { + this.editFenceCounts.delete(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); + this.releasePlaylistEdit(playlistId); + } + /** * Retires the session artifacts a repair installed for a playlist: the * ACTIVE override and the cached token (authenticated for the pre-edit @@ -244,90 +289,92 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { */ async repairPortal(playlist: PlaylistMeta): Promise { const playlistId = playlist._id; - - const pending = this.pendingRepairs.get(playlistId); - if (pending) { - // Wait the in-flight probe out, then RE-ENTER: the caller may - // carry a different (edited) configuration whose fingerprint - // was never attempted — it must get its own probe instead of - // inheriting whatever the old repair concluded. - await pending; - return this.repairPortal(playlist); + if ((this.editFenceCounts.get(playlistId) ?? 0) > 0) { + return null; } - const fingerprint = this.repairSourceFingerprint(playlist); + return this.repairAuthority.run(playlistId, () => + this.repairPortalWithAuthority(playlist) + ); + } + + private async repairPortalWithAuthority( + playlist: PlaylistMeta + ): Promise { + const playlistId = playlist._id; + const editGeneration = this.editGenerations.get(playlistId) ?? 0; + + const fingerprint = stalkerRepairSourceFingerprint(playlist); const history = this.probeHistory.get(playlistId) ?? new Map(); const record = history.get(fingerprint) as - | StalkerProbeRecord - | undefined; + StalkerProbeRecord | undefined; if (record === 'discarded') { // The probe for this configuration was discarded because the // row had moved on mid-probe. If the row has since been // RESTORED to it, the outcome was never recorded — probe again. // A stale snapshot (row still elsewhere) stays declined, gated // by one cheap row read instead of a discovery run. - if (!(await this.rowCurrentlyMatches(playlist))) { + const rowMatches = await this.rowCurrentlyMatches(playlist); + if (this.editBlocksRepair(playlistId, editGeneration)) { + return null; + } + if (!rowMatches) { return this.reapplyIfChanged(playlist); } history.delete(fingerprint); } else if (record !== undefined) { - if ( - record !== 'no-change' && - !this.overrides.has(playlistId) && + if (record !== 'no-change' && !this.overrides.has(playlistId)) { // Reinstall ONLY when the persisted row actually carries // this configuration again. A stale request for A while the // row now holds an unrelated C must not resurrect A's // override — that would retry against B and repoint the // watchdog away from C. - (await this.rowCurrentlyMatches(playlist)) - ) { - // The user restored a configuration whose override was - // dropped by an intermediate edit: reinstall the remembered - // outcome — probing again is unnecessary, and doing nothing - // would leave the restored configuration broken until - // restart. - this.overrides.set(playlistId, record); - // Same synchronization as a fresh repair: if the - // intermediate configuration stopped the active watchdog, - // the restored full-portal session needs its keepalive back. - this.stalkerSession.refreshActiveWatchdogPlaylist( - toStalkerSessionPlaylist(this.applyOverride(playlist)) - ); + const rowMatches = await this.rowCurrentlyMatches(playlist); + if (this.editBlocksRepair(playlistId, editGeneration)) { + return null; + } + if (rowMatches) { + // The user restored a configuration whose override was + // dropped by an intermediate edit: reinstall the + // remembered outcome — probing again is unnecessary, and + // doing nothing would leave it broken until restart. + this.overrides.set(playlistId, record); + // Same synchronization as a fresh repair: if the + // intermediate configuration stopped the active watchdog, + // the restored full-portal session needs keepalive back. + this.stalkerSession.refreshActiveWatchdogPlaylist( + toStalkerSessionPlaylist(this.applyOverride(playlist)) + ); + } } return this.reapplyIfChanged(playlist); } + if (this.editBlocksRepair(playlistId, editGeneration)) { + return null; + } + // Reserved BEFORE the probe; the run overwrites it with the // produced override or the 'discarded' marker. history.set(fingerprint, 'no-change'); this.probeHistory.set(playlistId, history); - const run = this.runRepair(playlist); - this.pendingRepairs.set(playlistId, run); + const authenticationFence = + this.stalkerSession.beginPortalRepairDiscovery(playlistId); + const run = authenticationFence.drained.then(() => + this.runRepair(playlist, editGeneration) + ); try { return await run; } finally { - this.pendingRepairs.delete(playlistId); + this.stalkerSession.completePortalRepairDiscovery( + authenticationFence + ); } } - /** - * Everything a probe's outcome depends on: endpoint, mode, MAC and the - * full Stalker identity — the same field set `rowStillMatchesSource` - * verifies before committing. - */ - private repairSourceFingerprint(playlist: PlaylistMeta): string { - // JSON-encoded for the same reason as the identity fingerprint: - // unrestricted values must not alias across field boundaries. - return JSON.stringify([ - playlist.portalUrl ?? '', - isFullStalkerPortalPlaylist(playlist), - stalkerIdentityFingerprint(playlist), - // Credentials are part of the discovery outcome now: a probe that - // failed on a wrong login must be retried once the login is - // corrected, instead of staying declined until the app restarts. - playlist.username ?? '', - playlist.password ?? '', - ]); + /** Lets request routing choose its effective row after repair settles. */ + async waitForPendingRepair(playlistId: string): Promise { + await this.repairAuthority.wait(playlistId); } private reapplyIfChanged(playlist: PlaylistMeta): PlaylistMeta | null { @@ -336,8 +383,21 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { } private async runRepair( - playlist: PlaylistMeta + playlist: PlaylistMeta, + editGeneration: number ): Promise { + // A request sent before an explicit Edit can fail only after that Edit + // commits and releases its fence. Verify the caller still owns the + // persisted source before discovery: confirmation may authenticate + // against the old portal and invalidate the freshly edited session + // even though the atomic transform below would reject its write. + if ( + !(await this.rowCurrentlyMatches(playlist)) || + this.editGenerationChanged(playlist._id, editGeneration) + ) { + return this.discardSupersededRepair(playlist); + } + const outcome = await this.discovery.discover( playlist.portalUrl ?? '', playlist.macAddress ?? '', @@ -359,9 +419,24 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { this.logger.info( `Portal probe found no working configuration (${outcome.status}); leaving playlist untouched` ); + if ( + outcome.status === 'auth-rejected' && + outcome.abandonedInFlight + ) { + // Returning the repair would release its session fence. The + // transport can outlive discovery's bounded error, so hold + // every runtime authenticator until the old get_profile has + // actually settled. Missing lifetime evidence fails closed. + await (outcome.abandonedAuthenticationSettled ?? + new Promise(() => undefined)); + } return null; } + if (this.editGenerationChanged(playlist._id, editGeneration)) { + return this.discardSupersededRepair(playlist); + } + const storedMode = isFullStalkerPortalPlaylist(playlist); if ( outcome.portalUrl === playlist.portalUrl && @@ -385,7 +460,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { this.injector .get(PlaylistsService) .transformPlaylistMeta(playlist._id, (row) => { - if (!this.rowMatchesSource(row, playlist, storedMode)) { + if ( + this.editGenerationChanged( + playlist._id, + editGeneration + ) || + !this.rowMatchesSource(row, playlist, storedMode) + ) { return null; } verifiedAgainstRow = true; @@ -400,36 +481,28 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { // A failed WRITE after successful verification keeps the // session-only override below; a failed READ means the premise // could not be verified and the repair is discarded. - this.logger.warn( - 'Persisting repaired portal mode failed', - error - ); + this.logger.warn('Persisting repaired portal mode failed', error); } - if (!verifiedAgainstRow) { - this.logger.info( - 'Portal configuration changed while probing; discarding repair' - ); - // Marked explicitly: a later failure of this configuration may - // probe again once the row is RESTORED to it — unlike a probe - // whose outcome genuinely applied ('no-change'/override). - this.probeHistory - .get(playlist._id) - ?.set(this.repairSourceFingerprint(playlist), 'discarded'); - return null; + if ( + !verifiedAgainstRow || + this.editGenerationChanged(playlist._id, editGeneration) + ) { + return this.discardSupersededRepair(playlist); } const override: StalkerPortalModeOverride = { sourcePortalUrl: playlist.portalUrl, sourceIsFullStalkerPortal: storedMode, identityFingerprint: stalkerIdentityFingerprint(playlist), + credentialsFingerprint: stalkerCredentialsFingerprint(playlist), portalUrl: outcome.portalUrl, isFullStalkerPortal: outcome.isFullStalkerPortal, }; this.overrides.set(playlist._id, override); this.probeHistory .get(playlist._id) - ?.set(this.repairSourceFingerprint(playlist), override); + ?.set(stalkerRepairSourceFingerprint(playlist), override); if (outcome.isFullStalkerPortal && outcome.token) { // The classification handshake already authenticated; adopt the @@ -450,7 +523,9 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { } ); } else if (!outcome.isFullStalkerPortal) { - this.stalkerSession.clearCachedToken(playlist._id); + this.stalkerSession.adoptDiscoveredSimplePortal( + toStalkerSessionPlaylist(this.applyOverride(playlist)) + ); } // If this playlist currently owns the watchdog, re-sync it with the @@ -468,11 +543,41 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { return this.applyOverride(playlist); } + private editGenerationChanged( + playlistId: string, + expected: number + ): boolean { + return (this.editGenerations.get(playlistId) ?? 0) !== expected; + } + + private editBlocksRepair( + playlistId: string, + expectedGeneration: number + ): boolean { + return ( + this.editFenceCounts.has(playlistId) || + this.editGenerationChanged(playlistId, expectedGeneration) + ); + } + + private discardSupersededRepair( + playlist: PlaylistMeta + ): PlaylistMeta | null { + this.logger.info( + 'Portal configuration changed while probing; discarding repair' + ); + // A later failure may probe again if this source is restored. + this.probeHistory + .get(playlist._id) + ?.set(stalkerRepairSourceFingerprint(playlist), 'discarded'); + return null; + } + /** - * Cheap gate for retrying a DISCARDED configuration: reads the current - * row and reports whether it now carries the caller's configuration. - * Only avoids pointless discovery runs — the authoritative check stays - * the atomic transform. + * Cheap preflight for an unrecorded or DISCARDED configuration: reads the + * current row and reports whether it still carries the caller's source. + * This prevents stale discovery from authenticating against an edited + * portal; the authoritative write guard remains the atomic transform. */ private async rowCurrentlyMatches( playlist: PlaylistMeta @@ -485,8 +590,8 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { ); return ( !!row && - this.repairSourceFingerprint(row) === - this.repairSourceFingerprint(playlist) + stalkerRepairSourceFingerprint(row) === + stalkerRepairSourceFingerprint(playlist) ); } catch { return false; @@ -516,5 +621,4 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { (row.password ?? '') === (playlist.password ?? '') ); } - } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-repair-authority-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-repair-authority-coordinator.ts new file mode 100644 index 000000000..6bcae2e09 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-repair-authority-coordinator.ts @@ -0,0 +1,70 @@ +import { reservePlaylistAuthority } from '@iptvnator/services'; +import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; + +/** Shares one repair locally and reserves its source across PWA tabs. */ +export class StalkerRepairAuthorityCoordinator { + private readonly pending = new Map>(); + private readonly blockers = new Map(); + + block(playlistId: string): void { + this.blockers.set(playlistId, (this.blockers.get(playlistId) ?? 0) + 1); + } + + unblock(playlistId: string): void { + const remaining = (this.blockers.get(playlistId) ?? 1) - 1; + if (remaining > 0) { + this.blockers.set(playlistId, remaining); + } else { + this.blockers.delete(playlistId); + } + } + + async run( + playlistId: string, + operation: () => Promise + ): Promise { + if (this.blockers.has(playlistId)) { + return null; + } + const pending = this.pending.get(playlistId); + if (pending) { + await pending; + return this.run(playlistId, operation); + } + + // Publish before the Web Lock request yields so concurrent failures + // in this tab share one reservation and outcome. + const run = this.runReserved(playlistId, operation); + this.pending.set(playlistId, run); + try { + return await run; + } finally { + if (this.pending.get(playlistId) === run) { + this.pending.delete(playlistId); + } + } + } + + async wait(playlistId: string): Promise { + await this.pending.get(playlistId)?.catch(() => null); + } + + private async runReserved( + playlistId: string, + operation: () => Promise + ): Promise { + const reservation = await reservePlaylistAuthority(playlistId).catch( + () => ({ status: 'unavailable' as const }) + ); + if (reservation.status !== 'acquired') { + return null; + } + + try { + return await operation(); + } finally { + reservation.release(); + await reservation.released; + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts index 01f311404..8249ae298 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -16,7 +16,9 @@ import type { StalkerAuthenticationResult } from './stalker-auth.api'; * playlist repointed at another portal would disclose the previous one's * bearer token to it. Origin alone is not enough: discovery deliberately * preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are - * different portals on one host. + * different portals on one host. URL Basic-auth credentials are part of + * the endpoint authority too: an edited userinfo identity must not receive + * a token negotiated through the previous one. * - **Identity** — an edited MAC/serial must not inherit the old session. * - **Credentials** — for a status-2 portal the login decides which account * the token represents, so changing it must not keep serving the previous @@ -64,7 +66,18 @@ function portalEndpoint(portalUrl: string | undefined): string { } try { const parsed = new URL(portalUrl); - return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; + const endpoint = `${parsed.origin}${parsed.pathname.replace( + /\/+$/, + '' + )}`; + + // URL.origin deliberately omits userinfo. Preserve the legacy value + // for ordinary endpoints so an upgrade does not invalidate healthy + // sessions, while Basic-auth URLs bind the token to the exact accepted + // credentials. JSON avoids ambiguous delimiter collisions. + return parsed.username || parsed.password + ? JSON.stringify([endpoint, parsed.username, parsed.password]) + : endpoint; } catch { // Unparseable: fall back to the raw string so a change is still a // change — never to a constant, which would alias every endpoint. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 86e53d0e6..c0cf534ee 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -243,6 +243,31 @@ describe('StalkerSessionService identity-tagged token cache', () => { expect(service.getCachedToken('portal-1')).not.toBe('EXPIRED'); }); + it('preserves HTTP status evidence when the authenticated retry also fails', async () => { + const forbidden = { + message: 'HTTP Error 403: Forbidden', + status: 403, + }; + service.setCachedToken('portal-1', 'EXPIRED', playlistA); + sendIpcEvent + .mockRejectedValueOnce(forbidden) + .mockResolvedValueOnce({ + js: { token: 'FRESH', random: 'r' }, + }) + .mockResolvedValueOnce({ js: {} }) + .mockRejectedValueOnce(forbidden); + + await expect( + service.makeAuthenticatedRequest(playlistA, { + action: 'get_genres', + }) + ).rejects.toBe(forbidden); + + // StalkerPortalRepairService consumes this exact transport evidence + // to decide that endpoint discovery is justified. + expect(forbidden.status).toBe(403); + }); + it.each(['Access denied.', 'Unauthorized request.'])( 'retires the token for the %j plain-text failure too', async (body) => { @@ -934,8 +959,7 @@ describe('StalkerSessionService identity payloads', () => { expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( 'playlist-17', expect.objectContaining({ - stalkerWatchdogTimeout: - STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + stalkerWatchdogTimeout: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, stalkerTimeslot: 0, }) ); @@ -1020,6 +1044,41 @@ describe('StalkerSessionService identity payloads', () => { ); }); + it('refuses a persisted token after URL Basic-auth credentials change', async () => { + const original = { + _id: 'playlist-basic-auth-change', + portalUrl: + 'https://old-user:secret@panel.example.com/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...original, + stalkerToken: 'OLD-BASIC-AUTH-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(original), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + const editedUrl = + 'https://new-user:secret@panel.example.com/server/load.php'; + + await service.ensureToken({ + ...original, + portalUrl: editedUrl, + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + editedUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + it('keeps the session for the same endpoint spelled with a trailing slash', async () => { // Normalisation must not turn a cosmetic difference into a forced // re-authentication. @@ -1032,11 +1091,30 @@ describe('StalkerSessionService identity payloads', () => { expect( stalkerSessionFingerprint({ ...portal, - portalUrl: 'https://panel.example.com/tenant-a/server/load.php/', + portalUrl: + 'https://panel.example.com/tenant-a/server/load.php/', } as Playlist) ).toBe(stalkerSessionFingerprint(portal)); }); + it('keeps the legacy fingerprint shape for endpoints without URL userinfo', () => { + const portal = { + _id: 'playlist-fingerprint-compatibility', + portalUrl: 'https://panel.example.com/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + expect(stalkerSessionFingerprint(portal)).toBe( + JSON.stringify([ + 'https://panel.example.com/server/load.php', + JSON.stringify([macAddress, '', '', '', '', '']), + '', + '', + ]) + ); + }); + it('refuses a persisted token when the playlist was repointed at another host', async () => { // ensureToken re-presents persisted tokens in a handshake, so an // identity-only check would disclose the previous portal's bearer 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 2af5538eb..6e7a3464c 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 @@ -1,11 +1,9 @@ import { Injectable, Injector, inject } from '@angular/core'; import { - extractStalkerAuthFailureBody, isFullStalkerPortalPlaylist, isFullStalkerPortalUrl, Playlist, PlaylistMeta, - STALKER_REQUEST, } from '@iptvnator/shared/interfaces'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; @@ -24,8 +22,13 @@ import { type StalkerPortalCredentials, type StalkerProfileResponse, } from './stalker-auth.api'; -import { isStalkerAuthorizationFailure } from './stalker-response-classification'; -import { StalkerPortalError } from './stalker-portal-error'; +import { StalkerAuthenticatedRequestClient } from './stalker-authenticated-request-client'; +import { StalkerEditedSessionCoordinator } from './stalker-edited-session-coordinator'; +import type { StalkerEditFence } from './stalker-edited-session-coordinator'; +import { + StalkerPortalRepairDiscoveryCoordinator, + type StalkerPortalRepairDiscoveryFence, +} from './stalker-portal-repair-discovery-coordinator'; import { stalkerSessionFingerprint, StalkerSessionStore, @@ -40,6 +43,8 @@ export { stalkerIdentityFingerprint, }; export type { StalkerPortalIdentity }; +export type { StalkerEditFence }; +export type { StalkerPortalRepairDiscoveryFence }; export type { StalkerAuthenticateOptions, StalkerAuthenticationResult, @@ -72,7 +77,16 @@ export class StalkerSessionService { // when a session is resolved from (or written back to) the stored row. private readonly injector = inject(Injector); private readonly logger = createLogger('StalkerSession'); - private readonly authApi = new StalkerAuthApi(this.dataService, this.logger); + private readonly authApi = new StalkerAuthApi( + this.dataService, + this.logger + ); + readonly performHandshake = this.authApi.performHandshake.bind( + this.authApi + ); + readonly getProfile = this.authApi.getProfile.bind(this.authApi); + readonly doAuth = this.authApi.doAuth.bind(this.authApi); + readonly authenticate = this.authApi.authenticate.bind(this.authApi); private readonly sessionStore = new StalkerSessionStore( () => this.injector.get(PlaylistsService), this.logger @@ -88,6 +102,22 @@ export class StalkerSessionService { // Session state for this run, identity-tagged so an edited playlist can // inherit neither a cached token nor an in-flight authentication. private readonly tokens = new StalkerTokenCache(); + private readonly editedSessions = new StalkerEditedSessionCoordinator( + this.tokens, + this.watchdog, + () => this.injector.get(PlaylistsService) + ); + private readonly portalRepairDiscoveries = + new StalkerPortalRepairDiscoveryCoordinator(this.tokens); + private readonly requestClient = new StalkerAuthenticatedRequestClient( + this.dataService, + this.tokens, + (playlist) => this.ensureToken(playlist), + (playlist, sessionFingerprint) => { + this.portalRepairDiscoveries.assertAvailable(playlist._id); + this.editedSessions.assertCurrent(playlist, sessionFingerprint); + } + ); /** * Checks if a URL looks like a full stalker portal URL (requires @@ -191,6 +221,8 @@ export class StalkerSessionService { timeslotSeconds?: number; } ): void { + const fingerprint = + this.editedSessions.markAuthoritative(identitySource); this.setCachedToken(playlistId, session.token, identitySource); this.applySessionOutcome( playlistId, @@ -206,88 +238,66 @@ export class StalkerSessionService { watchdogTimeoutSeconds: identitySource.stalkerWatchdogTimeout, timeslotSeconds: identitySource.stalkerTimeslot, }, - stalkerSessionFingerprint(identitySource) + fingerprint + ); + } + + /** Retires a full-portal session after discovery proves a simple portal. */ + adoptDiscoveredSimplePortal(identitySource: Playlist): void { + this.editedSessions.markAuthoritative(identitySource); + this.clearCachedToken(identitySource._id); + } + + /** + * Reserves the playlist for Edit and drains authentication that began + * before discovery. The opaque fence keeps new authentication out until + * the result is either cancelled or atomically persisted. + */ + beginEditDiscovery( + playlist: Playlist, + sourcePlaylist: Playlist = playlist, + beforeCrossContextReservation: Promise = Promise.resolve() + ): Promise { + return this.editedSessions.beginEdit( + playlist, + sourcePlaylist, + beforeCrossContextReservation ); } /** - * Performs handshake to get a session token for a full stalker portal. - * An optional persisted token is re-presented: the handshake is - * idempotent, so a still-valid token comes back unchanged. + * Blocks new runtime authentication while lazy repair probes this + * playlist, and snapshots authentication that was already in flight so + * repair can drain it before issuing its own profile request. */ - performHandshake( - portalUrl: string, - macAddress: string, - identity: StalkerPortalIdentity = {}, - storedToken?: string - ): Promise<{ token: string; random: string; notValid: boolean }> { - return this.authApi.performHandshake( - portalUrl, - macAddress, - identity, - storedToken - ); + beginPortalRepairDiscovery( + playlistId: string + ): StalkerPortalRepairDiscoveryFence { + return this.portalRepairDiscoveries.begin(playlistId); + } + + /** Releases the exact lazy-repair authentication owner. */ + completePortalRepairDiscovery( + fence: StalkerPortalRepairDiscoveryFence + ): void { + this.portalRepairDiscoveries.complete(fence); + } + + /** Releases a discovery reservation whose result will not be saved. */ + cancelEditDiscovery(fence: StalkerEditFence): void { + this.editedSessions.cancelEdit(fence); } /** - * Gets account profile information to validate the portal and check - * subscription. + * Installs the session produced by explicit Edit discovery. The new + * fingerprint becomes authoritative only after the atomic row write. */ - getProfile( - portalUrl: string, - macAddress: string, - token: string, - identity: StalkerPortalIdentity, - handshakeRandom: string, - options: { authSecondStep?: boolean; notValidToken?: boolean } = {} - ): Promise { - return this.authApi.getProfile( - portalUrl, - macAddress, - token, - identity, - handshakeRandom, - options - ); - } - - /** - * Performs do_auth — the login/password step behind get_profile status 2. - */ - doAuth( - portalUrl: string, - macAddress: string, - token: string, - credentials: StalkerPortalCredentials, - identity: StalkerPortalIdentity = {} - ): Promise { - return this.authApi.doAuth( - portalUrl, - macAddress, - token, - credentials, - identity - ); - } - - /** - * Performs the full authentication flow: handshake → get_profile, driving - * the status-2 `do_auth` login flow when the portal demands credentials. - * Throws `StalkerPortalError` with the portal's own `msg`/`block_msg` - * text when the portal refuses the session. - */ - authenticate( - portalUrl: string, - macAddress: string, - identity: StalkerPortalIdentity = {}, - options: StalkerAuthenticateOptions = {} - ): Promise { - return this.authApi.authenticate( - portalUrl, - macAddress, - identity, - options - ); + replaceSessionAfterEdit( + playlist: Playlist, + fence: StalkerEditFence, + options: { preserveCurrentMetadata?: boolean } = {} + ): Promise { + return this.editedSessions.replace(playlist, fence, options); } /** @@ -300,6 +310,15 @@ export class StalkerSessionService { async ensureToken( playlist: Playlist ): Promise<{ token: string | null; serialNumber?: string }> { + const pendingRepair = this.portalRepairDiscoveries.waitIfPending( + playlist._id + ); + if (pendingRepair) { + await pendingRepair; + } + const fingerprint = await this.editedSessions.guard(playlist); + this.portalRepairDiscoveries.assertAvailable(playlist._id); + // If not a full stalker portal, no token needed if (!isFullStalkerPortalPlaylist(playlist)) { return { token: null }; @@ -309,8 +328,6 @@ export class StalkerSessionService { // One key for both caches: endpoint + identity + credentials. An // identity-only in-run key would hand the cached bearer token to a // freshly edited endpoint before the persisted check ever ran. - const fingerprint = stalkerSessionFingerprint(playlist); - // Only the session negotiated for THIS identity may be reused. const cachedToken = this.tokens.takeFor(playlist._id, fingerprint); if (cachedToken) { @@ -345,6 +362,11 @@ export class StalkerSessionService { const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; + // guard() may have yielded for a persisted-row authority rebase. An + // Edit can reserve the playlist before this continuation claims the + // token slot, so close that final pre-handshake window as well. + this.editedSessions.assertCurrent(playlist, fingerprint); + // Create the authentication promise and store it to prevent concurrent auth attempts // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { @@ -356,6 +378,10 @@ export class StalkerSessionService { playlist, fingerprint ); + // Repair may have claimed the playlist while the persisted + // session read yielded. Its fence will drain this published + // slot; abort before putting another get_profile on the wire. + this.portalRepairDiscoveries.assertAvailable(playlist._id); const result = await this.authenticate( portalUrl, macAddress, @@ -376,6 +402,8 @@ export class StalkerSessionService { stored.watchdogTimeoutSeconds !== undefined, } ); + this.portalRepairDiscoveries.assertAvailable(playlist._id); + this.editedSessions.assertCurrent(playlist, fingerprint); this.setCachedToken(playlist._id, result.token, playlist); this.applySessionOutcome( playlist._id, @@ -420,13 +448,20 @@ export class StalkerSessionService { throw new Error('Portal URL and MAC address are required'); } + const pendingRepair = this.portalRepairDiscoveries.waitIfPending( + playlist._id + ); + if (pendingRepair) { + await pendingRepair; + } const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; const identity = getStalkerPortalIdentityFromPlaylist(playlist); // One key for both caches: endpoint + identity + credentials. An // identity-only in-run key would hand the cached bearer token to a // freshly edited endpoint before the persisted check ever ran. - const fingerprint = stalkerSessionFingerprint(playlist); + const fingerprint = await this.editedSessions.guard(playlist); + this.portalRepairDiscoveries.assertAvailable(playlist._id); // Claim the per-playlist slot. Re-check after every await: one // settled promise releases every waiter at once, so a single @@ -441,6 +476,8 @@ export class StalkerSessionService { // performs its own handshake either way. await inFlight.promise.catch(() => undefined); } + this.portalRepairDiscoveries.assertAvailable(playlist._id); + this.editedSessions.assertCurrent(playlist, fingerprint); // Publish the slot before the first await so no other waiter can // observe it as free while this handshake is starting. @@ -481,6 +518,8 @@ export class StalkerSessionService { }, } ); + this.portalRepairDiscoveries.assertAvailable(playlist._id); + this.editedSessions.assertCurrent(playlist, fingerprint); this.setCachedToken(playlist._id, result.token, playlist); // This path always ran a real get_profile, so the decoded cadence // is authoritative; the previous values are only the write-back @@ -536,68 +575,10 @@ export class StalkerSessionService { params: Record, retryOnAuthFailure = true ): Promise { - // Get token (will wait if auth is in progress) - const { token, serialNumber } = await this.ensureToken(playlist); - - try { - const response = await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params, - token, - ...(serialNumber ? { serialNumber } : {}), - } - ); - - // Check for authorization failure in response - if (isStalkerAuthorizationFailure(response)) { - // Retire the failed token even when not retrying (e.g. - // watchdog pings): leaving it cached would hand a dead - // session to the next caller. - this.tokens.retireFailed(playlist._id, token); - if ( - retryOnAuthFailure && - isFullStalkerPortalPlaylist(playlist) - ) { - // Retry once with fresh authentication - return this.makeAuthenticatedRequest( - playlist, - params, - false - ); - } - - const failureBody = - extractStalkerAuthFailureBody(response) ?? undefined; - throw new StalkerPortalError( - 'auth-failed', - failureBody, - failureBody - ); - } - - return response; - } catch (error) { - // Check if error indicates auth failure - if (isStalkerAuthorizationFailure(error)) { - // Same rule as above: a failed session is retired even on - // the no-retry path. - this.tokens.retireFailed(playlist._id, token); - if ( - retryOnAuthFailure && - isFullStalkerPortalPlaylist(playlist) - ) { - // Retry with a fresh handshake - return this.makeAuthenticatedRequest( - playlist, - params, - false - ); - } - } - throw error; - } + return this.requestClient.request( + playlist, + params, + retryOnAuthFailure + ); } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index 457d1b496..54991d516 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -27,6 +27,9 @@ describe('StalkerStore API compatibility smoke', () => { useValue: { ensureToken: jest.fn(), makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), }, }, { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index ea631d084..49fe0ce7f 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -150,6 +150,9 @@ describe('withStalkerContent failure states', () => { provide: StalkerSessionService, useValue: { makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), }, }, { @@ -481,7 +484,13 @@ describe('withStalkerContent failure states', () => { await waitForCondition(() => store.getPaginatedContent().length === 1); store.setPage(1); - await waitForCondition(() => store.hasContentAppendError()); + // The grid renders the retry action only after the failed resource + // has settled; while loading it renders the append spinner instead. + await waitForCondition( + () => + store.hasContentAppendError() && + !store.isPaginatedContentLoading() + ); // The failed append left page 1 on screen, not the empty state. expect( @@ -667,6 +676,9 @@ describe('withStalkerContent full ITV channel list cache', () => { provide: StalkerSessionService, useValue: { makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), }, }, { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts index bdc3ac141..4573c561c 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts @@ -42,6 +42,7 @@ describe('withStalkerEpg', () => { let runtimeSupportsEpg: boolean; let stalkerSessionService: { makeAuthenticatedRequest: jest.Mock, unknown[]>; + ensureToken: jest.Mock, unknown[]>; }; let epgBridge: { supportsEpgMapping: boolean; @@ -56,6 +57,7 @@ describe('withStalkerEpg', () => { }; stalkerSessionService = { makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }; epgBridge = { supportsEpgMapping: true, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index e42b528b9..6012af0a7 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -112,6 +112,9 @@ describe('withStalkerPlayer', () => { useValue: { getCachedToken: jest.fn(), makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), }, }, { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts index b7ef4a5da..e5464a9e0 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts @@ -94,6 +94,9 @@ describe('withStalkerSeries serialSeasonsResource gating', () => { provide: StalkerSessionService, useValue: { makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), }, }, { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts index 413ab6d0e..2e8ff6b7e 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts @@ -59,7 +59,14 @@ describe('withStalkerSnapshotRefresh', () => { providers: [ TestSnapshotRefreshStore, { provide: DataService, useValue: dataService }, - { provide: StalkerSessionService, useValue: {} }, + { + provide: StalkerSessionService, + useValue: { + ensureToken: jest.fn().mockResolvedValue({ + token: null, + }), + }, + }, ], }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts index 302f33ce5..96c6d8e87 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts @@ -317,7 +317,7 @@ describe('stalker-player-request.utils', () => { ); }); - it('skips the handshake for a simple portal', async () => { + it('guards a simple portal without requiring a token', async () => { // The command has to be PORTAL-OWNED, or the foreign-host early // return would skip the handshake by itself and this would pass // without saying anything about portal mode. `PLAYLIST` is a @@ -330,7 +330,12 @@ describe('stalker-player-request.utils', () => { }); expect(streamUrl).toBe('http://demo.example/live/42.m3u8'); - expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ + _id: PLAYLIST._id, + isFullStalkerPortal: false, + }) + ); expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts index 568055315..9fca2fb36 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts @@ -17,6 +17,7 @@ function createDeps(): StalkerRequestDeps { }, stalkerSession: { makeAuthenticatedRequest: jest.fn().mockResolvedValue({ js: [] }), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }, } as unknown as StalkerRequestDeps; } @@ -75,6 +76,67 @@ describe('executeStalkerRequest', () => { expect( deps.stalkerSession.makeAuthenticatedRequest ).not.toHaveBeenCalled(); + expect(deps.stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ + ...playlist, + lastUsage: '', + }) + ); + }); + + it('does not dispatch a token-free request from a stale portal mode', async () => { + const deps = createDeps(); + const stalePlaylist = { + _id: 'stalker-stale-simple', + title: 'Stale simple snapshot', + portalUrl: 'https://portal.example.test/server/load.php', + macAddress: 'has-mac-address', + isFullStalkerPortal: false, + } as PlaylistMeta; + (deps.stalkerSession.ensureToken as jest.Mock).mockRejectedValue( + new Error('Stale Stalker playlist configuration') + ); + + await expect( + executeStalkerRequest(deps, stalePlaylist, CATEGORY_PARAMS) + ).rejects.toThrow(/stale/i); + + expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('rejects a direct response completed after its mode becomes stale', async () => { + const deps = createDeps(); + const simplePlaylist = { + _id: 'stalker-in-flight-simple', + title: 'In-flight simple snapshot', + portalUrl: 'https://portal.example.test/server/load.php', + macAddress: 'has-mac-address', + isFullStalkerPortal: false, + } as PlaylistMeta; + let resolveResponse!: (value: unknown) => void; + (deps.dataService.sendIpcEvent as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => (resolveResponse = resolve)) + ); + (deps.stalkerSession.ensureToken as jest.Mock) + .mockResolvedValueOnce({ token: null }) + .mockRejectedValueOnce( + new Error('Stale Stalker playlist configuration') + ); + + const request = executeStalkerRequest( + deps, + simplePlaylist, + CATEGORY_PARAMS + ); + while ( + (deps.dataService.sendIpcEvent as jest.Mock).mock.calls.length === 0 + ) { + await Promise.resolve(); + } + resolveResponse({ js: ['STALE_CATEGORY'] }); + + await expect(request).rejects.toThrow(/stale/i); + expect(deps.stalkerSession.ensureToken).toHaveBeenCalledTimes(2); }); }); @@ -111,6 +173,7 @@ describe('executeStalkerRequest lazy portal repair', () => { overrides: Partial = {} ): StalkerPortalRepairApi { return { + waitForPendingRepair: jest.fn().mockResolvedValue(undefined), applyOverride: jest.fn((playlist) => playlist), shouldAttemptRepair: jest.fn().mockReturnValue(false), repairPortal: jest.fn().mockResolvedValue(null), @@ -118,6 +181,42 @@ describe('executeStalkerRequest lazy portal repair', () => { } as StalkerPortalRepairApi; } + it('waits for a pending repair before choosing the effective connection', async () => { + const deps = createDeps(); + let releaseRepair: () => void = () => undefined; + const repairSettled = new Promise((resolve) => { + releaseRepair = resolve; + }); + const repaired = { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta; + const repair = createRepair({ + waitForPendingRepair: jest.fn(() => repairSettled), + applyOverride: jest.fn().mockReturnValue(repaired), + }); + deps.portalRepair = repair; + + const request = executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS); + await Promise.resolve(); + + expect(repair.applyOverride).not.toHaveBeenCalled(); + expect( + deps.stalkerSession.makeAuthenticatedRequest + ).not.toHaveBeenCalled(); + + releaseRepair(); + await request; + + expect(repair.applyOverride).toHaveBeenCalledWith(PLAYLIST); + expect( + deps.stalkerSession.makeAuthenticatedRequest + ).toHaveBeenCalledWith( + expect.objectContaining({ isFullStalkerPortal: true }), + CATEGORY_PARAMS + ); + }); + it('retries once against the repaired configuration after an auth-failure body', async () => { const deps = createDeps(); deps.dataService.sendIpcEvent = jest @@ -196,9 +295,7 @@ describe('executeStalkerRequest lazy portal repair', () => { await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS); - expect( - deps.stalkerSession.makeAuthenticatedRequest - ).toHaveBeenCalled(); + expect(deps.stalkerSession.makeAuthenticatedRequest).toHaveBeenCalled(); expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled(); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts index 58d0dc6bf..f3ed08ded 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -13,6 +13,8 @@ import { StalkerSessionService } from '../../stalker-session.service'; * at the lib root and depend on these utils without a cycle. */ export interface StalkerPortalRepairApi { + /** Waits until repair can no longer change the effective connection. */ + waitForPendingRepair?(playlistId: string): Promise; /** Applies a completed repair; returns the SAME reference when no-op. */ applyOverride(playlist: T): T; /** Whether this failure shape justifies probing the portal at all. */ @@ -70,6 +72,8 @@ export async function ensureStalkerSession( return false; } + await deps.portalRepair?.waitForPendingRepair?.(playlist._id); + // The repair override is applied here for the same reason // `executeStalkerRequest` applies it on its first line: a completed repair // may have moved the endpoint or the mode while the caller still holds the @@ -79,17 +83,14 @@ export async function ensureStalkerSession( ? deps.portalRepair.applyOverride(playlist) : playlist; - // A token-free panel needs no session, so it can serve credentialed - // streams as well as it ever could. - if (!isFullStalkerPortalPlaylist(effective)) { - return true; - } - try { const { token } = await deps.stalkerSession.ensureToken( toStalkerSessionPlaylist(effective) ); - return Boolean(token); + // `ensureToken` is also the post-Edit configuration guard. For a + // simple portal it returns immediately with no token and no network + // request, but still rejects a snapshot whose observed mode is stale. + return isFullStalkerPortalPlaylist(effective) ? Boolean(token) : true; } catch (error) { logger?.warn('Could not establish the Stalker session', error); return false; @@ -115,11 +116,22 @@ async function dispatchStalkerRequest( ); } - return deps.dataService.sendIpcEvent(STALKER_REQUEST, { + // A direct request has no authentication layer to invoke the Edit + // authority guard. `ensureToken` is network-free in simple mode, and + // prevents a pre-Edit simple snapshot from issuing a token-free request + // after the same endpoint has been reclassified as full. + const sessionPlaylist = toStalkerSessionPlaylist(playlist); + await deps.stalkerSession.ensureToken(sessionPlaylist); + + const response = await deps.dataService.sendIpcEvent(STALKER_REQUEST, { url: playlist.portalUrl, macAddress: playlist.macAddress, params, }); + // Re-check after transport: Edit may have committed while the direct + // request was in flight. In simple mode this guard remains network-free. + await deps.stalkerSession.ensureToken(sessionPlaylist); + return response; } /** @@ -159,6 +171,7 @@ export async function executeStalkerRequest( playlist: PlaylistMeta, params: Record ): Promise { + await deps.portalRepair?.waitForPendingRepair?.(playlist._id); const effective = deps.portalRepair ? deps.portalRepair.applyOverride(playlist) : playlist; diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 7827a0d89..162cc9a0b 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/playback-position.service'; export * from './lib/playlist-delete-cleanup.token'; export * from './lib/playlist-delete-action.service'; export * from './lib/playlist-backup.service'; +export * from './lib/playlist-cross-context-lock'; export * from './lib/playlist-refresh.service'; export * from './lib/playlists.service'; export * from './lib/portal-status.service'; diff --git a/libs/services/src/lib/playlist-cross-context-lock.ts b/libs/services/src/lib/playlist-cross-context-lock.ts new file mode 100644 index 000000000..3461e7830 --- /dev/null +++ b/libs/services/src/lib/playlist-cross-context-lock.ts @@ -0,0 +1,147 @@ +const PLAYLIST_AUTHORITY_BARRIER = 'iptvnator:playlist-authority'; + +export type PlaylistAuthorityReservation = + | { + readonly status: 'acquired'; + readonly release: () => void; + readonly released: Promise; + } + | { readonly status: 'busy' } + | { readonly status: 'unavailable' }; + +function getLockManager(): LockManager | undefined { + return globalThis.navigator?.locks; +} + +function getPlaylistAuthorityLockName(playlistId: string): string { + return `${PLAYLIST_AUTHORITY_BARRIER}:${playlistId}`; +} + +async function runWithPlaylistRowLocks( + locks: LockManager, + playlistIds: readonly string[], + operation: () => Promise, + index = 0 +): Promise { + if (index >= playlistIds.length) { + return operation(); + } + + return locks.request( + getPlaylistAuthorityLockName(playlistIds[index]), + { mode: 'exclusive' }, + () => runWithPlaylistRowLocks(locks, playlistIds, operation, index + 1) + ); +} + +/** + * Coordinates row replacement operations with long-running Stalker Edit + * discovery in every browser tab from the same origin. + */ +export async function runWithPlaylistAuthorityMutation( + playlistIds: readonly string[], + operation: () => Promise +): Promise { + const locks = getLockManager(); + if (!locks) { + return operation(); + } + const orderedIds = [...new Set(playlistIds.filter(Boolean))].sort(); + + return locks.request(PLAYLIST_AUTHORITY_BARRIER, { mode: 'shared' }, () => + runWithPlaylistRowLocks(locks, orderedIds, operation) + ); +} + +/** Prevents a store-wide clear from crossing any row-scoped reservation. */ +export async function runWithPlaylistAuthorityReset( + operation: () => Promise +): Promise { + const locks = getLockManager(); + if (!locks) { + return operation(); + } + + return locks.request( + PLAYLIST_AUTHORITY_BARRIER, + { mode: 'exclusive' }, + operation + ); +} + +/** Tries to hold one origin-wide row reservation until its owner releases it. */ +export async function reservePlaylistAuthority( + playlistId: string +): Promise { + const locks = getLockManager(); + if (!locks) { + const isElectronRenderer = + typeof window !== 'undefined' && + Boolean((window as Window & { electron?: unknown }).electron); + if (typeof window === 'undefined' || isElectronRenderer) { + return { + status: 'acquired', + release: () => undefined, + released: Promise.resolve(), + }; + } + return { status: 'unavailable' }; + } + + let releaseHeldLock: () => void = () => undefined; + const held = new Promise((resolve) => { + releaseHeldLock = resolve; + }); + let requestSettled = Promise.resolve(); + const acquired = new Promise((resolve, reject) => { + const request = locks.request( + PLAYLIST_AUTHORITY_BARRIER, + { mode: 'shared' }, + () => + locks.request( + getPlaylistAuthorityLockName(playlistId), + { ifAvailable: true, mode: 'exclusive' }, + async (lock) => { + if (!lock) { + resolve(false); + return; + } + resolve(true); + await held; + } + ) + ); + requestSettled = request.then(() => undefined); + void request.catch(reject); + }); + + if (!(await acquired)) { + return { status: 'busy' }; + } + + let released = false; + return { + status: 'acquired', + release: () => { + if (!released) { + released = true; + releaseHeldLock(); + } + }, + released: requestSettled, + }; +} + +/** Holds one origin-wide Edit reservation until persistence or cancellation. */ +export async function acquirePlaylistAuthorityEditReservation( + playlistId: string +): Promise<() => void> { + const reservation = await reservePlaylistAuthority(playlistId); + if (reservation.status === 'acquired') { + return reservation.release; + } + if (reservation.status === 'unavailable') { + throw new Error('Cross-context playlist edit locking is unavailable'); + } + throw new Error('Stalker playlist edit already in progress'); +} diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index dbf621fa1..4c424f473 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, of } from 'rxjs'; +import { EMPTY, firstValueFrom, of } from 'rxjs'; import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { PlaylistsService, resolvePlaylistParser } from './playlists.service'; @@ -9,9 +9,14 @@ const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG = describe('PlaylistsService', () => { const testWindow = window as unknown as { electron?: unknown }; const originalElectron = testWindow.electron; + const originalLockManager = globalThis.navigator?.locks; afterEach(() => { testWindow.electron = originalElectron; + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: originalLockManager, + }); localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG); jest.useRealTimers(); jest.restoreAllMocks(); @@ -26,10 +31,11 @@ describe('PlaylistsService', () => { dbService: { clear: jest.fn(() => of(undefined)), delete: jest.fn(() => of(undefined)), - // IndexedDB operations always run the Stalker metadata migration first, - // and that migration reads all playlists before the requested operation. + // IndexedDB operations run the Stalker metadata migration first; + // the default cursor represents an empty playlist store. getAll: jest.fn(() => of([])), getByID: jest.fn(() => of(undefined)), + openCursor: jest.fn(() => EMPTY), update: jest.fn(() => of(undefined)), ...overrides, }, @@ -42,8 +48,7 @@ describe('PlaylistsService', () => { runtime: { get supportsSqlite() { const electron = testWindow.electron as - | Record - | undefined; + Record | undefined; return ( !!electron && typeof electron['dbGetAppPlaylists'] === 'function' && @@ -307,6 +312,56 @@ describe('PlaylistsService', () => { ); }); + it('coordinates browser row replacements with the cross-context edit reservation', async () => { + const request = jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => + callback({ + name, + mode: options.mode ?? 'exclusive', + } as Lock) + ); + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { request }, + }); + const playlist = { + _id: 'playlist-replacement-lock', + title: 'Replacement', + count: 0, + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + } as Playlist; + const dbService = { + add: jest.fn(() => of('generated-key')), + delete: jest.fn(() => of(undefined)), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await firstValueFrom(service.addPlaylist(playlist)); + await firstValueFrom(service.deletePlaylist(playlist._id)); + + expect( + request.mock.calls.map(([name, options]) => [name, options]) + ).toEqual([ + ['iptvnator:playlist-authority', { mode: 'shared' }], + [ + `iptvnator:playlist-authority:${playlist._id}`, + { mode: 'exclusive' }, + ], + ['iptvnator:playlist-authority', { mode: 'shared' }], + [ + `iptvnator:playlist-authority:${playlist._id}`, + { mode: 'exclusive' }, + ], + ]); + }); + it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => { let storedPlaylists: Playlist[] = [ { @@ -396,7 +451,7 @@ describe('PlaylistsService', () => { ); }); - it('migrates legacy Stalker portal flags in IndexedDB before returning full playlists', async () => { + it('migrates legacy Stalker portal flags in one IndexedDB cursor transaction', async () => { let storedPlaylists: Playlist[] = [ { _id: 'stalker-2', @@ -409,14 +464,19 @@ describe('PlaylistsService', () => { portalUrl: 'http://example.com/portal/c/', } as Playlist, ]; + const cursorUpdate = jest.fn((playlist: Playlist) => { + storedPlaylists = [playlist]; + }); + const cursorContinue = jest.fn(); const dbService = { getAll: jest.fn(() => of(storedPlaylists)), - update: jest.fn((_storeName: string, playlist: Playlist) => { - storedPlaylists = storedPlaylists.map((current) => - current._id === playlist._id ? playlist : current - ); - return of(playlist); - }), + openCursor: jest.fn(() => + of({ + value: storedPlaylists[0], + update: cursorUpdate, + continue: cursorContinue, + }) + ), }; testWindow.electron = undefined; @@ -429,13 +489,18 @@ describe('PlaylistsService', () => { }), ]); - expect(dbService.update).toHaveBeenCalledWith( - DbStores.Playlists, + expect(dbService.openCursor).toHaveBeenCalledWith({ + storeName: DbStores.Playlists, + mode: 'readwrite', + }); + expect(cursorUpdate).toHaveBeenCalledWith( expect.objectContaining({ _id: 'stalker-2', isFullStalkerPortal: false, }) ); + expect(cursorContinue).toHaveBeenCalledTimes(1); + expect(dbService.getAll).toHaveBeenCalledTimes(1); expect( localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) ).toBe('1'); @@ -587,6 +652,249 @@ describe('PlaylistsService', () => { ); }); + it('aborts a guarded metadata update in one readwrite transaction when the row no longer matches', async () => { + const replacementPlaylist = { + _id: 'stalker-replaced', + title: 'Restored Portal', + portalUrl: 'https://restored.example.com/portal.php', + } as Playlist; + const cursorUpdate = jest.fn(); + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(), + update: jest.fn(), + openCursor: jest.fn(() => + of({ + value: replacementPlaylist, + update: cursorUpdate, + }) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await expect( + firstValueFrom( + service.updatePlaylistMetaIfCurrent( + { + _id: replacementPlaylist._id, + portalUrl: 'https://late.example.com/server/load.php', + } as PlaylistMeta, + (current) => + current.portalUrl === + 'https://original.example.com/portal.php' + ) + ) + ).resolves.toBeNull(); + + expect(dbService.openCursor).toHaveBeenCalledWith({ + storeName: DbStores.Playlists, + query: replacementPlaylist._id, + mode: 'readwrite', + }); + expect(cursorUpdate).not.toHaveBeenCalled(); + expect(dbService.getByID).not.toHaveBeenCalled(); + expect(dbService.update).not.toHaveBeenCalled(); + }); + + it('merges and commits a matching guarded browser update through its cursor', async () => { + const currentPlaylist = { + _id: 'stalker-guarded-write', + title: 'Original Portal', + portalUrl: 'https://original.example.com/portal.php', + macAddress: '00:1A:79:00:00:01', + } as Playlist; + const cursorUpdate = jest.fn(); + const dbService = { + getAll: jest.fn(() => of([])), + openCursor: jest.fn(() => + of({ + value: currentPlaylist, + update: cursorUpdate, + }) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + const result = await firstValueFrom( + service.updatePlaylistMetaIfCurrent( + { + _id: currentPlaylist._id, + portalUrl: 'https://resolved.example.com/server/load.php', + stalkerSessionPatch: null, + } as never, + (current) => current.portalUrl === currentPlaylist.portalUrl + ) + ); + + expect(cursorUpdate).toHaveBeenCalledWith(result); + expect(result).toEqual( + expect.objectContaining({ + title: currentPlaylist.title, + portalUrl: 'https://resolved.example.com/server/load.php', + macAddress: currentPlaylist.macAddress, + stalkerToken: undefined, + }) + ); + }); + + it('commits a browser conditional transform through the same readwrite cursor', async () => { + const currentPlaylist = { + _id: 'stalker-cursor-write', + title: 'Original Portal', + portalUrl: 'https://original.example.com/portal.php', + } as Playlist; + const cursorUpdate = jest.fn(); + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(), + update: jest.fn(), + openCursor: jest.fn(() => + of({ + value: currentPlaylist, + update: cursorUpdate, + }) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + const result = await firstValueFrom( + service.transformPlaylistMeta(currentPlaylist._id, (current) => ({ + ...current, + portalUrl: 'https://resolved.example.com/server/load.php', + })) + ); + + expect(dbService.openCursor).toHaveBeenCalledWith({ + storeName: DbStores.Playlists, + query: currentPlaylist._id, + mode: 'readwrite', + }); + expect(cursorUpdate).toHaveBeenCalledWith(result); + expect(result?.portalUrl).toBe( + 'https://resolved.example.com/server/load.php' + ); + expect(dbService.getByID).not.toHaveBeenCalled(); + expect(dbService.update).not.toHaveBeenCalled(); + }); + + describe('Stalker session patches in playlist meta updates', () => { + function createStalkerPlaylist(): Playlist { + return { + _id: 'stalker-session-patch', + title: 'Stalker Portal', + count: 0, + importDate: '2026-08-08T00:00:00.000Z', + lastUsage: '2026-08-08T00:00:00.000Z', + autoRefresh: false, + stalkerToken: 'OLD_TOKEN', + stalkerSessionIdentity: 'old-fingerprint', + stalkerWatchdogTimeout: 120, + stalkerTimeslot: 15, + stalkerAccountInfo: { + login: 'old-login', + expireDate: 1800000000, + }, + }; + } + + it('preserves the current session when the transient patch is absent', async () => { + const existingPlaylist = createStalkerPlaylist(); + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await firstValueFrom( + service.updatePlaylistMeta({ + _id: existingPlaylist._id, + title: 'Renamed Portal', + } as PlaylistMeta) + ); + + expect(dbService.update.mock.calls[0][1]).toEqual( + expect.objectContaining({ + stalkerToken: 'OLD_TOKEN', + stalkerSessionIdentity: 'old-fingerprint', + stalkerWatchdogTimeout: 120, + stalkerTimeslot: 15, + stalkerAccountInfo: existingPlaylist.stalkerAccountInfo, + }) + ); + }); + + it('clears every stored session field when the transient patch is null', async () => { + const existingPlaylist = createStalkerPlaylist(); + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await firstValueFrom( + service.updatePlaylistMeta({ + _id: existingPlaylist._id, + stalkerSessionPatch: null, + } as never) + ); + + const written = dbService.update.mock.calls[0][1]; + expect(written.stalkerToken).toBeUndefined(); + expect(written.stalkerSessionIdentity).toBeUndefined(); + expect(written.stalkerWatchdogTimeout).toBeUndefined(); + expect(written.stalkerTimeslot).toBeUndefined(); + expect(written.stalkerAccountInfo).toBeUndefined(); + expect(written).not.toHaveProperty('stalkerSessionPatch'); + }); + + it('fully replaces session metadata without persisting the transient patch', async () => { + const existingPlaylist = createStalkerPlaylist(); + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await firstValueFrom( + service.updatePlaylistMeta({ + _id: existingPlaylist._id, + stalkerSessionPatch: { + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + }, + } as never) + ); + + const written = dbService.update.mock.calls[0][1]; + expect(written).toEqual( + expect.objectContaining({ + stalkerToken: 'NEW_TOKEN', + stalkerSessionIdentity: 'new-fingerprint', + stalkerWatchdogTimeout: 90, + }) + ); + expect(written.stalkerTimeslot).toBeUndefined(); + expect(written.stalkerAccountInfo).toBeUndefined(); + expect(written).not.toHaveProperty('stalkerSessionPatch'); + }); + }); + it('updates many browser playlists with refresh metadata', async () => { jest.spyOn(Date, 'now').mockReturnValue(1770000000000); // Auto-refresh snapshots always come from playlists that had @@ -942,6 +1250,37 @@ describe('PlaylistsService', () => { expect(dbService.clear).toHaveBeenCalledWith(DbStores.Playlists); }); + it('takes the exclusive cross-context authority barrier before clearing playlists', async () => { + const request = jest.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise + ) => + callback({ + name, + mode: options.mode ?? 'exclusive', + } as Lock) + ); + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: { request }, + }); + const dbService = { + clear: jest.fn(() => of('cleared')), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await firstValueFrom(service.removeAll()); + + expect(request).toHaveBeenCalledWith( + 'iptvnator:playlist-authority', + { mode: 'exclusive' }, + expect.any(Function) + ); + }); + it('adds many browser playlists through bulkAdd', async () => { const playlists = [ { @@ -1623,7 +1962,7 @@ describe('PlaylistsService', () => { }); it('transformPlaylistMeta aborts without writing when the transform returns null', async () => { - const { store, electron } = createStatefulElectronStore( + const { electron } = createStatefulElectronStore( createBasePlaylist('portal-meta-abort') ); testWindow.electron = electron; @@ -1662,7 +2001,10 @@ describe('PlaylistsService', () => { 'portal-meta-write', (current) => current.title === 'Edited Title' - ? { ...current, portalUrl: 'http://x/portal.php' } + ? { + ...current, + portalUrl: 'http://x/portal.php', + } : null ) ), diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 942f980c8..c26a6142e 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -7,7 +7,7 @@ import { createPlaylistObject, resolvePlaylistEpgSourceState, } from '@iptvnator/shared/m3u-utils'; -import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { DBMode, NgxIndexedDBService } from 'ngx-indexed-db'; import { combineLatest, defer, @@ -27,13 +27,17 @@ import { M3uFavoriteChannel, M3uRecentlyViewedItem, Playlist, - PlaylistMeta, + PlaylistMetaUpdate, PlaylistRecentlyViewedItem, PlaylistUpdateState, StalkerPortalItem, normalizeStalkerDate, } from '@iptvnator/shared/interfaces'; import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token'; +import { + runWithPlaylistAuthorityMutation, + runWithPlaylistAuthorityReset, +} from './playlist-cross-context-lock'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; @@ -346,21 +350,32 @@ export class PlaylistsService { return; } - const storedPlaylists = await firstValueFrom( - this.dbService.getAll(DbStores.Playlists) - ); - const updates = - this.collectStalkerMetadataMigrationUpdates(storedPlaylists); - - if (updates.length > 0) { - await firstValueFrom( - combineLatest( - updates.map((playlist) => - this.dbService.update(DbStores.Playlists, playlist) - ) - ) - ); - } + await new Promise((resolve, reject) => { + this.dbService + .openCursor({ + storeName: DbStores.Playlists, + mode: DBMode.readwrite, + }) + .subscribe({ + next: (cursor) => { + try { + const migratedPlaylist = + this.withExplicitLegacyStalkerPortalFlag( + cursor.value + ); + if (migratedPlaylist !== cursor.value) { + cursor.update(migratedPlaylist); + } + cursor.continue(); + } catch (error) { + cursor.request.transaction?.abort(); + reject(error); + } + }, + error: reject, + complete: resolve, + }); + }); this.writeIndexedDbMigrationFlag( STALKER_PLAYLIST_METADATA_MIGRATION_FLAG @@ -441,6 +456,36 @@ export class PlaylistsService { ); } + private transformIndexedDbPlaylistMeta( + playlistId: string, + transform: (current: Playlist) => Playlist | null + ): Promise { + return new Promise((resolve, reject) => { + let nextPlaylist: Playlist | null = null; + this.dbService + .openCursor({ + storeName: DbStores.Playlists, + query: playlistId, + mode: DBMode.readwrite, + }) + .subscribe({ + next: (cursor) => { + try { + nextPlaylist = transform(cursor.value); + if (nextPlaylist !== null) { + cursor.update(nextPlaylist); + } + } catch (error) { + cursor.request.transaction?.abort(); + reject(error); + } + }, + error: reject, + complete: () => resolve(nextPlaylist), + }); + }); + } + getAllPlaylists() { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { @@ -463,13 +508,18 @@ export class PlaylistsService { } addPlaylist(playlist: Playlist) { - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(playlist); - } + return this.serializePlaylistWrite(playlist._id, () => + runWithPlaylistAuthorityMutation([playlist._id], async () => { + if (this.isElectronStorageAvailable) { + return firstValueFrom(this.upsertSqlitePlaylist(playlist)); + } - return this.dbService - .add(DbStores.Playlists, playlist) - .pipe(map(() => playlist)); + await firstValueFrom( + this.dbService.add(DbStores.Playlists, playlist) + ); + return playlist; + }) + ); } getPlaylist(id: string) { @@ -486,20 +536,21 @@ export class PlaylistsService { // row back and resurrect the playlist. const delete$: Observable = this.serializePlaylistWrite( playlistId, - async () => { - if (this.isElectronStorageAvailable) { - await this.ensureElectronPlaylistMigrations(); - const electron = this.electronApi; - if (electron) { - await electron.dbDeletePlaylist(playlistId); + () => + runWithPlaylistAuthorityMutation([playlistId], async () => { + if (this.isElectronStorageAvailable) { + await this.ensureElectronPlaylistMigrations(); + const electron = this.electronApi; + if (electron) { + await electron.dbDeletePlaylist(playlistId); + } + return undefined; } - return undefined; - } - return firstValueFrom( - this.dbService.delete(DbStores.Playlists, playlistId) - ); - } + return firstValueFrom( + this.dbService.delete(DbStores.Playlists, playlistId) + ); + }) ); return delete$.pipe( @@ -627,115 +678,173 @@ export class PlaylistsService { ); } - updatePlaylistMeta(updatedPlaylist: PlaylistMeta) { + updatePlaylistMeta(updatedPlaylist: PlaylistMetaUpdate) { + return this.updatePlaylistMetaInQueue(updatedPlaylist); + } + + /** Applies a meta update only while the storage-current row still matches. */ + updatePlaylistMetaIfCurrent( + updatedPlaylist: PlaylistMetaUpdate, + isCurrent: (playlist: Playlist) => boolean + ) { + return this.updatePlaylistMetaInQueue(updatedPlaylist, isCurrent); + } + + private updatePlaylistMetaInQueue( + updatedPlaylist: PlaylistMetaUpdate + ): Observable; + private updatePlaylistMetaInQueue( + updatedPlaylist: PlaylistMetaUpdate, + isCurrent: (playlist: Playlist) => boolean + ): Observable; + private updatePlaylistMetaInQueue( + updatedPlaylist: PlaylistMetaUpdate, + isCurrent?: (playlist: Playlist) => boolean + ): Observable { return this.serializePlaylistWrite(updatedPlaylist._id, async () => { + if (isCurrent && !this.isElectronStorageAvailable) { + await this.ensureIndexedDbPlaylistMigrations(); + return this.transformIndexedDbPlaylistMeta( + updatedPlaylist._id, + (current) => + isCurrent(current) + ? this.mergePlaylistMeta(current, updatedPlaylist) + : null + ); + } + const playlist = await firstValueFrom( this.getPlaylistById(updatedPlaylist._id) ); - const epgSourceState = resolvePlaylistEpgSourceState({ - detectedEpgUrls: - updatedPlaylist.detectedEpgUrls ?? playlist.detectedEpgUrls, - enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls, - manualEpgUrls: - updatedPlaylist.manualEpgUrls ?? playlist.manualEpgUrls, - disabledEpgUrls: - updatedPlaylist.disabledEpgUrls ?? playlist.disabledEpgUrls, - }); - const nextPlaylist: Playlist = { - ...playlist, - ...(updatedPlaylist.title != null - ? { title: updatedPlaylist.title } - : {}), - ...(updatedPlaylist.autoRefresh != null - ? { autoRefresh: updatedPlaylist.autoRefresh } - : {}), - ...(updatedPlaylist.userAgent != null - ? { userAgent: updatedPlaylist.userAgent } - : {}), - ...(updatedPlaylist.referrer !== undefined - ? { referrer: updatedPlaylist.referrer } - : {}), - ...(updatedPlaylist.origin !== undefined - ? { origin: updatedPlaylist.origin } - : {}), - ...(updatedPlaylist.serverUrl != null - ? { serverUrl: updatedPlaylist.serverUrl } - : {}), - ...(updatedPlaylist.portalUrl != null - ? { portalUrl: updatedPlaylist.portalUrl } - : {}), - ...(updatedPlaylist.isFullStalkerPortal !== undefined - ? { - isFullStalkerPortal: - updatedPlaylist.isFullStalkerPortal, - } - : {}), - ...(updatedPlaylist.macAddress != null - ? { macAddress: updatedPlaylist.macAddress } - : {}), - ...(updatedPlaylist.username != null - ? { username: updatedPlaylist.username } - : {}), - ...(updatedPlaylist.password != null - ? { password: updatedPlaylist.password } - : {}), - ...(updatedPlaylist.favorites != null - ? { favorites: updatedPlaylist.favorites } - : {}), - ...(updatedPlaylist.recentlyViewed != null - ? { recentlyViewed: updatedPlaylist.recentlyViewed } - : {}), - ...(updatedPlaylist.hiddenGroupTitles != null - ? { - hiddenGroupTitles: updatedPlaylist.hiddenGroupTitles, - } - : {}), - ...(updatedPlaylist.detectedEpgUrls !== undefined - ? { detectedEpgUrls: epgSourceState.detectedEpgUrls } - : {}), - ...(updatedPlaylist.manualEpgUrls !== undefined - ? { manualEpgUrls: epgSourceState.manualEpgUrls } - : {}), - ...(updatedPlaylist.disabledEpgUrls !== undefined - ? { disabledEpgUrls: epgSourceState.disabledEpgUrls } - : {}), - ...(updatedPlaylist.epgUrls !== undefined || - updatedPlaylist.detectedEpgUrls !== undefined || - updatedPlaylist.manualEpgUrls !== undefined || - updatedPlaylist.disabledEpgUrls !== undefined - ? { epgUrls: epgSourceState.epgUrls } - : {}), - ...(updatedPlaylist.updateDate !== undefined - ? { updateDate: updatedPlaylist.updateDate } - : {}), - ...(updatedPlaylist.stalkerSerialNumber !== undefined - ? { - stalkerSerialNumber: - updatedPlaylist.stalkerSerialNumber, - } - : {}), - ...(updatedPlaylist.stalkerDeviceId1 !== undefined - ? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 } - : {}), - ...(updatedPlaylist.stalkerDeviceId2 !== undefined - ? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 } - : {}), - ...(updatedPlaylist.stalkerSignature1 !== undefined - ? { - stalkerSignature1: updatedPlaylist.stalkerSignature1, - } - : {}), - ...(updatedPlaylist.stalkerSignature2 !== undefined - ? { - stalkerSignature2: updatedPlaylist.stalkerSignature2, - } - : {}), - }; - - return this.persistPlaylistMutation(nextPlaylist); + if (isCurrent && !isCurrent(playlist)) { + return null; + } + return this.persistPlaylistMutation( + this.mergePlaylistMeta(playlist, updatedPlaylist) + ); }); } + private mergePlaylistMeta( + playlist: Playlist, + updatedPlaylist: PlaylistMetaUpdate + ): Playlist { + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls: + updatedPlaylist.detectedEpgUrls ?? playlist.detectedEpgUrls, + enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls, + manualEpgUrls: + updatedPlaylist.manualEpgUrls ?? playlist.manualEpgUrls, + disabledEpgUrls: + updatedPlaylist.disabledEpgUrls ?? playlist.disabledEpgUrls, + }); + return { + ...playlist, + ...(updatedPlaylist.title != null + ? { title: updatedPlaylist.title } + : {}), + ...(updatedPlaylist.autoRefresh != null + ? { autoRefresh: updatedPlaylist.autoRefresh } + : {}), + ...(updatedPlaylist.userAgent != null + ? { userAgent: updatedPlaylist.userAgent } + : {}), + ...(updatedPlaylist.referrer !== undefined + ? { referrer: updatedPlaylist.referrer } + : {}), + ...(updatedPlaylist.origin !== undefined + ? { origin: updatedPlaylist.origin } + : {}), + ...(updatedPlaylist.serverUrl != null + ? { serverUrl: updatedPlaylist.serverUrl } + : {}), + ...(updatedPlaylist.portalUrl != null + ? { portalUrl: updatedPlaylist.portalUrl } + : {}), + ...(updatedPlaylist.isFullStalkerPortal !== undefined + ? { + isFullStalkerPortal: updatedPlaylist.isFullStalkerPortal, + } + : {}), + ...(updatedPlaylist.macAddress != null + ? { macAddress: updatedPlaylist.macAddress } + : {}), + ...(updatedPlaylist.username != null + ? { username: updatedPlaylist.username } + : {}), + ...(updatedPlaylist.password != null + ? { password: updatedPlaylist.password } + : {}), + ...(updatedPlaylist.favorites != null + ? { favorites: updatedPlaylist.favorites } + : {}), + ...(updatedPlaylist.recentlyViewed != null + ? { recentlyViewed: updatedPlaylist.recentlyViewed } + : {}), + ...(updatedPlaylist.hiddenGroupTitles != null + ? { + hiddenGroupTitles: updatedPlaylist.hiddenGroupTitles, + } + : {}), + ...(updatedPlaylist.detectedEpgUrls !== undefined + ? { detectedEpgUrls: epgSourceState.detectedEpgUrls } + : {}), + ...(updatedPlaylist.manualEpgUrls !== undefined + ? { manualEpgUrls: epgSourceState.manualEpgUrls } + : {}), + ...(updatedPlaylist.disabledEpgUrls !== undefined + ? { disabledEpgUrls: epgSourceState.disabledEpgUrls } + : {}), + ...(updatedPlaylist.epgUrls !== undefined || + updatedPlaylist.detectedEpgUrls !== undefined || + updatedPlaylist.manualEpgUrls !== undefined || + updatedPlaylist.disabledEpgUrls !== undefined + ? { epgUrls: epgSourceState.epgUrls } + : {}), + ...(updatedPlaylist.updateDate !== undefined + ? { updateDate: updatedPlaylist.updateDate } + : {}), + ...(updatedPlaylist.stalkerSerialNumber !== undefined + ? { + stalkerSerialNumber: updatedPlaylist.stalkerSerialNumber, + } + : {}), + ...(updatedPlaylist.stalkerDeviceId1 !== undefined + ? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 } + : {}), + ...(updatedPlaylist.stalkerDeviceId2 !== undefined + ? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 } + : {}), + ...(updatedPlaylist.stalkerSignature1 !== undefined + ? { + stalkerSignature1: updatedPlaylist.stalkerSignature1, + } + : {}), + ...(updatedPlaylist.stalkerSignature2 !== undefined + ? { + stalkerSignature2: updatedPlaylist.stalkerSignature2, + } + : {}), + ...(updatedPlaylist.stalkerSessionPatch !== undefined + ? { + stalkerToken: + updatedPlaylist.stalkerSessionPatch?.stalkerToken, + stalkerSessionIdentity: + updatedPlaylist.stalkerSessionPatch + ?.stalkerSessionIdentity, + stalkerWatchdogTimeout: + updatedPlaylist.stalkerSessionPatch + ?.stalkerWatchdogTimeout, + stalkerTimeslot: + updatedPlaylist.stalkerSessionPatch?.stalkerTimeslot, + stalkerAccountInfo: + updatedPlaylist.stalkerSessionPatch + ?.stalkerAccountInfo, + } + : {}), + }; + } + /** * Persists a freshly negotiated Stalker session on the playlist so the * next app start can re-present the token (the portal handshake is @@ -824,13 +933,11 @@ export class PlaylistsService { } /** - * Applies an atomic, conditional meta mutation: the transform runs on - * the freshly read row INSIDE the per-playlist write queue and may - * return null to abort without writing. Callers use this when the - * decision to write depends on the row's CURRENT state — e.g. the lazy - * Stalker portal repair verifying the row still carries the - * configuration it probed; a plain read-check-then-update pair would - * race a user edit already queued but not yet committed. + * Applies an atomic, conditional meta mutation. Electron uses the + * per-playlist write queue; IndexedDB additionally performs the read, + * predicate, and cursor update in one readwrite transaction so another + * browser context cannot interleave a replacement. The transform may + * return null to abort without writing. */ transformPlaylistMeta( playlistId: string, @@ -841,6 +948,14 @@ export class PlaylistsService { } return this.serializePlaylistWrite(playlistId, async () => { + if (!this.isElectronStorageAvailable) { + await this.ensureIndexedDbPlaylistMigrations(); + return this.transformIndexedDbPlaylistMeta( + playlistId, + transform + ); + } + const playlist = await firstValueFrom( this.getPlaylistById(playlistId) ); @@ -1088,13 +1203,24 @@ export class PlaylistsService { addManyPlaylists( playlists: Playlist[] ): Observable { - if (this.isElectronStorageAvailable) { - return this.upsertManySqlitePlaylists(playlists); - } + return defer(() => + runWithPlaylistAuthorityMutation( + playlists.map((playlist) => playlist._id), + async () => { + if (this.isElectronStorageAvailable) { + return firstValueFrom( + this.upsertManySqlitePlaylists(playlists) + ); + } - return this.dbService.bulkAdd( - DbStores.Playlists, - playlists as unknown as Playlist[] + return firstValueFrom( + this.dbService.bulkAdd( + DbStores.Playlists, + playlists as unknown as Playlist[] + ) + ); + } + ) ); } @@ -1151,19 +1277,21 @@ export class PlaylistsService { } removeAll(): Observable { - if (this.isElectronStorageAvailable) { - return this.runOnSqlite(async () => { - const electron = this.electronApi; - if (electron) { - await electron.dbDeleteAllPlaylists(); + return defer(() => + runWithPlaylistAuthorityReset(async () => { + if (this.isElectronStorageAvailable) { + await this.ensureElectronPlaylistMigrations(); + const electron = this.electronApi; + if (electron) { + await electron.dbDeleteAllPlaylists(); + } + return undefined; } - return undefined; - }).pipe(map(() => undefined)); - } - return this.dbService - .clear(DbStores.Playlists) - .pipe(map(() => undefined)); + await firstValueFrom(this.dbService.clear(DbStores.Playlists)); + return undefined; + }) + ); } private normalizePortalRecentIdentity(value: unknown): string { diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index e06cd9185..ade1f5fdc 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -35,3 +35,23 @@ export type PlaylistMeta = Pick< | 'stalkerSignature1' | 'stalkerSignature2' >; + +export interface StalkerPlaylistSessionMetadata { + stalkerToken: string; + stalkerSessionIdentity?: string; + stalkerWatchdogTimeout?: number; + stalkerTimeslot?: number; + stalkerAccountInfo?: Playlist['stalkerAccountInfo']; +} + +/** + * Metadata update accepted by the persistence boundary. + * + * `stalkerSessionPatch` is transient: absence preserves the negotiated + * session, `null` clears it, and an object fully replaces it. The patch is + * projected onto the existing flat playlist fields and is never persisted as + * its own database or backup property. + */ +export interface PlaylistMetaUpdate extends PlaylistMeta { + stalkerSessionPatch?: StalkerPlaylistSessionMetadata | null; +}