Files
iptvnator/tools/release/capture-navigation-portal-actions.ts
T
4grayandClaude Opus 5 186497bf42 feat(website): add the EPG troubleshooting post, fix the mock identity check
New post at /blog/epg-wrong-program-fix/: a table separating the three
symptoms (an empty row, a channel showing another station's schedule, and
every programme shifted by a fixed amount), how the tvg-id → tvg-name → name
lookup chain produces the first two, the manual "Map EPG channel" flow, and
the display-only EPG time offset with the sign to use. Seven FAQ entries.

The three screenshots are mock-backed. The Xtream mock gains /demo/guide.xml,
an XMLTV guide for its marketing live channels whose ids deliberately match no
playlist tvg-id, which is what makes the manual mapping worth showing; the
capture imports it through the settings, accepting the private-network
confirmation a loopback source raises, then right-clicks a channel of the M3U
fixture and searches the dialog. The new actions live in
capture-navigation-epg-actions.ts, alongside the setup, portal and download
modules, and borrow one entry point from each of its two neighbours instead of
duplicating their navigation.

Also fixes assertMockServerIdentity: it checked both expected categories
against get_vod_categories, but "Urban Drama" is a series category, so the
reuse path rejected every already-running mock and a capture could only run
when the port happened to be free. Each category is now checked against its
own endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:28:33 +02:00

211 lines
7.2 KiB
TypeScript

/**
* Setup actions that browse the seeded sources for a shot: portal catalogs
* and live lists, the M3U groups view, and alternative sources. Actions
* here call each other directly rather than through `runAction`, which
* keeps this module free of a dependency on the dispatcher.
*/
import type { Page } from '@playwright/test';
import {
type CaptureAction,
clickHrefSuffix,
goHome,
openXtreamSection,
requirePlaylistId,
} from './capture-navigation-helpers';
/* ------------------------------------------------------------------ */
/* Portal catalogs */
/* ------------------------------------------------------------------ */
async function openXtreamVod(page: Page, param: string | null): Promise<void> {
await openXtreamSection(page, 'vod', param ?? 'Action & Mystery');
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, {
timeout: 30_000,
});
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
}
export async function openXtreamSeries(
page: Page,
param: string | null
): Promise<void> {
await openXtreamSection(page, 'series', param ?? 'Urban Drama');
await page.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/,
{ timeout: 30_000 }
);
await page
.locator('app-season-container')
.waitFor({ state: 'visible', timeout: 30_000 });
// Season tabs auto-select a season; click the first pill only
// when no episodes rendered on their own.
const episode = page.locator('.episode-card, .episode-list-item').first();
if (!(await episode.isVisible().catch(() => false))) {
await page
.locator('.season-tabs__pill, [data-testid="season-dropdown"]')
.first()
.click();
}
await episode.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(700);
}
export async function openM3uGroups(page: Page): Promise<void> {
const playlistId = requirePlaylistId('playlists');
await goHome(page);
await page
.locator(`a[href*="/workspace/playlists/${playlistId}"]`)
.first()
.click();
await page.waitForURL(
(url) => url.href.includes(`/workspace/playlists/${playlistId}/`),
{ timeout: 20_000 }
);
await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`);
await page
.locator('.group-nav-item')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('.group-nav-item').first().click();
// Deliberately no channel click: starting playback would pull a
// real HLS stream (the mock redirects to a public demo stream),
// and third-party video frames must never enter a release shot.
await page
.locator('[data-test-id="channel-item"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* Live lists */
/* ------------------------------------------------------------------ */
/**
* Opens a portal's live section and selects a category. Deliberately no
* channel click: playback would pull the mock's redirect to a public demo
* stream, and third-party video frames must never enter a published shot.
*/
async function openLiveCategory(
page: Page,
provider: 'xtreams' | 'stalker',
liveSection: 'live' | 'itv',
param: string | null
): Promise<void> {
const playlistId = requirePlaylistId(provider);
await goHome(page);
await clickHrefSuffix(page, `/workspace/${provider}/${playlistId}/vod`);
await clickHrefSuffix(
page,
`/workspace/${provider}/${playlistId}/${liveSection}`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
}
async function openXtreamLive(page: Page, param: string | null): Promise<void> {
await openLiveCategory(page, 'xtreams', 'live', param);
}
async function openStalkerLive(
page: Page,
param: string | null
): Promise<void> {
await openLiveCategory(page, 'stalker', 'itv', param);
}
async function openXtreamLiveChannel(
page: Page,
param: string | null
): Promise<void> {
// Unlike `open-xtream-live`, this selects a channel: the marketing
// scenario serves live streams from local bytes (`local-media`),
// so playback never reaches a public stream. The player shows a
// format error, which the phone-view shot never frames; what it
// needs is the remote status the selection publishes.
await openXtreamLive(page, param);
const channel = page.locator('app-channel-list-item').first();
await channel.click();
await page.waitForTimeout(1500);
}
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens a movie of the primary portal whose copy also exists in the secondary
* one (seeded with `secondaryXtream`), and waits for lazy discovery to render
* the Sources chip.
*/
async function openXtreamVodSources(
page: Page,
param: string | null
): Promise<void> {
requirePlaylistId('xtreams-secondary');
await openXtreamVod(page, param ?? 'Action & Mystery');
await page
.locator('app-vod-sources-chip button')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
}
async function openXtreamVodSourcesMenu(
page: Page,
param: string | null
): Promise<void> {
await openXtreamVodSources(page, param);
await page.locator('app-vod-sources-chip button').click();
const menu = page.locator('app-vod-sources-menu');
await menu.waitFor({ state: 'visible', timeout: 15_000 });
// "check all" probes every unchecked copy (one get_vod_info plus a
// HEAD against the mock) so the rows carry verdicts in the frame.
const checkAll = menu.locator('.sources-menu__check-all');
if (await checkAll.isVisible().catch(() => false)) {
await checkAll.click();
await checkAll
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
await page.waitForTimeout(700);
}
export const PORTAL_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-xtream-vod': openXtreamVod,
'open-xtream-series': openXtreamSeries,
'open-m3u-groups': openM3uGroups,
'open-xtream-live': openXtreamLive,
'open-xtream-live-channel': openXtreamLiveChannel,
'open-stalker-live': openStalkerLive,
'open-xtream-vod-sources': openXtreamVodSources,
'open-xtream-vod-sources-menu': openXtreamVodSourcesMenu,
};