mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(stalker): make edited connection persistence atomic
This commit is contained in:
1 parent
8d6fe1e02d
commit
3505eafdd1
17 files changed
+398
-87
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 `<base>/portal.php` → `<base>/server/load.php` → `<base>/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 `<base>/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.
|
||||
|
||||
@@ -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<void>((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<PlaylistMeta>((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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
+44
@@ -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,
|
||||
|
||||
+10
-6
@@ -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'
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ export interface StalkerPlaylistConnectionEditor {
|
||||
resolveConnection(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerPlaylistConnectionResult>;
|
||||
/** Synchronizes in-run Stalker state after the resolved update dispatches. */
|
||||
/** Atomically persists a resolved edit, then synchronizes in-run state. */
|
||||
applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
private readonly replacements = new Map<string, Promise<void>>();
|
||||
private readonly pendingFingerprints = new Map<string, string>();
|
||||
private readonly replacements = new Map<string, Promise<Playlist>>();
|
||||
|
||||
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<void> {
|
||||
replace(playlist: Playlist): Promise<Playlist> {
|
||||
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<void> {
|
||||
): Promise<Playlist> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
replaceSessionAfterEdit(playlist: Playlist): Promise<Playlist> {
|
||||
return this.editedSessions.replace(playlist);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user