From 6ad9f3ff8a91964f84c40ad615e3e40bd0d44d00 Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:34:49 +0200
Subject: [PATCH] feat(stalker): discover portal endpoints on add and edit
(#1391)
* feat(stalker): discover portal connection on edit
* docs(stalker): document smart endpoint discovery
* fix(stalker): make edited connection persistence atomic
* fix(stalker): serialize edit discovery
* fix(stalker): fence all edit authentication
* fix(stalker): serialize overlapping edits
* fix(stalker): hydrate playlist identity before edit
* test(stalker): await edit hydration
* fix(stalker): release abandoned edit fences
* fix(stalker): reject stale repairs before discovery
* fix(stalker): fence stale portal modes
* test(stalker): align simple portal session guard
* fix(stalker): reject superseded portal responses
* test(stalker): await settled append failure
* fix(stalker): retain abandoned auth fences
* fix(stalker): fence abandoned discovery retries
* fix(stalker): retire restored repair overrides
* fix(stalker): verify repair override retirement
* fix(stalker): preserve edit-owned repair tokens
* fix(stalker): defer repair retirement during edits
* fix(stalker): fence repair history reads
* fix(stalker): fingerprint portal URL credentials
* fix(stalker): persist submitted identity after navigation
* fix(stalker): merge late connection saves
* fix(stalker): keep edits off Xtream save path
* fix(stalker): preserve concurrent edit state
* fix(stalker): reject replaced late edit targets
* fix(stalker): guard every resolved edit write
* fix(stalker): make pwa edit guard transactional
* fix(stalker): migrate pwa flags transactionally
* fix(stalker): reserve pwa edits across tabs
* fix(stalker): coordinate playlist replacements with edit
* fix(stalker): reserve lazy repairs across tabs
* fix(stalker): drain local repair before edit lock
* fix(stalker): block queued repairs during edit drain
---
.changes/stalker-smart-discovery.md | 10 +
CLAUDE.md | 11 +-
.../src/stalker-portal-discovery.e2e.ts | 78 +-
apps/web/src/app/app.config.ts | 6 +
...playlist-connection-editor.service.spec.ts | 572 ++++++++++++++
...lker-playlist-connection-editor.service.ts | 310 ++++++++
apps/web/src/assets/i18n/ar.json | 4 +-
apps/web/src/assets/i18n/ary.json | 4 +-
apps/web/src/assets/i18n/by.json | 4 +-
apps/web/src/assets/i18n/de.json | 4 +-
apps/web/src/assets/i18n/el.json | 4 +-
apps/web/src/assets/i18n/en.json | 4 +-
apps/web/src/assets/i18n/es.json | 4 +-
apps/web/src/assets/i18n/fr.json | 4 +-
apps/web/src/assets/i18n/hu.json | 4 +-
apps/web/src/assets/i18n/it.json | 4 +-
apps/web/src/assets/i18n/ja.json | 4 +-
apps/web/src/assets/i18n/ko.json | 4 +-
apps/web/src/assets/i18n/nl.json | 4 +-
apps/web/src/assets/i18n/pl.json | 4 +-
apps/web/src/assets/i18n/pt.json | 4 +-
apps/web/src/assets/i18n/ru.json | 4 +-
apps/web/src/assets/i18n/tr.json | 4 +-
apps/web/src/assets/i18n/zh.json | 4 +-
apps/web/src/assets/i18n/zhtw.json | 4 +-
docs/architecture/stalker-portal.md | 362 ++++++---
libs/m3u-state/src/lib/actions.ts | 10 +-
libs/m3u-state/src/lib/effects.ts | 1 +
.../src/lib/playlist-meta.effect.spec.ts | 69 ++
.../lib/reducers/playlist.reducers.spec.ts | 131 +++-
.../src/lib/reducers/playlist.reducers.ts | 39 +-
.../stalker-portal-import.component.html | 6 +-
.../stalker-portal-import.component.spec.ts | 100 ++-
.../stalker-portal-import.component.ts | 21 +-
libs/playlist/shared/ui/src/index.ts | 1 +
.../playlist-info.component.html | 637 ++++++++--------
.../playlist-info.component.spec.ts | 647 ++++++++++++++--
.../playlist-info/playlist-info.component.ts | 195 ++++-
...talker-playlist-connection-editor.token.ts | 57 ++
.../stalker-playlist-edit.utils.ts | 59 ++
.../stream-resolver.service.spec.ts | 12 +-
.../lib/stalker-account-info.service.spec.ts | 20 +-
.../src/lib/stalker-account-info.service.ts | 1 +
.../stalker-authenticated-request-client.ts | 100 +++
.../stalker-edit-cross-context-reservation.ts | 1 +
...stalker-edited-session-coordinator.spec.ts | 698 ++++++++++++++++++
.../lib/stalker-edited-session-coordinator.ts | 414 +++++++++++
.../src/lib/stalker-itv-cache.service.spec.ts | 14 +-
.../stalker-portal-discovery.service.spec.ts | 87 ++-
.../lib/stalker-portal-discovery.service.ts | 30 +-
.../stalker-portal-discovery.utils.spec.ts | 16 +-
.../src/lib/stalker-portal-discovery.utils.ts | 16 +-
...ker-portal-repair-discovery-coordinator.ts | 79 ++
.../src/lib/stalker-portal-repair-state.ts | 32 +
.../lib/stalker-portal-repair.service.spec.ts | 583 ++++++++++++++-
.../src/lib/stalker-portal-repair.service.ts | 328 +++++---
.../stalker-repair-authority-coordinator.ts | 70 ++
.../src/lib/stalker-session-store.ts | 17 +-
.../src/lib/stalker-session.service.spec.ts | 84 ++-
.../src/lib/stalker-session.service.ts | 269 ++++---
.../src/lib/stalker.store.compat.spec.ts | 3 +
.../with-stalker-content.feature.spec.ts | 14 +-
.../features/with-stalker-epg.feature.spec.ts | 2 +
.../with-stalker-player.feature.spec.ts | 3 +
.../with-stalker-series.feature.spec.ts | 3 +
...h-stalker-snapshot-refresh.feature.spec.ts | 9 +-
.../stalker-player-request.utils.spec.ts | 9 +-
.../utils/stalker-request.utils.spec.ts | 103 ++-
.../lib/stores/utils/stalker-request.utils.ts | 29 +-
libs/services/src/index.ts | 1 +
.../src/lib/playlist-cross-context-lock.ts | 147 ++++
.../src/lib/playlists.service.spec.ts | 374 +++++++++-
libs/services/src/lib/playlists.service.ts | 450 +++++++----
.../interfaces/src/lib/playlist-meta.type.ts | 20 +
74 files changed, 6353 insertions(+), 1083 deletions(-)
create mode 100644 .changes/stalker-smart-discovery.md
create mode 100644 apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts
create mode 100644 apps/web/src/app/services/stalker-playlist-connection-editor.service.ts
create mode 100644 libs/m3u-state/src/lib/playlist-meta.effect.spec.ts
create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts
create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-edit-cross-context-reservation.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-repair-authority-coordinator.ts
create mode 100644 libs/services/src/lib/playlist-cross-context-lock.ts
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 @@