fix(stalker): close route session races

This commit is contained in:
4gray committed 2026-07-27 11:57:46 +02:00
1 parent f99380c65d
commit 3ebe31f041
6 files changed
+682 -51

No files matched your search

@@ -15,7 +15,7 @@ import {
StalkerSessionConnectionOutcome,
StalkerSessionFullReadyOutcome,
} from '@iptvnator/shared/interfaces';
import { EMPTY, of, throwError } from 'rxjs';
import { EMPTY, Subject, of, throwError } from 'rxjs';
import { StalkerCredentialsDialogComponent } from './stalker-credentials-dialog.component';
import { StalkerConnectionFlowService } from './stalker-connection-flow.service';
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
@@ -63,6 +63,57 @@ const CONTROL_SUCCESS = {
requestId: 'request-commit-route-1',
};
const PERSISTED_READY: StalkerSessionFullReadyOutcome = {
capabilities: {
authenticatedSession: true,
playbackContext: true,
},
connectionMode: 'persisted-open',
endpoint: 'https://portal.example/stalker_portal/server/load.php',
kind: 'ready',
landingUrl: 'https://portal.example/c/',
leaseRef: 'lease-route-reopened',
persistenceDraft: {
isFullStalkerPortal: true,
portalUrl: 'https://portal.example/stalker_portal/server/load.php',
stalkerLandingUrl: 'https://portal.example/c/',
stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z',
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'full-session',
stalkerSourceUrl: 'https://source.example/c/',
},
recipe: 'full-session',
requestId: 'request-ready-route-reopened',
};
function createDeferred<T>(): {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
} {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function flushPromises(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve();
}
}
function createClosableDialogRef() {
const closed = new Subject<unknown>();
return {
afterClosed: () => closed.asObservable(),
close: jest.fn(() => {
closed.next(undefined);
closed.complete();
}),
};
}
function createSessionMock() {
let recoveryHandler: StalkerSessionRecoveryHandler | undefined;
return {
@@ -175,8 +226,7 @@ describe('StalkerConnectionFlowService', () => {
.calls[0][0] as Playlist;
expect(persistedDraft).toMatchObject({
isFullStalkerPortal: true,
portalUrl:
'https://portal.example/stalker_portal/server/load.php',
portalUrl: 'https://portal.example/stalker_portal/server/load.php',
stalkerRequestRecipe: 'full-session',
stalkerSourceUrl: 'https://source.example/c/',
});
@@ -201,15 +251,16 @@ describe('StalkerConnectionFlowService', () => {
await expect(service.ensureConnected(simple)).resolves.toBe(simple);
session.supportsTypedSessions.mockReturnValueOnce(false);
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBe(LEGACY_PLAYLIST);
await expect(service.ensureConnected(LEGACY_PLAYLIST)).resolves.toBe(
LEGACY_PLAYLIST
);
expect(session.open).not.toHaveBeenCalled();
});
it('shows exact origin and credential challenges and persists only accepted credentials', async () => {
const origin: StalkerSessionConnectionOutcome = {
attemptRef: 'attempt-route-1',
challengeRef: 'challenge-origin-route-1',
finalOrigin: 'https://portal.example',
kind: 'origin-approval-required',
@@ -218,6 +269,7 @@ describe('StalkerConnectionFlowService', () => {
};
const credentials: StalkerSessionConnectionOutcome = {
attemptNumber: 2,
attemptRef: 'attempt-route-1',
challengeRef: 'challenge-credentials-route-1',
kind: 'credentials-required',
requestId: 'request-credentials-route-1',
@@ -291,6 +343,7 @@ describe('StalkerConnectionFlowService', () => {
it('consumes a rejected origin challenge without persisting anything', async () => {
const origin: StalkerSessionConnectionOutcome = {
attemptRef: 'attempt-origin-rejected',
challengeRef: 'challenge-origin-rejected',
finalOrigin: 'https://other.example',
kind: 'origin-approval-required',
@@ -311,16 +364,13 @@ describe('StalkerConnectionFlowService', () => {
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(session.continue).toHaveBeenCalledWith(
LEGACY_PLAYLIST._id,
{
challengeRef: 'challenge-origin-rejected',
response: {
approved: false,
kind: 'origin-approval',
},
}
);
expect(session.continue).toHaveBeenCalledWith(LEGACY_PLAYLIST._id, {
challengeRef: 'challenge-origin-rejected',
response: {
approved: false,
kind: 'origin-approval',
},
});
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
});
@@ -359,15 +409,121 @@ describe('StalkerConnectionFlowService', () => {
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
});
it('turns a rejected promotion into a discarded runtime attempt', async () => {
session.open.mockResolvedValue(READY);
it('reopens the persisted connection after discarding a rejected promotion', async () => {
session.open
.mockResolvedValueOnce(READY)
.mockResolvedValueOnce(PERSISTED_READY);
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
const connected = await service.ensureConnected(LEGACY_PLAYLIST);
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
expect(session.open).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
portalUrl:
'https://portal.example/stalker_portal/server/load.php',
stalkerRequestRecipe: 'full-session',
})
);
expect(connected).toMatchObject({
portalUrl: 'https://portal.example/stalker_portal/server/load.php',
stalkerRequestRecipe: 'full-session',
});
});
it('discards a provisional ready outcome that arrives after cancellation of open', async () => {
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
session.open.mockReturnValueOnce(deferredOpen.promise);
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
await flushPromises();
expect(session.open).toHaveBeenCalledTimes(1);
await service.cancel();
deferredOpen.resolve(READY);
await expect(connecting).resolves.toBeUndefined();
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
});
it('discards a provisional ready outcome that arrives after cancellation of continue', async () => {
const credentials: StalkerSessionConnectionOutcome = {
attemptNumber: 1,
attemptRef: 'attempt-route-1',
challengeRef: 'challenge-late-ready',
kind: 'credentials-required',
requestId: 'request-late-ready',
};
const deferredContinue =
createDeferred<StalkerSessionConnectionOutcome>();
session.open.mockResolvedValueOnce(credentials);
session.continue.mockReturnValueOnce(deferredContinue.promise);
queuedDialogResults.push({
password: 'password',
username: 'user',
});
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
await flushPromises();
expect(session.continue).toHaveBeenCalledTimes(1);
await service.cancel();
deferredContinue.resolve(READY);
await expect(connecting).resolves.toBeUndefined();
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
});
it('discards an origin challenge attempt when cancellation closes its active dialog', async () => {
const origin: StalkerSessionConnectionOutcome = {
attemptRef: 'attempt-origin-dialog',
challengeRef: 'challenge-origin-dialog',
finalOrigin: 'https://portal.example',
kind: 'origin-approval-required',
requestId: 'request-origin-dialog',
sourceOrigin: 'https://source.example',
};
const dialogRef = createClosableDialogRef();
session.open.mockResolvedValueOnce(origin);
dialog.open.mockReturnValueOnce(dialogRef);
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
await flushPromises();
expect(dialog.open).toHaveBeenCalledTimes(1);
await service.cancel();
await expect(connecting).resolves.toBeUndefined();
expect(dialogRef.close).toHaveBeenCalledTimes(1);
expect(session.discard).toHaveBeenCalledWith('attempt-origin-dialog');
});
it('discards a credential challenge attempt when cancellation closes its active dialog', async () => {
const credentials: StalkerSessionConnectionOutcome = {
attemptNumber: 1,
attemptRef: 'attempt-credentials-dialog',
challengeRef: 'challenge-credentials-dialog',
kind: 'credentials-required',
requestId: 'request-credentials-dialog',
};
const dialogRef = createClosableDialogRef();
session.open.mockResolvedValueOnce(credentials);
dialog.open.mockReturnValueOnce(dialogRef);
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
await flushPromises();
expect(dialog.open).toHaveBeenCalledTimes(1);
await service.cancel();
await expect(connecting).resolves.toBeUndefined();
expect(dialogRef.close).toHaveBeenCalledTimes(1);
expect(session.discard).toHaveBeenCalledWith(
'attempt-credentials-dialog'
);
});
it('opens a provisional replacement for a principal transition and enables one facade reissue', async () => {
@@ -399,6 +555,7 @@ describe('StalkerConnectionFlowService', () => {
it('routes force-redetect through the active lease and handles its challenge', async () => {
const credentials: StalkerSessionConnectionOutcome = {
attemptNumber: 1,
attemptRef: 'attempt-force-redetect',
challengeRef: 'challenge-force-redetect',
kind: 'credentials-required',
requestId: 'request-force-redetect',
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- route connection recovery state is intentionally kept in one owner */
import { DestroyRef, Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -88,7 +89,7 @@ export class StalkerConnectionFlowService {
if (!this.session.supportsTypedSessions()) {
return playlist;
}
let leaseRef = this.session.getLeaseRef(playlist._id);
const leaseRef = this.session.getLeaseRef(playlist._id);
if (leaseRef === undefined) {
const runId = ++this.runId;
await this.discardPending();
@@ -164,7 +165,12 @@ export class StalkerConnectionFlowService {
): Promise<Playlist | undefined> {
let outcome = initialOutcome;
let credentials: StalkerCredentialsDialogResult | undefined;
while (runId === this.runId) {
while (true) {
if (await this.discardOutcomeWhenStale(outcome, runId)) {
return undefined;
}
this.activeAttemptRef =
this.getOutcomeAttemptRef(outcome) ?? this.activeAttemptRef;
if (outcome.kind === 'ready') {
const draft = this.applyReadyOutcome(
playlist,
@@ -190,6 +196,9 @@ export class StalkerConnectionFlowService {
kind: 'origin-approval',
},
});
if (await this.discardOutcomeWhenStale(outcome, runId)) {
return undefined;
}
if (!approved) {
return undefined;
}
@@ -293,12 +302,16 @@ export class StalkerConnectionFlowService {
pending.outcome.attemptRef
);
} catch {
await this.discardAttempt(pending.outcome.attemptRef);
return undefined;
return this.reopenPersistedAfterPromotionFailure(
pending,
persisted
);
}
if (promotion.kind !== 'success') {
await this.discardAttempt(pending.outcome.attemptRef);
return undefined;
return this.reopenPersistedAfterPromotionFailure(
pending,
persisted
);
}
}
this.activeAttemptRef = null;
@@ -309,6 +322,63 @@ export class StalkerConnectionFlowService {
return persisted;
}
private async reopenPersistedAfterPromotionFailure(
pending: PendingPersistence,
persisted: Playlist
): Promise<Playlist | undefined> {
await this.discardAttempt(pending.outcome.attemptRef);
this.activeAttemptRef = null;
this.pendingPersistence = null;
if (pending.runId !== this.runId) {
return undefined;
}
let outcome: StalkerSessionConnectionOutcome;
try {
outcome = await this.session.open(persisted);
} catch {
this.notifyPromotionFailure();
return undefined;
}
if (await this.discardOutcomeWhenStale(outcome, pending.runId)) {
return undefined;
}
if (outcome.kind !== 'ready') {
const recovered = await this.handleOutcome(
persisted,
outcome,
pending.runId,
pending.announceReady
);
if (recovered === undefined && pending.runId === this.runId) {
this.notifyPromotionFailure();
}
return recovered;
}
if (pending.announceReady) {
this.readySubject.next(persisted);
}
return persisted;
}
private async discardOutcomeWhenStale(
outcome: StalkerSessionConnectionOutcome,
runId: number
): Promise<boolean> {
if (runId === this.runId) {
return false;
}
await this.discardAttempt(this.getOutcomeAttemptRef(outcome));
return true;
}
private getOutcomeAttemptRef(
outcome: StalkerSessionConnectionOutcome
): string | undefined {
return 'attemptRef' in outcome ? outcome.attemptRef : undefined;
}
private applyReadyOutcome(
playlist: Playlist,
outcome: ReadyOutcome,
@@ -338,6 +408,17 @@ export class StalkerConnectionFlowService {
.subscribe(() => void this.retryPendingPersistence());
}
private notifyPromotionFailure(): void {
this.snackBar.open(
this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
{ reason: 'session-promotion-failed' }
),
undefined,
{ duration: 10_000 }
);
}
private async discardPending(): Promise<void> {
const attemptRef =
this.pendingPersistence?.outcome.attemptRef ??
@@ -348,7 +429,9 @@ export class StalkerConnectionFlowService {
await this.discardAttempt(attemptRef);
}
private async discardAttempt(attemptRef: string | undefined): Promise<void> {
private async discardAttempt(
attemptRef: string | undefined
): Promise<void> {
if (attemptRef !== undefined) {
await this.session.discard(attemptRef).catch(() => undefined);
}
@@ -1,9 +1,13 @@
/* eslint-disable max-lines -- route lifecycle and navigation race matrix is kept together */
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { EMPTY, Subject, of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
StalkerSessionService,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
@@ -29,10 +33,20 @@ const FULL_STALKER_PLAYLIST: PlaylistMeta = {
} as PlaylistMeta;
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
for (let index = 0; index < 12; index += 1) {
await Promise.resolve();
}
}
function createDeferred<T>(): {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
} {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function getStalkerSectionFromUrl(url: string): string | null {
@@ -74,9 +88,26 @@ describe('StalkerWorkspaceRouteSession', () => {
const connectionFlow = {
cancel: jest.fn().mockResolvedValue(undefined),
connectionReady$: connectionReady.asObservable(),
ensureConnected: jest.fn(
async (playlist: PlaylistMeta) => playlist
),
ensureConnected: jest.fn(async (playlist: PlaylistMeta) => playlist),
};
const session = {
activate: jest.fn().mockResolvedValue({
action: 'activate',
kind: 'success',
requestId: 'activate-success',
}),
close: jest.fn().mockResolvedValue({
action: 'close',
kind: 'success',
requestId: 'close-success',
}),
deactivate: jest.fn().mockResolvedValue({
action: 'deactivate',
kind: 'success',
requestId: 'deactivate-success',
}),
getLeaseRef: jest.fn((playlistId: string) => `lease-${playlistId}`),
};
const router = {
@@ -113,6 +144,10 @@ describe('StalkerWorkspaceRouteSession', () => {
playlistsService.getPlaylistById.mockClear();
connectionFlow.cancel.mockClear();
connectionFlow.ensureConnected.mockClear();
session.activate.mockClear();
session.close.mockClear();
session.deactivate.mockClear();
session.getLeaseRef.mockClear();
await TestBed.configureTestingModule({
providers: [
@@ -137,6 +172,10 @@ describe('StalkerWorkspaceRouteSession', () => {
provide: StalkerConnectionFlowService,
useValue: connectionFlow,
},
{
provide: StalkerSessionService,
useValue: session,
},
],
});
});
@@ -159,6 +198,10 @@ describe('StalkerWorkspaceRouteSession', () => {
).toBeLessThan(
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
);
expect(session.activate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
expect(session.activate.mock.invocationCallOrder[0]).toBeLessThan(
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
);
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv');
expect(selectedContentType()).toBe('itv');
expect(
@@ -257,6 +300,11 @@ describe('StalkerWorkspaceRouteSession', () => {
await flushEffects();
expect(connectionFlow.cancel).toHaveBeenCalledTimes(1);
expect(session.deactivate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
expect(session.close).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
expect(session.deactivate.mock.invocationCallOrder[0]).toBeLessThan(
session.close.mock.invocationCallOrder[0]
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
undefined
);
@@ -274,4 +322,170 @@ describe('StalkerWorkspaceRouteSession', () => {
FULL_STALKER_PLAYLIST
);
});
it('does not connect a stale playlist after its lookup resolves behind a newer route', async () => {
const staleLookup = new Subject<PlaylistMeta>();
const nextPlaylist = {
...FULL_STALKER_PLAYLIST,
_id: 'stalker-2',
title: 'Second Stalker',
};
playlistsService.getPlaylistById.mockImplementation(
(playlistId: string) =>
playlistId === PLAYLIST_ID
? staleLookup.asObservable()
: of(nextPlaylist)
);
playlistContext.syncFromUrl.mockImplementation((url: string) => {
const match = url.match(
/^\/workspace\/stalker\/([^/]+)\/([^/?]+)(?:\/|$)/
);
return {
inWorkspace: true,
provider: match ? 'stalker' : null,
playlistId: match?.[1] ?? null,
section: (match?.[2] ?? null) as
| 'favorites'
| 'itv'
| 'radio'
| 'recent'
| 'search'
| 'series'
| 'vod'
| null,
};
});
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
PLAYLIST_ID
);
router.url = '/workspace/stalker/stalker-2/series';
routerEvents.next(
new NavigationEnd(
2,
`/workspace/stalker/${PLAYLIST_ID}/vod`,
router.url
)
);
await flushEffects();
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
nextPlaylist
);
staleLookup.next(FULL_STALKER_PLAYLIST);
staleLookup.complete();
await flushEffects();
expect(connectionFlow.ensureConnected).not.toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
nextPlaylist
);
});
it('keeps a newer section while the same playlist connection is still completing', async () => {
const deferredConnection = createDeferred<PlaylistMeta>();
connectionFlow.ensureConnected.mockReturnValueOnce(
deferredConnection.promise
);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(connectionFlow.ensureConnected).toHaveBeenCalledTimes(1);
router.url = `/workspace/stalker/${PLAYLIST_ID}/itv`;
routerEvents.next(
new NavigationEnd(
2,
`/workspace/stalker/${PLAYLIST_ID}/vod`,
router.url
)
);
await flushEffects();
expect(selectedContentType()).toBe('itv');
deferredConnection.resolve(FULL_STALKER_PLAYLIST);
await flushEffects();
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
expect(selectedContentType()).toBe('itv');
});
it('closes the old lease before activating a switched Stalker playlist', async () => {
const nextPlaylist = {
...FULL_STALKER_PLAYLIST,
_id: 'stalker-2',
title: 'Second Stalker',
};
playlistsService.getPlaylistById.mockImplementation(
(playlistId: string) =>
of(
playlistId === PLAYLIST_ID
? FULL_STALKER_PLAYLIST
: nextPlaylist
)
);
playlistContext.syncFromUrl.mockImplementation((url: string) => {
const match = url.match(
/^\/workspace\/stalker\/([^/]+)\/([^/?]+)(?:\/|$)/
);
return {
inWorkspace: true,
provider: match ? 'stalker' : null,
playlistId: match?.[1] ?? null,
section: (match?.[2] ?? null) as
| 'favorites'
| 'itv'
| 'radio'
| 'recent'
| 'search'
| 'series'
| 'vod'
| null,
};
});
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
router.url = '/workspace/stalker/stalker-2/vod';
routerEvents.next(
new NavigationEnd(
2,
`/workspace/stalker/${PLAYLIST_ID}/vod`,
router.url
)
);
await flushEffects();
expect(session.deactivate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
expect(session.close).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
expect(session.activate).toHaveBeenCalledWith('lease-stalker-2');
expect(session.close.mock.invocationCallOrder[0]).toBeLessThan(
session.activate.mock.invocationCallOrder.at(-1) as number
);
});
it('deactivates and closes the current lease when the route provider is destroyed', async () => {
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
session.deactivate.mockClear();
session.close.mockClear();
TestBed.resetTestingModule();
await flushEffects();
expect(session.deactivate).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
expect(session.close).toHaveBeenCalledWith(`lease-${PLAYLIST_ID}`);
});
});
@@ -13,6 +13,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
StalkerContentType,
StalkerSessionService,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
@@ -26,10 +27,12 @@ export class StalkerWorkspaceRouteSession {
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly session = inject(StalkerSessionService);
private readonly stalkerStore = inject(StalkerStore);
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(null);
private routeSyncGeneration = 0;
private syncGeneration = 0;
constructor() {
@@ -48,13 +51,21 @@ export class StalkerWorkspaceRouteSession {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((playlist) => {
if (playlist._id === this.currentPlaylistId) {
void this.stalkerStore.setCurrentPlaylist(playlist);
void this.activateAndSetCurrentPlaylist(playlist);
}
});
void this.syncRouteContext();
this.destroyRef.onDestroy(() => {
const playlistId = this.currentPlaylistId;
this.currentPlaylistId = null;
this.routeSyncGeneration += 1;
this.syncGeneration += 1;
void this.connectionFlow.cancel();
if (playlistId !== null) {
void this.releasePlaylistSession(playlistId);
}
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
@@ -62,16 +73,22 @@ export class StalkerWorkspaceRouteSession {
}
private async syncRouteContext(): Promise<void> {
const generation = ++this.syncGeneration;
const routeGeneration = ++this.routeSyncGeneration;
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
const playlistId =
routeContext.provider === 'stalker'
? routeContext.playlistId
: null;
const generation =
playlistId === this.currentPlaylistId
? this.syncGeneration
: ++this.syncGeneration;
if (!playlistId && this.currentPlaylistId !== null) {
const previousPlaylistId = this.currentPlaylistId;
this.currentPlaylistId = null;
await this.connectionFlow.cancel();
await this.releasePlaylistSession(previousPlaylistId);
if (generation !== this.syncGeneration) {
return;
}
@@ -80,16 +97,30 @@ export class StalkerWorkspaceRouteSession {
this.stalkerStore.clearSelectedItem();
await this.stalkerStore.setCurrentPlaylist(undefined);
} else if (playlistId && this.currentPlaylistId !== playlistId) {
if (this.currentPlaylistId !== null) {
await this.connectionFlow.cancel();
}
const previousPlaylistId = this.currentPlaylistId;
this.currentPlaylistId = playlistId;
if (previousPlaylistId !== null) {
await this.connectionFlow.cancel();
await this.releasePlaylistSession(previousPlaylistId);
if (
generation !== this.syncGeneration ||
playlistId !== this.currentPlaylistId
) {
return;
}
}
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
const playlist = await this.resolveStalkerPlaylist(playlistId);
if (
generation !== this.syncGeneration ||
playlistId !== this.currentPlaylistId
) {
return;
}
const connected =
playlist === undefined
? undefined
@@ -103,11 +134,46 @@ export class StalkerWorkspaceRouteSession {
return;
}
if (connected !== undefined) {
await this.stalkerStore.setCurrentPlaylist(connected);
await this.activateAndSetCurrentPlaylist(connected);
}
}
this.syncRouteState(routeContext.section);
if (routeGeneration === this.routeSyncGeneration) {
this.syncRouteState(routeContext.section);
}
}
private async activateAndSetCurrentPlaylist(
playlist: Playlist
): Promise<void> {
if (playlist._id !== this.currentPlaylistId) {
return;
}
const leaseRef = this.session.getLeaseRef(playlist._id);
if (leaseRef !== undefined) {
try {
const activation = await this.session.activate(leaseRef);
if (activation.kind !== 'success') {
await this.releasePlaylistSession(playlist._id);
return;
}
} catch {
await this.releasePlaylistSession(playlist._id);
return;
}
}
if (playlist._id === this.currentPlaylistId) {
await this.stalkerStore.setCurrentPlaylist(playlist);
}
}
private async releasePlaylistSession(playlistId: string): Promise<void> {
const leaseRef = this.session.getLeaseRef(playlistId);
if (leaseRef === undefined) {
return;
}
await this.session.deactivate(leaseRef).catch(() => undefined);
await this.session.close(leaseRef).catch(() => undefined);
}
private syncRouteState(section: PortalRailSection | null): void {
@@ -1748,9 +1748,11 @@ describe('PlaylistsService', () => {
importDate: '2026-07-27T00:00:00.000Z',
lastUsage: '2026-07-27T00:00:00.000Z',
macAddress: '00:1A:79:AA:BB:CC',
password: undefined,
portalUrl: 'https://portal.example/portal.php',
stalkerRequestRecipe: 'stateless-mac',
title: 'Updated Portal',
username: undefined,
})
);
@@ -1758,6 +1760,73 @@ describe('PlaylistsService', () => {
expect(store.current.password).toBe('saved-password');
});
it('merges only connection-owned fields from a stale Stalker authentication snapshot', async () => {
const staleSnapshot = {
...createBasePlaylist('stalker-concurrent-metadata'),
autoRefresh: false,
favorites: [
{ stream_id: 1, title: 'Favorite before authentication' },
],
macAddress: '00:1A:79:AA:BB:CC',
password: 'old-password',
portalUrl: 'https://old.example/server/load.php',
recentlyViewed: [],
title: 'Title before authentication',
username: 'old-user',
} as Playlist;
const { store, electron } =
createStatefulElectronStore(staleSnapshot);
store.current = {
...staleSnapshot,
autoRefresh: true,
favorites: [{ stream_id: 9, title: 'Concurrent Favorite' }],
hiddenGroupTitles: ['Hidden concurrently'],
position: 7,
recentlyViewed: [
{
category_id: 'vod',
id: 'concurrent-recent',
title: 'Concurrent Recent',
},
],
title: 'Renamed while authentication was running',
} as Playlist;
testWindow.electron = electron;
const service = createService();
await firstValueFrom(
service.persistStalkerConnection({
...staleSnapshot,
password: 'new-password',
portalUrl: 'https://new.example/server/load.php',
stalkerLandingUrl: 'https://new.example/c/',
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'full-session',
stalkerSourceUrl: 'https://new.example/c/',
username: 'new-user',
})
);
expect(store.current).toEqual(
expect.objectContaining({
autoRefresh: true,
favorites: [{ stream_id: 9, title: 'Concurrent Favorite' }],
hiddenGroupTitles: ['Hidden concurrently'],
password: 'new-password',
portalUrl: 'https://new.example/server/load.php',
position: 7,
recentlyViewed: [
expect.objectContaining({
id: 'concurrent-recent',
}),
],
stalkerRequestRecipe: 'full-session',
title: 'Renamed while authentication was running',
username: 'new-user',
})
);
});
it('does not mutate the stored Stalker row when persistence fails', async () => {
const initial = {
...createBasePlaylist('stalker-write-failure'),
+50 -8
View File
@@ -74,6 +74,31 @@ type PlaylistParserModule = Partial<typeof import('iptv-playlist-parser')> & {
default?: Partial<typeof import('iptv-playlist-parser')>;
};
const STALKER_CONNECTION_FIELDS = [
'isFullStalkerPortal',
'macAddress',
'origin',
'password',
'portalUrl',
'referrer',
'stalkerAccountInfo',
'stalkerDeviceId1',
'stalkerDeviceId2',
'stalkerIdentityOverrides',
'stalkerLandingUrl',
'stalkerLastVerifiedAt',
'stalkerProfilePreset',
'stalkerRecipeClassifierVersion',
'stalkerRequestRecipe',
'stalkerSerialNumber',
'stalkerSignature1',
'stalkerSignature2',
'stalkerSourceUrl',
'stalkerTransportConfiguration',
'userAgent',
'username',
] as const satisfies readonly (keyof Playlist)[];
export function resolvePlaylistParser(parserModule: PlaylistParserModule) {
const parse = parserModule.parse ?? parserModule.default?.parse;
@@ -463,9 +488,10 @@ export class PlaylistsService {
}
/**
* Persists the complete, already verified Stalker connection in one
* serialized row write. This is the promotion boundary used before the
* main-process provisional session is committed.
* Persists an already verified Stalker connection in one serialized row
* write. Existing rows merge only connection-owned fields from the
* pre-authentication draft, so concurrent title, collection, playback,
* refresh, and display-setting changes survive this promotion boundary.
*
* The legacy bearer token is always removed. Omitted credentials preserve
* an existing accepted pair, which lets saved-credential reconnects update
@@ -484,11 +510,14 @@ export class PlaylistsService {
);
const safeDraft = { ...draft };
delete safeDraft.stalkerToken;
const nextPlaylist = {
...(currentPlaylist ?? {}),
...safeDraft,
_id: playlistId,
} as Playlist;
const nextPlaylist =
currentPlaylist === undefined
? safeDraft
: {
...currentPlaylist,
...this.pickStalkerConnectionFields(safeDraft),
_id: playlistId,
};
delete nextPlaylist.stalkerToken;
await this.persistPlaylistMutation(nextPlaylist);
@@ -496,6 +525,19 @@ export class PlaylistsService {
});
}
private pickStalkerConnectionFields(draft: Playlist): Partial<Playlist> {
const patch: Partial<Playlist> = {};
for (const field of STALKER_CONNECTION_FIELDS) {
if (
Object.prototype.hasOwnProperty.call(draft, field) &&
draft[field] !== undefined
) {
Object.assign(patch, { [field]: draft[field] });
}
}
return patch;
}
getPlaylist(id: string) {
if (id === 'global-favorites') {
return this.getPlaylistWithGlobalFavorites();