From 4d63f764079c49ab47bfa8d1007422fb35889a6b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:54:30 +0200 Subject: [PATCH] perf(stalker): skip wasted series-seasons request for non-series items (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setSelectedItem` mirrored every selection's id into `selectedSerialId`, and `serialSeasonsResource` fires a `get_ordered_list&type=series&movie_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 `` 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 --- docs/architecture/stalker-portal.md | 8 + .../stalker-store-api-baseline.md | 10 +- .../with-stalker-selection.feature.spec.ts | 88 ++++++- .../with-stalker-selection.feature.ts | 12 +- .../with-stalker-series.feature.spec.ts | 224 ++++++++++++++++++ 5 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c75d97f4f..e2bc183ea 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -271,6 +271,14 @@ Stalker has multiple real-world data shapes. The current implementation supports - Seasons come from API resource (`serialSeasonsResource`). - Episodes are derived from season payload. +- This is the only mode that sets `selectedSerialId`, which is what drives + `serialSeasonsResource`. It is set purely from `selectedContentType === +'series'` — the `series` detail branch renders `` + with no `vodWithSeries` input, so the API resource is its only episode + source and the fetch must never be gated on item shape. +- Modes 2 and 3 below are always opened under the `vod` content type, which + leaves the id unset — otherwise every VOD detail open would fire a + `get_ordered_list&type=series` request whose result is discarded. 2. VOD with Embedded `series[]`: diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index bfb4bb4aa..b062fd7ca 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -123,7 +123,15 @@ Consumer directories sampled: ## Invariants to Preserve During Refactor -- Selection IDs (`selectedVodId`, `selectedSerialId`, `selectedItvId`) are synchronized in `setSelectedItem`. +- Selection IDs (`selectedVodId`, `selectedItvId`) are synchronized in `setSelectedItem`. +- `selectedSerialId` is set only when `selectedContentType` is `series`, and is + cleared for every other content type. `serialSeasonsResource` fires a + `get_ordered_list&type=series` portal request on each change of that id, and + it is the only episode source for a `series` selection — while VOD-context + shapes (embedded `series[]`, Ministra `is_series`) resolve their episodes + elsewhere, so carrying the id there only wastes a request. The gate must stay + on content type alone: gating it on item shape would leave a series-section + item that happens to carry `is_series`/`series[]` with an empty episode list. - `setSelectedCategory(...)` resets `page` to `0`. - `getPaginatedContent()` and `getCategoryResource()` always return arrays, even when the underlying request fails. diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts index 54c3f1856..c609a4ab1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts @@ -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'); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index 064aa2d56..618956b63 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -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') diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts new file mode 100644 index 000000000..976d7b50d --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts @@ -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 { + TestBed.flushEffects(); + await Promise.resolve(); + await Promise.resolve(); + TestBed.flushEffects(); + await Promise.resolve(); +} + +async function waitForCondition( + predicate: () => boolean, + attempts = 20 +): Promise { + 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, unknown[]> +) { + return sendIpcEvent.mock.calls.filter( + ([, payload]) => + (payload as { params?: { type?: string } })?.params?.type === + 'series' + ); +} + +describe('withStalkerSeries serialSeasonsResource gating', () => { + let store: InstanceType; + let dataService: { + sendIpcEvent: jest.Mock, 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 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); + }); +});