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