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,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,
}) => {