mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(stalker): close route session races
This commit is contained in:
1 parent
f99380c65d
commit
3ebe31f041
6 files changed
+682
-51
No files matched your search
+178
-21
@@ -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',
|
||||
|
||||
+90
-7
@@ -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);
|
||||
}
|
||||
|
||||
+222
-8
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user