diff --git a/apps/website/README.md b/apps/website/README.md
index 95701aa20..1470427fd 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -82,7 +82,8 @@ skip shows up in your output.
Evergreen how-to posts live in the blog collection next to release notes
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`,
-`alternative-sources-guide.mdx` and `remote-control-guide.mdx` in
+`alternative-sources-guide.mdx`, `remote-control-guide.mdx` and
+`epg-wrong-program-fix.mdx` in
`apps/website/src/content/blog/`). Three conventions set them apart:
- **`ContentDisclaimer`.** Every guide opens with
@@ -119,6 +120,10 @@ Evergreen how-to posts live in the blog collection next to release notes
`tools/release/capture-release-screenshots.ts`). Its setup selects a live
channel and saves the remote-control setting, so the app's own server answers
on port 8765 for the duration of the run.
+ The EPG-mapping shots import the mock's XMLTV guide (`/demo/guide.xml`,
+ channel ids that match no playlist `tvg-id` on purpose) into the isolated
+ database through the settings, then right-click a channel of the M3U
+ fixture and search the dialog.
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
each guide for the FAQPage schema, a link to the download hub and the presence
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png
new file mode 100644
index 000000000..eb09ddd5b
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png
new file mode 100644
index 000000000..d3dbf569e
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png
new file mode 100644
index 000000000..b20164c89
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png
new file mode 100644
index 000000000..6e38c5661
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png
new file mode 100644
index 000000000..2ea6d330f
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png
new file mode 100644
index 000000000..4af321e15
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png differ
diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt
index 350b5a7a3..5ef4012a4 100644
--- a/apps/website/public/llms.txt
+++ b/apps/website/public/llms.txt
@@ -21,6 +21,7 @@
- Guide, offline viewing with the download manager (desktop): https://4gray.github.io/iptvnator/blog/offline-downloads-guide/
- Guide, alternative sources for a movie across your own playlists (desktop): https://4gray.github.io/iptvnator/blog/alternative-sources-guide/
- Guide, phone remote control setup (desktop): https://4gray.github.io/iptvnator/blog/remote-control-guide/
+- Troubleshooting, EPG shows the wrong program or nothing (channel mapping, time offset): https://4gray.github.io/iptvnator/blog/epg-wrong-program-fix/
- Features overview: https://4gray.github.io/iptvnator/features/
- Feature, M3U playlist player: https://4gray.github.io/iptvnator/features/m3u-player/
- Feature, Xtream Codes player: https://4gray.github.io/iptvnator/features/xtream-codes-player/
diff --git a/apps/website/src/content/blog/epg-wrong-program-fix.mdx b/apps/website/src/content/blog/epg-wrong-program-fix.mdx
new file mode 100644
index 000000000..dd9efb807
--- /dev/null
+++ b/apps/website/src/content/blog/epg-wrong-program-fix.mdx
@@ -0,0 +1,145 @@
+---
+title: EPG Shows the Wrong Program or Nothing? Fix Channel Mapping and Time Offset in IPTVnator
+description: When the program guide is empty for a channel, shows another channel's schedule, or is off by a few hours, the fix is one of three things. How IPTVnator matches guide data to channels, how to map a channel by hand, and how to correct the time offset without touching the guide.
+pubDate: 2026-09-06
+author: 4gray
+heroImage: /iptvnator/blog/guides/screenshots/guide-epg-map-dialog-dark.png
+tags:
+ - troubleshooting
+ - epg
+draft: false
+faq:
+- q: Why does one channel have no program information while the others do?
+ a: The guide has no entry the app can attach to that channel. IPTVnator matches an XMLTV channel by the playlist's tvg-id, then by tvg-name, then by the channel name; when none of them matches an id or display name in the guide, the row stays empty. Map the channel by hand with a right-click and "Map EPG channel".
+- q: Where is the mapping stored, and does it survive a playlist refresh?
+ a: On your device, in the local database of the desktop app, keyed by the channel, not by the playlist file. Refreshing the playlist or the guide keeps it. "Remove mapping" in the same dialog deletes it.
+- q: Every program is shown a few hours early or late. Is the guide broken?
+ a: Usually the guide labels its times with a timezone that does not match the broadcast, or your provider's clock is off. Settings › EPG › EPG time offset shifts every displayed time by a number of minutes without refreshing the guide. Use a negative value when programs appear too late and a positive value when they appear too early.
+- q: Does the offset change catch-up or recordings?
+ a: No. It only changes what is displayed. Catch-up requests, recording snapshots and the stored guide keep the provider's own times, so the offset can never break playback.
+- q: Can I map channels from an Xtream or Stalker portal?
+ a: Yes, in the desktop app. Portals bring their own guide, but you can attach an uploaded XMLTV channel to any live channel by right-clicking it in the channel list. For Xtream sources, the setting "Prefer your EPG sources for Xtream playlists" makes the uploaded guide win over the provider's whenever both exist.
+- q: Does the browser version support mapping and the offset?
+ a: No. Both belong to the desktop app, which is where XMLTV guides are downloaded and stored. The browser version shows the guide a portal provides and nothing else.
+- q: The mapping dialog finds no channels at all. Why?
+ a: No guide is loaded yet, or the guide has not finished importing. Add the XMLTV source in Settings › EPG, fetch it, and wait for the import to finish before you search. The search needs at least two characters.
+---
+
+import Alert from '../../components/blog/Alert.astro';
+import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
+import PostButton from '../../components/blog/PostButton.astro';
+import StepRail from '../../components/blog/StepRail.astro';
+import LinkCards from '../../components/blog/LinkCards.astro';
+
+The guide is loaded, most channels show what is on, and then one row says *No program
+information available*, another shows the schedule of a different station, and the evening
+news appears at two in the afternoon. All three are common, all three have one specific
+cause, and none of them needs a new playlist.
+
+This post explains how IPTVnator decides which guide entry belongs to which channel, how to
+map a channel by hand when that decision goes wrong, and how to correct times that are off by
+a fixed amount. It applies to the desktop app on Windows, macOS and Linux, where XMLTV guides
+are downloaded and stored.
+
+
+
+## Which of the three problems you have
+
+| What you see | Cause | Fix |
+| --- | --- | --- |
+| No program information for one or a few channels | No guide entry could be attached to the channel | Map the channel by hand |
+| A channel shows another channel's schedule | The playlist's id points at the wrong guide entry | Map the channel by hand |
+| Every program is early or late by the same amount | The guide's times carry the wrong timezone | Set the EPG time offset |
+| Everything is empty | The guide did not load at all | Check the source in Settings › EPG |
+
+The last case is covered in the [M3U and EPG setup guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/);
+this post is about the first three.
+
+## How a channel finds its guide entry
+
+An XMLTV guide lists channels with an id and a display name, and programs that refer to the
+id. When the desktop app looks for the guide of a playlist channel, it tries, in this order:
+
+1. the playlist's `tvg-id` against the guide's channel id,
+2. the playlist's `tvg-name` against the guide's display names,
+3. the channel name itself against the display names.
+
+When the playlist carries ids from a different guide than the one you loaded, step one fails,
+and steps two and three only succeed when the names happen to be identical. "Aurora News HD"
+in the playlist and "Aurora News" in the guide are different names to the matcher. That is the
+empty row. A playlist whose ids are simply wrong gives the second symptom: the id exists in the
+guide, but belongs to another station.
+
+## Map a channel by hand
+
+
+
+
+
+
+
+A mapping belongs to the channel, not to the playlist file. Refreshing the playlist, refreshing
+the guide or restarting the app keeps it. Open the same dialog to change it; **Remove mapping**
+returns the channel to automatic matching.
+
+
+ Xtream and Stalker sources receive program data from the provider and do not need an XMLTV
+ file. Mapping is still available for them: it attaches an uploaded guide entry to a portal
+ channel whose provider data is missing or wrong. For Xtream, the setting "Prefer your EPG
+ sources for Xtream playlists" in Settings › EPG decides which guide wins when both exist.
+
+
+## Correct times that are off by hours
+
+If every program is shifted by the same amount, the guide is fine and only its clock is not.
+Many providers label their XMLTV times with the wrong timezone, and a few servers run on a
+clock that is simply off. Open **Settings › EPG › EPG time offset** and enter the correction
+in minutes, from −720 to 720. The change applies immediately to every guide view, the channel
+list, the dashboard and the timeline, for every source.
+
+
+
+Which sign to use:
+
+- Programs appear **too early** (the news that starts at 20:00 is shown at 18:00): the guide is
+ behind; enter a **positive** value, here `120`.
+- Programs appear **too late**: enter a **negative** value.
+
+The offset changes only what you see. The guide stays stored as delivered, catch-up requests
+and recording snapshots keep the provider's own times, and a refresh of the guide does not
+reset the setting.
+
+
+ A guide that carries the wrong timezone is usually wrong by a different amount after a
+ daylight-saving change. If programs drift by an hour in spring or autumn, adjust the offset
+ rather than the source.
+
+
+## When it is the source after all
+
+Mapping and the offset fix a guide that is present but attached or timed wrongly. They cannot
+fix a guide that does not carry the station at all. If the search in the mapping dialog finds
+nothing sensible, the loaded XMLTV covers a different region or provider than your playlist.
+Add a guide that matches the channels, or ask the provider for the URL of theirs; playlists
+often carry it in the `url-tvg` attribute of the first line, and the desktop app offers to
+import those automatically.
+
+## Related
+
+
+
+
diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md
index 3f53466c3..3f40c233e 100644
--- a/apps/xtream-mock-server/README.md
+++ b/apps/xtream-mock-server/README.md
@@ -233,6 +233,7 @@ application code.
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
- **Unusable timezone name**: `tzoffset:tzoffset` serves the same EPG fixture behind a panel whose `server_info.timezone` is the ICU-unknown spelling `UTC+3`, while its `time_now` / `timestamp_now` clock pair reveals a +03:00 offset — the catch-up URL must be derived from the clock pair (issue #1562)
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
+- **Demo XMLTV guide**: `GET /demo/guide.xml` returns an XMLTV file for the marketing live channels (ids such as `aurora-news.fictional`, eight half-hour programmes around now, logos from the local asset route), so the desktop app can import an EPG source hermetically; guide screenshots use it for the manual channel-mapping flow
- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
- **Local download media**: `marketing:marketing` also serves `/movie/...`, `/series/...` and `/live/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
- **Performance fixture**: `performance:performance` returns exactly 100,000
diff --git a/apps/xtream-mock-server/src/app/demo-xmltv.ts b/apps/xtream-mock-server/src/app/demo-xmltv.ts
new file mode 100644
index 000000000..85d0bf9ff
--- /dev/null
+++ b/apps/xtream-mock-server/src/app/demo-xmltv.ts
@@ -0,0 +1,72 @@
+import { MARKETING_LIVE_CHANNELS } from '@iptvnator/shared/marketing-fixtures';
+
+/**
+ * `/demo/guide.xml`: an XMLTV guide for the marketing live channels, so the
+ * desktop app can import an EPG source that never leaves the machine. The
+ * channel ids deliberately differ from any playlist's `tvg-id`, which is what
+ * makes the manual "Map EPG channel" flow worth showing in guide screenshots.
+ */
+export function renderDemoXmltv(origin: string, now = new Date()): string {
+ const slotMs = 30 * 60 * 1000;
+ const roundedNow = now.getTime() - (now.getTime() % slotMs);
+ const channels = MARKETING_LIVE_CHANNELS.map((channel) => ({
+ id: `${slugify(channel.name)}.fictional`,
+ name: channel.name,
+ icon: `${origin}/assets/marketing/logo/${slugify(channel.name)}.svg?size=256x256`,
+ titles: channel.epgTitles,
+ }));
+ const lines = [
+ '',
+ '',
+ '',
+ ];
+
+ for (const channel of channels) {
+ lines.push(
+ ` `,
+ ` ${escapeXml(channel.name)}`,
+ ` `,
+ ' '
+ );
+ }
+
+ for (const channel of channels) {
+ for (let index = 0; index < 8; index += 1) {
+ const title = channel.titles[index % channel.titles.length];
+ const start = roundedNow + (index - 2) * slotMs;
+ lines.push(
+ ` `,
+ ` ${escapeXml(title)}`,
+ ` ${escapeXml(`${title} on ${channel.name}, part of the fictional IPTVnator demo schedule.`)}`,
+ ' '
+ );
+ }
+ }
+
+ lines.push('', '');
+ return lines.join('\n');
+}
+
+function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+function formatXmltvDate(timestamp: number): string {
+ const date = new Date(timestamp);
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return (
+ `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
+ `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())} +0000`
+ );
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts
index 9ee3a6201..d17a6225c 100644
--- a/apps/xtream-mock-server/src/app/server.spec.ts
+++ b/apps/xtream-mock-server/src/app/server.spec.ts
@@ -147,6 +147,26 @@ describe('Xtream mock server factory', () => {
}
});
+ it('serves an XMLTV guide for the marketing live channels at /demo/guide.xml', async () => {
+ const running = await startLoopbackServer(
+ createXtreamMockApp({ host: '127.0.0.1', port: 0 })
+ );
+ try {
+ const response = await fetch(`${running.origin}/demo/guide.xml`);
+ const xml = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toContain('xml');
+ expect(xml).toContain('Aurora News');
+ expect(xml).toContain('channel="aurora-news.fictional"');
+ expect(xml).toContain(`${running.origin}/assets/marketing/logo/aurora-news.svg`);
+ expect(xml).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/);
+ } finally {
+ await running.close();
+ }
+ });
+
it('serves the marketing catalog twice so the second credential pair is an alternative source', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts
index cb001deba..b409f81ff 100644
--- a/apps/xtream-mock-server/src/app/server.ts
+++ b/apps/xtream-mock-server/src/app/server.ts
@@ -16,6 +16,7 @@ import {
} from './performance-control.js';
import { dispatchAction } from './routes/dispatch.js';
import { getScenario, type ScenarioConfig } from './scenarios.js';
+import { renderDemoXmltv } from './demo-xmltv.js';
import {
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS,
LOCAL_MEDIA_LIVE_STREAM_OPTIONS,
@@ -107,6 +108,7 @@ export function createXtreamMockApp(
.send(M3U_FIXTURE);
});
installMarketingAssetRoute(app);
+ installDemoGuideRoute(app);
app.get('/player_api.php', (request, response, next) => {
dispatchWithControl(controller, request, response, next, 'direct', () =>
@@ -214,6 +216,17 @@ function dispatchProxyAction(request: Request, response: Response): void {
response.json({ payload, action });
}
+/** XMLTV for the marketing live channels; see `demo-xmltv.ts`. */
+function installDemoGuideRoute(app: express.Express): void {
+ app.get('/demo/guide.xml', (request, response) => {
+ const origin = `${request.protocol}://${request.get('host') ?? `localhost:${DEFAULT_PORT}`}`;
+ response
+ .type('application/xml')
+ .set('Cache-Control', 'no-store')
+ .send(renderDemoXmltv(origin));
+ });
+}
+
function installMarketingAssetRoute(app: express.Express): void {
app.get('/assets/marketing/:kind/:slug', (request, response) => {
const kind = request.params['kind'] as
diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts
index 70af02958..cfbe7dd0e 100644
--- a/tools/release/capture-app-driver.ts
+++ b/tools/release/capture-app-driver.ts
@@ -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 {
* the marketing catalog with the exact synthetic titles the shots rely on.
*/
async function assertMockServerIdentity(): Promise {
- 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.`
);
}
}
diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts
index a06c9a0e7..cfda0f827 100644
--- a/tools/release/capture-fixtures.ts
+++ b/tools/release/capture-fixtures.ts
@@ -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 = [
diff --git a/tools/release/capture-navigation-epg-actions.ts b/tools/release/capture-navigation-epg-actions.ts
new file mode 100644
index 000000000..e6533e030
--- /dev/null
+++ b/tools/release/capture-navigation-epg-actions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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> = {
+ 'open-settings-epg-offset': openSettingsEpgOffset,
+ 'load-demo-epg': loadDemoEpg,
+ 'open-m3u-channel-menu': openM3uChannelContextMenu,
+ 'open-epg-mapping-dialog': openEpgMappingDialog,
+};
diff --git a/tools/release/capture-navigation-portal-actions.ts b/tools/release/capture-navigation-portal-actions.ts
index 2f35704d6..7ae2ecf1a 100644
--- a/tools/release/capture-navigation-portal-actions.ts
+++ b/tools/release/capture-navigation-portal-actions.ts
@@ -58,7 +58,7 @@ export async function openXtreamSeries(
await page.waitForTimeout(700);
}
-async function openM3uGroups(page: Page): Promise {
+export async function openM3uGroups(page: Page): Promise {
const playlistId = requirePlaylistId('playlists');
await goHome(page);
diff --git a/tools/release/capture-navigation-setup-actions.ts b/tools/release/capture-navigation-setup-actions.ts
index b8b8bc69a..dfaf4f14c 100644
--- a/tools/release/capture-navigation-setup-actions.ts
+++ b/tools/release/capture-navigation-setup-actions.ts
@@ -129,7 +129,8 @@ async function openAddPlaylistM3uUrl(page: Page): Promise {
/* Settings sections */
/* ------------------------------------------------------------------ */
-async function openSettingsEpg(page: Page): Promise {
+/** Navigates to Settings › EPG and waits for the section, without staging anything. */
+export async function openEpgSettingsSection(page: Page): Promise {
await openSettings(page);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
@@ -138,20 +139,28 @@ async function openSettingsEpg(page: Page): Promise {
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 {
+ 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);
}
diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts
index 6238fb738..6c9e0e8d4 100644
--- a/tools/release/capture-navigation.ts
+++ b/tools/release/capture-navigation.ts
@@ -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> = {
...SETUP_ACTIONS,
...PORTAL_ACTIONS,
...DOWNLOAD_ACTIONS,
+ ...EPG_ACTIONS,
};
/* ------------------------------------------------------------------ */
diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs
index 89b04f86f..5ce15ea00 100644
--- a/tools/release/screenshot-guards.mjs
+++ b/tools/release/screenshot-guards.mjs
@@ -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',
];
/**
diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json
index cb5d448c9..137b169a1 100644
--- a/tools/release/screenshots.manifest.json
+++ b/tools/release/screenshots.manifest.json
@@ -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"
+ ]
}
]
}
diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs
index cd51708e7..513c0dec8 100644
--- a/tools/testing/website-guides.test.mjs
+++ b/tools/testing/website-guides.test.mjs
@@ -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');