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);
+ });
+});