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<typeof TestSnapshotRefreshStore>; + 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<StalkerVodSource | null> => { + // 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<StalkerOrderedListResponse>( + 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<boolean> { + 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<string, unknown>; + 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 &&