mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
14a658f608
commit
4d63f76407
5 files changed
+338
-4
No files matched your search
@@ -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 `<app-stalker-series-view />`
|
||||
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[]`:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+87
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -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')
|
||||
|
||||
+224
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user