feat(collections): open a Stalker live favorite inside its portal (#1639)

Live channels in Favorites and Recently viewed now offer "Open in <playlist>"
for Stalker portals, the counterpart of the VOD "View in portal" action. The
chip in the programme panel and the channel's context menu jump to the channel
inside its portal's Live TV, with its genre selected and the channel playing.
Radio stays hidden: it is a separate legacy-paged section with no
open-on-arrival contract.

The handoff refuses to guess. The store records which genre the rendered ITV
list belongs to, so the deferred play waits for a list that can actually serve
the channel instead of inferring it from array identity. An id claimed by one
channel as its `id` and another as its `stream_id` is ambiguous, so neither is
played and the user still lands in the right genre. The handoff is abandoned
when the user changes genre, search, portal or section first.

Two pre-existing defects surfaced during review and are fixed here:

- A blank provider id shadowed a valid one. `a ?? b` keeps an empty string, so
  a channel with a blank `stream_id` was stored with no identity at all and
  could not be selected, played or found again. Six writers had that shape and
  three private copies of the skip-the-blank rule; all now go through one
  `firstNonBlankStalkerId` helper.
- Route-session readiness could publish before the store held the portal's
  row. The constructor starts one sync and the first navigation starts another,
  and the second skipped the bootstrap because the playlist id was claimed
  before the awaits that install it. Arrivals are now serialized, the id is
  claimed only after the store write resolves, and only the newest sync
  publishes readiness.

Both fixes carry regression tests that fail on the old behavior.
This commit is contained in:
4gray authored and GitHub committed 2026-09-20 22:55:29 +02:00
1 parent 6578e4073c
commit f80a21beef
36 files changed
+2211 -121

No files matched your search

@@ -0,0 +1,6 @@
---
type: feature
area: collections
---
"Open in <playlist>" 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.
+6
View File
@@ -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.
+15
View File
@@ -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
+10 -4
View File
@@ -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 <playlist>" 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`.
@@ -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<string[]> {
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;
}
@@ -0,0 +1,70 @@
import { expect, Page } from '@playwright/test';
/**
* Favorites → "Open in <portal>" → 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<void> {
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,
});
}
+9 -26
View File
@@ -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<string[]> {
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,
}) => {
+57 -3
View File
@@ -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
+6
View File
@@ -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
@@ -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);
@@ -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,
@@ -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();
@@ -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;
}
/**
@@ -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 `<playlist>-<index>` 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',
@@ -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 (`<playlist>-<index>`, 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<string, unknown>;
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;
}
@@ -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;
@@ -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);
});
@@ -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<string, WritableSignal<number>>();
private readonly channelsByKey = new Map<string, StalkerItvChannel[]>();
private readonly unsupportedKeys = new Set<string>();
/**
* 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<ReadonlySet<string>>(new Set());
private readonly inflight = new Map<string, Promise<void>>();
/** Wall-clock (ms) until which a portal's crawl error suppresses retries. */
private readonly errorCooldownUntil = new Map<string, number>();
@@ -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);
@@ -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<StalkerItvLoadOutcome> {
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(
@@ -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();
});
});
@@ -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<string, unknown>,
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<T>(
refreshVersion: getRefreshVersion(),
};
},
stream: ({ params }) =>
streamFactory(playlistService, params.portalId),
stream: ({ params }) => streamFactory(playlistService, params.portalId),
});
}
@@ -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 } }) => {
@@ -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<void> {
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 });
@@ -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();
@@ -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(
@@ -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;
@@ -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 ?? '',
};
}
@@ -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('');
});
});
@@ -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,
};
}
@@ -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;
@@ -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<string | null>(null);
readonly pendingPlaylistId = signal<string | null>(null);
readonly pendingCategoryId = signal<string | null>(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.
}
}
}
@@ -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<unknown>;
const store = {
currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }),
selectedContentType: signal<string | null>('itv'),
selectedCategoryId: signal<string | null>(null),
itvChannelsCategory: signal<string | null>(null),
searchPhrase: signal(''),
itvFullChannelList: signal<StalkerItvChannel[]>([]),
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<string, unknown> = {
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<void>((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<void>((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<void>((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<void>((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<void>((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);
}
);
});
@@ -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<string | null | undefined>;
selectedCategoryId: Signal<string | null | undefined>;
/** The category the channels on screen were served for (see the store). */
itvChannelsCategory: Signal<string | null>;
searchPhrase: Signal<string>;
itvFullChannelList: Signal<StalkerItvChannel[]>;
itvFullListActive: Signal<boolean>;
itvFullListUnsupported: Signal<boolean>;
/** Resolves when the full-list load settles (also when it fails). */
preloadItvChannels(): Promise<void> | 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<DeferredPlay | null>(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);
});
}
}
@@ -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<typeof setTimeout> | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
@@ -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<void> {
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<PlaylistMeta>((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<PlaylistMeta>((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
);
});
});
@@ -28,6 +28,29 @@ export class StalkerWorkspaceRouteSession {
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(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<void> = 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<void> {
private syncRouteContext(): Promise<void> {
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<void> => {
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<PlaylistContextFacade['syncFromUrl']>
): Promise<void> {
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 {