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>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-09-08 07:28:33 +02:00
1 parent 9f950a1530
commit 186497bf42
22 files changed
+472 -33

No files matched your search

+32 -21
View File
@@ -40,8 +40,19 @@ export {
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
/** Synthetic categories that only the marketing fixture generator produces. */
const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama'];
/**
* Synthetic categories that only the marketing fixture generator produces,
* per catalog. They must be checked against their OWN endpoint: a series
* category can never appear in `get_vod_categories`, and asserting it there
* made the reuse path below reject every already-running mock.
*/
const MOCK_FIXTURE_CATEGORIES: ReadonlyArray<{
action: 'get_vod_categories' | 'get_series_categories';
name: string;
}> = [
{ action: 'get_vod_categories', name: 'Action & Mystery' },
{ action: 'get_series_categories', name: 'Urban Drama' },
];
/**
* Live categories of the Stalker mock's marketing-demo scenario, from
* `MARKETING_LIVE_CATEGORIES` in `@iptvnator/shared/marketing-fixtures`.
@@ -203,27 +214,27 @@ async function assertStalkerMockServerIdentity(): Promise<void> {
* the marketing catalog with the exact synthetic titles the shots rely on.
*/
async function assertMockServerIdentity(): Promise<void> {
const response = await fetch(
`${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=get_vod_categories`
).catch(() => null);
if (!response?.ok) {
throw new Error(
`Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.`
);
}
const categories = (await response.json().catch(() => null)) as
| { category_name?: string }[]
| null;
const names = new Set(
(categories ?? []).map((entry) => entry.category_name)
);
for (const expected of MOCK_FIXTURE_CATEGORIES) {
if (!names.has(expected)) {
const response = await fetch(
`${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=${expected.action}`
).catch(() => null);
if (!response?.ok) {
throw new Error(
`The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing category "${expected}"). Refusing to capture screenshots from unknown data.`
`Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.`
);
}
const categories = (await response.json().catch(() => null)) as
| { category_name?: string }[]
| null;
const names = new Set(
(categories ?? []).map((entry) => entry.category_name)
);
if (!names.has(expected.name)) {
throw new Error(
`The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing ${expected.action} entry "${expected.name}"). Refusing to capture screenshots from unknown data.`
);
}
}
+2
View File
@@ -60,6 +60,8 @@ export const CAPTURE_DOWNLOAD_FOLDER_NAME = 'IPTVnator downloads';
export const M3U_FIXTURE_PLAYLIST_URL = `${XTREAM_MOCK_ORIGIN}/demo/channels.m3u8`;
export const M3U_FIXTURE_PLAYLIST_TITLE = 'Fictional TV playlist';
export const EPG_FIXTURE_URL = `${XTREAM_MOCK_ORIGIN}/demo/guide.xml.gz`;
/** Served by the Xtream mock (`/demo/guide.xml`); the EPG-mapping guide shots import it for real. */
export const DEMO_EPG_URL = `${XTREAM_MOCK_ORIGIN}/demo/guide.xml`;
/** A fictional "your subscription is ready" message for the Auto-detect shot. */
export const AUTO_DETECT_FIXTURE_MESSAGE = [
@@ -0,0 +1,121 @@
/**
* Setup actions for the EPG-mapping guide shots: importing the mock's XMLTV
* guide, opening a channel's context menu and staging the mapping dialog.
* They sit between the settings pages (`capture-navigation-setup-actions.ts`)
* and the channel lists (`capture-navigation-portal-actions.ts`), so they
* borrow one entry point from each rather than duplicating the navigation.
*/
import type { Page } from '@playwright/test';
import { DEMO_EPG_URL } from './capture-fixtures';
import { type CaptureAction, settleUi } from './capture-navigation-helpers';
import { openM3uGroups } from './capture-navigation-portal-actions';
import { openEpgSettingsSection } from './capture-navigation-setup-actions';
async function openSettingsEpgOffset(page: Page): Promise<void> {
await openEpgSettingsSection(page);
const offset = page.locator('[data-test-id="epg-offset-minutes"]');
await offset.waitFor({ state: 'visible', timeout: 10_000 });
// Staged in the form only; discarded before the next action.
await offset.fill('60');
await page.waitForTimeout(400);
}
/**
* Imports the mock's XMLTV guide as an EPG source and saves the settings, so
* the mapping dialog has channels to search. Idempotent: a saved row with the
* demo URL means the guide is already in the isolated database.
*/
async function loadDemoEpg(page: Page): Promise<void> {
await openEpgSettingsSection(page);
const section = page.locator('#epg');
const existing = await section
.locator('.epg-source-row input')
.evaluateAll((inputs) =>
inputs.map((input) => (input as HTMLInputElement).value)
);
if (existing.includes(DEMO_EPG_URL)) {
return;
}
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('.epg-source-row input').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(DEMO_EPG_URL, { timeout: 10_000 });
// The row's first icon button fetches the source right away.
await section
.locator('.epg-source-row')
.last()
.locator('button')
.first()
.click();
// A loopback source trips the app's private-network confirmation; the
// mock is the one private address this capture trusts.
const allow = page.getByRole('button', { name: /allow source/i });
if (
await allow
.waitFor({ state: 'visible', timeout: 5_000 })
.then(() => true)
.catch(() => false)
) {
await allow.click();
}
await page
.locator(
'.epg-progress-panel .import-item.status-complete, .epg-progress-panel .stat-badge'
)
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
const save = page.locator('[data-test-id="save-settings"]').first();
if (await save.isEnabled().catch(() => false)) {
await save.click();
}
await settleUi(page);
}
/** Right-clicks the first channel of the M3U groups view and waits for its menu. */
async function openM3uChannelContextMenu(page: Page): Promise<void> {
await openM3uGroups(page);
const channel = page.locator('app-channel-list-item').first();
await channel.waitFor({ state: 'visible', timeout: 30_000 });
await channel.click({ button: 'right' });
await page
.getByRole('menuitem', { name: /map epg channel/i })
.waitFor({ state: 'visible', timeout: 10_000 });
await page.waitForTimeout(400);
}
async function openEpgMappingDialog(page: Page): Promise<void> {
await loadDemoEpg(page);
await openM3uChannelContextMenu(page);
await page.getByRole('menuitem', { name: /map epg channel/i }).click();
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible', timeout: 15_000 });
await dialog.locator('input').first().fill('Aurora');
const result = dialog.locator('.epg-mapping-result').first();
await result.waitFor({ state: 'visible', timeout: 15_000 });
await result.click();
await page.waitForTimeout(500);
}
export const EPG_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-settings-epg-offset': openSettingsEpgOffset,
'load-demo-epg': loadDemoEpg,
'open-m3u-channel-menu': openM3uChannelContextMenu,
'open-epg-mapping-dialog': openEpgMappingDialog,
};
@@ -58,7 +58,7 @@ export async function openXtreamSeries(
await page.waitForTimeout(700);
}
async function openM3uGroups(page: Page): Promise<void> {
export async function openM3uGroups(page: Page): Promise<void> {
const playlistId = requirePlaylistId('playlists');
await goHome(page);
@@ -129,7 +129,8 @@ async function openAddPlaylistM3uUrl(page: Page): Promise<void> {
/* Settings sections */
/* ------------------------------------------------------------------ */
async function openSettingsEpg(page: Page): Promise<void> {
/** Navigates to Settings › EPG and waits for the section, without staging anything. */
export async function openEpgSettingsSection(page: Page): Promise<void> {
await openSettings(page);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
@@ -138,20 +139,28 @@ async function openSettingsEpg(page: Page): Promise<void> {
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/epg/, { timeout: 15_000 });
await page.locator('#epg').waitFor({ state: 'visible', timeout: 15_000 });
}
async function openSettingsEpg(page: Page): Promise<void> {
await openEpgSettingsSection(page);
const section = page.locator('#epg');
await section.waitFor({ state: 'visible', timeout: 15_000 });
// Show a filled source row instead of the empty state. The value
// is staged in the form only; nothing is saved or fetched. The
// dirty form is discarded by `discardUnsavedSettings` before the
// next action or the app teardown — the settings close guard
// would otherwise hold `app.close()` open forever.
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('input[type="url"]').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
// would otherwise hold `app.close()` open forever. A row a previous
// step already saved (`load-demo-epg`) fills the frame on its own.
if ((await section.locator('.epg-source-row').count()) === 0) {
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('input[type="url"]').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
}
await page.waitForTimeout(500);
}
+4 -1
View File
@@ -7,7 +7,8 @@
* (`capture-navigation-setup-actions.ts`: dialogs, settings, remote
* control; `capture-navigation-portal-actions.ts`: portal browsing and
* alternative sources; `capture-navigation-download-actions.ts`: the
* download manager) over the shared page helpers in
* download manager; `capture-navigation-epg-actions.ts`: the EPG guide
* import and channel mapping) over the shared page helpers in
* `capture-navigation-helpers.ts`. This file only dispatches a step name
* and re-exports the API the seeding driver and the capture script use.
*/
@@ -21,6 +22,7 @@ import {
settleUi,
} from './capture-navigation-helpers';
import { DOWNLOAD_ACTIONS } from './capture-navigation-download-actions';
import { EPG_ACTIONS } from './capture-navigation-epg-actions';
import { PORTAL_ACTIONS } from './capture-navigation-portal-actions';
import { SETUP_ACTIONS } from './capture-navigation-setup-actions';
@@ -38,6 +40,7 @@ const ACTIONS: Readonly<Record<string, CaptureAction>> = {
...SETUP_ACTIONS,
...PORTAL_ACTIONS,
...DOWNLOAD_ACTIONS,
...EPG_ACTIONS,
};
/* ------------------------------------------------------------------ */
+4
View File
@@ -59,6 +59,10 @@ export const KNOWN_ACTIONS = [
'open-xtream-live-channel',
'open-settings-remote-control',
'enable-remote-control',
'open-settings-epg-offset',
'load-demo-epg',
'open-m3u-channel-menu',
'open-epg-mapping-dialog',
];
/**
+24
View File
@@ -171,6 +171,30 @@
"height": 1100
}
}
},
{
"slug": "guide-epg-map-menu",
"title": "Channel context menu with Map EPG channel",
"group": "guides",
"setup": [
"open-m3u-channel-menu"
]
},
{
"slug": "guide-epg-map-dialog",
"title": "Map EPG channel dialog with a search result",
"group": "guides",
"setup": [
"open-epg-mapping-dialog"
]
},
{
"slug": "guide-epg-offset",
"title": "Settings: EPG time offset",
"group": "guides",
"setup": [
"open-settings-epg-offset"
]
}
]
}
+8
View File
@@ -57,6 +57,14 @@ const GUIDES = [
'blog/guides/screenshots/guide-remote-phone-dark.png',
],
},
{
slug: 'epg-wrong-program-fix',
screenshots: [
'blog/guides/screenshots/guide-epg-map-menu-dark.png',
'blog/guides/screenshots/guide-epg-map-dialog-dark.png',
'blog/guides/screenshots/guide-epg-offset-dark.png',
],
},
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');