feat(website): add the M3U playlist and EPG setup guide

Publish "How to Load an M3U Playlist and Add an EPG in IPTVnator": the
three import methods plus drag-and-drop and OS file opening, the
playlist views, refresh and startup auto-update, attaching an XMLTV
guide through Settings or a url-tvg header, the tvg-id / tvg-name /
name matching order with manual mapping, catch-up attributes,
troubleshooting and a seven-question FAQ. The three guides now link to
each other, the download pages point at all three, and llms.txt lists
the new one.

Three guide shots join the manifest: the M3U URL dialog, the Groups
view (reusing open-m3u-groups under the guides group) and the EPG
settings section with a staged source row. A settings shot leaves the
form dirty, which arms the app's close guard and blocked app.close()
indefinitely; the capture now discards unsaved settings before every
action and before teardown, and bounds every locator wait.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-04 16:39:09 +02:00
1 parent fe3c86394c
commit 50b980af7a
21 files changed
+335 -13

No files matched your search

+2 -2
View File
@@ -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`
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+1
View File
@@ -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
@@ -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.
<Alert type="info" title="IPTVnator is a player, not a service">
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.
</Alert>
## Three ways to import
<StepRail
title="In IPTVnator"
steps={[
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add M3U playlist".',
'Choose "M3U URL" and paste the link your provider gave you, or choose "M3U file" and pick the file, or choose "Raw m3u text" and paste the playlist itself.',
'Give the playlist a title. For a URL the title is optional; IPTVnator falls back to the file name.',
'Click "Add playlist". The channels are parsed, grouped by their group-title attribute, and the playlist opens.',
]}
/>
![Add playlist dialog with the M3U URL method selected and a playlist address filled in](/iptvnator/blog/guides/screenshots/guide-m3u-add-playlist-dark.png)
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.
<Alert type="success" title="Playlist links with credentials">
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.
</Alert>
## Inside the playlist
![Live TV of an M3U playlist with the Groups view open and a group's channel list](/iptvnator/blog/guides/screenshots/guide-m3u-live-groups-dark.png)
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
<Alert type="warning" title="Desktop only">
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.
</Alert>
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:
<StepRail
title="Settings › EPG"
steps={[
'Open Settings from the bottom of the sidebar and choose the EPG section.',
'Click "Add EPG source" and paste the URL of the .xml or .xml.gz file.',
'Save. IPTVnator downloads and parses the guide in the background and shows a status chip next to the source: up to date, needs refresh, or failed.',
'Open a channel. The current program appears in the channel list and the timeline under the player fills in.',
]}
/>
![Settings with the EPG section open and an EPG source URL entered](/iptvnator/blog/guides/screenshots/guide-epg-settings-dark.png)
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
<LinkCards
links={[
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'Windows, macOS and Linux builds, package managers and the browser version.', icon: 'download' },
{ label: 'Understanding the program guide', href: '/iptvnator/blog/epg-guide/', hint: 'A closer look at how XMLTV data reaches your channels.', icon: 'docs' },
{ label: 'Add an Xtream Codes account', href: '/iptvnator/blog/xtream-codes-setup-guide/', hint: 'When your provider link is really an Xtream login.', icon: 'docs' },
]}
/>
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
@@ -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' },
]}
/>
@@ -179,8 +179,8 @@ them there; the next request uses the new connection.
<LinkCards
links={[
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'Windows, macOS and Linux builds, package managers and the browser version.', icon: 'download' },
{ 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: 'Understanding the program guide', href: '/iptvnator/blog/epg-guide/', hint: 'How XMLTV data reaches your channels.', icon: 'docs' },
{ label: 'Connect a Stalker or Ministra portal', href: '/iptvnator/blog/stalker-portal-setup-guide/', hint: 'The MAC-address way of connecting a provider.', 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' },
]}
/>
+1 -1
View File
@@ -298,7 +298,7 @@ sudo emerge iptvnator-bin</TerminalBlock>
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['xtream-codes-setup-guide', 'stalker-portal-setup-guide', 'beware-unofficial-iptvnator-websites']} />
<RelatedPosts slugs={['m3u-playlist-epg-setup-guide', 'xtream-codes-setup-guide', 'stalker-portal-setup-guide']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
+1 -1
View File
@@ -207,7 +207,7 @@ const jsonLd = buildDownloadPageSchema({
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['xtream-codes-setup-guide', 'stalker-portal-setup-guide', 'macos-arm-app-damaged-fix']} />
<RelatedPosts slugs={['m3u-playlist-epg-setup-guide', 'xtream-codes-setup-guide', 'stalker-portal-setup-guide']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
@@ -228,7 +228,7 @@ const jsonLd = buildDownloadPageSchema({
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['xtream-codes-setup-guide', 'stalker-portal-setup-guide', 'beware-unofficial-iptvnator-websites']} />
<RelatedPosts slugs={['m3u-playlist-epg-setup-guide', 'xtream-codes-setup-guide', 'stalker-portal-setup-guide']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
+5
View File
@@ -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.',
+71 -3
View File
@@ -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<void> {
// 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<void> {
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<void> {
const dialogs = page.locator('mat-dialog-container');
+13 -2
View File
@@ -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<void> {
: undefined;
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-'));
let app: Awaited<ReturnType<typeof driver.launchApp>> | undefined;
let page: Page | undefined;
let recordedRequests: string[] = [];
let captured = 0;
let primaryError: unknown;
@@ -184,7 +190,7 @@ async function main(): Promise<void> {
// 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<void> {
// 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);
+2
View File
@@ -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',
];
/**
+12
View File
@@ -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'],
}
);
+24
View File
@@ -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"
]
}
]
}
+8
View File
@@ -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');