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 + + + +![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. + + + 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 + +![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 + + + 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: + + + +![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 + + + + 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');