diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 941d288cf..8931b3b2d 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "تصفية وفرز", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 06166b71e..3249e63e1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "فلتر وترتيب", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 01444fd44..33de874a0 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Фільтр і сартаванне", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d1288edc9..39eafd937 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 50352f30c..346867fbb 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Φιλτράρισμα & Ταξινόμηση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ea0773c52..dba0f2414 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3cd9c72d3..69a0d13a6 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 8b49acb86..f69a040fd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index dccdd2cbc..2edd06f69 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 09d6ace61..19c310b20 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d769b24b4..852239bba 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "フィルターと並び替え", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d89760094..56ded5ab1 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "필터 및 정렬", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 80fafad1e..a21d24e16 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ed73a0ea5..58c22058c 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 32f6e5782..eeeb1e806 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index ded1370f4..1439ffc4f 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Фильтр и сортировка", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7a42e3d44..cd703b182 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index d75eb607c..56dda24dd 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "筛选与排序", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9fd15faaa..2fcdf9873 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "篩選與排序", diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts index d5600ef48..c90fa08c0 100644 --- a/libs/portal/stalker/feature/src/index.ts +++ b/libs/portal/stalker/feature/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts new file mode 100644 index 000000000..06f97f410 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts @@ -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( + '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); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts new file mode 100644 index 000000000..4de152642 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts @@ -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; + 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', + } + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts new file mode 100644 index 000000000..3c881b6f1 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts @@ -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(); + 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 { + 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 { + 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 { + const pending = this.pendingPersistence; + if (pending === null || pending.runId !== this.runId) { + return undefined; + } + return this.persistAndCommit({ + ...pending, + announceReady: true, + }); + } + + async cancel(): Promise { + this.runId += 1; + this.activeDialog?.close(); + this.activeDialog = null; + await this.discardPending(); + } + + private async recover( + request: StalkerSessionRecoveryRequest + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' + ); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts new file mode 100644 index 000000000..a2786280c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts @@ -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: ` +

+ {{ 'HOME.STALKER_PORTAL.CREDENTIALS_TITLE' | translate }} +

+
+ +

+ {{ + 'HOME.STALKER_PORTAL.CREDENTIALS_DESCRIPTION' + | translate + }} +

+ @if (data.savedCredentialsRejected) { + + } +
+ + + {{ 'HOME.STALKER_PORTAL.USERNAME' | translate }} + + + + + + {{ 'HOME.STALKER_PORTAL.PASSWORD' | translate }} + + + + +
+
+ + + + +
+ `, +}) +export class StalkerCredentialsDialogComponent { + private readonly dialogRef = + inject< + MatDialogRef< + StalkerCredentialsDialogComponent, + StalkerCredentialsDialogResult | null + > + >(MatDialogRef); + private readonly formBuilder = inject(NonNullableFormBuilder); + readonly data = inject(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); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-origin-approval-dialog.component.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-origin-approval-dialog.component.ts new file mode 100644 index 000000000..13025bb5d --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-origin-approval-dialog.component.ts @@ -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: ` +

+ {{ 'HOME.STALKER_PORTAL.ORIGIN_APPROVAL_TITLE' | translate }} +

+ +

+ {{ + 'HOME.STALKER_PORTAL.ORIGIN_APPROVAL_DESCRIPTION' + | translate + }} +

+
+
+
+ {{ 'HOME.STALKER_PORTAL.ORIGIN_SOURCE' | translate }} +
+
{{ data.sourceOrigin }}
+
+
+
+ {{ 'HOME.STALKER_PORTAL.ORIGIN_TARGET' | translate }} +
+
{{ data.finalOrigin }}
+
+
+
+ + + + + `, +}) +export class StalkerOriginApprovalDialogComponent { + private readonly dialogRef = + inject< + MatDialogRef + >(MatDialogRef); + readonly data = inject(MAT_DIALOG_DATA); + + approve(): void { + this.dialogRef.close(true); + } + + reject(): void { + this.dialogRef.close(false); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts index 93854bffa..77f1d4e01 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -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 { 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(); + const connectionReady = new Subject(); const activePlaylist = signal(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 + ); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts index 7aa702d7b..60a253a51 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -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(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 { + 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,