perf(stalker): skip wasted series-seasons request for non-series items (#1241)

`setSelectedItem` mirrored every selection's id into `selectedSerialId`, and
`serialSeasonsResource` fires a `get_ordered_list&type=series&movie_id=<id>`
portal request on every change of that id. Opening any Stalker detail page —
plain VOD, vclub items with embedded `series[]` (whose result
`mapRegularSeriesSeasons` discards), Ministra `is_series` items, and ITV
channel clicks — therefore issued a pointless request, on every entry path
(browse, favorites, recent, dashboard, search).

Set `selectedSerialId` only when `selectedContentType === 'series'`, clearing
it otherwise. The gate is deliberately on content type alone, not item shape:
under the `series` content type `serialSeasonsResource` is the only episode
source (the detail templates render `<app-stalker-series-view />` with no
`vodWithSeries` input, and `isVodSeries()` requires content type `vod`), so
gating on `is_series`/`series[]` would leave a series-section item carrying
either field with a silently empty episode list.

Adds selection-state and request-level regression coverage, and corrects a
stale invariant in the Stalker architecture docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-25 16:54:30 +02:00
1 parent 14a658f608
commit 4d63f76407
5 files changed
+338 -4

No files matched your search

@@ -49,7 +49,93 @@ describe('withStalkerSelection', () => {
});
expect(store.selectedVodId()).toBe('55');
expect(store.selectedSerialId()).toBe('55');
expect(store.selectedItvId()).toBe('55');
});
it('sets the serial id for a regular series selection', () => {
store.setSelectedContentType('series');
store.setSelectedItem({
id: '55',
name: 'Regular series',
});
expect(store.selectedSerialId()).toBe('55');
});
it('does not set the serial id for a plain VOD selection', () => {
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '55',
name: 'Plain movie',
});
expect(store.selectedSerialId()).toBeUndefined();
expect(store.selectedVodId()).toBe('55');
});
it('does not set the serial id for items with embedded series episodes', () => {
// vclub items carry their episodes inline and are always opened
// under the VOD content type.
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '55',
name: 'Embedded series',
series: [1, 2, 3],
});
expect(store.selectedSerialId()).toBeUndefined();
});
it('still sets the serial id for a series selection carrying is_series', () => {
// Under the `series` content type the seasons API is the only
// episode source, so the fetch must not be gated on item shape.
store.setSelectedContentType('series');
store.setSelectedItem({
id: '55',
name: 'Series flagged is_series',
is_series: '1',
});
expect(store.selectedSerialId()).toBe('55');
});
it('still sets the serial id for a series selection carrying series[]', () => {
store.setSelectedContentType('series');
store.setSelectedItem({
id: '55',
name: 'Series carrying series[]',
series: [1, 2, 3],
});
expect(store.selectedSerialId()).toBe('55');
});
it('does not set the serial id for Ministra VOD-series items', () => {
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '55',
name: 'VOD series',
is_series: '1',
});
expect(store.selectedSerialId()).toBeUndefined();
});
it('clears a stale serial id when a non-series item is selected', () => {
store.setSelectedContentType('series');
store.setSelectedItem({ id: '55', name: 'Regular series' });
expect(store.selectedSerialId()).toBe('55');
store.setSelectedContentType('vod');
store.setSelectedItem({ id: '77', name: 'Plain movie' });
expect(store.selectedSerialId()).toBeUndefined();
expect(store.selectedVodId()).toBe('77');
});
});
@@ -102,9 +102,18 @@ export function withStalkerSelection() {
selectedIdRaw !== undefined
? normalizeStalkerEntityId(selectedIdRaw)
: undefined;
// serialSeasonsResource fetches regular-series seasons
// (get_ordered_list&type=series) on every selectedSerialId
// change, and it is the only episode source for a `series`
// selection. Every other content type resolves episodes
// elsewhere — embedded series[] and Ministra is_series items
// are always opened as `vod` — so carrying the id there only
// fires a portal request whose result is discarded.
const contentType = store.selectedContentType();
patchState(store, {
selectedVodId: selectedId,
selectedSerialId: selectedId,
selectedSerialId:
contentType === 'series' ? selectedId : undefined,
selectedItvId: selectedId,
selectedItem,
});
@@ -112,7 +121,6 @@ export function withStalkerSelection() {
// Async, best-effort TMDB enrichment for VOD/series detail
// selections. Applies via patchState (not setSelectedItem)
// so the hook cannot recurse; live/radio items are skipped.
const contentType = store.selectedContentType();
if (
selectedItem &&
(contentType === 'vod' || contentType === 'series')
@@ -0,0 +1,224 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { DataService, TmdbEnrichmentService } from '@iptvnator/services';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerSelection } from './with-stalker-selection.feature';
import { withStalkerSeries } from './with-stalker-series.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-14T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
const TestSeriesStore = signalStore(
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
}),
withMethods((store) => ({
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
},
})),
withStalkerSelection(),
withStalkerSeries()
);
async function flushResources(): Promise<void> {
TestBed.flushEffects();
await Promise.resolve();
await Promise.resolve();
TestBed.flushEffects();
await Promise.resolve();
}
async function waitForCondition(
predicate: () => boolean,
attempts = 20
): Promise<void> {
for (let index = 0; index < attempts; index += 1) {
if (predicate()) {
return;
}
await flushResources();
}
throw new Error('Timed out waiting for resource activity');
}
function seriesRequestCalls(
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>
) {
return sendIpcEvent.mock.calls.filter(
([, payload]) =>
(payload as { params?: { type?: string } })?.params?.type ===
'series'
);
}
describe('withStalkerSeries serialSeasonsResource gating', () => {
let store: InstanceType<typeof TestSeriesStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: [] }),
};
TestBed.configureTestingModule({
providers: [
TestSeriesStore,
{ provide: DataService, useValue: dataService },
{
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => false,
},
},
],
});
store = TestBed.inject(TestSeriesStore);
store.setCurrentPlaylist(PLAYLIST);
void store.isSerialSeasonsLoading();
void store.isVodSeriesSeasonsLoading();
});
it('fetches seasons for a regular series selection', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: [{ id: '42:1', name: 'Season 1', series: [1, 2] }],
});
store.setSelectedContentType('series');
store.setSelectedItem({ id: '42', name: 'Regular series' });
await waitForCondition(
() => seriesRequestCalls(dataService.sendIpcEvent).length > 0
);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: expect.objectContaining({
action: StalkerPortalActions.GetOrderedList,
type: 'series',
movie_id: '42',
}),
})
);
});
it('does not fire a series request for a plain VOD selection', async () => {
store.setSelectedContentType('vod');
store.setSelectedItem({ id: '7', name: 'Plain movie' });
await flushResources();
await flushResources();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('does not fire a series request for an item with embedded series episodes', async () => {
// vclub items carry their episodes inline and are always opened
// under the VOD content type, where the seasons API is not the
// episode source.
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '9',
name: 'Embedded series',
series: [1, 2, 3],
});
await flushResources();
await flushResources();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('still fetches seasons for a series selection carrying is_series', async () => {
// Regression guard: under the `series` content type the detail view
// renders <app-stalker-series-view /> without a vodWithSeries input,
// so serialSeasonsResource is the only episode source. Gating the
// fetch on item shape would render an empty episode list.
store.setSelectedContentType('series');
store.setSelectedItem({
id: '42',
name: 'Series flagged is_series',
is_series: '1',
});
await waitForCondition(
() => seriesRequestCalls(dataService.sendIpcEvent).length > 0
);
expect(
seriesRequestCalls(dataService.sendIpcEvent)[0][1]
).toMatchObject({
params: expect.objectContaining({
type: 'series',
movie_id: '42',
}),
});
});
it('does not fire a series request for a Ministra VOD-series item', async () => {
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '11',
name: 'VOD series',
is_series: '1',
});
// The legit vod-series season request (type=vod) may fire; the
// wasted regular-series request (type=series) must not.
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(0);
});
it('does not refetch seasons when a VOD item is selected after a series', async () => {
store.setSelectedContentType('series');
store.setSelectedItem({ id: '42', name: 'Regular series' });
await waitForCondition(
() => seriesRequestCalls(dataService.sendIpcEvent).length === 1
);
store.setSelectedContentType('vod');
store.setSelectedItem({ id: '7', name: 'Plain movie' });
await flushResources();
await flushResources();
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(1);
});
});