diff --git a/apps/website/README.md b/apps/website/README.md
index b19341cc2..500dc5065 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -66,8 +66,8 @@ without depending on a specific version.
## Guides
Evergreen how-to posts live in the blog collection next to release notes
-(`xtream-codes-setup-guide.mdx` and `stalker-portal-setup-guide.mdx` in
-`apps/website/src/content/blog/`).
+(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx` and
+`m3u-playlist-epg-setup-guide.mdx` in `apps/website/src/content/blog/`).
Two conventions set them apart:
- **`faq` frontmatter.** An optional list of `{ q, a }` entries. `BlogPost.astro`
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-settings-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-settings-dark.png
new file mode 100644
index 000000000..1583c4546
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-settings-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-settings-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-settings-light.png
new file mode 100644
index 000000000..86e13a8bb
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-settings-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-dark.png b/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-dark.png
new file mode 100644
index 000000000..f8d61d7ed
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-light.png b/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-light.png
new file mode 100644
index 000000000..af070aab1
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-m3u-live-groups-dark.png b/apps/website/public/blog/guides/screenshots/guide-m3u-live-groups-dark.png
new file mode 100644
index 000000000..70cf971c4
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-m3u-live-groups-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-m3u-live-groups-light.png b/apps/website/public/blog/guides/screenshots/guide-m3u-live-groups-light.png
new file mode 100644
index 000000000..22aedba6b
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-m3u-live-groups-light.png differ
diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt
index 141ddf237..f98aad8ef 100644
--- a/apps/website/public/llms.txt
+++ b/apps/website/public/llms.txt
@@ -13,6 +13,7 @@
- Download for Linux: https://4gray.github.io/iptvnator/download/linux/
- Guide, Xtream Codes setup: https://4gray.github.io/iptvnator/blog/xtream-codes-setup-guide/
- Guide, Stalker/Ministra portal setup: https://4gray.github.io/iptvnator/blog/stalker-portal-setup-guide/
+- Guide, M3U playlist and EPG setup: https://4gray.github.io/iptvnator/blog/m3u-playlist-epg-setup-guide/
- Release Notes (v0.18): https://4gray.github.io/iptvnator/blog/v0-18-release-notes/
- Troubleshooting (macOS app damaged fix): https://4gray.github.io/iptvnator/blog/macos-arm-app-damaged-fix/
- Source Code: https://github.com/4gray/iptvnator
diff --git a/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx b/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx
new file mode 100644
index 000000000..634fc7a02
--- /dev/null
+++ b/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx
@@ -0,0 +1,191 @@
+---
+title: How to Load an M3U Playlist and Add an EPG in IPTVnator
+description: Import an M3U or M3U8 playlist into IPTVnator from a URL, a file or pasted text, keep it updated, and attach an XMLTV program guide. Covers channel groups, catch-up attributes, EPG matching by tvg-id, and the usual reasons a playlist or guide does not load.
+pubDate: 2026-09-04
+author: 4gray
+heroImage: /iptvnator/blog/guides/screenshots/guide-m3u-live-groups-dark.png
+tags:
+- guide
+- m3u
+- epg
+- setup
+- tutorial
+draft: false
+faq:
+- q: What is the difference between an M3U and an M3U8 playlist?
+ a: Nothing that matters to IPTVnator. Both are the same text format; M3U8 only signals that the file is UTF-8 encoded. Both extensions are accepted from a URL, a file or pasted text, and both can carry the same attributes for logos, groups, program guide ids and catch-up.
+- q: Does IPTVnator refresh a playlist that was added from a URL?
+ a: On request always, and automatically on every start when you enable Auto-update in the playlist details. The desktop app downloads the file again, keeps your favorites and settings, and reports what changed. A playlist imported from a local file is refreshed from that file.
+- q: Why do my channels show no program information?
+ a: The channel is not matched to a guide entry. IPTVnator matches by tvg-id first, then by tvg-name, then by the channel name. Check that the playlist carries tvg-id values that exist in the XMLTV source, or map the channel by hand with a right-click and "Map EPG channel". The program guide is a desktop feature; the browser version does not load XMLTV.
+- q: Do I have to add the EPG URL myself?
+ a: Not when the playlist declares it. Many providers put url-tvg or x-tvg-url in the first line of the file, and IPTVnator imports those sources automatically and lists them in the playlist details. Otherwise add the URL under Settings › EPG or in the playlist details.
+- q: Does catch-up work with M3U playlists?
+ a: Yes, when the provider marks channels with catch-up attributes such as catchup, catchup-source and catchup-days, or with tvg-rec and timeshift. Archived programs can then be started from the timeline under the player. Without those attributes the timeline shows the schedule only.
+- q: Can I open a playlist file by double-clicking it?
+ a: Yes. The desktop app registers the .m3u and .m3u8 extensions, so opening a file from the file manager, dropping it anywhere onto the IPTVnator window, or passing it on the command line imports it like the dialog does.
+- q: Why does the browser version warn about a proxy when I paste a playlist URL?
+ a: Browsers block cross-origin downloads, so the browser version fetches remote playlists through a proxy. The self-hosted Docker image ships its own backend for that. If the playlist URL contains credentials you would rather not route through a proxy, import the file instead.
+---
+
+import Alert from '../../components/blog/Alert.astro';
+import PostButton from '../../components/blog/PostButton.astro';
+import StepRail from '../../components/blog/StepRail.astro';
+import LinkCards from '../../components/blog/LinkCards.astro';
+
+An M3U playlist is the plainest way to get channels into IPTVnator: a text file that lists
+streams, one per line, with optional attributes for logos, groups and program guide ids.
+Providers hand it out as a link or as a file, and IPTVnator accepts both, plus the file's
+raw text. This guide walks through the import, the maintenance options, and how to attach
+an XMLTV program guide so channels show what is on.
+
+It applies to the desktop app on Windows, macOS and Linux and, with the limits noted below,
+to the self-hosted browser version.
+
+
+ It does not include playlists or channels. The playlist link, file and guide URL in this
+ guide come from the provider you already have a contract with.
+
+
+## Three ways to import
+
+
+
+
+
+Two shortcuts exist on the desktop app. Drop an `.m3u` or `.m3u8` file anywhere onto the
+IPTVnator window and it is imported on the spot. And because the app registers both file
+extensions, opening a playlist from the file manager or passing it on the command line
+imports it as well, even while IPTVnator is already running.
+
+
+ Many provider links end in `get.php` with a username and password in the query string.
+ Such a link works as an M3U URL, but it is really an Xtream Codes account, and adding it
+ as one gives you movies, series, provider metadata and catch-up on top of the channel
+ list. The Auto-detect method recognizes the link and offers both.
+
+
+## Inside the playlist
+
+
+
+The playlist page keeps the player on the right and a sidebar with four views:
+
+- **All channels**, a virtualized list that stays fast with tens of thousands of entries,
+ with search that filters as you type.
+- **Groups**, one expandable section per `group-title`. Groups you do not need can be
+ hidden with **Manage groups** in the header of this view; the hidden set survives
+ refreshes.
+- **Favorites**, reorderable by drag and drop.
+- **Recent**, the channels you watched last.
+
+Under the player, the program guide runs as a horizontal timeline once a guide is attached,
+with a vertical single-day list as the alternative view in the settings. Channels marked
+with `radio="true"` open in a dedicated audio layout, and entries that look like movie
+files open in a movie detail view when TMDB metadata is enabled.
+
+## Keep the playlist current
+
+Open the playlist details from the playlist switcher in the header or from the source card
+on the dashboard. Besides the title you will find:
+
+- **Refresh playlist**, which downloads the file again from its URL or re-reads it from
+ disk while keeping your favorites, hidden groups and settings.
+- **Auto-update**, which does the same automatically on every start of the desktop app,
+ with the result reported once the update is done.
+- **User agent**, for providers that only answer known player signatures.
+- **Export playlist as m3u**, the current channel list as a file.
+- **EPG sources**, the guide URLs used for this playlist, described next.
+
+## Attach a program guide
+
+
+ The program guide is downloaded, parsed and stored on your device by the desktop app. The
+ browser version plays the channels but does not load XMLTV.
+
+
+IPTVnator reads guides in the **XMLTV** format, as a plain `.xml` file or compressed
+`.xml.gz`. Providers publish the URL next to the playlist, and many put it straight into
+the playlist's first line as `url-tvg` or `x-tvg-url`. In that case there is nothing to do:
+the sources are imported when the playlist is added, and the playlist details list them
+with a note on how many were picked. Very long provider lists are trimmed to the guides that
+match the playlist's country and language hints.
+
+For everything else, add the URL yourself:
+
+
+
+
+
+Sources added in the settings apply to every playlist. A source added in the playlist
+details applies to that playlist only and is kept across refreshes, as is a source you
+removed there, so a provider cannot silently re-enable it.
+
+### How channels are matched
+
+A guide entry is attached to a channel by comparing ids, in this order:
+
+1. the playlist's `tvg-id` against the XMLTV channel id,
+2. the playlist's `tvg-name` against the XMLTV display name,
+3. the channel name itself.
+
+If none match, the channel simply shows no program. The fix is either a playlist with proper
+`tvg-id` values, or a manual mapping: right-click the channel, choose **Map EPG channel**,
+and search the guide for the right entry. Mappings are stored on your device and survive
+playlist refreshes.
+
+### Catch-up from the timeline
+
+Providers that keep an archive mark channels with `catchup`, `catchup-source` and
+`catchup-days`, or with the older `tvg-rec` and `timeshift` attributes. IPTVnator reads
+them from the playlist, shows how many days of archive a channel has, and lets you start a
+past program from the timeline. Without those attributes the timeline is a schedule only;
+the guide alone cannot make a stream replayable.
+
+## Troubleshooting
+
+- **Failed to fetch the playlist**: the URL is wrong or the host is down. The desktop app
+ shows the HTTP status it received; 401 and 403 mean the link needs credentials or is
+ restricted, 404 means it does not point to a playlist.
+- **Certificate for this playlist host is invalid**: the provider's TLS certificate is
+ broken. IPTVnator offers to trust that one host; other hosts still need valid
+ certificates.
+- **The playlist imported but has no groups**: the file carries no `group-title`
+ attributes. Everything is in All channels, and Groups stays empty.
+- **A guide is attached but a channel shows nothing**: see the matching order above, then
+ map the channel by hand. A guide that was fetched hours ago can also be refreshed from
+ the settings.
+- **The guide fails to download**: check the URL in a browser first. Sources on a private
+ or local network address ask for confirmation before IPTVnator connects to them.
+- **Some channels do not play**: the built-in web players cannot decode every codec. Open
+ the stream in MPV or VLC via Settings › Playback, or enable the experimental embedded
+ MPV engine. DASH streams with ClearKey keys declared through `#KODIPROP` lines play in
+ the built-in player.
+
+## Related
+
+
+
+
diff --git a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx
index b20698ac3..d6f32f68c 100644
--- a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx
+++ b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx
@@ -184,7 +184,7 @@ exactly those values.
links={[
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'Windows, macOS and Linux builds, package managers and the browser version.', icon: 'download' },
{ label: 'Add an Xtream Codes account', href: '/iptvnator/blog/xtream-codes-setup-guide/', hint: 'The username-and-password way of connecting a provider.', icon: 'docs' },
- { label: 'Why some streams need an external player', href: '/iptvnator/blog/why-external-players-help/', hint: 'Codecs, containers and when MPV or VLC is the right tool.', icon: 'docs' },
+ { label: 'Load an M3U playlist and add an EPG', href: '/iptvnator/blog/m3u-playlist-epg-setup-guide/', hint: 'Plain playlists, refresh options and XMLTV guides.', icon: 'docs' },
]}
/>
diff --git a/apps/website/src/content/blog/xtream-codes-setup-guide.mdx b/apps/website/src/content/blog/xtream-codes-setup-guide.mdx
index a2c4351d3..d66da1b72 100644
--- a/apps/website/src/content/blog/xtream-codes-setup-guide.mdx
+++ b/apps/website/src/content/blog/xtream-codes-setup-guide.mdx
@@ -179,8 +179,8 @@ them there; the next request uses the new connection.
diff --git a/apps/website/src/pages/download/linux.astro b/apps/website/src/pages/download/linux.astro
index 6e4a84604..a65837cd2 100644
--- a/apps/website/src/pages/download/linux.astro
+++ b/apps/website/src/pages/download/linux.astro
@@ -298,7 +298,7 @@ sudo emerge iptvnator-bin
-
+
diff --git a/apps/website/src/pages/download/macos.astro b/apps/website/src/pages/download/macos.astro
index 6408dab70..212255b3d 100644
--- a/apps/website/src/pages/download/macos.astro
+++ b/apps/website/src/pages/download/macos.astro
@@ -207,7 +207,7 @@ const jsonLd = buildDownloadPageSchema({
-
+
diff --git a/apps/website/src/pages/download/windows.astro b/apps/website/src/pages/download/windows.astro
index fb59486e3..c5ca8765a 100644
--- a/apps/website/src/pages/download/windows.astro
+++ b/apps/website/src/pages/download/windows.astro
@@ -228,7 +228,7 @@ const jsonLd = buildDownloadPageSchema({
-
+
diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts
index 817625a82..e431e0503 100644
--- a/tools/release/capture-fixtures.ts
+++ b/tools/release/capture-fixtures.ts
@@ -29,6 +29,11 @@ export const XTREAM_FIXTURE_CREDENTIALS = {
password: 'marketing',
} as const;
+/** Fictional playlist and guide addresses typed into forms for the M3U guide shots; never fetched. */
+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`;
+
/** A fictional "your subscription is ready" message for the Auto-detect shot. */
export const AUTO_DETECT_FIXTURE_MESSAGE = [
'Welcome to Fictional TV! Your account is ready.',
diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts
index d65a5ed07..e7aa7cf41 100644
--- a/tools/release/capture-navigation.ts
+++ b/tools/release/capture-navigation.ts
@@ -8,6 +8,9 @@ import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
+ EPG_FIXTURE_URL,
+ M3U_FIXTURE_PLAYLIST_TITLE,
+ M3U_FIXTURE_PLAYLIST_URL,
STALKER_FIXTURE_MAC,
STALKER_FIXTURE_PORTAL_URL,
STALKER_FIXTURE_TITLE,
@@ -82,10 +85,12 @@ export async function runAction(
action: string,
param: string | null
): Promise {
- // Manifest steps are order-independent, and two of them end with a modal
- // dialog open. Close whatever the previous step left behind before this
- // one starts navigating, or the dialog backdrop swallows every click.
+ // Manifest steps are order-independent, and some of them end with a modal
+ // dialog open or a dirty settings form. Clear whatever the previous step
+ // left behind before this one starts navigating: a dialog backdrop
+ // swallows every click, and unsaved settings raise a leave prompt.
await dismissDialogs(page);
+ await discardUnsavedSettings(page);
switch (action) {
case 'open-settings': {
@@ -299,6 +304,51 @@ export async function runAction(
await page.waitForTimeout(700);
return;
}
+ case 'open-add-playlist-m3u-url': {
+ await goHome(page);
+ await openAddPlaylistDialog(page);
+ const dialog = page.locator('mat-dialog-container').last();
+
+ await clickDialogOption(dialog, /m3u url/i);
+ // Typed only: the dialog fetches nothing until Add is clicked,
+ // and the address points at the local mock anyway.
+ await dialog
+ .locator('input[formcontrolname="playlistUrl"]')
+ .fill(M3U_FIXTURE_PLAYLIST_URL);
+ await dialog
+ .locator('input[formcontrolname="playlistName"]')
+ .fill(M3U_FIXTURE_PLAYLIST_TITLE);
+ await page.waitForTimeout(500);
+ return;
+ }
+ case 'open-settings-epg': {
+ await runAction(page, 'open-settings', null);
+ const sectionLink = page
+ .locator('[data-test-id="settings-section-epg"]')
+ .first();
+
+ await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
+ await sectionLink.click({ timeout: 10_000 });
+ await page.waitForURL(/\/workspace\/settings\/epg/, {
+ timeout: 15_000,
+ });
+
+ 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 });
+ await page.waitForTimeout(500);
+ return;
+ }
default:
throw new Error(`Unknown setup action: ${action}`);
}
@@ -334,6 +384,24 @@ export async function clickDialogOption(
throw new Error(`Dialog option matching ${label} not found`);
}
+/**
+ * Settings edits staged by a shot (the EPG source row) must never persist:
+ * saving would start a fetch, and a dirty form arms the app's close guard,
+ * which blocks `app.close()` until someone answers the save/discard prompt.
+ */
+export async function discardUnsavedSettings(page: Page): Promise {
+ const discard = page.locator('[data-test-id="discard-settings"]').first();
+
+ if ((await discard.count()) === 0 || !(await discard.isVisible())) {
+ return;
+ }
+
+ await discard.click({ timeout: 10_000 });
+ await discard
+ .waitFor({ state: 'hidden', timeout: 10_000 })
+ .catch(() => undefined);
+}
+
async function dismissDialogs(page: Page): Promise {
const dialogs = page.locator('mat-dialog-container');
diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts
index 4a6b80c07..342fb1d0c 100644
--- a/tools/release/capture-release-screenshots.ts
+++ b/tools/release/capture-release-screenshots.ts
@@ -52,7 +52,12 @@ import {
validateReleaseSlug,
} from './screenshot-guards.mjs';
import * as driver from './capture-app-driver';
-import { applyTheme, runAction, settleUi } from './capture-navigation';
+import {
+ applyTheme,
+ discardUnsavedSettings,
+ runAction,
+ settleUi,
+} from './capture-navigation';
import { assertTmdbDisabled } from './capture-tmdb-check';
import {
drainRecordedRequests,
@@ -162,6 +167,7 @@ async function main(): Promise {
: undefined;
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-'));
let app: Awaited> | undefined;
+ let page: Page | undefined;
let recordedRequests: string[] = [];
let captured = 0;
let primaryError: unknown;
@@ -184,7 +190,7 @@ async function main(): Promise {
// main process itself fetches.
await installRequestRecorder(app, networkPolicy());
- const page = await driver.findMainWindow(app);
+ page = await driver.findMainWindow(app);
// G3, second layer: page-level deny-by-default, which also blocks.
await page.route('**/*', async (route) => {
@@ -268,6 +274,11 @@ async function main(): Promise {
// isolation override is most likely, so G1 must still be evaluated.
primaryError = error;
} finally {
+ // A shot may leave the settings form dirty, which arms the app's
+ // close guard and would block the close below indefinitely.
+ if (page) {
+ await discardUnsavedSettings(page).catch(() => undefined);
+ }
// Close before the G1 comparison: SQLite runs in WAL mode, so writes
// may sit in -wal until the worker shuts down and checkpoints.
await app?.close().catch(() => undefined);
diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs
index bbb9bdbc4..43682e497 100644
--- a/tools/release/screenshot-guards.mjs
+++ b/tools/release/screenshot-guards.mjs
@@ -50,6 +50,8 @@ export const KNOWN_ACTIONS = [
'open-xtream-live',
'open-add-playlist-stalker',
'open-stalker-live',
+ 'open-add-playlist-m3u-url',
+ 'open-settings-epg',
];
/**
diff --git a/tools/release/screenshot-guards.test.mjs b/tools/release/screenshot-guards.test.mjs
index de59fe697..624303620 100644
--- a/tools/release/screenshot-guards.test.mjs
+++ b/tools/release/screenshot-guards.test.mjs
@@ -195,6 +195,18 @@ describe('manifest validation', () => {
title: 'Stalker live',
group: 'guides',
setup: ['open-stalker-live'],
+ },
+ {
+ slug: 'guide-m3u-add-playlist',
+ title: 'M3U URL form',
+ group: 'guides',
+ setup: ['open-add-playlist-m3u-url'],
+ },
+ {
+ slug: 'guide-epg-settings',
+ title: 'EPG settings',
+ group: 'guides',
+ setup: ['open-settings-epg'],
}
);
diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json
index 2b356c932..a8e8c5c9a 100644
--- a/tools/release/screenshots.manifest.json
+++ b/tools/release/screenshots.manifest.json
@@ -83,6 +83,30 @@
"setup": [
"open-stalker-live"
]
+ },
+ {
+ "slug": "guide-m3u-add-playlist",
+ "title": "Add playlist: M3U URL",
+ "group": "guides",
+ "setup": [
+ "open-add-playlist-m3u-url"
+ ]
+ },
+ {
+ "slug": "guide-m3u-live-groups",
+ "title": "M3U Live TV groups",
+ "group": "guides",
+ "setup": [
+ "open-m3u-groups"
+ ]
+ },
+ {
+ "slug": "guide-epg-settings",
+ "title": "Settings: EPG sources",
+ "group": "guides",
+ "setup": [
+ "open-settings-epg"
+ ]
}
]
}
diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs
index 668f98a65..6892329d5 100644
--- a/tools/testing/website-guides.test.mjs
+++ b/tools/testing/website-guides.test.mjs
@@ -27,6 +27,14 @@ const GUIDES = [
'blog/guides/screenshots/guide-stalker-live-dark.png',
],
},
+ {
+ slug: 'm3u-playlist-epg-setup-guide',
+ screenshots: [
+ 'blog/guides/screenshots/guide-m3u-add-playlist-dark.png',
+ 'blog/guides/screenshots/guide-m3u-live-groups-dark.png',
+ 'blog/guides/screenshots/guide-epg-settings-dark.png',
+ ],
+ },
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');