fix(stalker): load full playlist for authenticated routes

Fix Stalker route initialization so full portal sessions load auth-capable playlist data before category/content requests. Add regression coverage for full playlist lookup, empty lookup fallback, fast-path explicit portal metadata, and authenticated request routing.
This commit is contained in:
4gray authored and GitHub committed 2026-06-22 01:19:24 +02:00
1 parent e801028005
commit 6373c798a1
4 files changed
+169 -9

No files matched your search

+6
View File
@@ -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
@@ -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();
});
});
@@ -1,7 +1,7 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import { EMPTY, Subject, of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
@@ -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<void> {
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,48 @@ 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
);
});
it('falls back to the active playlist when the full playlist lookup completes empty', async () => {
activePlaylist.set(ACTIVE_PLAYLIST);
playlistsService.getPlaylistById.mockReturnValue(EMPTY);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
PLAYLIST_ID
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
ACTIVE_PLAYLIST
);
});
it('uses active Stalker playlist metadata directly when the portal mode is explicit', async () => {
activePlaylist.set(FULL_STALKER_PLAYLIST);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(playlistsService.getPlaylistById).not.toHaveBeenCalled();
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
});
});
@@ -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,33 @@ export class StalkerWorkspaceRouteSession {
this.stalkerStore.setSearchPhrase('');
}
}
private async resolveStalkerPlaylist(
playlistId: string
): Promise<PlaylistMeta | undefined> {
const activePlaylist = this.playlistContext.activePlaylist();
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
return activePlaylist;
}
const storedPlaylist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId),
{ defaultValue: null }
);
return storedPlaylist ?? activePlaylist ?? undefined;
}
private hasExplicitStalkerPortalMode(
playlistId: string,
playlist: PlaylistMeta | null
): playlist is PlaylistMeta {
return (
playlist?._id === playlistId &&
playlist.isFullStalkerPortal !== undefined
);
}
}
export function provideStalkerWorkspaceRouteSession(): Provider[] {