diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts
index 9e7e1af44..cd46f306b 100644
--- a/apps/stalker-mock-server/src/app/data-generator.ts
+++ b/apps/stalker-mock-server/src/app/data-generator.ts
@@ -55,7 +55,8 @@ export interface RawVodItem {
category_id: string;
is_series: 0 | 1 | '1';
has_files: number;
- series?: RawEmbeddedEpisode[];
+ /** vclub-style embedded episode numbers, e.g. ['1', '2', '3'] */
+ series?: string[];
}
export interface RawSeriesItem {
@@ -95,12 +96,6 @@ export interface RawSeason {
series: string[];
}
-export interface RawEmbeddedEpisode {
- id: number;
- name: string;
- cmd: string;
-}
-
export interface RawEpgProgram {
id: string;
name: string;
@@ -388,19 +383,19 @@ function generateVodItems(
};
if (hasEmbeddedSeries) {
- item.series = generateEmbeddedEpisodes(id, seasonsPerSeries * episodesPerSeason);
+ // Real vclub portals expose embedded episodes as an array of
+ // episode numbers; playback appends the number to the item cmd.
+ item.series = generateEmbeddedEpisodes(
+ seasonsPerSeries * episodesPerSeason
+ );
}
return item;
});
}
-function generateEmbeddedEpisodes(parentId: string, count: number): RawEmbeddedEpisode[] {
- return Array.from({ length: count }, (_, i) => ({
- id: parseInt(parentId) * 100 + i,
- name: `Episode ${i + 1}`,
- cmd: `ffrt4://vod/${parentId}/ep${i + 1}/index.m3u8`,
- }));
+function generateEmbeddedEpisodes(count: number): string[] {
+ return Array.from({ length: count }, (_, i) => String(i + 1));
}
// ---------------------------------------------------------------------------
diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts
index f381d33cf..9129bc0ca 100644
--- a/apps/web-e2e/src/stalker.e2e.ts
+++ b/apps/web-e2e/src/stalker.e2e.ts
@@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
+/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
+const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
+
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
@@ -565,6 +568,115 @@ test('@stalker mock server returns radio categories and stations', async ({
);
});
+test('@stalker favorites — embedded-series favorite refreshes newly released episodes', async ({
+ page,
+ request,
+}) => {
+ // Find an embedded-series VOD item in the mock catalog first
+ const listResponse = await request.get(
+ `${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
+ );
+ const listBody = await listResponse.json();
+ const embeddedItem = listBody.payload.js.data.find(
+ (item: { series?: unknown[] }) =>
+ Array.isArray(item.series) && item.series.length > 0
+ );
+ expect(embeddedItem).toBeDefined();
+ const episodeCount: number = embeddedItem.series.length;
+
+ await addStalkerPortal(page, {
+ name: 'Embedded Series Portal',
+ mac: EMBEDDED_SERIES_MAC,
+ });
+
+ // Open the embedded-series item from its category and favorite it —
+ // this persists a snapshot with the current episode list
+ const categories = page.locator('.category-item');
+ await expect(categories.first()).toBeVisible({ timeout: 10_000 });
+ await categories.nth(1).click();
+ const card = page.getByText(embeddedItem.name).first();
+ await expect(card).toBeVisible({ timeout: 10_000 });
+ await card.click();
+
+ await expect(
+ page.getByRole('heading', {
+ name: `${episodeCount}. Episode ${episodeCount}`,
+ exact: true,
+ })
+ ).toBeVisible({ timeout: 10_000 });
+ await page.getByRole('button', { name: 'Add to favorites' }).click();
+ // Wait for the async favorite persistence before navigating away
+ await expect(
+ page.getByRole('button', { name: 'Remove from favorites' })
+ ).toBeVisible({ timeout: 10_000 });
+
+ // From now on the portal has "released" one more episode: extend
+ // series[] in every search response (the background snapshot refresh
+ // re-fetches the item via a title search)
+ await page.route('**/localhost:3000/stalker**', async (route) => {
+ const originalUrl = new URL(route.request().url());
+ if (!originalUrl.searchParams.get('search')) {
+ await route.fallback();
+ return;
+ }
+
+ const mockUrl = new URL(BACKEND_PROXY);
+ const targetId = originalUrl.searchParams.get('targetId');
+ const providerUrl = targetId
+ ? Buffer.from(targetId, 'base64url').toString()
+ : originalUrl.searchParams.get('url');
+ if (providerUrl) {
+ mockUrl.searchParams.set('url', providerUrl);
+ }
+ originalUrl.searchParams.forEach((value, key) => {
+ if (key === 'targetId') {
+ return;
+ }
+ mockUrl.searchParams.set(key, value);
+ });
+
+ const response = await route.fetch({ url: mockUrl.toString() });
+ const body = await response.json();
+ const rows: { series?: string[] }[] =
+ body?.payload?.js?.data ?? body?.js?.data ?? [];
+ for (const row of rows) {
+ if (Array.isArray(row.series) && row.series.length > 0) {
+ row.series = [
+ ...row.series,
+ String(row.series.length + 1),
+ ];
+ }
+ }
+ await route.fulfill({ response, body: JSON.stringify(body) });
+ });
+
+ // Open the item from the Favorites view: the stored snapshot renders
+ // first, then the background refresh patches in the new episode
+ const favoritesUrl = new URL(page.url());
+ favoritesUrl.pathname = favoritesUrl.pathname.replace(
+ /\/vod.*$/,
+ '/favorites'
+ );
+ favoritesUrl.search = '';
+ await page.goto(favoritesUrl.toString());
+
+ const favoriteCard = page.getByText(embeddedItem.name).first();
+ await expect(favoriteCard).toBeVisible({ timeout: 10_000 });
+ await favoriteCard.click();
+
+ // Snapshot episodes are visible immediately…
+ await expect(
+ page.getByRole('heading', { name: '1. Episode 1', exact: true })
+ ).toBeVisible({ timeout: 10_000 });
+ // …and the newly released episode appears after the background refresh
+ await expect(
+ page.getByRole('heading', {
+ name: `${episodeCount + 1}. Episode ${episodeCount + 1}`,
+ exact: true,
+ })
+ ).toBeVisible({ timeout: 10_000 });
+});
+
test('@stalker series — seasons load for a series item', async ({
request,
}) => {
diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md
index e2bc183ea..930b2c554 100644
--- a/docs/architecture/stalker-portal.md
+++ b/docs/architecture/stalker-portal.md
@@ -370,6 +370,36 @@ Navigation rule to preserve:
branch.
- See [Portal Detail Navigation](./portal-detail-navigation.md).
+### Embedded-series snapshot refresh
+
+Favorites and recently-viewed rows store the whole Stalker item as a JSON
+snapshot, so an embedded `series[]` episode list (and its playback `cmd`)
+freezes at the moment the row was written — newly released episodes would
+never appear when the item is reopened from favorites, recents, or the
+dashboard rails. `withStalkerSnapshotRefresh()`
+(`stores/features/with-stalker-snapshot-refresh.feature.ts`) fixes this with a
+snapshot-first + background re-fetch contract:
+
+- The stored snapshot renders immediately; the store method
+ `refreshEmbeddedSeriesSelection()` then re-fetches the item via a portal
+ title search (`get_ordered_list&type=vod&search=
`, item matched by
+ id, paginated up to 5 pages, wildcard-category retry) in the background.
+- When the episode list or `cmd` changed, the selection is patched in place.
+ The guard requires both the item id **and** the active playlist id to be
+ unchanged, because Stalker ids are only unique per portal.
+- Only the in-memory selection is patched — the stored snapshot row is
+ deliberately left alone. Every entry path into the detail view runs this
+ refresh, so a stale stored episode list is never rendered for longer than
+ one background request, and writing it back would add an uncontrolled
+ background writer to the whole-playlist read-modify-write that every
+ favorite/recent mutation performs (lost-update risk).
+- Triggers: `stalker-collection-detail.component.ts` (favorites/recent tabs,
+ global collections, dashboard handoffs) and the optional catalog-facade hook
+ `refreshSnapshotSelection()` for snapshot-injected browse detail
+ (`openStalkerItem` navigation state).
+- Regular `type=series` and Ministra `is_series` favorites are unaffected —
+ their seasons/episodes are always fetched fresh on open.
+
## Backup and Restore
Versioned playlist backups include Stalker connection metadata plus playlist-
diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts
index 173d0db49..afc62d439 100644
--- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts
+++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts
@@ -259,6 +259,9 @@ export class CategoryContentViewComponent implements OnInit {
}
this.catalog.selectItem(item as CategoryContentItem);
+ // The item came from a stored snapshot, not the live list — let the
+ // provider refresh stale embedded data (e.g. episode lists).
+ this.catalog.refreshSnapshotSelection?.();
clearNavigationStateKeys([
OPEN_STALKER_ITEM_STATE_KEY,
'openFavoriteItem',
diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts
index d379cb597..62101dd08 100644
--- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts
+++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts
@@ -62,6 +62,13 @@ export interface PortalCatalogFacade<
setContentSortMode(mode: PortalCatalogSortMode): void;
setMinRating?(value: number | null): void;
selectItem(item: TItem): string[] | null;
+ /**
+ * Optional: re-fetches a selection that was injected from a stored
+ * snapshot (navigation state, favorites) so stale embedded data —
+ * e.g. a Stalker embedded-series episode list — is refreshed in the
+ * background. No-op for providers whose selections are always fresh.
+ */
+ refreshSnapshotSelection?(): void;
getItemProgress(item: TItem): PortalCatalogItemProgress;
}
diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.ts
index 19bb69a6c..2c30ca67d 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker.store.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker.store.ts
@@ -8,6 +8,7 @@ import {
withStalkerRecent,
withStalkerSelection,
withStalkerSeries,
+ withStalkerSnapshotRefresh,
} from './stores/features';
/**
@@ -18,6 +19,7 @@ export const StalkerStore = signalStore(
{ providedIn: 'root' },
withStalkerPortal(),
withStalkerSelection(),
+ withStalkerSnapshotRefresh(),
withStalkerContent(),
withStalkerSeries(),
withStalkerPlayer(),
diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/index.ts b/libs/portal/stalker/data-access/src/lib/stores/features/index.ts
index 264f02961..81f0ebf8e 100644
--- a/libs/portal/stalker/data-access/src/lib/stores/features/index.ts
+++ b/libs/portal/stalker/data-access/src/lib/stores/features/index.ts
@@ -6,4 +6,5 @@ export * from './with-stalker-portal.feature';
export * from './with-stalker-recent.feature';
export * from './with-stalker-selection.feature';
export * from './with-stalker-series.feature';
+export * from './with-stalker-snapshot-refresh.feature';
diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts
new file mode 100644
index 000000000..6bd6416e0
--- /dev/null
+++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts
@@ -0,0 +1,283 @@
+import { TestBed } from '@angular/core/testing';
+import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
+import { DataService } from '@iptvnator/services';
+import { PlaylistMeta } from '@iptvnator/shared/interfaces';
+import { StalkerVodSource } from '../../models';
+import { StalkerSessionService } from '../../stalker-session.service';
+import { withStalkerSnapshotRefresh } from './with-stalker-snapshot-refresh.feature';
+
+const PLAYLIST = {
+ _id: 'portal-1',
+ title: 'Demo Stalker',
+ portalUrl: 'http://demo.example/stalker_portal/server/load.php',
+ macAddress: '00:1A:79:00:00:01',
+} as PlaylistMeta;
+
+const SNAPSHOT: StalkerVodSource = {
+ id: '20001',
+ cmd: '/media/file_20001.mpg',
+ series: [1],
+ category_id: '12',
+ info: { name: 'Embedded Series' },
+};
+
+const TestSnapshotRefreshStore = signalStore(
+ withState<{
+ currentPlaylist: PlaylistMeta | undefined;
+ selectedItem: StalkerVodSource | null | undefined;
+ }>({
+ currentPlaylist: PLAYLIST,
+ selectedItem: SNAPSHOT,
+ }),
+ withMethods((store) => ({
+ setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
+ patchState(store, { currentPlaylist: playlist });
+ },
+ setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
+ patchState(store, { selectedItem });
+ },
+ })),
+ withStalkerSnapshotRefresh()
+);
+
+describe('withStalkerSnapshotRefresh', () => {
+ let store: InstanceType;
+ let dataService: { sendIpcEvent: jest.Mock };
+
+ const mockPortalRows = (rows: unknown[]) => {
+ dataService.sendIpcEvent.mockResolvedValue({ js: { data: rows } });
+ };
+
+ beforeEach(() => {
+ dataService = { sendIpcEvent: jest.fn() };
+
+ TestBed.configureTestingModule({
+ providers: [
+ TestSnapshotRefreshStore,
+ { provide: DataService, useValue: dataService },
+ { provide: StalkerSessionService, useValue: {} },
+ ],
+ });
+
+ store = TestBed.inject(TestSnapshotRefreshStore);
+ });
+
+ it('patches the selection when new episodes appeared', async () => {
+ mockPortalRows([
+ { id: '20001', cmd: '/media/file_20001.mpg', series: [1, 2] },
+ ]);
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ true
+ );
+
+ expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ params: expect.objectContaining({
+ action: 'get_ordered_list',
+ type: 'vod',
+ category: '12',
+ search: 'Embedded Series',
+ }),
+ })
+ );
+ expect(store.selectedItem()?.series).toEqual([1, 2]);
+ });
+
+ it('updates a stale playback cmd even when the episode list is unchanged', async () => {
+ mockPortalRows([
+ { id: '20001', cmd: '/media/file_20001_v2.mpg', series: [1] },
+ ]);
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ true
+ );
+
+ expect(store.selectedItem()?.cmd).toBe('/media/file_20001_v2.mpg');
+ });
+
+ it('does nothing when the portal returns identical data', async () => {
+ mockPortalRows([
+ { id: '20001', cmd: '/media/file_20001.mpg', series: [1] },
+ ]);
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ false
+ );
+
+ expect(store.selectedItem()?.series).toEqual([1]);
+ });
+
+ it('retries with the wildcard category when the snapshot category misses', async () => {
+ dataService.sendIpcEvent
+ .mockResolvedValueOnce({ js: { data: [] } })
+ .mockResolvedValueOnce({
+ js: {
+ data: [
+ {
+ id: '20001',
+ cmd: '/media/file_20001.mpg',
+ series: [1, 2, 3],
+ },
+ ],
+ },
+ });
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ true
+ );
+
+ expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
+ expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ params: expect.objectContaining({ category: '*' }),
+ })
+ );
+ expect(store.selectedItem()?.series).toEqual([1, 2, 3]);
+ });
+
+ it('searches with the wildcard category when the snapshot category is a routing artifact', async () => {
+ store.setSelectedItem({ ...SNAPSHOT, category_id: 'series' });
+ mockPortalRows([
+ { id: '20001', cmd: '/media/file_20001.mpg', series: [1, 2] },
+ ]);
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ true
+ );
+
+ expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
+ expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ params: expect.objectContaining({ category: '*' }),
+ })
+ );
+ });
+
+ it('finds the item on a later search page when page 1 is full of other matches', async () => {
+ const filler = Array.from({ length: 14 }, (_, i) => ({
+ id: `9${i}`,
+ series: [1],
+ }));
+ dataService.sendIpcEvent
+ .mockResolvedValueOnce({
+ js: {
+ data: filler,
+ total_items: 15,
+ max_page_items: 14,
+ total_pages: 2,
+ },
+ })
+ .mockResolvedValueOnce({
+ js: {
+ data: [
+ {
+ id: '20001',
+ cmd: '/media/file_20001.mpg',
+ series: [1, 2],
+ },
+ ],
+ total_items: 15,
+ max_page_items: 14,
+ total_pages: 2,
+ },
+ });
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ true
+ );
+
+ expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
+ expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ params: expect.objectContaining({ p: 2 }),
+ })
+ );
+ expect(store.selectedItem()?.series).toEqual([1, 2]);
+ });
+
+ it('never patches after the active portal changed while the request was in flight', async () => {
+ dataService.sendIpcEvent.mockImplementation(async () => {
+ // Same item id, but the user switched to another portal
+ store.setCurrentPlaylist({
+ ...PLAYLIST,
+ _id: 'portal-2',
+ } as PlaylistMeta);
+ return {
+ js: {
+ data: [
+ {
+ id: '20001',
+ cmd: '/media/other_portal.mpg',
+ series: [1, 2, 3, 4],
+ },
+ ],
+ },
+ };
+ });
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ false
+ );
+
+ expect(store.selectedItem()?.series).toEqual([1]);
+ expect(store.selectedItem()?.cmd).toBe('/media/file_20001.mpg');
+ });
+
+ it('never patches a selection that changed while the request was in flight', async () => {
+ dataService.sendIpcEvent.mockImplementation(async () => {
+ store.setSelectedItem({
+ id: '99999',
+ series: [1],
+ info: { name: 'Другой сериал' },
+ });
+ return {
+ js: {
+ data: [
+ {
+ id: '20001',
+ cmd: '/media/file_20001.mpg',
+ series: [1, 2],
+ },
+ ],
+ },
+ };
+ });
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ false
+ );
+
+ expect(store.selectedItem()?.id).toBe('99999');
+ expect(store.selectedItem()?.series).toEqual([1]);
+ });
+
+ it('skips selections without an embedded episode list', async () => {
+ store.setSelectedItem({
+ id: '555',
+ cmd: '/media/file_555.mpg',
+ info: { name: 'Plain Movie' },
+ });
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ false
+ );
+
+ expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
+ });
+
+ it('resolves false when the portal request fails', async () => {
+ dataService.sendIpcEvent.mockRejectedValue(new Error('portal down'));
+
+ await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
+ false
+ );
+
+ expect(store.selectedItem()?.series).toEqual([1]);
+ });
+
+});
diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts
new file mode 100644
index 000000000..7c427590c
--- /dev/null
+++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts
@@ -0,0 +1,276 @@
+import { inject } from '@angular/core';
+import {
+ patchState,
+ signalStoreFeature,
+ type,
+ withMethods,
+} from '@ngrx/signals';
+import { createLogger } from '@iptvnator/portal/shared/util';
+import { DataService } from '@iptvnator/services';
+import {
+ PlaylistMeta,
+ StalkerPortalActions,
+} from '@iptvnator/shared/interfaces';
+import { StalkerVodSource } from '../../models';
+import { StalkerSessionService } from '../../stalker-session.service';
+import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
+import { StalkerPortalStoreContract } from '../stalker-store.contracts';
+import { executeStalkerRequest, StalkerRequestDeps } from '../utils';
+
+interface StalkerOrderedListResponse {
+ js?: {
+ data?: StalkerVodSource[];
+ total_items?: number | string;
+ max_page_items?: number | string;
+ total_pages?: number | string;
+ };
+}
+
+/** Upper bound on paginated search pages scanned per category attempt. */
+const MAX_SEARCH_PAGES = 5;
+
+/**
+ * Favorites/recently-viewed rows store embedded-series ("vclub") items as
+ * full JSON snapshots, so their episode list (`series[]`) and playback
+ * `cmd` freeze at the moment the snapshot was written. This feature
+ * re-fetches the item from the portal in the background and patches the
+ * live selection, so newly released episodes appear when a series is
+ * opened from favorites, recents, or the dashboard.
+ */
+export function withStalkerSnapshotRefresh() {
+ const logger = createLogger('withStalkerSnapshotRefresh');
+
+ return signalStoreFeature(
+ {
+ state: type<{
+ selectedItem: StalkerVodSource | null | undefined;
+ }>(),
+ },
+ withMethods(
+ (
+ store,
+ dataService = inject(DataService),
+ stalkerSession = inject(StalkerSessionService)
+ ) => {
+ const storeContext = store as typeof store &
+ StalkerPortalStoreContract;
+ const requestDeps: StalkerRequestDeps = {
+ dataService,
+ stalkerSession,
+ };
+
+ const findFreshRow = async (
+ playlist: PlaylistMeta,
+ snapshotId: string,
+ title: string,
+ category: string
+ ): Promise => {
+ // Short/reused titles can push the item past page 1, so
+ // follow the portal's reported page count (bounded).
+ let totalPages = 1;
+ for (let page = 1; page <= totalPages; page += 1) {
+ const response =
+ await executeStalkerRequest(
+ requestDeps,
+ playlist,
+ {
+ action: StalkerPortalActions.GetOrderedList,
+ type: 'vod',
+ sortby: 'added',
+ genre: '0',
+ category,
+ search: title,
+ p: page,
+ }
+ );
+
+ const rows = Array.isArray(response?.js?.data)
+ ? response.js.data
+ : [];
+ const match = rows.find((row) => {
+ const rowId = normalizeStalkerEntityId(
+ row?.id ?? row?.stream_id
+ );
+ return rowId !== '' && rowId === snapshotId;
+ });
+ if (match) {
+ return match;
+ }
+ if (rows.length === 0) {
+ return null;
+ }
+
+ totalPages = Math.min(
+ resolveTotalPages(response),
+ MAX_SEARCH_PAGES
+ );
+ }
+
+ return null;
+ };
+
+ return {
+ /**
+ * Re-fetches the currently selected embedded-series item
+ * via a portal title search and patches the selection
+ * when the episode list or cmd changed. Safe to fire and
+ * forget; resolves to true when the selection changed.
+ */
+ async refreshEmbeddedSeriesSelection(): Promise {
+ const playlist = storeContext.currentPlaylist();
+ const snapshot = store.selectedItem();
+ const snapshotId = normalizeStalkerEntityId(
+ snapshot?.id ?? snapshot?.stream_id
+ );
+ const title = resolveSnapshotTitle(snapshot);
+
+ if (
+ !playlist?._id ||
+ !snapshot ||
+ !snapshotId ||
+ !hasEmbeddedSeries(snapshot) ||
+ !title
+ ) {
+ return false;
+ }
+
+ let fresh: StalkerVodSource | null = null;
+ try {
+ const category = toSearchCategory(
+ snapshot.category_id
+ );
+ fresh = await findFreshRow(
+ playlist,
+ snapshotId,
+ title,
+ category
+ );
+ if (!fresh && category !== '*') {
+ fresh = await findFreshRow(
+ playlist,
+ snapshotId,
+ title,
+ '*'
+ );
+ }
+ } catch (error) {
+ logger.debug(
+ 'Embedded-series snapshot refresh failed',
+ error
+ );
+ return false;
+ }
+
+ if (!fresh || !hasEmbeddedSeries(fresh)) {
+ return false;
+ }
+
+ // The user may have navigated on while the portal
+ // request was in flight — never patch a selection
+ // that no longer belongs to this snapshot. Stalker
+ // item IDs are only unique per portal, so the active
+ // playlist must still be the one that was queried.
+ const current = store.selectedItem();
+ if (
+ storeContext.currentPlaylist()?._id !==
+ playlist._id ||
+ !current ||
+ normalizeStalkerEntityId(
+ current.id ?? current.stream_id
+ ) !== snapshotId
+ ) {
+ return false;
+ }
+
+ const nextCmd =
+ typeof fresh.cmd === 'string' &&
+ fresh.cmd.trim() !== ''
+ ? fresh.cmd
+ : current.cmd;
+ if (
+ nextCmd === current.cmd &&
+ sameEpisodeList(current.series, fresh.series)
+ ) {
+ return false;
+ }
+
+ // Only the in-memory selection is patched. The stored
+ // snapshot is deliberately left untouched: every entry
+ // path into the detail view runs this refresh, so a
+ // stale stored episode list is never rendered for
+ // longer than one background request, and writing it
+ // back would add an uncontrolled background writer to
+ // the whole-playlist read-modify-write used by every
+ // favorite/recent mutation.
+ patchState(store, {
+ selectedItem: {
+ ...current,
+ series: fresh.series,
+ cmd: nextCmd,
+ },
+ });
+
+ return true;
+ },
+ };
+ }
+ )
+ );
+}
+
+function hasEmbeddedSeries(
+ item: StalkerVodSource | null | undefined
+): item is StalkerVodSource & { series: unknown[] } {
+ return Array.isArray(item?.series) && item.series.length > 0;
+}
+
+function resolveSnapshotTitle(
+ item: StalkerVodSource | null | undefined
+): string {
+ const title = String(
+ item?.info?.name ?? item?.name ?? item?.title ?? item?.o_name ?? ''
+ ).trim();
+ return title === 'Unknown' ? '' : title;
+}
+
+function resolveTotalPages(response: StalkerOrderedListResponse): number {
+ const js = response?.js;
+ const declared = Number(js?.total_pages);
+ if (Number.isFinite(declared) && declared > 0) {
+ return Math.floor(declared);
+ }
+
+ const totalItems = Number(js?.total_items);
+ const pageSize = Number(js?.max_page_items);
+ if (
+ Number.isFinite(totalItems) &&
+ Number.isFinite(pageSize) &&
+ pageSize > 0
+ ) {
+ return Math.max(1, Math.ceil(totalItems / pageSize));
+ }
+
+ return 1;
+}
+
+/** Portals expect a real category id; snapshot ids like 'vod'/'series' are routing artifacts. */
+function toSearchCategory(categoryId: unknown): string {
+ const raw = String(categoryId ?? '').trim();
+ return /^\d+$/.test(raw) ? raw : '*';
+}
+
+function sameEpisodeList(
+ a: unknown[] | undefined,
+ b: unknown[] | undefined
+): boolean {
+ const left = Array.isArray(a) ? a : [];
+ const right = Array.isArray(b) ? b : [];
+ if (left.length !== right.length) {
+ return false;
+ }
+ try {
+ return JSON.stringify(left) === JSON.stringify(right);
+ } catch {
+ return false;
+ }
+}
diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts
index d7e0ec52d..893374232 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts
@@ -194,6 +194,10 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
return null;
}
+ refreshSnapshotSelection(): void {
+ void this.stalkerStore.refreshEmbeddedSeriesSelection();
+ }
+
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
const numericId = Number(item.id);
if (Number.isNaN(numericId)) {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts
index e856120ac..ec7278c47 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts
@@ -71,6 +71,7 @@ describe('StalkerCollectionDetailComponent', () => {
setSelectedContentType: jest.Mock;
setSelectedCategory: jest.Mock;
setSelectedItem: jest.Mock;
+ refreshEmbeddedSeriesSelection: jest.Mock;
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
createLinkToPlayVod: jest.Mock;
@@ -121,6 +122,7 @@ describe('StalkerCollectionDetailComponent', () => {
setSelectedItem: jest.fn((value: unknown) => {
selectedItem.set(value);
}),
+ refreshEmbeddedSeriesSelection: jest.fn(async () => false),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
createLinkToPlayVod: jest.fn(),
@@ -311,6 +313,42 @@ describe('StalkerCollectionDetailComponent', () => {
expect(
fixture.componentInstance.inlineDetail().seriesItem?.series
).toEqual([1, 2]);
+ // The snapshot renders immediately, but a background portal
+ // re-fetch must be triggered so new episodes can appear.
+ expect(
+ stalkerStore.refreshEmbeddedSeriesSelection
+ ).toHaveBeenCalled();
+ });
+
+ it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ contentType: 'series',
+ categoryId: 'series',
+ stalkerItem: {
+ id: '20001',
+ title: 'Embedded Series',
+ category_id: 'series',
+ cmd: '/media/file_20001.mpg',
+ series: [1],
+ },
+ })
+ );
+
+ await settleDetail(fixture);
+ expect(
+ fixture.componentInstance.inlineDetail().seriesItem?.series
+ ).toEqual([1]);
+
+ // Simulate the store-side refresh completing with a fresh portal row.
+ const current = selectedItem() as Record;
+ selectedItem.set({ ...current, series: [1, 2] });
+ await settleDetail(fixture);
+
+ expect(
+ fixture.componentInstance.inlineDetail().seriesItem?.series
+ ).toEqual([1, 2]);
});
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts
index 8ec50c0c6..370867993 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts
@@ -326,6 +326,14 @@ export class StalkerCollectionDetailComponent {
this.stalkerStore.setSelectedItem(itemDetails);
this.itemDetails.set(itemDetails);
+ if (detailMode.hasEmbeddedSeries) {
+ // Embedded-series snapshots freeze the episode list at
+ // favorite/recent time; re-fetch in the background so new
+ // episodes appear. The store patch flows back into
+ // itemDetails via the selected-item sync effect above.
+ void this.stalkerStore.refreshEmbeddedSeriesSelection();
+ }
+
if (
detailMode.selectedContentType === 'vod' &&
!detailMode.hasEmbeddedSeries &&