feat(stalker): add route connection recovery flow

This commit is contained in:
4gray committed 2026-07-27 11:19:02 +02:00
1 parent 2125e5a907
commit a6fd436e35
27 files changed
+1421 -22

No files matched your search

+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "التصفية حسب الاسم",
"FILTER_AND_SORT": "تصفية وفرز",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "فلتر بالاسم",
"FILTER_AND_SORT": "فلتر وترتيب",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Фільтраваць па імені",
"FILTER_AND_SORT": "Фільтр і сартаванне",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Verwenden Sie nur Werte Ihres Anbieters oder eines registrierten Geräts. IPTVnator erfindet diese Werte nicht.",
"USER_AGENT": "Browser-User-Agent überschreiben (optional)",
"VALIDATE_CREDENTIALS": "Anmeldedaten prüfen",
"SAVE_AGAIN": "Erneut speichern"
"SAVE_AGAIN": "Erneut speichern",
"USERNAME": "Benutzername",
"PASSWORD": "Passwort"
},
"FILTER_BY_NAME": "Nach Namen filtern",
"FILTER_AND_SORT": "Filtern & Sortieren",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filter by name",
"FILTER_AND_SORT": "Filter & Sort",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filtrar por nombre",
"FILTER_AND_SORT": "Filtrar y ordenar",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filtrer par nom",
"FILTER_AND_SORT": "Filtrer et trier",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Szűrés név alapján",
"FILTER_AND_SORT": "Szűrés és rendezés",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filtra per nome",
"FILTER_AND_SORT": "Filtra e ordina",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "名前でフィルター",
"FILTER_AND_SORT": "フィルターと並び替え",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "이름으로 필터",
"FILTER_AND_SORT": "필터 및 정렬",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filter op naam",
"FILTER_AND_SORT": "Filteren & sorteren",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filtruj według nazwy",
"FILTER_AND_SORT": "Filtruj i sortuj",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "Filtrar por nome",
"FILTER_AND_SORT": "Filtrar e ordenar",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Используйте только значения от провайдера или уже зарегистрированного устройства. IPTVnator не придумывает эти значения.",
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
"VALIDATE_CREDENTIALS": "Проверить данные",
"SAVE_AGAIN": "Сохранить снова"
"SAVE_AGAIN": "Сохранить снова",
"USERNAME": "Имя пользователя",
"PASSWORD": "Пароль"
},
"FILTER_BY_NAME": "Фильтровать по имени",
"FILTER_AND_SORT": "Фильтр и сортировка",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "İsime göre filtrele",
"FILTER_AND_SORT": "Filtrele ve Sırala",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "按名称筛选",
"FILTER_AND_SORT": "筛选与排序",
+3 -1
View File
@@ -222,7 +222,9 @@
"ADVANCED_IDENTITY_HINT": "Use only values supplied by your provider or an existing registered device. IPTVnator does not invent these values.",
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again"
"SAVE_AGAIN": "Save again",
"USERNAME": "Username",
"PASSWORD": "Password"
},
"FILTER_BY_NAME": "依名稱篩選",
"FILTER_AND_SORT": "篩選與排序",
+3
View File
@@ -2,6 +2,9 @@ export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
export * from './lib/stalker-collection-detail.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-connection-flow/stalker-connection-flow.service';
export * from './lib/stalker-connection-flow/stalker-credentials-dialog.component';
export * from './lib/stalker-connection-flow/stalker-origin-approval-dialog.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
@@ -0,0 +1,165 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
StalkerCredentialsDialogComponent,
StalkerCredentialsDialogData,
} from './stalker-credentials-dialog.component';
import {
StalkerOriginApprovalDialogComponent,
StalkerOriginApprovalDialogData,
} from './stalker-origin-approval-dialog.component';
function configureTranslations(): void {
const translate = TestBed.inject(TranslateService);
translate.setTranslation(
'en',
{
CANCEL: 'Cancel',
HOME: {
STALKER_PORTAL: {
APPROVE_ORIGIN: 'Approve target',
CREDENTIALS_DESCRIPTION:
'This portal requires registered credentials.',
CREDENTIALS_REJECTED:
'The saved credentials were rejected.',
CREDENTIALS_TITLE: 'Portal credentials',
HIDE_PASSWORD: 'Hide password',
ORIGIN_APPROVAL_DESCRIPTION:
'The MAC is sent only after approval.',
ORIGIN_APPROVAL_TITLE: 'Approve portal redirect',
ORIGIN_SOURCE: 'Entered origin',
ORIGIN_TARGET: 'Redirect target',
SHOW_PASSWORD: 'Show password',
PASSWORD: 'Password',
USERNAME: 'Username',
VALIDATE_CREDENTIALS: 'Validate credentials',
},
},
},
true
);
translate.use('en');
}
describe('Stalker route connection dialogs', () => {
afterEach(() => TestBed.resetTestingModule());
it('shows the exact redirect origins and returns the explicit approval decision', async () => {
const dialogRef = { close: jest.fn() };
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
StalkerOriginApprovalDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
finalOrigin: 'https://portal.example:8443',
sourceOrigin: 'https://source.example',
} satisfies StalkerOriginApprovalDialogData,
},
{ provide: MatDialogRef, useValue: dialogRef },
],
}).compileComponents();
configureTranslations();
const fixture = TestBed.createComponent(
StalkerOriginApprovalDialogComponent
);
fixture.detectChanges();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('https://source.example');
expect(text).toContain('https://portal.example:8443');
fixture.componentInstance.approve();
fixture.componentInstance.reject();
expect(dialogRef.close).toHaveBeenNthCalledWith(1, true);
expect(dialogRef.close).toHaveBeenNthCalledWith(2, false);
});
it('keeps the password masked by default and returns only submitted credentials', async () => {
const dialogRef = { close: jest.fn() };
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
StalkerCredentialsDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
savedCredentialsRejected: true,
username: 'saved-user',
} satisfies StalkerCredentialsDialogData,
},
{ provide: MatDialogRef, useValue: dialogRef },
],
}).compileComponents();
configureTranslations();
const fixture = TestBed.createComponent(
StalkerCredentialsDialogComponent
);
fixture.detectChanges();
const component = fixture.componentInstance;
const password = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLInputElement>(
'input[formControlName="password"]'
);
expect(password?.type).toBe('password');
expect(
(fixture.nativeElement as HTMLElement).textContent
).toContain('The saved credentials were rejected.');
component.passwordVisible.set(true);
fixture.detectChanges();
expect(password?.type).toBe('text');
component.form.setValue({
password: ' password with spaces ',
username: ' replacement-user ',
});
component.submit();
expect(dialogRef.close).toHaveBeenCalledWith({
password: ' password with spaces ',
username: 'replacement-user',
});
});
it('does not submit a blank username and supports explicit cancellation', async () => {
const dialogRef = { close: jest.fn() };
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
StalkerCredentialsDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
savedCredentialsRejected: false,
} satisfies StalkerCredentialsDialogData,
},
{ provide: MatDialogRef, useValue: dialogRef },
],
}).compileComponents();
configureTranslations();
const component = TestBed.createComponent(
StalkerCredentialsDialogComponent
).componentInstance;
component.form.setValue({ password: '', username: ' ' });
component.submit();
expect(dialogRef.close).not.toHaveBeenCalled();
component.cancel();
expect(dialogRef.close).toHaveBeenCalledWith(null);
});
});
@@ -0,0 +1,447 @@
/* eslint-disable max-lines -- route connection state matrix is intentionally kept together */
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
StalkerSessionRecoveryHandler,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
import {
Playlist,
StalkerSessionConnectionOutcome,
StalkerSessionFullReadyOutcome,
} from '@iptvnator/shared/interfaces';
import { EMPTY, 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';
const LEGACY_PLAYLIST: Playlist = {
_id: 'stalker-route-1',
autoRefresh: false,
count: 0,
importDate: '2026-07-27T00:00:00.000Z',
lastUsage: '2026-07-27T00:00:00.000Z',
macAddress: '00:1A:79:12:34:56',
portalUrl: 'https://source.example/c/',
stalkerToken: 'legacy-token-must-be-removed',
title: 'Living room',
};
const READY: StalkerSessionFullReadyOutcome = {
attemptRef: 'attempt-route-1',
capabilities: {
authenticatedSession: true,
playbackContext: true,
},
connectionMode: 'provisional',
endpoint: 'https://portal.example/stalker_portal/server/load.php',
kind: 'ready',
landingUrl: 'https://portal.example/c/',
leaseRef: 'lease-route-1',
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/',
},
provisionalReason: 'migration',
recipe: 'full-session',
requestId: 'request-ready-route-1',
};
const CONTROL_SUCCESS = {
action: 'commit' as const,
kind: 'success' as const,
requestId: 'request-commit-route-1',
};
function createSessionMock() {
let recoveryHandler: StalkerSessionRecoveryHandler | undefined;
return {
activate: jest.fn(),
commit: jest.fn().mockResolvedValue(CONTROL_SUCCESS),
continue: jest.fn(),
discard: jest.fn().mockResolvedValue({
action: 'discard',
kind: 'success',
requestId: 'request-discard-route-1',
}),
forceRedetect: jest.fn(),
getLeaseRef: jest.fn().mockReturnValue('lease-route-1'),
open: jest.fn(),
registerRecoveryHandler: jest.fn(
(handler: StalkerSessionRecoveryHandler) => {
recoveryHandler = handler;
return () => {
if (recoveryHandler === handler) {
recoveryHandler = undefined;
}
};
}
),
supportsTypedSessions: jest.fn().mockReturnValue(true),
recoveryHandler: () => recoveryHandler,
};
}
describe('StalkerConnectionFlowService', () => {
let service: StalkerConnectionFlowService;
let session: ReturnType<typeof createSessionMock>;
let playlists: {
persistStalkerConnection: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
let dialog: {
open: jest.Mock;
};
let queuedDialogResults: unknown[];
beforeEach(() => {
session = createSessionMock();
playlists = {
persistStalkerConnection: jest.fn((playlist: Playlist) =>
of(playlist)
),
};
store = {
dispatch: jest.fn(),
};
queuedDialogResults = [];
dialog = {
open: jest.fn(() => ({
afterClosed: () => of(queuedDialogResults.shift()),
close: jest.fn(),
})),
};
TestBed.configureTestingModule({
providers: [
StalkerConnectionFlowService,
{
provide: StalkerSessionService,
useValue: session,
},
{
provide: PlaylistsService,
useValue: playlists,
},
{
provide: Store,
useValue: store,
},
{
provide: MatDialog,
useValue: dialog,
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(() => ({
onAction: () => EMPTY,
})),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
service = TestBed.inject(StalkerConnectionFlowService);
});
it('classifies an ambiguous legacy full portal lazily and persists before commit', async () => {
session.open.mockResolvedValue(READY);
const connected = await service.ensureConnected(LEGACY_PLAYLIST);
expect(session.open).toHaveBeenCalledWith(LEGACY_PLAYLIST, {
connectionMode: 'provisional',
provisionalReason: 'migration',
});
const persistedDraft = playlists.persistStalkerConnection.mock
.calls[0][0] as Playlist;
expect(persistedDraft).toMatchObject({
isFullStalkerPortal: true,
portalUrl:
'https://portal.example/stalker_portal/server/load.php',
stalkerRequestRecipe: 'full-session',
stalkerSourceUrl: 'https://source.example/c/',
});
expect(persistedDraft.stalkerToken).toBeUndefined();
expect(
playlists.persistStalkerConnection.mock.invocationCallOrder[0]
).toBeLessThan(session.commit.mock.invocationCallOrder[0]);
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.stalkerConnectionPersisted({
playlist: persistedDraft,
})
);
expect(connected).toEqual(persistedDraft);
});
it('keeps an explicitly simple record and PWA behavior unchanged', async () => {
const simple = {
...LEGACY_PLAYLIST,
isFullStalkerPortal: false,
};
await expect(service.ensureConnected(simple)).resolves.toBe(simple);
session.supportsTypedSessions.mockReturnValueOnce(false);
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 = {
challengeRef: 'challenge-origin-route-1',
finalOrigin: 'https://portal.example',
kind: 'origin-approval-required',
requestId: 'request-origin-route-1',
sourceOrigin: 'https://source.example',
};
const credentials: StalkerSessionConnectionOutcome = {
attemptNumber: 2,
challengeRef: 'challenge-credentials-route-1',
kind: 'credentials-required',
requestId: 'request-credentials-route-1',
savedCredentialsRejected: true,
};
session.open.mockResolvedValue(origin);
session.continue
.mockResolvedValueOnce(credentials)
.mockResolvedValueOnce(READY);
queuedDialogResults.push(true, {
password: 'replacement-password',
username: 'replacement-user',
});
const connected = await service.ensureConnected({
...LEGACY_PLAYLIST,
password: 'old-password',
username: 'old-user',
});
expect(dialog.open).toHaveBeenNthCalledWith(
1,
StalkerOriginApprovalDialogComponent,
expect.objectContaining({
data: {
finalOrigin: 'https://portal.example',
sourceOrigin: 'https://source.example',
},
disableClose: true,
})
);
expect(dialog.open).toHaveBeenNthCalledWith(
2,
StalkerCredentialsDialogComponent,
expect.objectContaining({
data: {
savedCredentialsRejected: true,
username: 'old-user',
},
disableClose: true,
})
);
expect(session.continue).toHaveBeenNthCalledWith(
1,
LEGACY_PLAYLIST._id,
{
challengeRef: 'challenge-origin-route-1',
response: {
approved: true,
kind: 'origin-approval',
},
}
);
expect(session.continue).toHaveBeenNthCalledWith(
2,
LEGACY_PLAYLIST._id,
{
challengeRef: 'challenge-credentials-route-1',
response: {
kind: 'credentials',
password: 'replacement-password',
username: 'replacement-user',
},
}
);
expect(connected).toMatchObject({
password: 'replacement-password',
username: 'replacement-user',
});
});
it('consumes a rejected origin challenge without persisting anything', async () => {
const origin: StalkerSessionConnectionOutcome = {
challengeRef: 'challenge-origin-rejected',
finalOrigin: 'https://other.example',
kind: 'origin-approval-required',
requestId: 'request-origin-rejected',
sourceOrigin: 'https://source.example',
};
session.open.mockResolvedValue(origin);
session.continue.mockResolvedValue({
kind: 'failure',
reason: 'origin-not-approved',
requestId: 'request-origin-rejected-result',
retryable: false,
stage: 'awaiting-origin-approval',
});
queuedDialogResults.push(false);
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(session.continue).toHaveBeenCalledWith(
LEGACY_PLAYLIST._id,
{
challengeRef: 'challenge-origin-rejected',
response: {
approved: false,
kind: 'origin-approval',
},
}
);
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
});
it('retains a ready attempt after a local write failure and retries the same draft', async () => {
session.open.mockResolvedValue(READY);
playlists.persistStalkerConnection.mockReturnValueOnce(
throwError(() => new Error('disk-full'))
);
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(session.commit).not.toHaveBeenCalled();
playlists.persistStalkerConnection.mockImplementationOnce(
(playlist: Playlist) => of(playlist)
);
const connected = await service.retryPendingPersistence();
expect(playlists.persistStalkerConnection).toHaveBeenCalledTimes(2);
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
expect(connected).toMatchObject({
stalkerRequestRecipe: 'full-session',
});
});
it('discards a ready attempt when navigation abandons persistence retry', async () => {
session.open.mockResolvedValue(READY);
playlists.persistStalkerConnection.mockReturnValue(
throwError(() => new Error('disk-full'))
);
await service.ensureConnected(LEGACY_PLAYLIST);
await service.cancel();
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
});
it('turns a rejected promotion into a discarded runtime attempt', async () => {
session.open.mockResolvedValue(READY);
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
});
it('opens a provisional replacement for a principal transition and enables one facade reissue', async () => {
session.open.mockResolvedValue(READY);
const handler = session.recoveryHandler();
expect(handler).toBeDefined();
await expect(
handler?.({
outcome: {
kind: 'failure',
reason: 'principal-transition-required',
requestId: 'request-principal-transition',
retryable: false,
stage: 'refreshing',
},
playlist: LEGACY_PLAYLIST,
trigger: 'request',
})
).resolves.toBe(true);
expect(session.open).toHaveBeenCalledWith(LEGACY_PLAYLIST, {
connectionMode: 'provisional',
provisionalReason: 'migration',
});
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
});
it('routes force-redetect through the active lease and handles its challenge', async () => {
const credentials: StalkerSessionConnectionOutcome = {
attemptNumber: 1,
challengeRef: 'challenge-force-redetect',
kind: 'credentials-required',
requestId: 'request-force-redetect',
};
session.forceRedetect.mockResolvedValue(credentials);
session.continue.mockResolvedValue(READY);
queuedDialogResults.push({
password: 'password',
username: 'user',
});
await expect(
service.forceRedetect(LEGACY_PLAYLIST)
).resolves.toMatchObject({
stalkerRequestRecipe: 'full-session',
});
expect(session.forceRedetect).toHaveBeenCalledWith('lease-route-1');
});
it('lets Test Connection reclassify an explicitly simple record', async () => {
session.getLeaseRef.mockReturnValueOnce(undefined);
session.open.mockResolvedValue(READY);
await expect(
service.forceRedetect({
...LEGACY_PLAYLIST,
isFullStalkerPortal: false,
stalkerRequestRecipe: 'stateless-mac',
})
).resolves.toMatchObject({
isFullStalkerPortal: true,
stalkerRequestRecipe: 'full-session',
});
expect(session.open).toHaveBeenCalledWith(
expect.objectContaining({
isFullStalkerPortal: false,
}),
{
connectionMode: 'provisional',
provisionalReason: 'migration',
}
);
});
});
@@ -0,0 +1,371 @@
import { DestroyRef, Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
StalkerSessionRecoveryRequest,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
import {
Playlist,
StalkerSessionConnectionOutcome,
StalkerSessionFullReadyOutcome,
StalkerSessionStatelessReadyOutcome,
} from '@iptvnator/shared/interfaces';
import { Subject, firstValueFrom, take } from 'rxjs';
import {
StalkerCredentialsDialogComponent,
StalkerCredentialsDialogResult,
} from './stalker-credentials-dialog.component';
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
type ReadyOutcome =
| StalkerSessionFullReadyOutcome
| StalkerSessionStatelessReadyOutcome;
interface PendingPersistence {
readonly announceReady: boolean;
readonly draft: Playlist;
readonly outcome: ReadyOutcome;
readonly runId: number;
}
@Injectable()
export class StalkerConnectionFlowService {
private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);
private readonly playlists = inject(PlaylistsService);
private readonly session = inject(StalkerSessionService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly readySubject = new Subject<Playlist>();
private activeDialog: { close: () => void } | null = null;
private activeAttemptRef: string | null = null;
private pendingPersistence: PendingPersistence | null = null;
private runId = 0;
readonly connectionReady$ = this.readySubject.asObservable();
constructor() {
const unregister = this.session.registerRecoveryHandler((request) =>
this.recover(request)
);
this.destroyRef.onDestroy(() => {
unregister();
void this.cancel();
this.readySubject.complete();
});
}
async ensureConnected(playlist: Playlist): Promise<Playlist | undefined> {
if (!this.shouldUseTypedSession(playlist)) {
return playlist;
}
const runId = ++this.runId;
await this.discardPending();
const needsMigration = !this.hasVerifiedRecipe(playlist);
try {
const outcome = await this.session.open(
playlist,
needsMigration
? {
connectionMode: 'provisional',
provisionalReason: 'migration',
}
: {}
);
return await this.handleOutcome(playlist, outcome, runId, false);
} catch {
return undefined;
}
}
async forceRedetect(playlist: Playlist): Promise<Playlist | undefined> {
if (!this.session.supportsTypedSessions()) {
return playlist;
}
let leaseRef = this.session.getLeaseRef(playlist._id);
if (leaseRef === undefined) {
const runId = ++this.runId;
await this.discardPending();
try {
const outcome = await this.session.open(playlist, {
connectionMode: 'provisional',
provisionalReason: 'migration',
});
return this.handleOutcome(playlist, outcome, runId, true);
} catch {
return undefined;
}
}
const runId = ++this.runId;
const outcome = await this.session.forceRedetect(leaseRef);
return outcome.kind === 'success'
? playlist
: this.handleOutcome(playlist, outcome, runId, true);
}
async retryPendingPersistence(): Promise<Playlist | undefined> {
const pending = this.pendingPersistence;
if (pending === null || pending.runId !== this.runId) {
return undefined;
}
return this.persistAndCommit({
...pending,
announceReady: true,
});
}
async cancel(): Promise<void> {
this.runId += 1;
this.activeDialog?.close();
this.activeDialog = null;
await this.discardPending();
}
private async recover(
request: StalkerSessionRecoveryRequest
): Promise<boolean> {
if (!this.shouldUseTypedSession(request.playlist)) {
return false;
}
const runId = ++this.runId;
await this.discardPending();
try {
const outcome =
request.outcome.kind === 'failure'
? await this.session.open(request.playlist, {
connectionMode: 'provisional',
provisionalReason: 'migration',
})
: request.outcome;
return (
(await this.handleOutcome(
request.playlist,
outcome,
runId,
true
)) !== undefined
);
} catch {
return false;
}
}
private async handleOutcome(
playlist: Playlist,
initialOutcome: StalkerSessionConnectionOutcome,
runId: number,
announceReady: boolean
): Promise<Playlist | undefined> {
let outcome = initialOutcome;
let credentials: StalkerCredentialsDialogResult | undefined;
while (runId === this.runId) {
if (outcome.kind === 'ready') {
const draft = this.applyReadyOutcome(
playlist,
outcome,
credentials
);
return this.persistAndCommit({
announceReady,
draft,
outcome,
runId,
});
}
if (outcome.kind === 'origin-approval-required') {
const approved = await this.requestOriginApproval(outcome);
if (runId !== this.runId) {
return undefined;
}
outcome = await this.session.continue(playlist._id, {
challengeRef: outcome.challengeRef,
response: {
approved,
kind: 'origin-approval',
},
});
if (!approved) {
return undefined;
}
continue;
}
if (outcome.kind === 'credentials-required') {
credentials = await this.requestCredentials(
playlist,
outcome.savedCredentialsRejected === true
);
if (runId !== this.runId || credentials === undefined) {
return undefined;
}
outcome = await this.session.continue(playlist._id, {
challengeRef: outcome.challengeRef,
response: {
kind: 'credentials',
...credentials,
},
});
continue;
}
return undefined;
}
return undefined;
}
private async requestOriginApproval(
outcome: Extract<
StalkerSessionConnectionOutcome,
{ kind: 'origin-approval-required' }
>
): Promise<boolean> {
const ref = this.dialog.open(StalkerOriginApprovalDialogComponent, {
data: {
finalOrigin: outcome.finalOrigin,
sourceOrigin: outcome.sourceOrigin,
},
disableClose: true,
});
this.activeDialog = ref;
const approved = await firstValueFrom(ref.afterClosed(), {
defaultValue: false,
});
if (this.activeDialog === ref) {
this.activeDialog = null;
}
return approved === true;
}
private async requestCredentials(
playlist: Playlist,
savedCredentialsRejected: boolean
): Promise<StalkerCredentialsDialogResult | undefined> {
const ref = this.dialog.open(StalkerCredentialsDialogComponent, {
data: {
savedCredentialsRejected,
username: playlist.username,
},
disableClose: true,
});
this.activeDialog = ref;
const credentials = await firstValueFrom(ref.afterClosed(), {
defaultValue: null,
});
if (this.activeDialog === ref) {
this.activeDialog = null;
}
return credentials ?? undefined;
}
private async persistAndCommit(
pending: PendingPersistence
): Promise<Playlist | undefined> {
this.activeAttemptRef = pending.outcome.attemptRef ?? null;
let persisted: Playlist;
try {
persisted = await firstValueFrom(
this.playlists.persistStalkerConnection(pending.draft)
);
} catch {
if (pending.runId === this.runId) {
this.pendingPersistence = pending;
this.offerPersistenceRetry();
}
return undefined;
}
if (pending.runId !== this.runId) {
await this.discardAttempt(pending.outcome.attemptRef);
return undefined;
}
this.store.dispatch(
PlaylistActions.stalkerConnectionPersisted({
playlist: persisted,
})
);
if (pending.outcome.attemptRef !== undefined) {
let promotion;
try {
promotion = await this.session.commit(
pending.outcome.attemptRef
);
} catch {
await this.discardAttempt(pending.outcome.attemptRef);
return undefined;
}
if (promotion.kind !== 'success') {
await this.discardAttempt(pending.outcome.attemptRef);
return undefined;
}
}
this.activeAttemptRef = null;
this.pendingPersistence = null;
if (pending.announceReady) {
this.readySubject.next(persisted);
}
return persisted;
}
private applyReadyOutcome(
playlist: Playlist,
outcome: ReadyOutcome,
credentials: StalkerCredentialsDialogResult | undefined
): Playlist {
const draft: Playlist = {
...playlist,
...outcome.persistenceDraft,
portalUrl: outcome.persistenceDraft.portalUrl,
...(credentials === undefined ? {} : credentials),
};
delete draft.stalkerToken;
return draft;
}
private offerPersistenceRetry(): void {
const ref = this.snackBar.open(
this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
{ reason: 'local-persistence-failed' }
),
this.translate.instant('HOME.STALKER_PORTAL.SAVE_AGAIN'),
{ duration: 120_000 }
);
ref.onAction()
.pipe(take(1))
.subscribe(() => void this.retryPendingPersistence());
}
private async discardPending(): Promise<void> {
const attemptRef =
this.pendingPersistence?.outcome.attemptRef ??
this.activeAttemptRef ??
undefined;
this.pendingPersistence = null;
this.activeAttemptRef = null;
await this.discardAttempt(attemptRef);
}
private async discardAttempt(attemptRef: string | undefined): Promise<void> {
if (attemptRef !== undefined) {
await this.session.discard(attemptRef).catch(() => undefined);
}
}
private shouldUseTypedSession(playlist: Playlist): boolean {
return (
this.session.supportsTypedSessions() &&
playlist.isFullStalkerPortal !== false &&
playlist.stalkerRequestRecipe !== 'stateless-mac'
);
}
private hasVerifiedRecipe(playlist: Playlist): boolean {
return (
playlist.stalkerRequestRecipe === 'full-session' &&
typeof playlist.stalkerRecipeClassifierVersion === 'number'
);
}
}
@@ -0,0 +1,172 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
export interface StalkerCredentialsDialogData {
readonly savedCredentialsRejected: boolean;
readonly username?: string;
}
export interface StalkerCredentialsDialogResult {
readonly username: string;
readonly password: string;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
selector: 'app-stalker-credentials-dialog',
styles: `
form,
.credential-fields {
display: grid;
}
.credential-fields {
gap: 0.75rem;
min-width: min(24rem, 72vw);
padding-top: 0.5rem;
}
.rejected {
color: var(--mat-sys-error);
}
`,
template: `
<h2 mat-dialog-title>
{{ 'HOME.STALKER_PORTAL.CREDENTIALS_TITLE' | translate }}
</h2>
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<mat-dialog-content>
<p>
{{
'HOME.STALKER_PORTAL.CREDENTIALS_DESCRIPTION'
| translate
}}
</p>
@if (data.savedCredentialsRejected) {
<p class="rejected" role="alert">
{{
'HOME.STALKER_PORTAL.CREDENTIALS_REJECTED'
| translate
}}
</p>
}
<div class="credential-fields">
<mat-form-field appearance="outline">
<mat-label>
{{ 'HOME.STALKER_PORTAL.USERNAME' | translate }}
</mat-label>
<input
matInput
autocomplete="username"
formControlName="username"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>
{{ 'HOME.STALKER_PORTAL.PASSWORD' | translate }}
</mat-label>
<input
matInput
autocomplete="current-password"
formControlName="password"
[type]="passwordVisible() ? 'text' : 'password'"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="
(passwordVisible()
? 'HOME.STALKER_PORTAL.HIDE_PASSWORD'
: 'HOME.STALKER_PORTAL.SHOW_PASSWORD'
) | translate
"
(click)="passwordVisible.update(visible => !visible)"
>
<mat-icon aria-hidden="true">
{{
passwordVisible()
? 'visibility_off'
: 'visibility'
}}
</mat-icon>
</button>
</mat-form-field>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()">
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button type="submit">
{{
'HOME.STALKER_PORTAL.VALIDATE_CREDENTIALS' | translate
}}
</button>
</mat-dialog-actions>
</form>
`,
})
export class StalkerCredentialsDialogComponent {
private readonly dialogRef =
inject<
MatDialogRef<
StalkerCredentialsDialogComponent,
StalkerCredentialsDialogResult | null
>
>(MatDialogRef);
private readonly formBuilder = inject(NonNullableFormBuilder);
readonly data = inject<StalkerCredentialsDialogData>(MAT_DIALOG_DATA);
readonly passwordVisible = signal(false);
readonly form = this.formBuilder.group({
username: [
this.data.username ?? '',
[Validators.required, Validators.pattern(/\S/)],
],
password: [''],
});
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const value = this.form.getRawValue();
this.dialogRef.close({
password: value.password,
username: value.username.trim(),
});
}
cancel(): void {
this.dialogRef.close(null);
}
}
@@ -0,0 +1,90 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
export interface StalkerOriginApprovalDialogData {
readonly sourceOrigin: string;
readonly finalOrigin: string;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
selector: 'app-stalker-origin-approval-dialog',
styles: `
.origin-context {
display: grid;
gap: 1rem;
margin: 1rem 0 0;
}
.origin-context div {
min-width: 0;
}
dt {
color: var(--mat-sys-on-surface-variant);
font: var(--mat-sys-label-medium);
}
dd {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
}
`,
template: `
<h2 mat-dialog-title>
{{ 'HOME.STALKER_PORTAL.ORIGIN_APPROVAL_TITLE' | translate }}
</h2>
<mat-dialog-content>
<p>
{{
'HOME.STALKER_PORTAL.ORIGIN_APPROVAL_DESCRIPTION'
| translate
}}
</p>
<dl class="origin-context">
<div>
<dt>
{{ 'HOME.STALKER_PORTAL.ORIGIN_SOURCE' | translate }}
</dt>
<dd>{{ data.sourceOrigin }}</dd>
</div>
<div>
<dt>
{{ 'HOME.STALKER_PORTAL.ORIGIN_TARGET' | translate }}
</dt>
<dd>{{ data.finalOrigin }}</dd>
</div>
</dl>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="reject()">
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button type="button" (click)="approve()">
{{ 'HOME.STALKER_PORTAL.APPROVE_ORIGIN' | translate }}
</button>
</mat-dialog-actions>
`,
})
export class StalkerOriginApprovalDialogComponent {
private readonly dialogRef =
inject<
MatDialogRef<StalkerOriginApprovalDialogComponent, boolean>
>(MatDialogRef);
readonly data = inject<StalkerOriginApprovalDialogData>(MAT_DIALOG_DATA);
approve(): void {
this.dialogRef.close(true);
}
reject(): void {
this.dialogRef.close(false);
}
}
@@ -6,6 +6,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { 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';
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
const PLAYLIST_ID = 'stalker-1';
@@ -30,6 +31,8 @@ const FULL_STALKER_PLAYLIST: PlaylistMeta = {
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
function getStalkerSectionFromUrl(url: string): string | null {
@@ -40,6 +43,7 @@ function getStalkerSectionFromUrl(url: string): string | null {
describe('StalkerWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const connectionReady = new Subject<PlaylistMeta>();
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>(
'vod'
@@ -67,6 +71,14 @@ describe('StalkerWorkspaceRouteSession', () => {
getPlaylistById: jest.fn(() => of(ACTIVE_PLAYLIST)),
};
const connectionFlow = {
cancel: jest.fn().mockResolvedValue(undefined),
connectionReady$: connectionReady.asObservable(),
ensureConnected: jest.fn(
async (playlist: PlaylistMeta) => playlist
),
};
const router = {
url: `/workspace/stalker/${PLAYLIST_ID}/vod`,
events: routerEvents.asObservable(),
@@ -99,6 +111,8 @@ describe('StalkerWorkspaceRouteSession', () => {
stalkerStore.setSelectedContentType.mockClear();
stalkerStore.setSearchPhrase.mockClear();
playlistsService.getPlaylistById.mockClear();
connectionFlow.cancel.mockClear();
connectionFlow.ensureConnected.mockClear();
await TestBed.configureTestingModule({
providers: [
@@ -119,6 +133,10 @@ describe('StalkerWorkspaceRouteSession', () => {
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: StalkerConnectionFlowService,
useValue: connectionFlow,
},
],
});
});
@@ -133,6 +151,14 @@ describe('StalkerWorkspaceRouteSession', () => {
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
ACTIVE_PLAYLIST
);
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
ACTIVE_PLAYLIST
);
expect(
connectionFlow.ensureConnected.mock.invocationCallOrder[0]
).toBeLessThan(
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
);
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv');
expect(selectedContentType()).toBe('itv');
expect(
@@ -200,4 +226,52 @@ describe('StalkerWorkspaceRouteSession', () => {
FULL_STALKER_PLAYLIST
);
});
it('does not expose a playlist to catalog resources when connection flow is cancelled', async () => {
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
});
it('cancels a provisional route attempt before leaving the Stalker workspace', async () => {
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
router.url = '/workspace/m3u/playlist-2';
playlistContext.syncFromUrl.mockReturnValueOnce({
inWorkspace: true,
playlistId: 'playlist-2',
provider: 'm3u',
section: null,
});
routerEvents.next(
new NavigationEnd(
2,
`/workspace/stalker/${PLAYLIST_ID}/vod`,
router.url
)
);
await flushEffects();
expect(connectionFlow.cancel).toHaveBeenCalledTimes(1);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
undefined
);
});
it('activates a connection completed by Save Again while the route is still current', async () => {
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
connectionReady.next(FULL_STALKER_PLAYLIST);
await flushEffects();
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
});
});
@@ -16,11 +16,13 @@ import {
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
@Injectable()
export class StalkerWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
private readonly connectionFlow = inject(StalkerConnectionFlowService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
@@ -28,6 +30,7 @@ export class StalkerWorkspaceRouteSession {
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(null);
private syncGeneration = 0;
constructor() {
this.router.events
@@ -41,6 +44,13 @@ export class StalkerWorkspaceRouteSession {
.subscribe(() => {
void this.syncRouteContext();
});
this.connectionFlow.connectionReady$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((playlist) => {
if (playlist._id === this.currentPlaylistId) {
void this.stalkerStore.setCurrentPlaylist(playlist);
}
});
void this.syncRouteContext();
@@ -52,13 +62,27 @@ export class StalkerWorkspaceRouteSession {
}
private async syncRouteContext(): Promise<void> {
const generation = ++this.syncGeneration;
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
const playlistId =
routeContext.provider === 'stalker'
? routeContext.playlistId
: null;
if (playlistId && this.currentPlaylistId !== playlistId) {
if (!playlistId && this.currentPlaylistId !== null) {
this.currentPlaylistId = null;
await this.connectionFlow.cancel();
if (generation !== this.syncGeneration) {
return;
}
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
await this.stalkerStore.setCurrentPlaylist(undefined);
} else if (playlistId && this.currentPlaylistId !== playlistId) {
if (this.currentPlaylistId !== null) {
await this.connectionFlow.cancel();
}
this.currentPlaylistId = playlistId;
this.stalkerStore.resetCategories();
@@ -66,7 +90,21 @@ export class StalkerWorkspaceRouteSession {
this.stalkerStore.clearSelectedItem();
const playlist = await this.resolveStalkerPlaylist(playlistId);
await this.stalkerStore.setCurrentPlaylist(playlist);
const connected =
playlist === undefined
? undefined
: await this.connectionFlow.ensureConnected(
playlist as Playlist
);
if (
generation !== this.syncGeneration ||
playlistId !== this.currentPlaylistId
) {
return;
}
if (connected !== undefined) {
await this.stalkerStore.setCurrentPlaylist(connected);
}
}
this.syncRouteState(routeContext.section);
@@ -134,6 +172,7 @@ export class StalkerWorkspaceRouteSession {
export function provideStalkerWorkspaceRouteSession(): Provider[] {
return [
StalkerConnectionFlowService,
StalkerWorkspaceRouteSession,
{
provide: ENVIRONMENT_INITIALIZER,