mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
9f950a1530
commit
186497bf42
22 files changed
+472
-33
No files matched your search
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user