diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 7c6f4f8cd..42b3f4b20 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -125,6 +125,12 @@ blank fields are not generated or forwarded to `get_profile`. - Generated MAG-like identity remains a future explicit setting. It must not be the default because strict portals can bind accounts to the first device fingerprint they receive. +- Stalker workspace routes must initialize `StalkerStore` from a playlist object + with an explicit `isFullStalkerPortal` mode. If the active route metadata is a + lightweight playlist record without that field, the route session must load the + full playlist by id before category/content resources run. Stalker auth + metadata is independent from M3U playlist EPG metadata and must not depend on + M3U-specific EPG fields. ## Live TV and Radio diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts new file mode 100644 index 000000000..47607e9ef --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts @@ -0,0 +1,78 @@ +import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; +import { + executeStalkerRequest, + type StalkerRequestDeps, +} from './stalker-request.utils'; + +const CATEGORY_PARAMS = { + type: 'itv', + action: 'get_genres', +}; + +function createDeps(): StalkerRequestDeps { + return { + dataService: { + sendIpcEvent: jest.fn().mockResolvedValue({ js: [] }), + }, + stalkerSession: { + makeAuthenticatedRequest: jest.fn().mockResolvedValue({ js: [] }), + }, + } as unknown as StalkerRequestDeps; +} + +describe('executeStalkerRequest', () => { + it('routes full Stalker portal category requests through the authenticated session path', async () => { + const deps = createDeps(); + const playlist = { + _id: 'stalker-full', + title: 'Full Stalker Portal', + portalUrl: + 'https://portal.example.test/stalker_portal/server/load.php', + macAddress: 'has-mac-address', + isFullStalkerPortal: true, + stalkerSerialNumber: 'has-serial', + stalkerDeviceId1: 'has-device-id-1', + stalkerDeviceId2: 'has-device-id-2', + stalkerSignature1: 'has-signature-1', + stalkerSignature2: 'has-signature-2', + } as PlaylistMeta; + + await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS); + + expect( + deps.stalkerSession.makeAuthenticatedRequest + ).toHaveBeenCalledWith( + expect.objectContaining({ + ...playlist, + lastUsage: '', + }), + CATEGORY_PARAMS + ); + expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('keeps lightweight Stalker portal requests on the IPC path', async () => { + const deps = createDeps(); + const playlist = { + _id: 'stalker-basic', + title: 'Basic Stalker Portal', + portalUrl: 'https://portal.example.test/load.php', + macAddress: 'has-mac-address', + isFullStalkerPortal: false, + } as PlaylistMeta; + + await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS); + + expect(deps.dataService.sendIpcEvent).toHaveBeenCalledWith( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: CATEGORY_PARAMS, + } + ); + expect( + deps.stalkerSession.makeAuthenticatedRequest + ).not.toHaveBeenCalled(); + }); +}); 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 9c0e7eccb..ea5b8ea5f 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 @@ -17,15 +17,23 @@ const ACTIVE_PLAYLIST: PlaylistMeta = { title: 'Test Stalker', } as PlaylistMeta; +const FULL_STALKER_PLAYLIST: PlaylistMeta = { + ...ACTIVE_PLAYLIST, + isFullStalkerPortal: true, + stalkerSerialNumber: 'CUSTOMSN123', + stalkerDeviceId1: 'DEVICE-ID-1', + stalkerDeviceId2: 'DEVICE-ID-2', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: 'SIGNATURE-2', +} as PlaylistMeta; + async function flushEffects(): Promise { await Promise.resolve(); await Promise.resolve(); } function getStalkerSectionFromUrl(url: string): string | null { - const match = url.match( - /^\/workspace\/stalker\/[^/]+\/([^/?]+)(?:\/|$)/ - ); + const match = url.match(/^\/workspace\/stalker\/[^/]+\/([^/?]+)(?:\/|$)/); return match?.[1] ?? null; } @@ -148,4 +156,21 @@ describe('StalkerWorkspaceRouteSession', () => { expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith(''); }); + + it('loads the full Stalker playlist when the active route meta lacks auth fields', async () => { + activePlaylist.set(ACTIVE_PLAYLIST); + playlistsService.getPlaylistById.mockReturnValue( + of(FULL_STALKER_PLAYLIST) + ); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + PLAYLIST_ID + ); + 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 5248d2b5c..5efa32349 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,6 +16,7 @@ import { StalkerStore, } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; @Injectable() export class StalkerWorkspaceRouteSession { @@ -64,11 +65,7 @@ export class StalkerWorkspaceRouteSession { this.stalkerStore.setSelectedCategory(null); this.stalkerStore.clearSelectedItem(); - const playlist = - this.playlistContext.activePlaylist() ?? - (await firstValueFrom( - this.playlistsService.getPlaylistById(playlistId) - )); + const playlist = await this.resolveStalkerPlaylist(playlistId); await this.stalkerStore.setCurrentPlaylist(playlist); } @@ -106,6 +103,34 @@ export class StalkerWorkspaceRouteSession { this.stalkerStore.setSearchPhrase(''); } } + + private async resolveStalkerPlaylist( + playlistId: string + ): Promise { + const activePlaylist = this.playlistContext.activePlaylist(); + + if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) { + return activePlaylist; + } + + return ( + (await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + )) ?? + activePlaylist ?? + undefined + ); + } + + private hasExplicitStalkerPortalMode( + playlistId: string, + playlist: PlaylistMeta | null + ): playlist is PlaylistMeta { + return ( + playlist?._id === playlistId && + playlist.isFullStalkerPortal !== undefined + ); + } } export function provideStalkerWorkspaceRouteSession(): Provider[] {