diff --git a/.changes/collections-open-stalker-live-channel-in-playlist.md b/.changes/collections-open-stalker-live-channel-in-playlist.md new file mode 100644 index 000000000..5295e1b16 --- /dev/null +++ b/.changes/collections-open-stalker-live-channel-in-playlist.md @@ -0,0 +1,6 @@ +--- +type: feature +area: collections +--- + +"Open in " for live channels in Favorites and Recently viewed now works for Stalker portals too: the chip in the programme panel and the channel's right-click menu jump to that channel inside its portal's Live TV, with its category selected and the channel playing. Radio stations keep the existing behaviour. diff --git a/.changes/stalker-blank-provider-id.md b/.changes/stalker-blank-provider-id.md new file mode 100644 index 000000000..b45639116 --- /dev/null +++ b/.changes/stalker-blank-provider-id.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Stalker items whose portal sends an empty `id` next to a valid one are no longer lost: such a channel or movie can be favorited, played, resumed and found again, instead of being stored without an identity and quietly refusing to open. diff --git a/AGENTS.md b/AGENTS.md index 01fbb67b8..57b493557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,6 +298,21 @@ header action clears search, returns to the accessible playing category and focuses its row without changing playback. Contract: `docs/architecture/remote-control.md` (Live channel return and playback order). +## Live Channel Open In Playlist + +Live rows in the unified favorites/recent tab carry the live counterpart of +the VOD "View in portal" handoff: `getLiveCollectionPlaylistNavigation()` +(`libs/portal/shared/util`) lands on the channel INSIDE its playlist — Xtream +via `openXtreamLiveItemId`, M3U via `openM3uChannelUrl`, Stalker via +`buildStalkerLiveNavigationTarget` + `openStalkerLiveItemId`, consumed by +`StalkerLiveAutoOpen` in the ITV layout (waits for the requested portal, +locates the channel in the full ITV list cache, selects its genre, defers +playback until that genre's rows are on screen; a portal without a full list, +a transient list failure or a censored channel falls back to the remembered +genre; Stalker radio stays hidden, M3U radio keeps the row menu). Surfaces: `app-open-in-playlist-chip` in the EPG +toolbar (`[epgToolbarAction]` slot) and the row context menu. Contract: +`docs/architecture/portal-detail-navigation.md`. + ## Stalker Live Search ITV sidebar and fullscreen searches independently filter the complete selected diff --git a/CLAUDE.md b/CLAUDE.md index 2165f4ebe..3e01da88c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1679,15 +1679,21 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the (`libs/portal/shared/util`) resolves the channel INSIDE its playlist — Xtream via `buildXtreamNavigationTarget` + `openXtreamLiveItemId` (the live layout's auto-open service plays it), M3U via `/workspace/playlists/:id/all` - + `openM3uChannelUrl` (the player selects it by URL); Stalker resolves to - `null` until its ITV layout gets an open-on-arrival contract, so the - affordance is hidden there rather than landing on the section root. Two + + `openM3uChannelUrl` (the player selects it by URL); Stalker via + `buildStalkerLiveNavigationTarget` + `openStalkerLiveItemId`, consumed by + `StalkerLiveAutoOpen` in the ITV layout, which waits for the requested + portal, locates the channel in the full ITV channel list cache, selects its + genre and plays it (a portal without a full list, or a censored channel + missing from it, falls back to the remembered genre); Stalker radio stays + hidden (separate legacy-paged section). Two surfaces share that verdict: `app-open-in-playlist-chip` (`libs/portal/shared/ui`), projected into the EPG timeline/list-view toolbars through their `[epgToolbarAction]` slot beside the channel name (visible collapsed too; absent for radio and without EPG support), and an "Open in " row in `app-global-favorites-list`'s context menu - (`openInPlaylistRequested`), which also covers radio rows. Both label with + (`openInPlaylistRequested`), which also reaches rows that are not playing + and M3U radio stations (Stalker radio resolves to null, so no surface + offers it). Both label with `playlistDisplayLabel` and reuse `PORTALS.VIEW_IN_PORTAL_TOOLTIP`; the tab navigates. Contract: `docs/architecture/portal-detail-navigation.md`. diff --git a/apps/web-e2e/src/stalker-itv-playback.fixture.ts b/apps/web-e2e/src/stalker-itv-playback.fixture.ts new file mode 100644 index 000000000..2a0ad5a62 --- /dev/null +++ b/apps/web-e2e/src/stalker-itv-playback.fixture.ts @@ -0,0 +1,32 @@ +import { expect, Page } from '@playwright/test'; + +/** + * Enters the ITV section, selects the second category (the first row is + * "All") and plays its first channel, returning every `create_link` request + * observed so callers can assert whether a link was minted. + */ +export async function playFirstItvChannel(page: Page): Promise { + const createLinkRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('action=create_link')) { + createLinkRequests.push(request.url()); + } + }); + + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + await channels.first().click(); + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); + await expect(page.locator('app-web-player-view')).toBeVisible({ + timeout: 20_000, + }); + + return createLinkRequests; +} diff --git a/apps/web-e2e/src/stalker-open-in-playlist.fixture.ts b/apps/web-e2e/src/stalker-open-in-playlist.fixture.ts new file mode 100644 index 000000000..79ed78d29 --- /dev/null +++ b/apps/web-e2e/src/stalker-open-in-playlist.fixture.ts @@ -0,0 +1,70 @@ +import { expect, Page } from '@playwright/test'; + +/** + * Favorites → "Open in " → the same channel selected and playing in + * the portal's ITV section (the live counterpart of "View in portal"). + * + * Runs against the PWA, where the collection tab renders no EPG panel and + * therefore no toolbar chip, so the row context menu is the entry point. The + * mock portal answers `get_all_channels`, so the layout locates the channel + * through the full ITV list and lands in its genre. + * + * Expects the portal to be imported already (`addStalkerPortal`). + */ +export async function verifyStalkerOpenInPlaylist( + page: Page, + portalName: string +): Promise { + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + const portalUrl = new URL(page.url()); + const itvPath = portalUrl.pathname; + + // Favorite the first channel of the second category (the first row is + // "All"), remembering its name for the round-trip assertion. + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + const channelName = ( + await channels.first().locator('.channel-name').innerText() + ).trim(); + expect(channelName).not.toBe(''); + await channels.first().locator('.favorite-button').click(); + await expect( + channels.first().locator('.favorite-button mat-icon') + ).toHaveText('star', { timeout: 10_000 }); + + // Global favorites, all playlists: the row offers the jump. + await page.goto('/workspace/global-favorites'); + const allPlaylists = page + .locator('.scope-toggle') + .getByRole('radio', { name: 'All playlists' }); + if (await allPlaylists.count()) { + await allPlaylists.click(); + } + const favoriteRow = page + .locator('app-global-favorites-list [data-test-id="channel-item"]') + .filter({ hasText: channelName }) + .first(); + await expect(favoriteRow).toBeVisible({ timeout: 20_000 }); + await favoriteRow.click({ button: 'right' }); + const openInPlaylist = page.locator( + '[data-testid="channel-open-in-playlist"]' + ); + await expect(openInPlaylist).toBeVisible({ timeout: 10_000 }); + await expect(openInPlaylist).toContainText(portalName); + await openInPlaylist.click(); + + // Lands inside the portal's ITV section with that channel selected and + // playing, in its own genre. + await page.waitForURL((url) => url.pathname === itvPath, { + timeout: 20_000, + }); + const activeRow = page.locator('[data-test-id="channel-item"].active'); + await expect(activeRow).toContainText(channelName, { timeout: 30_000 }); + await expect(page.locator('app-web-player-view')).toBeVisible({ + timeout: 20_000, + }); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index be251b322..5b2143329 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -6,6 +6,8 @@ import { verifyUncachedStalkerSearch, } from './stalker-category-search.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; +import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; +import { playFirstItvChannel } from './stalker-itv-playback.fixture'; import { expect, test } from './fixtures'; import { getRegisteredProviderUrl, @@ -467,6 +469,13 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => { expect(shortEpgRequests).toHaveLength(0); }); +test('@stalker ITV favorite opens inside its portal from global favorites', async ({ + page, +}) => { + await addStalkerPortal(page); + await verifyStalkerOpenInPlaylist(page, 'Mock Stalker Portal'); +}); + test('@stalker ITV playback survives a category switch', async ({ page }) => { await addStalkerPortal(page); await verifyStalkerPlaybackCategoryReturn(page); @@ -891,32 +900,6 @@ test('@stalker create_link returns a playable stream URL', async ({ * Play the first channel of the first ITV category and report every * `create_link` request the page made while doing so. */ -async function playFirstItvChannel(page: Page): Promise { - const createLinkRequests: string[] = []; - page.on('request', (request) => { - if (request.url().includes('action=create_link')) { - createLinkRequests.push(request.url()); - } - }); - - await page.getByRole('link', { name: /live|itv/i }).click(); - await page.waitForURL(/stalker.*itv/); - - const categories = page.locator('.category-item'); - await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - - const channels = page.locator('[data-test-id="channel-item"]'); - await expect(channels.first()).toBeVisible({ timeout: 20_000 }); - await channels.first().click(); - await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); - await expect(page.locator('app-web-player-view')).toBeVisible({ - timeout: 20_000, - }); - - return createLinkRequests; -} - test('@stalker ITV plays an unflagged channel without minting a link', async ({ page, }) => { diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 18c17f8c2..1bc555910 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -176,9 +176,63 @@ with a return handler keep Back available. effect re-runs as the store switches and loads; M3U navigates to `/workspace/playlists/:id/all` with `openM3uChannelUrl` (`OPEN_M3U_CHANNEL_URL_STATE_KEY`), the same key global search writes and - the M3U player selects by URL. Stalker resolves to `null` — its ITV layout - has no open-on-arrival contract yet, and a jump that only reached `/itv` - would not be the affordance promised — so the action is hidden there. + the M3U player selects by URL; Stalker navigates to `/workspace/stalker/:id/itv` + with `openStalkerLiveItemId` + `openStalkerLivePlaylistId` (+ the row's + genre as `openStalkerLiveCategoryId` when known) from + `buildStalkerLiveNavigationTarget`, which `StalkerLiveAutoOpen` + (`stalker-live-stream-layout/stalker-live-auto-open.ts`, the Stalker + counterpart of the Xtream service + effect) consumes: it reads the state at + construction and on every `NavigationEnd`, waits until `currentPlaylist` is + the requested portal (channel ids are provider-local, so a colliding id in + the previous portal's list must never match), then locates the channel in + the full ITV channel list cache — `get_ordered_list` is server-paged, so + the row may sit on any page of its genre — selects that genre (`'*'` for a + channel without one), expands the rail and plays it. While the list loads it + waits (the cache turning ready re-runs the effect); a portal that cannot + serve a full list (`itvFullListUnsupported`, the cache's reactive + unsupported set), a load that fails transiently (the cache only arms a + retry cooldown and changes no signal, so the flow awaits the preload + promise and treats "settled, neither ready nor unsupported" as the same + outcome) or a channel missing from the list (censored genres are excluded + from `get_all_channels`) falls back to selecting the remembered genre, so + the user still lands in the right list, and the handoff is consumed either + way. That remembered genre is the stored row's `tv_genre_id` (an opaque + portal id, numeric on most panels but not all); the row's `categoryId` + counts only when it is not a section marker, because app-written + favorites/recent rows carry `'itv'` there. Playback is deferred whenever + selecting the genre changes the list scope (another genre, the All Items + grid — `null`, a different row source from the `'*'` All list — or an + active search): `playChannel` → `navigation.prepare` captures the + displayed rows as the remote/numeric channel order, and the store serves a + category a tick after `setSelectedCategory` — even from the full-list + cache — so playing right away would capture the previous scope's queue. + The store answers "whose channels are on screen?" with + `itvChannelsCategory` (set wherever `itvChannels` is served, cleared by + `setItvChannels`), which is what the deferred play waits for. Array + identity cannot answer it: filtering by `'*'` hands back the cache by + reference, and clearing a search replaces the rendered list without the + source moving. Clearing the search IS synchronous, so a genre already on + screen plays at once. A pending play is dropped when a newer handoff + arrives, when the user switches portal, genre or section or starts a + search, and when the layout is destroyed. Two things settle before any of + that: `StalkerWorkspaceRouteSession.isReady` (its sync resets the selected + category and item for the arrival, and the store keeps the previous + portal's playlist and cache across a revisit, so the playlist check alone + passes too early and the selection would be wiped a tick later), and the + user — the handoff is abandoned when the genre or search changes away from + what it captured once it became actionable, which is measured after the + session's own resets so they never read as a user action. Readiness is + published by the NEWEST sync only, and never before the store holds that + portal's row: the session applies arrivals one at a time and claims the + playlist id only after `setCurrentPlaylist()` resolves. The constructor + starts a sync before the first `NavigationEnd` starts another, so two run + at once — the second used to find the id already claimed, skip the + bootstrap and report ready while the first was still awaiting that write, + which let a revisited same-id portal whose endpoint or credentials had + changed play against the PREVIOUS row. A failed bootstrap leaves readiness + false rather than handing the arrival a stale row. Stalker radio stations resolve to `null`: they live in the separate + `radio` section, whose station list is legacy-paged with no + open-on-arrival contract, so the action stays hidden for them. Two surfaces render the one verdict: `app-open-in-playlist-chip` (`libs/portal/shared/ui`), projected into the EPG timeline / list-view toolbar through the panels' `[epgToolbarAction]` content slot beside the diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 2e7d8726e..afcc9584c 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1278,6 +1278,12 @@ The Stalker live route and radio route intentionally share in the channel header. Remote up/down, numeric selection and status retain the captured playback order while browsing. See the [queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order). +- Arrival with `openStalkerLiveItemId` in history state (a collection's + "open in playlist") is served by `StalkerLiveAutoOpen` + (`stalker-live-auto-open.ts`): once the store serves the requested portal + and the full ITV list (below) is ready, the channel is located there, its + genre selected and playback started; see + [Portal Detail Navigation](./portal-detail-navigation.md). ## Full ITV Channel List Cache diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index 8cdf687c4..4187ac620 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -284,11 +284,13 @@ describe('GlobalFavoritesListComponent', () => { ); }); - it('hides "open in playlist" for Stalker rows and never emits for them', async () => { + it('hides "open in playlist" for Stalker radio rows and never emits for them', async () => { const row = buildChannel('s', 'Stalker', { sourceType: 'stalker', streamUrl: undefined, + stalkerId: '30', stalkerCmd: 'ffmpeg http://stalker/30', + radio: 'true', }); const requested = jest.fn(); fixture.componentInstance.openInPlaylistRequested.subscribe(requested); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts index 0d9745477..2bf3c87fb 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts @@ -2,6 +2,8 @@ import { computed, Signal } from '@angular/core'; import { deriveVisibleFavoriteChannels, FavoritesChannelSortMode, + resolveStalkerLiveGenreId, + resolveStalkerProviderId, UnifiedCollectionItem, UnifiedFavoriteChannel, } from '@iptvnator/portal/shared/util'; @@ -74,6 +76,16 @@ export function toUnifiedFavoriteChannel( tvArchive: item.tvArchive ?? null, tvArchiveDuration: item.tvArchiveDuration ?? null, tvgId: item.tvgId, + // Rows have no stored item to re-check, so only the row's proven + // provider id travels; a synthetic list id would open nothing. + stalkerId: + item.sourceType !== 'stalker' || item.stalkerItem == null + ? item.stalkerId + : (resolveStalkerProviderId(item.stalkerItem) ?? undefined), + stalkerGenreId: + item.sourceType === 'stalker' + ? resolveStalkerLiveGenreId(item) + : undefined, stalkerCmd: item.stalkerCmd, stalkerPortalUrl: item.stalkerPortalUrl, stalkerMacAddress: item.stalkerMacAddress, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts index 10d8bbf9c..f2a1fb9e2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts @@ -191,11 +191,39 @@ describe('UnifiedLiveTabComponent open in playlist', () => { ); }); - it('keeps the chip out of the panel for a Stalker channel', async () => { + it('offers the chip for a Stalker channel and lands on its ITV section', async () => { await activate('stalker'); + expect(chip()).not.toBeNull(); + ( + chip().componentInstance as OpenInPlaylistChipComponent + ).activated.emit(); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', 'pl-3', 'itv'], + { + state: { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveTitle: 'Stalker Live', + openStalkerLivePoster: 'stalker.png', + }, + } + ); + }); + + it('keeps the chip out of the panel for a Stalker radio station', async () => { + const item = { ...buildLiveItem('stalker'), radio: 'true' }; + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.openInPlaylistTarget()).toBeNull(); - expect(chip()).toBeNull(); + const list = fixture.debugElement.query( + By.directive(StubGlobalFavoritesListComponent) + ).componentInstance as StubGlobalFavoritesListComponent; + list.openInPlaylistRequested.emit(list.channels()[0]); + expect(navigate).not.toHaveBeenCalled(); }); it('navigates when a sidebar row asks to open in its playlist', async () => { @@ -216,7 +244,7 @@ describe('UnifiedLiveTabComponent open in playlist', () => { }); it('ignores row requests whose target does not resolve', async () => { - const item = buildLiveItem('stalker'); + const item = { ...buildLiveItem('stalker'), stalkerId: undefined }; fixture.componentRef.setInput('items', [item]); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts index 690d44773..057662a3d 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -73,6 +73,11 @@ export interface UnifiedFavoriteChannel { /** Stalker portal credentials needed to resolve the live stream */ stalkerPortalUrl?: string; stalkerMacAddress?: string; + + /** Stalker channel id — lets a row be opened inside its portal's ITV list */ + stalkerId?: string | number; + /** Stalker ITV genre id (numeric), the fallback list when the channel cannot be located */ + stalkerGenreId?: string | null; } /** diff --git a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts index 888f2a198..3d22a5f0a 100644 --- a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts @@ -41,7 +41,157 @@ describe('getLiveCollectionPlaylistNavigation', () => { }); }); - it('hides the action for Stalker rows until their ITV layout can open a channel', () => { + it('opens a Stalker channel inside its portal ITV section, remembering its genre', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + contentType: 'live', + name: 'Stalker Live', + logo: 'stalker.png', + stalkerId: 30, + stalkerItem: { id: 30, tv_genre_id: 7 }, + }) + ).toEqual({ + link: ['/workspace', 'stalker', 'pl-3', 'itv'], + state: { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '7', + openStalkerLiveTitle: 'Stalker Live', + openStalkerLivePoster: 'stalker.png', + }, + }); + // The stored row's genre wins; `categoryId` is the section marker + // on app-written favorites and counts only when it is not one. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: '5', + stalkerItem: { id: 30, tv_genre_id: 7 }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('7'); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: '5', + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('5'); + // A stored row without a genre still falls back to the All list; + // the section marker alone (no stored row) yields no fallback. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + stalkerItem: { id: 30, tv_genre_id: ' ' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('*'); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + })?.state + ).not.toHaveProperty('openStalkerLiveCategoryId'); + // A stored row's own `category_id` is a genre when it is not the + // section marker. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + stalkerItem: { id: 30, category_id: '8' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('8'); + // ...and the row's section marker does not shadow the collection + // row's own genre. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: '4', + stalkerItem: { id: 30, category_id: 'itv' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('4'); + // Genre ids are opaque portal strings, not necessarily numeric. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + stalkerItem: { id: 30, tv_genre_id: 'sports' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('sports'); + // List rows carry the genre already resolved by the collection tab. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + stalkerGenreId: '9', + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('9'); + }); + + it('hides the action for a stored Stalker row whose id is synthetic', () => { + // Collection services mint `-` for id-less rows; + // the ITV catalog cannot match it. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: 'pl-3-0', + stalkerItem: { name: 'Nameless', cmd: 'x' }, + }) + ).toBeNull(); + // VOD identities are not channel ids: the ITV list cannot match them. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '77', + stalkerItem: { series_id: 77, cmd: 'x' }, + }) + ).toBeNull(); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + stalkerItem: { stream_id: 30 }, + })?.link + ).toEqual(['/workspace', 'stalker', 'pl-3', 'itv']); + // The stored row's own id wins over a synthetic list id minted from + // a blank `id` beside a valid `stream_id`. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '-', + stalkerItem: { id: '', stream_id: 30 }, + })?.state?.['openStalkerLiveItemId'] + ).toBe('30'); + }); + + it('hides the action for Stalker radio stations and rows without a channel id', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: 30, + radio: 'true', + }) + ).toBeNull(); expect( getLiveCollectionPlaylistNavigation({ sourceType: 'stalker', diff --git a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts index 6c04ac3de..d0bd554b9 100644 --- a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts @@ -1,4 +1,5 @@ import { + buildStalkerLiveNavigationTarget, buildXtreamNavigationTarget, WorkspaceNavigationTarget, } from './workspace-portal-navigation'; @@ -25,6 +26,20 @@ export interface LiveCollectionPlaylistNavigationSource { logo?: string | null; xtreamId?: number | null; streamUrl?: string; + /** Stalker channel id (`UnifiedCollectionItem.stalkerId`). */ + stalkerId?: string | number | null; + /** + * Stalker: used as the fallback genre unless it is a section marker. + * Favorites/recent rows written by the app carry the SECTION marker here + * (`'itv'`), which is not a genre. + */ + categoryId?: string | number | null; + /** Stalker: the stored row; its `tv_genre_id` is the channel's real genre. */ + stalkerItem?: unknown; + /** Stalker: the genre already resolved by `resolveStalkerLiveGenreId` (list rows). */ + stalkerGenreId?: string | null; + /** `'true'` for radio stations, which live in a different Stalker section. */ + radio?: string; } /** @@ -34,14 +49,15 @@ export interface LiveCollectionPlaylistNavigationSource { * The target is the channel itself, not the playlist root: Xtream lands on * the live layout with `openXtreamLiveItemId`, which its auto-open state * service turns into a playing, selected channel; M3U lands on the player's - * `all` view with `openM3uChannelUrl`, which selects the channel by URL. - * Stalker returns `null` for now — its ITV layout has no open-on-arrival - * contract yet, and a jump that only reached the section root would not be - * the affordance this promises. + * `all` view with `openM3uChannelUrl`, which selects the channel by URL; + * Stalker lands on the ITV section with `openStalkerLiveItemId`, which + * `StalkerLiveAutoOpen` resolves through the full channel list cache. Stalker + * radio stations return `null`: they live in the separate `radio` section, + * whose station list is legacy-paged and has no open-on-arrival contract. * * Like `getUnifiedCollectionDetailNavigation`, this never degrades to a - * playlist-only route: without a positive stream id (Xtream) or a stream URL - * (M3U) the caller is expected to hide the action. + * playlist-only route: without a positive stream id (Xtream), a stream URL + * (M3U) or a channel id (Stalker) the caller is expected to hide the action. */ export function getLiveCollectionPlaylistNavigation( source: LiveCollectionPlaylistNavigationSource @@ -82,5 +98,107 @@ export function getLiveCollectionPlaylistNavigation( }; } + if (source.sourceType === 'stalker') { + if (source.radio === 'true') { + return null; + } + // The stored row is the authority on the provider id: the collection + // services mint a synthetic list-only id (`-`, or + // `-` for a blank `id` beside a valid `stream_id`) for rows they + // cannot identify, and the ITV catalog can never match those. + const itemId = + source.stalkerItem === undefined || source.stalkerItem === null + ? source.stalkerId + : resolveStalkerProviderId(source.stalkerItem); + if (!itemId) { + return null; + } + + return buildStalkerLiveNavigationTarget({ + playlistId, + itemId, + categoryId: resolveStalkerLiveGenreId(source), + title: source.name, + imageUrl: source.logo ?? null, + }); + } + return null; } + +/** + * The Stalker live row's genre for the auto-open fallback: an already + * resolved value, else the stored row's `tv_genre_id` (genre ids are opaque + * portal strings, numeric on most panels but not all), else `categoryId` + * unless it is a section marker — app-written favorites/recent carry + * `'itv'` there, which is not a genre. + */ +export function resolveStalkerLiveGenreId( + source: Pick< + LiveCollectionPlaylistNavigationSource, + 'stalkerGenreId' | 'stalkerItem' | 'categoryId' + > +): string | null { + const stored = source.stalkerItem; + return ( + stalkerGenreId(source.stalkerGenreId) ?? + stalkerItemGenre(stored) ?? + stalkerCategoryGenre(source.categoryId) ?? + // An authoritative row answers even without a genre: a genreless + // channel lives in the All list, and a portal without a full list + // would otherwise land on the empty "select a category" screen. + (stored === undefined || stored === null ? null : '*') + ); +} + +const STALKER_SECTION_MARKERS = new Set(['itv', 'radio', 'vod', 'series']); + +/** + * The stored Stalker LIVE row's provider id: the first NON-BLANK of + * `id`/`stream_id`, or `null` for an id-less row. Exactly the fields the + * ITV cache mapper and crawler identify a channel by — `series_id` / + * `movie_id` name VOD entities that no channel list can match, so a row + * carrying only those is not openable here. (Unlike the collection + * services' extractor, a blank `id` does not shadow a valid `stream_id`.) + */ +export function resolveStalkerProviderId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const raw = item as Record; + for (const key of ['id', 'stream_id']) { + const value = String(raw[key] ?? '').trim(); + if (value) { + return value; + } + } + return null; +} + +/** + * The stored row's own genre, read in the order the live navigation reads + * it (`tv_genre_id` then `category_id`); the row's `category_id` can be the + * section marker, which is not a genre. + */ +function stalkerItemGenre(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const raw = item as { tv_genre_id?: unknown; category_id?: unknown }; + return ( + stalkerGenreId(raw.tv_genre_id) ?? stalkerCategoryGenre(raw.category_id) + ); +} + +/** Any non-blank genre id, the All pseudo-genre included. */ +function stalkerGenreId(value: unknown): string | null { + const text = String(value ?? '').trim(); + return text || null; +} + +function stalkerCategoryGenre(value: unknown): string | null { + const genre = stalkerGenreId(value); + return genre && !STALKER_SECTION_MARKERS.has(genre.toLowerCase()) + ? genre + : null; +} diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index fe06778fa..0b79f1df5 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -20,6 +20,12 @@ export interface WorkspaceNavigationTarget { } export const OPEN_LIVE_COLLECTION_ITEM_STATE_KEY = 'openLiveCollectionItem'; +/** Stalker ITV arrival: the channel to select and play (consumed by the live layout). */ +export const OPEN_STALKER_LIVE_ITEM_STATE_KEY = 'openStalkerLiveItemId'; +export const OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY = 'openStalkerLivePlaylistId'; +export const OPEN_STALKER_LIVE_CATEGORY_STATE_KEY = 'openStalkerLiveCategoryId'; +export const OPEN_STALKER_LIVE_TITLE_STATE_KEY = 'openStalkerLiveTitle'; +export const OPEN_STALKER_LIVE_POSTER_STATE_KEY = 'openStalkerLivePoster'; export const OPEN_COLLECTION_DETAIL_STATE_KEY = 'openCollectionDetailItem'; export const OPEN_STALKER_ITEM_STATE_KEY = 'openStalkerItem'; export const STALKER_RETURN_TO_STATE_KEY = 'stalkerReturnTo'; @@ -196,6 +202,40 @@ export function buildXtreamNavigationTarget(params: { }; } +/** + * Lands on a Stalker live channel inside its portal's ITV section. The + * layout locates the channel through the full ITV channel list, scoped to + * `playlistId` (channel ids are provider-local); `categoryId` is the + * fallback list to open when the channel cannot be located. + */ +export function buildStalkerLiveNavigationTarget(params: { + playlistId: string; + itemId: string | number | null | undefined; + categoryId?: string | number | null; + title?: string; + imageUrl?: string | null; +}): WorkspaceNavigationTarget | null { + const playlistId = toPathSegment(params.playlistId); + const itemId = toPathSegment(params.itemId); + if (!playlistId || !itemId) { + return null; + } + + const categoryId = toPathSegment(params.categoryId); + return { + link: ['/workspace', 'stalker', playlistId, 'itv'], + state: { + [OPEN_STALKER_LIVE_ITEM_STATE_KEY]: itemId, + [OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY]: playlistId, + ...(categoryId + ? { [OPEN_STALKER_LIVE_CATEGORY_STATE_KEY]: categoryId } + : {}), + [OPEN_STALKER_LIVE_TITLE_STATE_KEY]: params.title || '', + [OPEN_STALKER_LIVE_POSTER_STATE_KEY]: params.imageUrl || '', + }, + }; +} + export function buildLiveCollectionNavigationTarget(params: { mode: 'favorites' | 'recent'; sourceType: CollectionSourceType; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts index bc5ab51bd..406da914d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts @@ -179,6 +179,30 @@ describe('StalkerItvCacheService', () => { expect(callsFor('get_ordered_list')).toBe(2); }); + it('keeps crawled rows whose blank id sits beside a stream_id', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: () => + pageOf( + [ + { + ...channel('', 'Stream id only', '5'), + stream_id: 30, + }, + channel('2', 'Plain', '5'), + ], + 2 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect( + service.getChannels(PLAYLIST)?.map((item) => String(item.id)) + ).toEqual(['30', '2']); + }); + it('reports crawl progress while loading and clears it afterwards', async () => { let resolveSecondPage!: (value: unknown) => void; mockRequests({ @@ -251,12 +275,14 @@ describe('StalkerItvCacheService', () => { page: () => pageOf([], 0), }); + expect(service.isUnsupported(PLAYLIST)).toBe(false); await service.ensureLoaded(PLAYLIST); const callsAfterFirstAttempt = sendIpcEvent.mock.calls.length; await service.ensureLoaded(PLAYLIST); expect(service.isReady(PLAYLIST)).toBe(false); + expect(service.isUnsupported(PLAYLIST)).toBe(true); expect(sendIpcEvent.mock.calls.length).toBe(callsAfterFirstAttempt); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts index e462ccde1..5279d6291 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts @@ -1,9 +1,6 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { - DataService, - resetHostConnectivityGuard, -} from '@iptvnator/services'; +import { DataService, resetHostConnectivityGuard } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { StalkerItvChannel } from './models'; import { @@ -57,7 +54,11 @@ export class StalkerItvCacheService { private readonly versionSignals = new Map>(); private readonly channelsByKey = new Map(); - private readonly unsupportedKeys = new Set(); + /** + * Portals whose full list cannot be served (legacy paged flow stays in + * charge). A signal so consumers can wait on "ready OR unsupported". + */ + private readonly unsupportedKeys = signal>(new Set()); private readonly inflight = new Map>(); /** Wall-clock (ms) until which a portal's crawl error suppresses retries. */ private readonly errorCooldownUntil = new Map(); @@ -83,6 +84,12 @@ export class StalkerItvCacheService { return key !== null && this.loadingKeys().has(key); } + /** Reactive: the portal answered but cannot provide a full list this session. */ + isUnsupported(playlist: PlaylistMeta | undefined): boolean { + const key = this.keyFor(playlist); + return key !== null && this.unsupportedKeys().has(key); + } + progressOf( playlist: PlaylistMeta | undefined ): StalkerItvLoadProgress | null { @@ -99,7 +106,9 @@ export class StalkerItvCacheService { * per-portal dependency via {@link versionFor}; boolean readiness for UI * stays on the reactive {@link isReady}. */ - getChannels(playlist: PlaylistMeta | undefined): StalkerItvChannel[] | null { + getChannels( + playlist: PlaylistMeta | undefined + ): StalkerItvChannel[] | null { const key = this.keyFor(playlist); return key === null ? null : (this.channelsByKey.get(key) ?? null); } @@ -114,7 +123,7 @@ export class StalkerItvCacheService { key === null || !playlist || this.readyKeys().has(key) || - this.unsupportedKeys.has(key) || + this.unsupportedKeys().has(key) || this.isInErrorCooldown(key) ) { return; @@ -143,7 +152,7 @@ export class StalkerItvCacheService { playlist.portalUrl ); - this.unsupportedKeys.delete(key); + this.patchKeySet(this.unsupportedKeys, key, false); this.errorCooldownUntil.delete(key); await this.runLoad(key, playlist); } @@ -194,7 +203,7 @@ export class StalkerItvCacheService { Date.now() + ERROR_COOLDOWN_MS ); } else if (!this.readyKeys().has(key)) { - this.unsupportedKeys.add(key); + this.patchKeySet(this.unsupportedKeys, key, true); } } finally { this.inflight.delete(key); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts index b90efc694..d8babfb04 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts @@ -3,6 +3,7 @@ import { StalkerPortalActions, } from '@iptvnator/shared/interfaces'; import { StalkerItvChannel } from './models'; +import { firstNonBlankStalkerIdText } from './stalker-vod.utils'; import { StalkerRequestDeps, executeStalkerRequest, @@ -21,9 +22,7 @@ export interface StalkerItvLoadProgress { * timeout, a page that failed both attempts) that is worth retrying later. */ export type StalkerItvLoadOutcome = - | StalkerItvChannel[] - | 'unsupported' - | 'error'; + StalkerItvChannel[] | 'unsupported' | 'error'; export interface StalkerItvLoadLogger { info(...args: unknown[]): void; @@ -112,7 +111,12 @@ async function crawlOrderedPages( onProgress: (loaded: number, total: number) => void, logger: StalkerItvLoadLogger ): Promise { - const firstResponse = await fetchOrderedPageWithRetry(deps, playlist, 1, logger); + const firstResponse = await fetchOrderedPageWithRetry( + deps, + playlist, + 1, + logger + ); if (firstResponse === null) { return 'error'; } @@ -208,13 +212,17 @@ function collectUnique( return added; } +/** + * First NON-BLANK of `id`/`stream_id`, matching `toStalkerItvChannel`: a + * blank `id` beside a valid `stream_id` must not make the row look id-less + * and get dropped by the de-duplication. + */ function rawChannelId(item: unknown): string | null { if (!item || typeof item !== 'object') { return null; } const source = item as { id?: unknown; stream_id?: unknown }; - const raw = source.id ?? source.stream_id; - return raw === undefined || raw === null || raw === '' ? null : String(raw); + return firstNonBlankStalkerIdText(source.id, source.stream_id) || null; } async function fetchOrderedPageWithRetry( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 8ac4bbda2..c41277099 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -2,6 +2,8 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces'; import { StalkerFavoriteItem } from './models'; import { buildStalkerFavoritePayload, + firstNonBlankStalkerId, + firstNonBlankStalkerIdText, buildStalkerSelectedVodItem, createStalkerInfo, createStalkerInlineDetailState, @@ -337,3 +339,28 @@ describe('stalker-vod.utils regressions', () => { }); }); }); + +describe('firstNonBlankStalkerId', () => { + it('skips a blank candidate instead of treating it as set', () => { + expect(firstNonBlankStalkerId('', ' ', '77')).toBe('77'); + expect(firstNonBlankStalkerId(null, undefined, 42)).toBe(42); + }); + + it('keeps a numeric id as a number so round-trips do not stringify it', () => { + expect(firstNonBlankStalkerId(12, '99')).toBe(12); + }); + + it('accepts zero, which is a set id and not a blank one', () => { + expect(firstNonBlankStalkerId(0, '99')).toBe(0); + }); + + it('skips a non-finite number rather than yielding "NaN"', () => { + expect(firstNonBlankStalkerId(Number.NaN, '5')).toBe('5'); + }); + + it('trims the text form and reports nothing when every candidate is blank', () => { + expect(firstNonBlankStalkerIdText(' 21 ')).toBe('21'); + expect(firstNonBlankStalkerIdText('', null, undefined)).toBe(''); + expect(firstNonBlankStalkerId('', null)).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index 398228470..18782b89e 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -1,7 +1,4 @@ -import { - signal, - WritableSignal, -} from '@angular/core'; +import { signal, WritableSignal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; import { Observable } from 'rxjs'; import { PlaylistsService } from '@iptvnator/services'; @@ -42,10 +39,7 @@ export interface StalkerFavoriteToggleOptions { item: Record, onComplete?: () => void ) => void; - removeFromFavorites: ( - favoriteId: string, - onComplete?: () => void - ) => void; + removeFromFavorites: (favoriteId: string, onComplete?: () => void) => void; onComplete?: () => void; } @@ -97,6 +91,44 @@ export function normalizeStalkerEntityId(value: unknown): string { return String(value).trim(); } +/** + * The first candidate that actually carries an identity. + * + * Portals send `id` and `stream_id` inconsistently, and a BLANK one is not + * an absent one: `a ?? b` keeps `''` and would store a row with no provider + * identity, which then cannot be selected, played or found again. Every + * reader normalizes with {@link normalizeStalkerEntityId}, so blank and + * whitespace-only values are equivalent to missing here too. + * + * Numbers keep their type so a numeric provider id survives round-trips; + * non-finite ones are not identities and are skipped rather than stringified + * into `"NaN"`. Callers own the PREFERENCE order — favorites read + * `stream_id` first, the channel cache reads `id` first — this only skips the + * empty ones. + */ +export function firstNonBlankStalkerId( + ...values: unknown[] +): string | number | undefined { + for (const value of values) { + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return value; + } + continue; + } + const text = normalizeStalkerEntityId(value); + if (text) { + return text; + } + } + return undefined; +} + +/** {@link firstNonBlankStalkerId} as the normalized text every reader compares. */ +export function firstNonBlankStalkerIdText(...values: unknown[]): string { + return normalizeStalkerEntityId(firstNonBlankStalkerId(...values)); +} + export function normalizeStalkerEntityIdAsNumber( value: unknown ): number | null { @@ -148,9 +180,7 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { // views normalize the selected item on every render ...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}), ...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}), - ...(info.tmdb_directors - ? { tmdb_directors: info.tmdb_directors } - : {}), + ...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}), ...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}), ...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}), ...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}), @@ -175,7 +205,7 @@ export function buildStalkerSelectedVodItem( forceSeries = false ): StalkerSelectedVodItem { return { - id: toStringOrFallback(item.id ?? item.stream_id), + id: firstNonBlankStalkerIdText(item.id, item.stream_id), cmd: toStringOrFallback(item.cmd), series: item.series, has_files: item.has_files, @@ -187,9 +217,7 @@ export function buildStalkerSelectedVodItem( : undefined, video_id: item.video_id, category_id: - typeof item.category_id === 'string' - ? item.category_id - : undefined, + typeof item.category_id === 'string' ? item.category_id : undefined, info: createStalkerInfo(item), }; } @@ -222,11 +250,10 @@ export function normalizeStalkerFavoriteItem( cmd: toStringOrFallback( (item as StalkerVodSource).cmd ?? source.cmd ), - id: toStringOrFallback(item.stream_id ?? item.id ?? source.id), + id: firstNonBlankStalkerIdText(item.stream_id, item.id, source.id), series: (item as StalkerVodSource).series ?? source.series, is_series: normalizedIsSeries ? true : undefined, - video_id: - (item as StalkerVodSource).video_id ?? source.video_id, + video_id: (item as StalkerVodSource).video_id ?? source.video_id, category_id: typeof (item as StalkerVodSource).category_id === 'string' ? (item as StalkerVodSource).category_id @@ -356,8 +383,7 @@ export function createPortalCollectionResource( refreshVersion: getRefreshVersion(), }; }, - stream: ({ params }) => - streamFactory(playlistService, params.portalId), + stream: ({ params }) => streamFactory(playlistService, params.portalId), }); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 3a4d0a669..23ffbb4f1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -120,6 +120,7 @@ function createItvCacheMock( return { versionFor: jest.fn(() => version()), getChannels: jest.fn(() => channels), + isUnsupported: jest.fn(() => false), ensureLoaded: jest.fn().mockResolvedValue(undefined), refresh: jest.fn().mockResolvedValue(undefined), isReady: jest.fn(() => channels !== null), @@ -396,6 +397,102 @@ describe('withStalkerContent failure states', () => { expect(store.hasMoreChannels()).toBe(false); }); + it('reports which category the ITV channels on screen were served for', async () => { + dataService.sendIpcEvent.mockImplementation(() => + Promise.resolve({ + js: { + data: [{ id: 'channel-1', name: 'One', category_id: '5' }], + total_items: 1, + }, + }) + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '9', category_name: 'Sports' }, + ]); + store.setCurrentPlaylist(PLAYLIST); + expect(store.itvChannelsCategory()).toBeNull(); + + store.setSelectedCategory('5'); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.itvChannels().length === 1); + + expect(store.itvChannelsCategory()).toBe('5'); + + // Selecting another genre does not retroactively re-label the rows + // still on screen: they belong to '5' until the new page arrives. + store.setSelectedCategory('9'); + expect(store.itvChannelsCategory()).toBe('5'); + + await waitForCondition(() => store.itvChannelsCategory() === '9'); + + // Clearing the list leaves no category on screen. + store.setItvChannels([]); + expect(store.itvChannelsCategory()).toBeNull(); + }); + + it("does not report another portal's served category as its own", async () => { + dataService.sendIpcEvent.mockImplementation(() => + Promise.resolve({ + js: { + data: [{ id: 'channel-1', name: 'One', category_id: '5' }], + total_items: 1, + }, + }) + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + ]); + store.setCurrentPlaylist(PLAYLIST); + store.setSelectedCategory('5'); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.itvChannelsCategory() === '5'); + + // Switching portal keeps the previous rows until the new portal's + // own load lands; genre ids are provider-local, so the marker must + // not answer for a portal that did not serve them. + store.setCurrentPlaylist({ ...PLAYLIST, _id: 'other-portal' }); + + expect(store.itvChannelsCategory()).toBeNull(); + }); + + it('forgets the served category when a failed ITV page clears the rows', async () => { + dataService.sendIpcEvent.mockImplementation(() => + Promise.resolve({ + js: { + data: [{ id: 'channel-1', name: 'One', category_id: '5' }], + total_items: 1, + }, + }) + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '9', category_name: 'Sports' }, + ]); + store.setCurrentPlaylist(PLAYLIST); + store.setSelectedCategory('5'); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.itvChannelsCategory() === '5'); + + // The next category's first page fails: the rows are cleared, so no + // category is on screen — a stale marker would tell an auto-open + // handoff for genre 5 that its channels are still rendered. + dataService.sendIpcEvent.mockImplementation(() => + Promise.reject(new Error('portal down')) + ); + store.setSelectedCategory('9'); + + await waitForCondition(() => store.contentError() !== null); + expect(store.itvChannels()).toEqual([]); + expect(store.itvChannelsCategory()).toBeNull(); + }); + it('appends later VOD pages into one continuous deduplicated list', async () => { dataService.sendIpcEvent.mockImplementation( (_event: unknown, payload: { params?: { p?: number } }) => { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index f4e69d4f6..739007c96 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -44,6 +44,18 @@ export interface StalkerContentState { radioCategories: StalkerCategoryItem[]; hasMoreChannels: boolean; itvChannels: StalkerItvChannel[]; + /** + * Who `itvChannels` belong to: the portal AND category they were served + * for, or `null` while no channels are. Rows lag `selectedCategoryId` — + * the resource resolves a tick later even when it serves from the + * full-list cache — so this is the only honest answer to "whose channels + * are on screen?". Array identity cannot answer it: filtering by `'*'` + * hands back the cache by reference, and clearing a search replaces the + * RENDERED list without the source changing at all. The portal is part + * of the record because a switch keeps the previous portal's rows until + * its own load lands, and two portals share genre ids. + */ + itvChannelsSource: { playlistKey: string | null; category: string } | null; radioChannels: StalkerItvChannel[]; paginatedContent: StalkerContentItem[]; categoryError: unknown; @@ -64,6 +76,7 @@ const initialContentState: StalkerContentState = { radioCategories: [], hasMoreChannels: false, itvChannels: [], + itvChannelsSource: null, radioChannels: [], paginatedContent: [], categoryError: null, @@ -170,6 +183,13 @@ function fallbackRadioCategories( return [buildAllCategory('radio', translateService)]; } +/** Stable identity of a Stalker portal inside the content resource. */ +function stalkerPlaylistKey( + playlist: { _id?: string; portalUrl?: string } | null | undefined +): string | null { + return playlist?._id ?? playlist?.portalUrl ?? null; +} + function buildEmptyContentPatch( contentType: StalkerContentType, error: unknown @@ -185,6 +205,11 @@ function buildEmptyContentPatch( patch.hasMoreChannels = false; if (contentType === 'itv') { patch.itvChannels = []; + // The source record describes the rows: cleared rows belong to + // nobody, or an auto-open handoff for the category these rows + // CAME from would read the stale marker as proof that its genre + // is on screen and prepare playback from an empty queue. + patch.itvChannelsSource = null; } else { patch.radioChannels = []; } @@ -458,6 +483,11 @@ export function withStalkerContent() { totalCount: channels.length, paginatedContent: channels, itvChannels: channels, + itvChannelsSource: { + playlistKey: + stalkerPlaylistKey(playlist), + category: String(categoryParam), + }, hasMoreChannels: false, contentError: null, }); @@ -471,17 +501,14 @@ export function withStalkerContent() { void itvCache.ensureLoaded(playlist); } - const paramsPlaylistKey = - params.currentPlaylist?._id ?? - params.currentPlaylist?.portalUrl ?? - null; + const paramsPlaylistKey = stalkerPlaylistKey( + params.currentPlaylist + ); const isCurrentRequest = (): boolean => { const currentPlaylist = storeContext.currentPlaylist(); const currentPlaylistKey = - currentPlaylist?._id ?? - currentPlaylist?.portalUrl ?? - null; + stalkerPlaylistKey(currentPlaylist); return ( !abortSignal.aborted && @@ -624,7 +651,16 @@ export function withStalkerContent() { paginatedContent: newItems, contentError: null, ...(params.contentType === 'itv' - ? { itvChannels: nextChannels } + ? { + itvChannels: nextChannels, + itvChannelsSource: { + playlistKey: + paramsPlaylistKey, + category: String( + params.category ?? '*' + ), + }, + } : { radioChannels: nextChannels }), hasMoreChannels: channels.length > 0 && @@ -753,6 +789,24 @@ export function withStalkerContent() { itvFullListLoading: computed(() => itvCache.isLoading(storeContext.currentPlaylist()) ), + /** + * The category the channels on screen were served for, but + * only while they belong to the portal on screen: a switch + * keeps the previous portal's rows until its own load lands, + * and two portals share genre ids. + */ + itvChannelsCategory: computed(() => { + const source = store.itvChannelsSource(); + return source && + source.playlistKey === + stalkerPlaylistKey(storeContext.currentPlaylist()) + ? source.category + : null; + }), + /** True once the portal proved it cannot serve a full list this session. */ + itvFullListUnsupported: computed(() => + itvCache.isUnsupported(storeContext.currentPlaylist()) + ), itvFullListProgress: computed(() => itvCache.progressOf(storeContext.currentPlaylist()) ), @@ -881,8 +935,10 @@ export function withStalkerContent() { * available immediately. Safe to call repeatedly — the cache * de-duplicates in-flight loads and memoizes unsupported portals. */ - preloadItvChannels(): void { - void itvCache.ensureLoaded(storeContext.currentPlaylist()); + preloadItvChannels(): Promise { + return itvCache.ensureLoaded( + storeContext.currentPlaylist() + ); }, /** * Re-runs the content loader with unchanged params — the retry @@ -922,7 +978,13 @@ export function withStalkerContent() { }); }, setItvChannels(channels: StalkerItvChannel[]) { - patchState(store, { itvChannels: channels }); + patchState(store, { + itvChannels: channels, + // The only caller clears the list for a category the + // resource has not served yet, so nothing on screen + // belongs to a category until it does. + itvChannelsSource: null, + }); }, setRadioChannels(channels: StalkerItvChannel[]) { patchState(store, { radioChannels: channels }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts index 474141b86..0577300c9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts @@ -53,9 +53,7 @@ describe('withStalkerFavorites', () => { addPortalFavorite: jest.fn(() => of({ favorites: [{ id: '42', title: 'Movie Title' }] }) ), - removeFromPortalFavorites: jest.fn(() => - of({ favorites: [] }) - ), + removeFromPortalFavorites: jest.fn(() => of({ favorites: [] })), }; snackBar = { open: jest.fn() }; ngrxStore = { dispatch: jest.fn() }; @@ -77,6 +75,22 @@ describe('withStalkerFavorites', () => { }); describe('addToFavorites', () => { + it('keeps a valid id when the provider sends a blank stream_id', () => { + // `??` would keep the blank value and leave the stored row with + // no provider identity at all. + store.addToFavorites({ + stream_id: '', + id: 'channel-7', + name: 'Blank stream id', + category_id: '17', + }); + + expect(playlistService.addPortalFavorite).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ id: 'channel-7' }) + ); + }); + it('persists the favorite with a normalized payload and syncs playlist meta', () => { const onDone = jest.fn(); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts index 3150ea696..6ad7e2634 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts @@ -5,6 +5,7 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistMeta, StalkerPortalItem } from '@iptvnator/shared/interfaces'; +import { firstNonBlankStalkerId } from '../../stalker-vod.utils'; import { StalkerSelectionStoreContract } from '../stalker-store.contracts'; import { dispatchStalkerPlaylistMetaUpdate, @@ -53,7 +54,16 @@ export function withStalkerFavorites() { storeContext.selectedContentType() ), added_at: Date.now(), - id: item.stream_id ?? item.id, + // First NON-BLANK: `??` keeps a blank + // `stream_id`, which would overwrite a valid + // `id` and leave the stored row with no + // provider identity at all — unremovable by + // id, and invisible to anything that looks + // the channel up again. + id: firstNonBlankStalkerId( + item.stream_id, + item.id + ), }) .subscribe((updatedPlaylist) => { dispatchStalkerPlaylistMetaUpdate( diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts index fb6c5c4fe..5b97905f4 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts @@ -6,8 +6,8 @@ import { import { StalkerVodInfo, TmdbMediaType } from '@iptvnator/shared/interfaces'; import { StalkerVodSource } from '../models'; import { + firstNonBlankStalkerIdText, isStalkerSeriesItem, - normalizeStalkerEntityId, } from '../stalker-vod.utils'; /** @@ -52,14 +52,14 @@ export async function enrichStalkerSelectionWithTmdb( return; } - const itemId = normalizeStalkerEntityId(item.id ?? item.stream_id); + const itemId = firstNonBlankStalkerIdText(item.id, item.stream_id); const isCurrent = (): StalkerVodSource | null => { const current = store.selectedItem(); if (!current) { return null; } const matches = itemId - ? normalizeStalkerEntityId(current.id ?? current.stream_id) === + ? firstNonBlankStalkerIdText(current.id, current.stream_id) === itemId : current === item; return matches ? current : null; diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts index fa7968792..615f60a9b 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts @@ -1,7 +1,7 @@ import { Store } from '@ngrx/store'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { PlaylistMeta, StalkerPortalItem } from '@iptvnator/shared/interfaces'; -import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; +import { firstNonBlankStalkerIdText } from '../../stalker-vod.utils'; import { StalkerContentType, StalkerRecentlyViewedItem, @@ -49,7 +49,7 @@ export function buildStalkerRecentlyViewedPayload( ), ...getStalkerSeriesRecentMetadata(selectedContentType), added_at: Date.now(), - id: normalizeStalkerEntityId(item.id ?? item.stream_id ?? ''), + id: firstNonBlankStalkerIdText(item.id, item.stream_id), title: item.title ?? item.name ?? item.o_name ?? '', }; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts index 3533e68ec..6d84c97cf 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts @@ -1,5 +1,8 @@ import { StalkerItvChannel } from '../../models'; -import { filterItvChannelsByGenre } from './stalker-content-mappers'; +import { + filterItvChannelsByGenre, + toStalkerItvChannel, +} from './stalker-content-mappers'; describe('filterItvChannelsByGenre', () => { const CHANNELS: StalkerItvChannel[] = [ @@ -30,3 +33,22 @@ describe('filterItvChannelsByGenre', () => { expect(filterItvChannelsByGenre(CHANNELS, '404')).toEqual([]); }); }); + +describe('toStalkerItvChannel', () => { + it('keeps a provided id', () => { + expect(toStalkerItvChannel({ id: 7, cmd: 'x' }).id).toBe(7); + }); + + it('does not let a blank id shadow the stream_id', () => { + expect( + toStalkerItvChannel({ id: '', stream_id: 30, cmd: 'x' }).id + ).toBe(30); + expect( + toStalkerItvChannel({ id: ' ', stream_id: '31', cmd: 'x' }).id + ).toBe('31'); + }); + + it('falls back to an empty id when neither is set', () => { + expect(toStalkerItvChannel({ cmd: 'x' }).id).toBe(''); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts index 359dcd5d1..229fda1c4 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts @@ -1,3 +1,4 @@ +import { firstNonBlankStalkerId } from '../../stalker-vod.utils'; import { StalkerContentItem, StalkerItvChannel, @@ -79,17 +80,17 @@ export function filterItvChannelsByGenre( ); } -export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { +export function toStalkerItvChannel( + item: StalkerContentItem +): StalkerItvChannel { return { ...item, - id: item.id ?? item.stream_id ?? '', + // First NON-BLANK id: a blank `id` beside a valid `stream_id` must + // not shadow it, or the row is unselectable and unplayable. + id: firstNonBlankStalkerId(item.id, item.stream_id) ?? '', cmd: String(item.cmd ?? ''), - name: - typeof item.name === 'string' ? item.name : undefined, - o_name: - typeof item.o_name === 'string' ? item.o_name : undefined, - logo: - typeof item.logo === 'string' ? item.logo : undefined, + name: typeof item.name === 'string' ? item.name : undefined, + o_name: typeof item.o_name === 'string' ? item.o_name : undefined, + logo: typeof item.logo === 'string' ? item.logo : undefined, }; } - diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts index e53c547ab..7e2c54dc0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts @@ -8,7 +8,7 @@ import type { UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { - normalizeStalkerEntityId, + firstNonBlankStalkerIdText, StalkerStore, } from '@iptvnator/portal/stalker/data-access'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @@ -223,11 +223,13 @@ function captureStalkerCollectionPlaybackOwner( const providerItem = item.stalkerItem as { id?: unknown; stream_id?: unknown } | undefined; const uidParts = item.uid.split('::'); - const contentId = normalizeStalkerEntityId( - providerItem?.id ?? - providerItem?.stream_id ?? - item.stalkerId ?? - uidParts[uidParts.length - 1] + // A blank provider id is not an absent one: `??` would keep it and leave + // the session without an identity instead of falling through. + const contentId = firstNonBlankStalkerIdText( + providerItem?.id, + providerItem?.stream_id, + item.stalkerId, + uidParts[uidParts.length - 1] ); if (!sourceId || !contentId) return null; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open-state.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open-state.ts new file mode 100644 index 000000000..a923ea54b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open-state.ts @@ -0,0 +1,95 @@ +import { signal } from '@angular/core'; +import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access'; +import { + OPEN_STALKER_LIVE_CATEGORY_STATE_KEY, + OPEN_STALKER_LIVE_ITEM_STATE_KEY, + OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY, + OPEN_STALKER_LIVE_POSTER_STATE_KEY, + OPEN_STALKER_LIVE_TITLE_STATE_KEY, +} from '@iptvnator/portal/shared/util'; + +/** + * The live handoff carried by the arrival's history state, and nothing else: + * which channel, in which portal, with which remembered genre. The + * counterpart of Xtream's `LiveStreamAutoOpenStateService`, kept apart from + * the coordinator that decides WHEN to act on it + * (`StalkerLiveAutoOpen`), because reading and retiring the browser state is + * a separate concern from waiting for the portal, the list and the user. + * + * `generation` is what makes a retired handoff final: work already in flight + * for it (a full-list preload promise) captures the value and drops itself + * when it no longer matches. + */ +export class StalkerLiveAutoOpenState { + readonly pendingItemId = signal(null); + readonly pendingPlaylistId = signal(null); + readonly pendingCategoryId = signal(null); + + private currentGeneration = 0; + + get generation(): number { + return this.currentGeneration; + } + + /** + * Reads the arrival's handoff. Returns true when a NEW pending item was + * recorded, so the caller can retire whatever the previous one started. + */ + captureFromHistoryState(): boolean { + const state = (window.history.state ?? null) as Record< + string, + unknown + > | null; + const itemId = normalizeStalkerEntityId( + state?.[OPEN_STALKER_LIVE_ITEM_STATE_KEY] + ); + if (!itemId) { + this.clearPendingItem(); + return false; + } + + this.pendingPlaylistId.set( + normalizeStalkerEntityId( + state?.[OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY] + ) || null + ); + this.pendingCategoryId.set( + normalizeStalkerEntityId( + state?.[OPEN_STALKER_LIVE_CATEGORY_STATE_KEY] + ) || null + ); + this.currentGeneration += 1; + this.pendingItemId.set(itemId); + return true; + } + + clearPendingItem(): void { + this.currentGeneration += 1; + this.pendingItemId.set(null); + this.pendingPlaylistId.set(null); + this.pendingCategoryId.set(null); + } + + /** Retires the handoff from the history entry so it cannot replay. */ + clearHistoryState(): void { + try { + const state = (window.history.state ?? {}) as Record< + string, + unknown + >; + if (!(OPEN_STALKER_LIVE_ITEM_STATE_KEY in state)) { + return; + } + + const nextState = { ...state }; + delete nextState[OPEN_STALKER_LIVE_ITEM_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_CATEGORY_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_TITLE_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_POSTER_STATE_KEY]; + window.history.replaceState(nextState, document.title); + } catch { + // Browser history state can be unavailable in restricted contexts. + } + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts new file mode 100644 index 000000000..8f36491ca --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts @@ -0,0 +1,639 @@ +import { DestroyRef, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { StalkerItvChannel } from '@iptvnator/portal/stalker/data-access'; +import { Subject } from 'rxjs'; +import { StalkerLiveAutoOpen } from './stalker-live-auto-open'; + +const channel = ( + id: string, + genre: string | number = 7 +): StalkerItvChannel => ({ + id, + name: `Channel ${id}`, + cmd: `stream-${id}`, + tv_genre_id: genre, +}); + +describe('StalkerLiveAutoOpen', () => { + let autoOpen: StalkerLiveAutoOpen; + let events: Subject; + const store = { + currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }), + selectedContentType: signal('itv'), + selectedCategoryId: signal(null), + itvChannelsCategory: signal(null), + searchPhrase: signal(''), + itvFullChannelList: signal([]), + itvFullListActive: signal(false), + itvFullListUnsupported: signal(false), + preloadItvChannels: jest.fn(() => Promise.resolve()), + setSearchPhrase: jest.fn((phrase: string) => + store.searchPhrase.set(phrase) + ), + setSelectedCategory: jest.fn((category: string | null) => + store.selectedCategoryId.set(category) + ), + setPage: jest.fn(), + }; + const sidebar = { expand: jest.fn() }; + const play = jest.fn(); + const routeReady = signal(true); + const options = () => ({ + store, + router: { events } as unknown as Router, + destroyRef: TestBed.inject(DestroyRef), + sidebar, + routeReady: () => routeReady(), + play, + }); + + function arrive( + state: Record = { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + } + ): void { + window.history.replaceState(state, ''); + events.next(new NavigationEnd(1, '/itv', '/itv')); + TestBed.tick(); + } + + beforeEach(() => { + jest.clearAllMocks(); + window.history.replaceState({}, ''); + store.currentPlaylist.set({ _id: 'pl-3' }); + store.selectedContentType.set('itv'); + store.selectedCategoryId.set(null); + store.itvChannelsCategory.set(null); + store.searchPhrase.set(''); + store.itvFullChannelList.set([]); + store.itvFullListActive.set(false); + store.itvFullListUnsupported.set(false); + store.preloadItvChannels.mockImplementation(() => Promise.resolve()); + routeReady.set(true); + events = new Subject(); + TestBed.configureTestingModule({}); + autoOpen = TestBed.runInInjectionContext( + () => new StalkerLiveAutoOpen(options()) + ); + TestBed.tick(); + }); + + /** The store serves the selected genre's channels (a tick later). */ + function serveCategory(category: string): void { + store.itvChannelsCategory.set(category); + TestBed.tick(); + } + + afterEach(() => { + window.history.replaceState({}, ''); + }); + + it('reads a handoff that was in history before construction', () => { + window.history.replaceState( + { openStalkerLiveItemId: '30', openStalkerLivePlaylistId: 'pl-3' }, + '' + ); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30')]); + store.selectedCategoryId.set('7'); + store.itvChannelsCategory.set('7'); + + const mounted = TestBed.runInInjectionContext( + () => new StalkerLiveAutoOpen({ ...options(), router: null }) + ); + TestBed.tick(); + + // Genre 7's channels were already on screen: play at once. + expect(play).toHaveBeenCalledWith(channel('30')); + expect(mounted.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('starts the full-list load and plays the channel in its genre once the list is ready', () => { + arrive(); + + expect(store.preloadItvChannels).toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + + store.itvFullChannelList.set([channel('1', 2), channel('30', 7)]); + store.itvFullListActive.set(true); + TestBed.tick(); + + expect(store.setSearchPhrase).toHaveBeenCalledWith(''); + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(store.setPage).toHaveBeenCalledWith(0); + expect(sidebar.expand).toHaveBeenCalledWith('portal'); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + // Playback waits for genre 7's channels: `navigation.prepare` would + // otherwise capture the previous scope's rows as the channel order. + expect(play).not.toHaveBeenCalled(); + + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('defers while another genre is still on screen, even when it shows the channel', () => { + // The All list on screen holds the channel, but those rows are not + // genre 7's: prepare() would capture the wrong channel order. + const allItems = [channel('1', 2), channel('30', 7)]; + store.selectedCategoryId.set('*'); + store.itvChannelsCategory.set('*'); + store.itvFullChannelList.set(allItems); + store.itvFullListActive.set(true); + + arrive(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(play).not.toHaveBeenCalled(); + + serveCategory('7'); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('plays at once when a search narrows the genre already on screen', () => { + // Clearing the search re-derives the rendered rows synchronously, + // and the source list is already genre 7's. + store.selectedCategoryId.set('7'); + store.itvChannelsCategory.set('7'); + store.searchPhrase.set('news'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + + expect(store.setSearchPhrase).toHaveBeenCalledWith(''); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('plays once a legacy-paged genre is served, even without the row on page 1', () => { + store.selectedCategoryId.set('2'); + store.itvChannelsCategory.set('2'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + expect(play).not.toHaveBeenCalled(); + + serveCategory('7'); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('keeps waiting while the previous genre is still the served list', () => { + store.selectedCategoryId.set('2'); + store.itvChannelsCategory.set('2'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + TestBed.tick(); + + expect(play).not.toHaveBeenCalled(); + }); + + it('lets a newer handoff supersede a channel still waiting for its rows', () => { + store.itvFullChannelList.set([channel('30', 7), channel('31', 7)]); + store.itvFullListActive.set(true); + + arrive(); + arrive({ + openStalkerLiveItemId: '31', + openStalkerLivePlaylistId: 'pl-3', + }); + serveCategory('7'); + + expect(play).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledWith(channel('31', 7)); + }); + + it('defers the jump from the All Items grid into the All category list', () => { + // The grid (`selectedCategoryId` null) is not a served category, so + // even a genre-less channel waits for the '*' list. + const noGenre = { id: '30', cmd: 'x', name: 'No genre' }; + store.selectedCategoryId.set(null); + store.itvFullChannelList.set([noGenre]); + store.itvFullListActive.set(true); + + arrive(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('*'); + expect(play).not.toHaveBeenCalled(); + + serveCategory('*'); + expect(play).toHaveBeenCalledWith(noGenre); + }); + + it('drops the deferred play when the user switches portal first', () => { + // A genre-less channel targets '*', which a fresh portal's reset + // (selectedCategoryId null) would otherwise look identical to. + const noGenre = { id: '30', cmd: 'x', name: 'No genre' }; + store.selectedCategoryId.set('2'); + store.itvChannelsCategory.set('2'); + store.itvFullChannelList.set([noGenre]); + store.itvFullListActive.set(true); + + arrive(); + expect(play).not.toHaveBeenCalled(); + + store.currentPlaylist.set({ _id: 'pl-9' }); + store.selectedCategoryId.set('*'); + TestBed.tick(); + serveCategory('*'); + + expect(play).not.toHaveBeenCalled(); + }); + + it('drops the deferred play when the user starts a search first', () => { + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + store.setSearchPhrase('sport'); + TestBed.tick(); + serveCategory('7'); + + expect(play).not.toHaveBeenCalled(); + }); + + it('drops the deferred play when the user selects another genre first', () => { + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + store.setSelectedCategory('2'); + TestBed.tick(); + serveCategory('7'); + + expect(play).not.toHaveBeenCalled(); + }); + + it('falls back to the remembered genre when the full-list load fails transiently', async () => { + // The cache resolves the preload without turning ready or unsupported + // (it only arms a retry cooldown), so nothing reactive would ever + // re-run the effect. + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive({ + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }); + expect(autoOpen.pendingItemId()).toBe('30'); + + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('5'); + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('retires the transient fallback when the layout is destroyed mid-load', async () => { + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + let destroy: (() => void) | undefined; + const destroyRef = { + onDestroy: (callback: () => void) => { + destroy = callback; + return () => undefined; + }, + } as unknown as DestroyRef; + window.history.replaceState( + { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }, + '' + ); + const mounted = TestBed.runInInjectionContext( + () => + new StalkerLiveAutoOpen({ + ...options(), + router: null, + destroyRef, + }) + ); + TestBed.tick(); + expect(mounted.pendingItemId()).toBe('30'); + + destroy?.(); + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(mounted.pendingItemId()).toBeNull(); + // History belongs to the page the user navigated to: left untouched. + expect(window.history.state.openStalkerLiveItemId).toBe('30'); + }); + + it('lets a load that turns ready win over its own settled promise', async () => { + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive(); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + TestBed.tick(); + serveCategory('7'); + expect(play).toHaveBeenCalledTimes(1); + + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledTimes(1); + }); + + it('waits while the store still serves another portal, even one holding the same id', () => { + store.currentPlaylist.set({ _id: 'pl-1' }); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30', 9)]); + + arrive(); + + expect(play).not.toHaveBeenCalled(); + expect(store.preloadItvChannels).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + + store.currentPlaylist.set({ _id: 'pl-3' }); + store.itvFullChannelList.set([channel('30', 7)]); + TestBed.tick(); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(channel('30', 7)); + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + }); + + it('matches a cached channel whose blank id sits beside its stream_id', () => { + const byStreamId = { + id: '', + stream_id: 30, + cmd: 'x', + name: 'Stream id only', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([byStreamId]); + + arrive(); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(byStreamId); + }); + + it('waits for the route session before touching the store', () => { + // Revisiting a portal keeps its playlist and cache, so the playlist + // check passes at once — but the session's own reset would wipe the + // selection a tick later. + routeReady.set(false); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30', 7)]); + + arrive(); + + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(store.preloadItvChannels).not.toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + + routeReady.set(true); + TestBed.tick(); + serveCategory('7'); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('drops the handoff when the user picks another genre while the list loads', () => { + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive(); + expect(autoOpen.pendingItemId()).toBe('30'); + + // The user moved on before the full list arrived. + store.selectedCategoryId.set('2'); + TestBed.tick(); + + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + + settle(); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + TestBed.tick(); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).not.toHaveBeenCalledWith('7'); + }); + + it('does not fall back over a genre the user chose while the preload was pending', async () => { + // The promise can settle before the effect that abandons the handoff + // runs, so the continuation rechecks the user's own state. + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive({ + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }); + expect(autoOpen.pendingItemId()).toBe('30'); + + // The user picks another genre; the preload settles before the + // effect pass that would retire the handoff. + store.selectedCategoryId.set('2'); + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).not.toHaveBeenCalledWith('5'); + expect(play).not.toHaveBeenCalled(); + }); + + it('drops the handoff when the user starts searching while the list loads', () => { + store.preloadItvChannels.mockImplementation( + () => new Promise(() => undefined) + ); + + arrive(); + expect(autoOpen.pendingItemId()).toBe('30'); + + store.searchPhrase.set('sport'); + TestBed.tick(); + + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it("does not read the route session's own reset as the user moving on", () => { + // The session clears the category for the arrival; that is not a + // user action and must not drop the handoff. + routeReady.set(false); + store.selectedCategoryId.set('4'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + store.selectedCategoryId.set(null); + routeReady.set(true); + TestBed.tick(); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('opens the All list for a cached row whose genre the cache cannot filter', () => { + // `filterItvChannelsByGenre` mirrors the portal's `genre=` filter, + // i.e. `tv_genre_id`: a row carrying only `category_id` is absent + // from that genre's cached slice, so selecting it would strand the + // channel behind a paged request. The All list always holds it. + const byCategoryId = { + id: '30', + cmd: 'x', + name: 'Category id only', + category_id: '8', + } as unknown as StalkerItvChannel; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([byCategoryId]); + + arrive(); + serveCategory('*'); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('*'); + expect(play).toHaveBeenCalledWith(byCategoryId); + }); + + it('finds a channel persisted under its stream_id while the cache keys it by id', () => { + // A favorite stores `stream_id ?? id`, a cached channel keeps + // `id ?? stream_id` — a row with two different ids must still match. + const twoIds = { + id: '5', + stream_id: '99', + cmd: 'x', + name: 'Two ids', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + const other = channel('7', 7); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([other, twoIds]); + + arrive({ + openStalkerLiveItemId: '99', + openStalkerLivePlaylistId: 'pl-3', + }); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(twoIds); + }); + + it('plays nothing when two channels claim the handoff id under different fields', () => { + // The handoff carries a bare value and cannot say which field it came + // from, so an ambiguous identity must not gamble on playback; the + // remembered genre still opens. + const byId = { + id: '99', + cmd: 'x', + name: 'By id', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + const byStreamId = { + id: '5', + stream_id: '99', + cmd: 'x', + name: 'By stream id', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([byStreamId, byId]); + + arrive({ + openStalkerLiveItemId: '99', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '4', + }); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('4'); + expect(autoOpen.pendingItemId()).toBeNull(); + }); + + it('does nothing outside the ITV section', () => { + store.selectedContentType.set('radio'); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30')]); + + arrive(); + + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + }); + + it('falls back to the remembered category when the portal has no full list', () => { + store.itvFullListUnsupported.set(true); + + arrive({ + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('5'); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('consumes a handoff whose channel is missing from the loaded list', () => { + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('1')]); + + arrive(); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it.each([ + { label: 'missing', genre: undefined }, + { label: 'blank', genre: ' ' }, + ])( + 'opens the All pseudo-category for a channel with a $label genre', + ({ genre }) => { + const noGenre = { + id: '30', + cmd: 'x', + name: 'No genre', + tv_genre_id: genre, + }; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([noGenre]); + + arrive(); + serveCategory('*'); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('*'); + expect(play).toHaveBeenCalledWith(noGenre); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.ts new file mode 100644 index 000000000..948454bdc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.ts @@ -0,0 +1,349 @@ +import { + DestroyRef, + effect, + signal, + untracked, + type Signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { + normalizeStalkerEntityId, + StalkerItvChannel, +} from '@iptvnator/portal/stalker/data-access'; +import { StalkerLiveAutoOpenState } from './stalker-live-auto-open-state'; + +/** The slice of `StalkerStore` the auto-open flow reads and drives. */ +export interface StalkerLiveAutoOpenStore { + currentPlaylist: Signal<{ _id?: string } | null | undefined>; + selectedContentType: Signal; + selectedCategoryId: Signal; + /** The category the channels on screen were served for (see the store). */ + itvChannelsCategory: Signal; + searchPhrase: Signal; + itvFullChannelList: Signal; + itvFullListActive: Signal; + itvFullListUnsupported: Signal; + /** Resolves when the full-list load settles (also when it fails). */ + preloadItvChannels(): Promise | void; + setSearchPhrase(phrase: string): void; + setSelectedCategory(category: string | null): void; + setPage(page: number): void; +} + +export interface StalkerLiveAutoOpenOptions { + store: StalkerLiveAutoOpenStore; + router: Router | null; + destroyRef: DestroyRef; + sidebar?: { expand(surface: 'portal'): void }; + /** + * False while the route session is still applying the route to the store + * (`StalkerWorkspaceRouteSession.isReady`). Defaults to ready when no + * session is provided. + */ + routeReady?: () => boolean; + play: (item: StalkerItvChannel) => void; +} + +interface DeferredPlay { + item: StalkerItvChannel; + /** The portal the channel belongs to; a portal switch drops the request. */ + playlistId: string; + /** The genre whose channels must be on screen before playback starts. */ + category: string; +} + +/** + * Opens the channel named in the arrival's history state + * (`openStalkerLiveItemId`, written by `buildStalkerLiveNavigationTarget`) + * inside the ITV layout — the Stalker counterpart of Xtream's + * `LiveStreamAutoOpenStateService` + effect. + * + * The state is read once at construction and again on every NavigationEnd: + * a cross-route arrival may activate this component after or before that + * event depending on the shell, and a playlist switch reuses it. The pending + * item is scoped to its playlist (`openStalkerLivePlaylistId`): the shared + * store may still serve the previous portal, and channel ids are + * provider-local, so nothing is matched until `currentPlaylist` is the + * requested one. + * + * Location goes through the full ITV channel list cache (the same list the + * count badges and search use), because `get_ordered_list` is server-paged + * and the channel may sit on any page of its genre. While that list loads the + * effect waits (it re-runs when the cache turns ready). A portal that cannot + * provide the list, or a channel missing from it (censored genres are + * excluded from `get_all_channels`), falls back to selecting the category the + * collection row remembered, when it did, so the user lands in the right list + * even though the row cannot be picked; then the handoff is consumed. A load + * that fails transiently (the cache only arms a retry cooldown, no signal + * changes) resolves the same way: the preload promise settles, the list is + * neither ready nor unsupported, and the genre fallback runs instead of + * leaving the handoff pending forever. + * + * Playback waits until the channels on screen are the genre's: `playChannel` + * → `navigation.prepare` captures the displayed rows as the remote/numeric + * channel order, and the store serves a category a tick after + * `setSelectedCategory` — even from the full-list cache — so playing right + * away would capture the previous scope's queue. The store answers "whose + * channels are these?" with `itvChannelsCategory`; array identity cannot, + * because filtering by `'*'` hands back the cache by reference and clearing + * a search replaces the rendered list without the source moving. Clearing + * the search IS synchronous, so a genre already on screen plays at once. A + * newer handoff, a portal/section/genre switch or a fresh search drops a + * pending play. + * + * Two things must settle before any of that: the route session, which resets + * the selected category and item on arrival and would wipe a selection made + * ahead of it (the store keeps the previous portal's playlist and cache + * across a revisit, so the playlist check alone passes too early), and the + * user, who may pick another genre or start searching while the full list + * loads — measured against the list state captured once the handoff became + * actionable, so the session's own resets never read as a user action. + */ +export class StalkerLiveAutoOpen { + private readonly state = new StalkerLiveAutoOpenState(); + readonly pendingItemId = this.state.pendingItemId; + readonly pendingPlaylistId = this.state.pendingPlaylistId; + readonly pendingCategoryId = this.state.pendingCategoryId; + private readonly deferredPlay = signal(null); + /** The list state when the handoff became actionable (see the class). */ + private pendingBaseline: { + category: string | null; + search: string; + } | null = null; + + constructor(private readonly options: StalkerLiveAutoOpenOptions) { + this.captureFromHistoryState(); + options.router?.events + ?.pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(options.destroyRef) + ) + .subscribe(() => this.captureFromHistoryState()); + + effect(() => this.run()); + effect(() => this.runDeferredPlay()); + // A preload promise can settle after the layout is gone; retire the + // handoff so its fallback cannot touch the store or history state of + // whatever page the user navigated to. + options.destroyRef.onDestroy(() => { + this.clearPendingItem(); + this.deferredPlay.set(null); + }); + } + + captureFromHistoryState(): void { + this.state.captureFromHistoryState(); + // Whatever the previous handoff started is retired either way: a + // newer one supersedes a channel still waiting for its genre's rows, + // and an arrival without a handoff has nothing to continue. + this.deferredPlay.set(null); + this.pendingBaseline = null; + } + + clearPendingItem(): void { + this.pendingBaseline = null; + this.state.clearPendingItem(); + } + + clearHistoryState(): void { + this.state.clearHistoryState(); + } + + private run(): void { + const pendingId = this.pendingItemId(); + if (!pendingId) { + return; + } + + const { store } = this.options; + if (store.selectedContentType() !== 'itv') { + return; + } + + // The route session resets the selected category and item for this + // arrival; settling first would be undone a tick later. + if (!(this.options.routeReady?.() ?? true)) { + return; + } + + const playlist = store.currentPlaylist(); + const pendingPlaylistId = this.pendingPlaylistId(); + if ( + !playlist || + (pendingPlaylistId && + normalizeStalkerEntityId(playlist._id) !== pendingPlaylistId) + ) { + return; + } + + // The user owns the list once the handoff is actionable: picking + // another genre or starting a search means they moved on, and the + // channel must not hijack the view when the full list finally lands. + if (!this.pendingBaseline) { + this.pendingBaseline = { + category: store.selectedCategoryId() ?? null, + search: store.searchPhrase().trim(), + }; + } else if (!this.handoffStillOwnsTheList()) { + untracked(() => { + this.clearPendingItem(); + this.clearHistoryState(); + }); + return; + } + + if (store.itvFullListUnsupported()) { + untracked(() => this.settle(null)); + return; + } + + if (!store.itvFullListActive()) { + // Idempotent: the layout's own preload effect may already have + // started it, and the cache de-duplicates in-flight loads. When + // the load settles without the list turning ready or unsupported + // it failed transiently — fall back to the genre rather than wait + // for a retry nothing schedules. + const generation = this.state.generation; + untracked(() => { + void Promise.resolve(store.preloadItvChannels()).then(() => { + // The promise can settle before the effect that would + // abandon a handoff the user walked away from, so the + // user's own state is rechecked here too. + if ( + generation !== this.state.generation || + !this.pendingItemId() || + store.itvFullListActive() || + store.itvFullListUnsupported() || + !this.handoffStillOwnsTheList() + ) { + return; + } + this.settle(null); + }); + }); + return; + } + + // Either provider id can be the one that was persisted: a favorite + // prefers `stream_id` (`with-stalker-favorites.feature.ts`) while a + // cached channel prefers `id`, both skipping blanks, so a row + // carrying two different non-blank ids is reachable only by + // accepting both. + // The handoff carries a bare value, so it cannot say WHICH field it + // came from: when one channel claims it as its id and another as its + // stream id, the identity is ambiguous and neither is played — + // guessing an order would open the wrong channel half the time. The + // genre fallback still runs, so the user lands in the right list. + const candidates = store + .itvFullChannelList() + .filter( + (channel) => + normalizeStalkerEntityId(channel.id) === pendingId || + normalizeStalkerEntityId(channel.stream_id) === pendingId + ); + untracked(() => + this.settle(candidates.length === 1 ? candidates[0] : null) + ); + } + + private settle(item: StalkerItvChannel | null): void { + const { store } = this.options; + // A located row is played FROM THE CACHE, and the cached list is + // filtered by `tv_genre_id` alone — `filterItvChannelsByGenre` + // deliberately mirrors the portal's own `genre=` filter. Selecting a + // genre that filter cannot serve (a row carrying only `category_id`) + // would strand the channel behind a paged request that may not + // answer, so the All list, which always holds it, is the honest + // destination. A blank genre (`''`, whitespace) is as absent as a + // missing one. The remembered genre for a row that could NOT be + // located is a different case: it is served by the portal, so it + // does read `category_id` (`resolveStalkerLiveGenreId`). + const category = item + ? normalizeStalkerEntityId(item.tv_genre_id) || '*' + : this.pendingCategoryId(); + + if (category) { + store.setSearchPhrase(''); + store.setSelectedCategory(category); + store.setPage(0); + this.options.sidebar?.expand('portal'); + } + if (item && category) { + if (store.itvChannelsCategory() === category) { + this.options.play(item); + } else { + this.deferredPlay.set({ + item, + playlistId: normalizeStalkerEntityId( + store.currentPlaylist()?._id + ), + category, + }); + } + } + + this.clearPendingItem(); + this.clearHistoryState(); + } + + /** + * Whether the handoff still describes what the user is looking at: the + * requested portal, the ITV section, and the genre and search it + * captured once it became actionable. + */ + private handoffStillOwnsTheList(): boolean { + const { store } = this.options; + if (store.selectedContentType() !== 'itv') { + return false; + } + + const playlist = store.currentPlaylist(); + const pendingPlaylistId = this.pendingPlaylistId(); + if ( + !playlist || + (pendingPlaylistId && + normalizeStalkerEntityId(playlist._id) !== pendingPlaylistId) + ) { + return false; + } + + const baseline = this.pendingBaseline; + return ( + !baseline || + (baseline.category === (store.selectedCategoryId() ?? null) && + baseline.search === store.searchPhrase().trim()) + ); + } + + private runDeferredPlay(): void { + const deferred = this.deferredPlay(); + if (!deferred) { + return; + } + + const { store } = this.options; + if ( + normalizeStalkerEntityId(store.currentPlaylist()?._id) !== + deferred.playlistId || + store.selectedContentType() !== 'itv' || + store.selectedCategoryId() !== deferred.category || + store.searchPhrase().trim() !== '' + ) { + // The user moved on (another portal, genre or section, or + // started a search) before the genre's rows arrived. + untracked(() => this.deferredPlay.set(null)); + return; + } + + if (store.itvChannelsCategory() !== deferred.category) { + return; + } + + untracked(() => { + this.deferredPlay.set(null); + this.options.play(deferred.item); + }); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 1ef6827fe..58bf07cbf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -1,6 +1,7 @@ import { ChannelScrollFocusDirective } from '@iptvnator/ui/components'; import { NgTemplateOutlet } from '@angular/common'; import { + DestroyRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -139,6 +140,8 @@ function matchesStalkerChannelTerm( } import { StalkerLiveNavigation } from './stalker-live-navigation'; +import { StalkerLiveAutoOpen } from './stalker-live-auto-open'; +import { StalkerWorkspaceRouteSession } from '../stalker-workspace-route-session.service'; @Component({ selector: 'app-stalker-live-stream-layout', @@ -188,6 +191,9 @@ export class StalkerLiveStreamLayoutComponent private readonly streamHeaders = inject(ElectronStreamHeadersService); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + private readonly routeSession = inject(StalkerWorkspaceRouteSession, { + optional: true, + }); private readonly liveSidebarStateService = inject( LiveLayoutSidebarStateService ); @@ -607,6 +613,17 @@ export class StalkerLiveStreamLayoutComponent this.isLoadingMore() || this.stalkerStore.isPaginatedContentLoading(), }); + /** Arrival handoff: select and play `openStalkerLiveItemId` (see the class). */ + readonly autoOpen = new StalkerLiveAutoOpen({ + store: this.stalkerStore, + router: inject(Router, { optional: true }), + destroyRef: inject(DestroyRef), + sidebar: this.liveSidebarStateService, + routeReady: () => this.routeSession?.isReady() ?? true, + play: (item) => { + void this.playChannel(item, true); + }, + }); private epgPreviewRefreshTimer: ReturnType | null = null; private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts index 93854bffa..92f60e416 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { EMPTY, Subject, of } from 'rxjs'; +import { EMPTY, Observable, Subject, of } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from '@iptvnator/services'; @@ -17,6 +17,13 @@ const ACTIVE_PLAYLIST: PlaylistMeta = { title: 'Test Stalker', } as PlaylistMeta; +const OTHER_PLAYLIST_ID = 'stalker-2'; +const OTHER_PLAYLIST: PlaylistMeta = { + ...ACTIVE_PLAYLIST, + _id: OTHER_PLAYLIST_ID, + title: 'Other Stalker', +} as PlaylistMeta; + const FULL_STALKER_PLAYLIST: PlaylistMeta = { ...ACTIVE_PLAYLIST, isFullStalkerPortal: true, @@ -28,8 +35,11 @@ const FULL_STALKER_PLAYLIST: PlaylistMeta = { } as PlaylistMeta; async function flushEffects(): Promise { - await Promise.resolve(); - await Promise.resolve(); + // The route session serializes its syncs on a promise queue, so settling + // one arrival costs several microtask hops rather than a fixed two. + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } } function getStalkerSectionFromUrl(url: string): string | null { @@ -99,6 +109,9 @@ describe('StalkerWorkspaceRouteSession', () => { stalkerStore.setSelectedContentType.mockClear(); stalkerStore.setSearchPhrase.mockClear(); playlistsService.getPlaylistById.mockClear(); + // mockClear keeps a return value a previous case installed, so restore + // the default here: a leaked pending observable hangs the next sync. + playlistsService.getPlaylistById.mockReturnValue(of(ACTIVE_PLAYLIST)); await TestBed.configureTestingModule({ providers: [ @@ -200,4 +213,88 @@ describe('StalkerWorkspaceRouteSession', () => { FULL_STALKER_PLAYLIST ); }); + it('stays unready until an overlapping sync has installed the portal row', async () => { + // The constructor starts one sync; a NavigationEnd starts a second + // while the first is still awaiting the playlist. The second must not + // skip the bootstrap and report ready against the previous row. + let releasePlaylist: (playlist: PlaylistMeta) => void = () => undefined; + playlistsService.getPlaylistById.mockReturnValue( + new Observable((subscriber) => { + releasePlaylist = (playlist) => { + subscriber.next(playlist); + subscriber.complete(); + }; + }) + ); + + const session = TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled(); + + routerEvents.next( + new NavigationEnd(1, router.url, router.url) as NavigationEnd + ); + await flushEffects(); + + expect(session.isReady()).toBe(false); + + releasePlaylist(FULL_STALKER_PLAYLIST); + await flushEffects(); + await flushEffects(); + + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes(1); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + FULL_STALKER_PLAYLIST + ); + expect(session.isReady()).toBe(true); + }); + + it('does not let a superseded sync publish readiness', async () => { + const session = TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(session.isReady()).toBe(true); + + let releaseSecond: (playlist: PlaylistMeta) => void = () => undefined; + playlistsService.getPlaylistById.mockReturnValue( + new Observable((subscriber) => { + releaseSecond = (playlist) => { + subscriber.next(playlist); + subscriber.complete(); + }; + }) + ); + + // Arrive at a DIFFERENT portal, then immediately at a third: the + // first arrival must not flip readiness back on behind the newest one. + activePlaylist.set(null); + router.url = `/workspace/stalker/${OTHER_PLAYLIST_ID}/itv`; + playlistContext.syncFromUrl.mockImplementation((url: string) => ({ + inWorkspace: true, + provider: 'stalker', + playlistId: OTHER_PLAYLIST_ID, + section: getStalkerSectionFromUrl(url) as 'itv', + })); + + routerEvents.next( + new NavigationEnd(2, router.url, router.url) as NavigationEnd + ); + await flushEffects(); + routerEvents.next( + new NavigationEnd(3, router.url, router.url) as NavigationEnd + ); + await flushEffects(); + + expect(session.isReady()).toBe(false); + + releaseSecond(OTHER_PLAYLIST); + await flushEffects(); + await flushEffects(); + + expect(session.isReady()).toBe(true); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + OTHER_PLAYLIST + ); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts index 7aa702d7b..053cdfb22 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -28,6 +28,29 @@ export class StalkerWorkspaceRouteSession { private currentPlaylistId: string | null = null; private readonly currentSection = signal(null); + private readonly synced = signal(false); + + /** + * Arrivals are applied one at a time, and only the newest one publishes + * readiness. Both halves are load-bearing: the constructor starts a sync + * before the first `NavigationEnd` starts another, so two can be in + * flight at once, and the playlist id is claimed only once the store + * actually holds that row — otherwise the second sync saw the id already + * claimed, skipped the bootstrap, and reported ready while the first was + * still awaiting `setCurrentPlaylist()`, leaving the auto-open handoff to + * resolve and play against the PREVIOUS portal's row. + */ + private syncGeneration = 0; + private pendingSync: Promise = Promise.resolve(); + /** + * False while this session is applying the route to the store. Its sync + * resets the selected category and item (and, on a section change, the + * search) before awaiting the playlist, and the store keeps the previous + * portal's playlist and cache across a revisit — so anything that acts on + * store state for an arrival (the live auto-open handoff) must wait, or + * its selection is wiped a tick later. + */ + readonly isReady = this.synced.asReadonly(); constructor() { this.router.events @@ -39,6 +62,11 @@ export class StalkerWorkspaceRouteSession { takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { + // Synchronous, and before the async sync: this session's + // subscription is registered from an ENVIRONMENT_INITIALIZER + // when the route injector is created, so it runs ahead of the + // components that read `isReady`. + this.synced.set(false); void this.syncRouteContext(); }); @@ -51,25 +79,53 @@ export class StalkerWorkspaceRouteSession { }); } - private async syncRouteContext(): Promise { + private syncRouteContext(): Promise { + const generation = ++this.syncGeneration; + // Read the route SYNCHRONOUSLY: `syncFromUrl` publishes the active + // playlist context, and deferring it onto the queue would leave every + // consumer of that context a tick behind the navigation. const routeContext = this.playlistContext.syncFromUrl(this.router.url); + const run = async (): Promise => { + try { + await this.applyRouteContext(generation, routeContext); + } catch { + // A failed bootstrap deliberately leaves readiness false: the + // store may still hold the previous portal, and acting on that + // is worse than not acting at all. + } + }; + + this.pendingSync = this.pendingSync.then(run, run); + + return this.pendingSync; + } + + private async applyRouteContext( + generation: number, + routeContext: ReturnType + ): Promise { const playlistId = routeContext.provider === 'stalker' ? routeContext.playlistId : null; if (playlistId && this.currentPlaylistId !== playlistId) { - this.currentPlaylistId = playlistId; - this.stalkerStore.resetCategories(); this.stalkerStore.setSelectedCategory(null); this.stalkerStore.clearSelectedItem(); const playlist = await this.resolveStalkerPlaylist(playlistId); await this.stalkerStore.setCurrentPlaylist(playlist); + this.currentPlaylistId = playlistId; + } + + if (generation !== this.syncGeneration) { + // A newer arrival is queued behind this one and owns readiness. + return; } this.syncRouteState(routeContext.section); + this.synced.set(true); } private syncRouteState(section: PortalRailSection | null): void {