feat(website): add task-based guides hub and seven article drafts

This commit is contained in:
4gray committed 2026-09-27 08:52:48 +02:00
1 parent 5dbad2383f
commit b40f2c310f
15 files changed
+1051 -7

No files matched your search

+14 -1
View File
@@ -108,6 +108,16 @@ skip shows up in your output.
## Guides
`/guides/` is the task-based entry point: Getting started, Live TV & EPG,
Player & playback, Your library and Updates. `src/lib/guides.ts` owns the
reading order; titles and descriptions come from the existing blog collection,
and article URLs stay at `/blog/<slug>/`. Add an article's slug to the matching
group when it should appear here. Missing slugs fail the build. Drafts follow
the blog's visibility rule (development mode or `PUBLIC_INCLUDE_DRAFTS=true`),
and preview entries carry a draft label. Updates also links to the latest
published release post. The hub emits CollectionPage and BreadcrumbList data
and is linked from the site header, footer and blog index.
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`,
@@ -156,7 +166,10 @@ Evergreen how-to posts live in the blog collection next to release notes
`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
of every referenced screenshot in the build output.
of every referenced screenshot in the build output. It also checks the task hub's
grouping, draft visibility, article links, structured data and navigation at
phone, tablet and desktop widths. Run the hub checks against a build made with
`PUBLIC_INCLUDE_DRAFTS=true` using the same environment flag to verify previews.
## Blog Tags
+1
View File
@@ -6,6 +6,7 @@
- Website: https://4gray.github.io/iptvnator/
- Blog: https://4gray.github.io/iptvnator/blog/
- Guides by task: https://4gray.github.io/iptvnator/guides/
- Latest Release: https://github.com/4gray/iptvnator/releases/latest
- Downloads overview: https://4gray.github.io/iptvnator/download/
- Download for Windows: https://4gray.github.io/iptvnator/download/windows/
+1
View File
@@ -27,6 +27,7 @@ const socialLinks = [
const footerLinks = [
{ label: 'Features', href: '/iptvnator/features/' },
{ label: 'Guides', href: '/iptvnator/guides/' },
{ label: 'Compare', href: '/iptvnator/compare/' },
{ label: 'Download', href: '/iptvnator/download/' },
{ label: 'Blog', href: '/iptvnator/blog/' },
+12 -6
View File
@@ -1,6 +1,7 @@
---
const navLinks = [
{ label: 'Features', href: '/iptvnator/features/' },
{ label: 'Guides', href: '/iptvnator/guides/' },
{ label: 'Compare', href: '/iptvnator/compare/' },
{ label: 'Screenshots', href: '/iptvnator/#screenshots' },
{ label: 'Download', href: '/iptvnator/download/' },
@@ -28,12 +29,13 @@ const navLinks = [
</a>
<!-- Desktop nav -->
<div class="hidden items-center gap-8 md:flex">
<div class="hidden items-center gap-5 lg:flex xl:gap-8">
{
navLinks.map((link) => (
<a
href={link.href}
class="text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors duration-200 hover:text-accent-400"
aria-current={Astro.url.pathname === link.href ? 'page' : undefined}
class="text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors duration-200 hover:text-accent-400 aria-[current=page]:text-accent-400"
>
{link.label}
</a>
@@ -42,7 +44,7 @@ const navLinks = [
</div>
<!-- Right side actions -->
<div class="hidden items-center gap-4 md:flex">
<div class="hidden items-center gap-4 lg:flex">
<a
href="https://github.com/4gray/iptvnator"
target="_blank"
@@ -65,8 +67,10 @@ const navLinks = [
<!-- Mobile menu button -->
<button
id="mobile-menu-btn"
class="inline-flex items-center justify-center rounded-lg p-2 text-surface-400 transition-colors hover:text-surface-100 md:hidden"
class="inline-flex items-center justify-center rounded-lg p-2 text-surface-400 transition-colors hover:text-surface-100 lg:hidden"
aria-label="Toggle menu"
aria-controls="mobile-menu"
aria-expanded="false"
>
<svg id="menu-icon-open" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16" />
@@ -80,14 +84,15 @@ const navLinks = [
<!-- Mobile menu -->
<div
id="mobile-menu"
class="hidden border-t border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl md:hidden"
class="hidden border-t border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl lg:hidden"
>
<div class="space-y-1 px-6 py-5">
{
navLinks.map((link) => (
<a
href={link.href}
class="block rounded-lg px-3 py-2.5 text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors hover:bg-surface-800/50 hover:text-accent-400"
aria-current={Astro.url.pathname === link.href ? 'page' : undefined}
class="block rounded-lg px-3 py-2.5 text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors hover:bg-surface-800/50 hover:text-accent-400 aria-[current=page]:text-accent-400"
>
{link.label}
</a>
@@ -125,6 +130,7 @@ const navLinks = [
btn?.addEventListener('click', () => {
const isHidden = menu?.classList.toggle('hidden');
btn?.setAttribute('aria-expanded', String(!isHidden));
iconOpen?.classList.toggle('hidden', !isHidden);
iconClose?.classList.toggle('hidden', isHidden);
});
@@ -0,0 +1,104 @@
---
title: Switch Channels and Episodes Without Leaving Fullscreen
description: Open IPTVnator's fullscreen side panel with C or the left edge of the video. Search live channels, choose a season, and switch episodes while keeping the player fullscreen.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
- playback
draft: true
faq:
- q: Why does C do nothing?
a: Start a live channel or series episode, enter player fullscreen, and check Channel and episode list in fullscreen under Settings → Playback. For web players, enable Unified controls for web players too. The shortcut does not run while you are typing in a text field.
- q: Can I use the panel with external VLC or MPV?
a: No. External players have their own windows and controls. The panel works with the built-in web players using unified controls and with experimental frame-copy Embedded MPV, not native-view Embedded MPV.
- q: How do I close the list without leaving fullscreen?
a: Use its close button, click the video outside it, or press Escape while the panel is open. Once the panel is closed, Escape can leave fullscreen normally.
- q: Does pressing C again close the panel?
a: No. C opens the panel and focuses its search field when one is present. Typing another C then searches for that character. Use Escape or the close button to dismiss the panel.
- q: Does the episode panel work for both Xtream and Stalker?
a: Yes. During series playback it shows season tabs and episodes for Xtream and Stalker sources, with the information available for that show. A standalone movie has no episode panel.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
Fullscreen is useful until changing channels makes you leave it. IPTVnator 0.24 puts a
channel or episode list over the side of the video, so you can choose what comes next and
keep watching at the same size.
For live TV, the panel provides channel search. For a series, it provides seasons and
episodes with artwork and watch progress. The content follows what you are playing.
<ContentDisclaimer />
## Check the playback settings
Open **Settings → Playback** and enable **Channel and episode list in fullscreen**.
If you use HTML5, Video.js or ArtPlayer, keep **Unified controls for web players** enabled
as well. These are the shared controls introduced as the default in 0.23.
The panel is available with those web players and the experimental **frame-copy** Embedded
MPV engine. Native-view Embedded MPV and external player windows cannot show this panel.
Enter fullscreen using the player's fullscreen control. Maximizing the desktop window
gives the app more room, but is not the same as putting the video into player fullscreen.
## Browse live channels
1. Start a live channel, then enter player fullscreen.
2. Press **C**, or rest the pointer on the left edge of the video. Clicking the edge opens
the panel too; on a touchscreen, tap it.
3. Use the search field to narrow the channel list.
4. Select a channel. With the shared controls, playback switches while fullscreen remains
active.
5. Press **Escape** or use the panel's close button when you want the whole picture again.
The channel list follows the source or collection you are watching. M3U offers views for
all channels, groups, favorites and recent channels; portal lists follow their relevant
channel context. Searching here chooses from that list rather than searching every
playlist in your library.
For M3U, **Page Up / Page Down** also let you step through channels without opening the
panel. Those keys have a different job inside the separate programme Guide, where they
move between days.
## Choose another episode
Start an Xtream or Stalker series episode and open the panel the same way. Instead of a
channel search, you get season navigation and an episode list. The list shows available
stills, descriptions, runtimes and watch progress, helping you find where you left off.
Choose a season, then an episode. The selected episode starts in the same fullscreen
view. If your source does not provide artwork or a description, the episode can still be
selected; missing metadata does not turn it into a different playback mode.
The panel is for series playback. A movie does not gain an episode list, and the feature
does not change the [phone remote's live-channel controls](/iptvnator/blog/remote-control-guide/).
## Keep fullscreen when switching
The shared controls keep fullscreen on the surrounding player area as the stream changes.
If you opt back into a web player's original controls, switching can leave fullscreen
because that player replaces the video surface. Enable unified controls if this is the
behavior you want to avoid.
If fullscreen still ends unexpectedly, note the selected player, whether unified controls
are enabled, and whether you changed a channel, an episode, or the source of a movie. Also
note whether an error appeared: a playback failure may need to reveal recovery actions.
## If the panel seems hard to reach
Use **C** first to distinguish a settings problem from a pointer interaction problem.
Click the video before trying the shortcut if focus is still in a text field. Once the
channel search has focus, letter keys are ordinary search input; pressing C again does
not toggle the panel closed.
Use Escape to close it, then try the left edge again. On touch, tap the edge above the
control bar rather than a playback button.
## Related
- [Control live TV from your phone](/iptvnator/blog/remote-control-guide/)
- [Compare playback engines](/iptvnator/compare/playback-engines/)
- [Fullscreen changes in 0.24](/iptvnator/blog/v0-24-release-notes/)
- [Download IPTVnator](/iptvnator/download/)
@@ -0,0 +1,111 @@
---
title: "Keep Your Library Tidy: Sources, Favorites and Watched Titles"
description: Review source availability, return from a favorite channel to its playlist, mark titles watched and simplify catalog views in IPTVnator without confusing hidden items with missing data.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
draft: true
faq:
- q: Does an unavailable source mean my channels were deleted?
a: No. Availability describes a connection check. A timeout, a temporary outage or an unverified account is different from deleting a saved source or losing its channels.
- q: Does source cleanup delete entries automatically?
a: No. The desktop cleanup dialog checks sources and lets you review the selection before deleting. Confirmed expired or disabled accounts can be preselected; uncertain results need review.
- q: What happens when I delete a source?
a: Its saved favorites, history and playback progress are removed with it. Downloaded files are kept. Export a playlist backup first if you want a restorable copy of supported source state.
- q: Can I mark a movie watched without playing it to the end?
a: Yes. In 0.24, Xtream and Stalker movie detail pages offer manual watched and unwatched actions, including details opened from Favorites or Recently Viewed.
- q: Does hiding titles under covers make names inaccessible?
a: No. Titles appear when you hover over a cover or focus it with the keyboard. Items without artwork keep their titles visible, and live channels and search results keep their labels.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
A library with several sources can become difficult to read even when everything works.
You might see an old account beside an active one, a favorite without remembering its
source, or a film you finished elsewhere still waiting to resume.
IPTVnator 0.23 and 0.24 add ways to handle those everyday details. Start with the view you
are in, then review the saved source itself only when something actually needs changing.
<ContentDisclaimer />
## First check whether the list is hidden
If a live channel is playing but its list has disappeared, look for **Show channels list**
or the list toggle in the workspace header. A collapsed panel is a layout choice, not
evidence that the playlist lost its channels.
Portal Live TV panels can collapse in steps: hide the categories while keeping the channel
list, or hide both for more video space. With categories hidden, the list header offers a
category selector, so you can still change the group of channels you are browsing.
In Favorites and Recently Viewed, also check whether the scope is **This playlist** or
**All playlists**. A different scope can explain why an item is visible in one view but
not another.
## Find the source behind a favorite
While watching a live channel from Favorites or Recently Viewed, look for its playlist
name in the programme panel. Click that source chip, or use **Open in…** from the channel's
right-click menu, to open the channel inside its original playlist.
In 0.24 this works for M3U, Xtream and Stalker live channels. It is useful when a favorite
plays correctly and you want to browse neighboring channels, or when the same name exists
in several sources and you need to know which copy you selected.
## Read source availability as a check result
The desktop source list shows availability information for Xtream accounts, Stalker
portals and M3U links. A result can tell you that an account is inactive or that a source
could not be verified. Those are different situations.
A temporary connection failure is a reason to inspect or retry, not proof that the source
should be removed. If several sources fail together, consider a shared network or VPN
problem before deciding that every account has expired.
## Review old sources together
1. Export a playlist backup from **Settings → Backup** if you want a copy before deleting.
2. On the desktop sources page, open **Clean up inactive sources…**.
3. Let the checks run. The dialog checks network sources across the library, even when the
page behind it has a filter applied.
4. Review both confirmed inactive accounts and uncertain results. Deselect anything you
want to keep.
5. Use **Delete selected** only for the sources you intend to remove, then read the
per-source results.
Busy or playing sources can be skipped. A skipped row is not a successful deletion, and
an uncertain result is not a confirmed expired account.
Deleting a source also removes its saved favorites, history and playback progress.
Downloaded files are kept. Hiding a category or collapsing a panel is a better fit when
your goal is simply to see less in the current view.
## Keep watch state in step with what you finished
For Xtream and Stalker movies, open the detail page and use **Mark as Watched** when you
have already finished the film. Use **Mark as Unwatched** to reverse the choice. The
catalog updates its watched indicator, and a watched movie offers Play rather than a
resume near the end.
Series already have controls for marking a season or the whole series watched. Use those
when you are catching the library up with your viewing, rather than opening every episode
just to seek to its end.
## Fit more covers on screen
Under **Settings → General**, turn off **Show titles under covers** for a denser movie
and series grid. Titles appear on hover or keyboard focus, and missing artwork keeps a
visible title. Live channels and search results retain their labels.
This changes presentation, not the names stored in the library. Turn it back on whenever
permanent labels are more useful than an extra row of posters.
## Related
- [Movie and series metadata with TMDB](/iptvnator/blog/tmdb-metadata-guide/)
- [Alternative movie sources](/iptvnator/blog/alternative-sources-guide/)
- [Library changes in 0.24](/iptvnator/blog/v0-24-release-notes/)
- [Download IPTVnator](/iptvnator/download/)
@@ -0,0 +1,102 @@
---
title: Browse the TV Guide Without Leaving Your Channel
description: Keep watching while you compare channel schedules in IPTVnator's M3U programme guide. Open the grid, filter by group or favorites, and switch channels without closing it.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
- epg
- m3u
draft: true
faq:
- q: Is this guide available in the browser version?
a: The multi-channel M3U Guide described here is a desktop feature in 0.24. The browser version and portal programme views do not offer this same layout.
- q: Does the small player open a second stream?
a: No. Opening the guide moves the existing inline player into a smaller area above the grid. Selecting another channel changes what that player is playing.
- q: Why does a channel appear without a programme?
a: The channel list comes from your playlist. A schedule appears only when imported EPG data can be matched to that channel for the selected time. Check the EPG source and channel mapping, or enable Only with EPG to hide empty rows.
- q: Can I open the guide while fullscreen?
a: No. Leave fullscreen before opening the Guide. For changing channels while staying fullscreen, use the channel panel opened with C instead.
- q: Will external MPV or VLC appear above the grid?
a: No. External players keep their own window. The guide shows a compact now-playing strip, without an embedded video preview.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
You are watching a channel, but want to see what else is on. Opening each channel in turn
makes that a slow search. IPTVnator 0.24 adds a different way to browse: a programme grid
with your M3U channels in playlist order and the current video still playing above it.
This guide covers the desktop M3U Guide. Have an M3U playlist and its XMLTV data loaded
first; the [playlist and EPG setup guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
covers that preparation.
<ContentDisclaimer />
## Open the guide while watching
1. Open an M3U playlist and start a live TV channel.
2. Leave fullscreen if it is active, and close any open settings dialog.
3. Click **Guide** in the programme panel or use the Guide action in the workspace header.
You can also press **G** while on the player page, outside a text field or menu.
4. Browse the grid. The inline player stays above it alongside information about the
channel you are watching.
Opening Guide does not restart the current channel. The same player changes size to make
room for the schedules. Collapse the preview strip when you want more space for the grid,
then expand it when you want to see the picture again.
## Choose the channels you want to compare
The rows come from **your playlist**, rather than every channel present in an XMLTV file.
This keeps their order familiar and avoids filling the grid with channels you cannot play.
Choose all channels, a group, or favorites. Opening the guide from the favorites view
starts with favorites; opening it while browsing a group keeps that group in view.
For a large playlist, a small group makes it easier to compare programmes side by side.
Use **Only with EPG** to hide channels without guide coverage. Switch it off again if you
are looking for a channel that plays correctly but has no schedule. Hiding a row here does
not remove the channel from the playlist.
Comfortable rows leave more room between channels. Compact rows fit more of the list on
screen. The guide remembers the density and Only with EPG choices for the next visit.
## Switch now, or inspect a programme first
| Action | Result |
| --- | --- |
| Click a channel row or its currently airing programme | Switch playback and keep browsing the guide |
| Double-click a channel | Switch and close the guide |
| Open another programme card | Read its programme details |
| Close the guide | Return to the usual player layout |
A future programme is a schedule entry, not a playable stream waiting to start. Likewise,
seeing a past programme in this grid does not establish that your source offers an archive.
## Navigate from the keyboard
With focus in the guide grid, the arrow keys move between channels and programmes.
**Enter** switches to the selected channel and closes the guide; **I** opens details,
**N** returns to now, and **Escape** closes the guide. **Page Up / Page Down** change the day.
These are guide controls. Outside the guide, Page Up / Page Down can switch M3U channels,
so close the grid before using those keys to zap through the playlist.
## If the grid looks empty
First check the selected group and Only with EPG filter. Then look at whether the same
channel has programme data in the ordinary player view. If both are empty, verify that the
XMLTV source loaded and contains the channel and date you are browsing.
If the schedule belongs to another channel or is several hours out, use the
[EPG mapping and time-offset guide](/iptvnator/blog/epg-wrong-program-fix/).
The multi-channel grid uses those mappings and the display offset too; importing the
same file again is not a substitute for correcting a mismatched channel.
## Related
- [Set up an M3U playlist and XMLTV guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
- [Fix the wrong programme or missing EPG](/iptvnator/blog/epg-wrong-program-fix/)
- [What changed in 0.24](/iptvnator/blog/v0-24-release-notes/)
- [Download IPTVnator for desktop](/iptvnator/download/)
@@ -0,0 +1,105 @@
---
title: "Subtitles, Quality and Picture-in-Picture: Get More from the Player"
description: Use IPTVnator's unified player controls to choose stream quality, load subtitles, adjust their timing and keep video visible in picture-in-picture. Learn why some options depend on the stream or player.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
- playback
draft: true
faq:
- q: Why is there no quality menu?
a: The menu appears when the stream exposes more than one video quality. A single video file or a channel with only one rendition has no alternatives for the player to select.
- q: Which subtitle files can I load?
a: The shared web-player controls accept SRT and WebVTT files. Embedded MPV also supports formats such as ASS, whose embedded styling may take precedence over the text-style controls.
- q: Why can I adjust one subtitle track's timing but not another's?
a: Timing support depends on the engine and track. In the shared web-player path, delay adjustment is available for the selected external subtitle file. Embedded MPV can apply delay to its subtitle tracks more broadly.
- q: Does Embedded MPV support the picture-in-picture button?
a: No. The shared picture-in-picture action belongs to supported web-player video playback. It is not available for Embedded MPV or external MPV and VLC windows.
- q: Will my quality and subtitle choices be remembered?
a: Subtitle text size and colour are saved. Manual quality selection and subtitle timing are session choices; do not assume they will carry over to the next stream. Auto is the default quality mode.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
Subtitles arrive a little late. A stream offers several qualities, but the connection is
struggling with the highest one. You want to keep an eye on a programme while using another
window. These are all tasks you can handle from the player controls.
Since 0.23, HTML5, Video.js and ArtPlayer use IPTVnator's unified controls by default.
The buttons look consistent across those players, while the available options still
depend on what the current stream and playback engine support.
<ContentDisclaimer />
## Start with unified controls
Open **Settings → Playback** and check **Unified controls for web players**. With it
enabled, the three built-in web players share their control bar. Turning it off restores
each player's own controls, so the steps below may no longer match what you see.
Move the pointer over a playing video to reveal the controls. On touch, tap the video to
show or hide them. A missing button often means that the current stream cannot offer that
action, rather than that a setting has been lost.
## Choose a quality the stream actually provides
Open **Quality** when the menu is available. **Auto** lets the engine select a rendition;
a label such as **720p** or **1080p** pins one of the variants supplied by the stream.
If playback repeatedly stalls, try a lower available quality and watch whether it becomes
smoother. That is a useful comparison, not a diagnosis by itself: interruptions can also
come from the source or decoding path.
A provider may list separate “HD” and “SD” channels rather than offering both qualities
inside one stream. In that case, change the channel or source. The quality menu cannot
create a rendition that was never supplied, and choosing 1080p cannot turn a lower-resolution
source into native 1080p video.
Quality selection is a session choice. Check the menu again after starting another stream
instead of assuming the previous selection still applies.
## Load a subtitle file
1. Start the movie or episode you want to watch.
2. Open **Subtitles** in the player controls.
3. Choose **Load subtitle file…** and select an `.srt` or `.vtt` file for the web players.
4. Select the loaded track if needed, then check a line of dialogue against the picture.
Embedded MPV accepts additional subtitle formats, including `.ass`. Use the player that
supports your file rather than simply changing its filename extension.
If the stream already includes subtitle tracks, you can choose one directly from the
same menu. **Off** hides subtitles without removing the movie or changing the audio track.
## Adjust timing and readability
For a selected external subtitle in the web players, use **Show subtitles earlier** or
**Show subtitles later**. Each step moves the timing by half a second. Reset the delay
when you want to return to the file's original timing.
Test a few lines before making another adjustment. If the error grows as the film plays,
a constant delay may not be enough: the subtitle file may belong to a different edit or
timing version of the film.
The subtitle menu also offers text size and colour. Those appearance choices are saved
for later sessions. Timing is separate and should be checked for each file. Styled ASS
subtitles in MPV can retain their own embedded appearance.
## Keep the video visible with picture-in-picture
On supported web-player playback, choose **Enter picture-in-picture** to put the video
in a small floating window. Use the PiP window's return control or the player's exit
action to bring it back.
The button appears only when the playback environment supports it. Embedded MPV does not
offer this shared PiP action. The small preview above the M3U programme grid is a different
layout: it remains inside the app and does not require PiP support.
## Related
- [Choose a playback engine](/iptvnator/compare/playback-engines/)
- [Why some streams need an external player](/iptvnator/blog/why-external-players-help/)
- [Player-control changes in 0.23](/iptvnator/blog/v0-23-release-notes/)
- [Download IPTVnator](/iptvnator/download/)
@@ -0,0 +1,109 @@
---
title: Back Up Your Playlists and Move to a New Computer
description: Export and restore an IPTVnator playlist backup, understand which favorites and playback state it carries, and check what needs to be moved separately when changing computers.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
- troubleshooting
draft: true
faq:
- q: Is a playlist backup a complete copy of the app?
a: No. It contains playlist definitions, supported playlist-specific user state and global EPG source addresses. It does not include the full settings profile, cached catalog and EPG data, or downloaded media.
- q: Are favorites included?
a: Yes. M3U, Xtream and Stalker entries include their supported favorites and recent-item state. Xtream also carries playback positions and hidden categories; Stalker playback positions are not included in the version 1 backup format.
- q: Does importing the same source create a duplicate?
a: A source the importer recognizes is merged with the existing entry. Its saved playlist state is restored from the backup, so this is not simply an additive merge of every old and new favorite. Unmatched sources are created as new entries.
- q: Does the backup include my provider password?
a: The current export includes connection secrets so sources can be restored. Playlist and EPG URLs can also contain credentials. Treat the JSON file as private account data and do not attach it to a public issue.
- q: Can I import an old raw JSON playlist dump?
a: The current importer expects a versioned IPTVnator playlist backup, not the older raw array of playlists. Use a backup exported through the current Settings → Backup workflow.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
Adding a source takes a moment. Rebuilding favorites, hidden categories and playback
positions takes longer. A playlist backup keeps the supported parts of that library
together so you can restore them after a problem or move them to another computer.
This guide describes the versioned backup available in IPTVnator 0.24. Its scope varies
by source type, so check the table below before relying on it as your only copy of data.
<ContentDisclaimer />
## Export before changing anything
1. On the computer with the library you want to keep, open **Settings → Backup**.
2. Under **Import/export playlist backups**, choose **Export**.
3. Save the JSON file. The suggested name includes the export date, for example
`iptvnator-playlist-backup-2026-09-27.json`.
4. Keep a copy outside the app's own data folder. If you are moving computers, transfer
it through a location you control.
Export the receiving computer's library too if it already has sources and favorites you
care about. Restoring a snapshot can replace saved state for a matching source; preserving
both backups gives you a record of each library before the import.
## What travels in the file
| Source or data | Included | Separate or rebuilt later |
| --- | --- | --- |
| M3U | Playlist text, source information, favorites, recent items and hidden groups | Media files referenced by local paths |
| Xtream | Connection details, favorites, recent items, hidden categories and playback positions | The downloaded catalog cache and offline media |
| Stalker | Connection and device details, favorites and recent-item snapshots | Active login sessions and playback positions in backup format v1 |
| Global EPG | Configured source addresses | Imported programme data and local XMLTV files |
| App preferences | The EPG addresses above | Other settings, including player and appearance choices |
An M3U backup includes the playlist text even if the original playlist came from a local
file. It does not include the video files those entries may point to. Paths that worked on
one computer may need updating on another.
The export includes connection credentials. Store it as private data, not as an attachment
to a public support thread. If a report needs an example playlist, prepare a separate
redacted example rather than sharing your backup.
## Restore on the destination
1. Install a current supported version of IPTVnator on the destination computer.
2. Open **Settings → Backup**, choose **Import**, and select the exported file.
3. Read the summary: it reports sources that were imported, merged, skipped or failed.
4. Open each restored source and let any required catalog initialization finish.
5. Check a few favorites, recent items and, for Xtream, a saved playback position.
Xtream restores may need the catalog to load before saved items can be matched to their
content. An imported source appearing in the list does not mean every catalog operation
has already completed.
Stalker connects again using its restored source definition rather than transferring a
running session. The provider's device and connection rules still apply on the destination.
## Understand what “merged” means
The importer recognizes sources from their connection identity. An M3U URL, an Xtream
server and username, or a Stalker portal and MAC address can identify an existing source.
For an M3U without a URL, the playlist content provides the match.
When an entry matches, the existing source is updated and its backed-up user state is
applied. This is not a promise to combine every favorite from two different points in time.
For example, importing an older snapshot can replace changes made after that snapshot.
## Move the remaining pieces deliberately
Downloaded movies, episodes and recordings are not inside the JSON file, nor does importing
a playlist backup recreate the Downloads library. Keep the media separately and do not
assume that copying files alone registers them in the new app's download manager.
Move local XMLTV files separately and select their new locations. Reapply other preferences,
such as the selected player and update channel, on the destination.
A backup is also separate from recovery of sources missing after an older upgrade. If data
has unexpectedly disappeared, first record the versions and what happened; repeatedly
importing snapshots can make it harder to tell which state you are examining.
## Related
- [Add an M3U playlist and EPG](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
- [Connect an Xtream account](/iptvnator/blog/xtream-codes-setup-guide/)
- [Connect a Stalker portal](/iptvnator/blog/stalker-portal-setup-guide/)
- [Download IPTVnator](/iptvnator/download/)
@@ -0,0 +1,104 @@
---
title: Stable or Nightly? How IPTVnator Updates Work
description: Choose an update channel in the desktop app, understand what happens when you switch back to Stable, and find the build details that make a nightly bug report useful.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
draft: true
faq:
- q: Which update channel should I use?
a: Stable is the default and suits everyday viewing. Nightly is for trying changes before the next stable release and helping test them. Nightly builds can introduce regressions.
- q: Does choosing Nightly immediately change the installed app?
a: Changing the selection does not install anything by itself. Save the setting, let the app check the chosen channel, and follow the update or manual-install action it offers.
- q: Why am I still on a nightly after choosing Stable?
a: The updater only moves forward. It keeps the installed nightly until a stable release with a newer version is available. Selecting Stable changes which channel is checked; it does not downgrade the app.
- q: Does every Linux package update inside IPTVnator?
a: No. The update flow depends on the installation format. Linux installations without AppImage use a manual-install fallback in the app. Package-manager installations should follow their package manager's update process.
- q: What details should I include when reporting a nightly problem?
a: Include the full version and build commit from About, your operating system, installation format, player and source type, and the shortest steps that reproduce the problem. Do not post account credentials or full playlist URLs.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
Stable releases collect changes into a version intended for everyday use. Nightly builds
let you try work merged since then. Since IPTVnator 0.24, the desktop app lets you choose
between those channels in **Settings → About**.
The choice controls where the app looks for updates. It does not create a second library
or an isolated testing profile: a nightly uses the data of the app you update.
<ContentDisclaimer />
## Choose the channel that matches your use
| Channel | A good fit when… | What to expect |
| --- | --- | --- |
| Stable | You mainly want to watch and update at release time | Tagged releases; this is the default |
| Nightly | You want to try a recent fix or help test development | Builds from merged development changes, including possible regressions |
Nightly can be useful when an issue you reported has been fixed but the next stable release
has not arrived. Include the build you tested when replying to that issue: two people using
“nightly” may be running different code.
## Switch to Nightly
1. Export a playlist backup from **Settings → Backup** and keep the file somewhere you can
find it. A playlist backup does not include downloaded media or every app preference.
2. Open **Settings → About** in the desktop app.
3. Change **Update channel** from **Stable** to **Nightly**.
4. Use **Save and check for Nightly updates**, or save the settings to apply the choice.
5. Follow the update action shown for your installation. If the app offers a manual
installation instead, use that path for your package format.
6. After the update, open About again and check the installed version and build information.
An update result belongs to the channel that was checked. If you change the selection but
have not saved it, the earlier “latest version” result does not tell you whether the newly
selected channel has an update. Read the channel label next to the result.
If an update is already downloading or ready to install, changing the channel does not
replace that download. The app identifies it as an update from the previous channel.
## Switching back to Stable
Choose **Stable** and save. Future checks now use the stable channel, but the installed
nightly stays in place until there is a newer stable release.
For example, a nightly between two stable releases can contain database changes that the
older stable app does not understand. Automatically installing that older version would
put your existing library at risk. The updater therefore waits for a stable version that
can take you forward.
Treat the switch as “follow stable releases from now on.” It is not an undo button for the
last update, and reinstalling an older executable is not a reliable way to reverse a data
migration.
## Installation format matters
The channel selector belongs to the desktop app. It does not update a self-hosted web
server or its Docker image.
On desktop, follow the action About actually offers. Linux packages without AppImage have
a manual-install path, and package-manager installations have their own update process.
When asking for help, “Linux” alone is not enough: say whether you installed an AppImage,
DEB, RPM, Flatpak, Snap, or another package.
## Report the result so it can be reproduced
A useful report can be short:
> Version/build: copy from About. OS and installation format: include both. Source/player:
> M3U, Xtream or Stalker and the selected player. Steps: what you opened and clicked.
> Expected: what should happen. Actual: what happened instead.
For update problems, say whether the build was not offered, failed to download, failed to
install, or installed but behaved incorrectly. These are different points in the process.
If the issue concerns missing favorites, also say whether it appeared immediately after
the update or only after refreshing a playlist.
## Related
- [Stable releases](https://github.com/4gray/iptvnator/releases)
- [Nightly builds](https://github.com/4gray/iptvnator-nightly/releases)
- [Desktop installation options](/iptvnator/download/)
- [The update-channel feature in 0.24](/iptvnator/blog/v0-24-release-notes/)
@@ -0,0 +1,105 @@
---
title: What Stream Info Can Tell You About Playback Problems
description: Read resolution, frame rate, bitrate, buffer and dropped frames in IPTVnator's Stream info panel, then collect a useful playback report without guessing from a single number.
pubDate: 2026-09-27
author: 4gray
tags:
- guide
- playback
- troubleshooting
draft: true
faq:
- q: Why are some Stream info values missing?
a: Different engines and streams expose different information. IPTVnator hides unavailable values rather than presenting them as zero. Let playback start before checking the panel.
- q: Why can Frame rate differ from Source frame rate?
a: Source frame rate is information declared by the stream when available. Frame rate measures presented frames over time, so stalls and dropped frames can lower it.
- q: Is stream bitrate an internet speed test?
a: No. It describes the stream where that information is available. It does not measure the maximum speed of your connection or prove that a network problem is responsible for a stall.
- q: Can I see Stream info in external VLC or MPV?
a: Those players have their own diagnostic tools. IPTVnator's overlay is available through its shared controls for built-in web players and experimental frame-copy Embedded MPV.
- q: What should I attach to a playback issue?
a: Include the exact app version, OS, selected player, source type and reproduction steps. If playback shows an error, use Copy diagnostics. The generated report omits stream URLs and credentials; review any extra screenshots or text you add yourself.
---
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
A picture that freezes every few seconds and one that plays smoothly at a lower resolution
are different problems. IPTVnator 0.24 adds **Stream info** so you can see what the player
knows while the video is running, instead of describing both as “bad quality.”
The panel is useful for comparing behavior and reporting a problem. Its numbers are
observations from the playback engine; no single value can identify every cause of a stall.
<ContentDisclaimer />
## Open Stream info
1. Start a channel, movie or episode with a built-in web player, using unified controls,
or with experimental frame-copy Embedded MPV.
2. Reveal the player controls and click the information button labelled **Stream info**.
3. Let the video run long enough for the reported values to settle, then observe what
changes when the problem occurs.
The panel shows only values the current engine can report. A missing audio bitrate or
source frame rate is not a zero reading. Immediately after starting, the panel may say
there is no stream data yet.
## Read the values in context
| Field | What it helps you understand |
| --- | --- |
| Resolution | The dimensions reported for the video, rather than the size of the app window |
| Frame rate | Frames actually presented over time; interruptions can lower the measurement |
| Source frame rate | The stream's declared frame rate, when known |
| Stream bitrate | Bitrate information for the stream; not a connection-speed test |
| Video and audio | Codec and bitrate details when exposed by the engine |
| Channels and sample rate | Information about the audio track |
| Buffer | How much playable data lies ahead of the current position |
| Dropped frames | Frames counted as dropped by the playback path |
Suppose a source declares 50 fps but the measured rate falls during a freeze. That tells
you the player is not presenting frames at the declared pace during that interval. It does
not, by itself, tell you whether the cause was delivery, decoding or another interruption.
Likewise, a dropped-frame count is more useful when you watch whether it keeps increasing.
A count left over from startup is different from a counter rising throughout playback.
## Make one comparison at a time
Start with the same channel and the same section of a movie when possible. Note the player
and what was happening when the fault appeared.
- If a stream offers several qualities, try a lower one and compare both smoothness and
the available buffer readings.
- If only one channel fails, compare another channel from the same source. Avoid assuming
that a working channel proves every stream on that source is healthy.
- If you try another player, record which engine you used. Different engines can expose
different metrics, so compare playback behavior as well as the visible numbers.
Changing several settings at once makes it hard to tell which change helped. A short
description such as “Video.js stalls on this channel; HTML5 plays it without that stall”
is more actionable than a screenshot of one bitrate value.
## When playback fails completely
The error screen serves a different purpose from Stream info. It reports the failure the
player observed and can offer recovery actions, such as retrying or trying another player.
For background on compatibility, see
[why some streams need an external player](/iptvnator/blog/why-external-players-help/).
Use **Copy diagnostics** when an error offers it. In 0.24, that report includes useful
failure details without the stream URL or account credentials. It can distinguish a
reported access or DRM failure from a generic playback problem without sending extra
diagnostic requests to the provider.
Add your app version, OS, player, source type and reproduction steps alongside the report.
If you attach a screenshot, check the surrounding UI for source addresses or account data;
the redaction of a copied report does not redact an unrelated screenshot.
## Related
- [Why some streams need an external player](/iptvnator/blog/why-external-players-help/)
- [Compare playback engines](/iptvnator/compare/playback-engines/)
- [Stream info and diagnostics in 0.24](/iptvnator/blog/v0-24-release-notes/)
- [Download IPTVnator](/iptvnator/download/)
+42
View File
@@ -0,0 +1,42 @@
/** Task-based reading order. Article titles and descriptions stay in the blog collection. */
export interface GuideGroup {
id: string;
title: string;
description: string;
posts: string[];
includeLatestRelease?: boolean;
}
export const GUIDE_GROUPS: GuideGroup[] = [
{
id: 'getting-started',
title: 'Getting started',
description: 'Connect your first source. Start with the kind of access you already have.',
posts: ['m3u-playlist-epg-setup-guide', 'xtream-codes-setup-guide', 'stalker-portal-setup-guide'],
},
{
id: 'live-tv-epg',
title: 'Live TV & EPG',
description: 'Find what is on, get the schedule right, and change channels from the sofa.',
posts: ['m3u-programme-guide', 'epg-guide', 'epg-wrong-program-fix', 'remote-control-guide'],
},
{
id: 'playback',
title: 'Player & playback',
description: 'Make the player work for you, from subtitles to a stream that will not start.',
posts: ['fullscreen-channel-episode-guide', 'player-controls-guide', 'stream-info-diagnostics-guide', 'why-external-players-help'],
},
{
id: 'library',
title: 'Your library',
description: 'Organize your sources, keep your place, and take your library with you.',
posts: ['library-organization-guide', 'playlist-backup-restore-guide', 'offline-downloads-guide', 'alternative-sources-guide', 'tmdb-metadata-guide'],
},
{
id: 'updates',
title: 'Updates',
description: 'Choose an update channel and catch up on what changed in the latest release.',
posts: ['stable-nightly-updates-guide'],
includeLatestRelease: true,
},
];
+3
View File
@@ -33,6 +33,9 @@ const remainingPosts = featuredPost ? posts.filter((post) => post.id !== feature
<p class="mt-4 text-lg text-surface-400">
Release notes, tutorials, and project updates.
</p>
<p class="mt-4 text-sm text-surface-400">
Looking for a how-to? <a href="/iptvnator/guides/" class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-300">Browse guides by task →</a>
</p>
</div>
<BlogTagRail posts={posts} class="mt-8" />
+141
View File
@@ -0,0 +1,141 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { GUIDE_GROUPS } from '../../lib/guides';
import { absoluteUrl, siteHref } from '../../lib/site';
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const allPosts = await getCollection('blog');
const byId = new Map(allPosts.map((post) => [post.id, post]));
const latestRelease = allPosts
.filter((post) => !post.data.draft && post.data.tags.includes('release'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())[0];
const groups = GUIDE_GROUPS.map((group) => {
const posts = group.posts.map((id) => {
const post = byId.get(id);
if (!post) throw new Error(`Guides group "${group.id}" references missing post "${id}".`);
return post;
}).filter((post) => includeDrafts || !post.data.draft);
if (group.includeLatestRelease && latestRelease) posts.push(latestRelease);
return { ...group, posts };
}).filter((group) => group.posts.length > 0);
const pageUrl = absoluteUrl('/guides/');
const description = 'Practical IPTVnator guides organized by task: getting started, live TV and EPG, player controls, your library and updates.';
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'IPTVnator Guides',
description,
url: pageUrl,
hasPart: groups.flatMap((group) => group.posts.map((post) => ({
'@type': 'BlogPosting',
headline: post.data.title,
url: absoluteUrl(`/blog/${post.id}/`),
}))),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') },
{ '@type': 'ListItem', position: 2, name: 'Guides', item: pageUrl },
],
},
];
---
<BaseLayout title="Guides | IPTVnator" description={description} jsonLd={jsonLd}>
<div class="mx-auto max-w-6xl px-6 pt-36 pb-24 sm:pt-40">
<header class="max-w-3xl">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Guides</span>
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
Get more from<br /><span class="text-accent-400">IPTVnator.</span>
</h1>
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-surface-400">
Set up your first source, find something to watch, or get playback working
the way you want. Pick a task and start there.
</p>
<p class="mt-5 text-sm text-surface-400">
New to the app? <a href={siteHref('/download/')} class="guide-link">Download IPTVnator</a>
or <a href={siteHref('/compare/m3u-vs-xtream-vs-stalker/')} class="guide-link">compare source types</a>.
</p>
</header>
<div class="mt-14 grid gap-12 border-t border-surface-800 pt-8 lg:grid-cols-[220px_minmax(0,1fr)] lg:gap-16 lg:pt-12">
<aside>
<nav aria-label="Guide topics" class="lg:sticky lg:top-28">
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.16em] text-surface-500">Find your task</p>
<ol class="grid gap-1 sm:grid-cols-2 lg:grid-cols-1">
{groups.map((group, index) => (
<li>
<a href={`#${group.id}`} class="topic-link flex items-center gap-3 rounded-lg px-3 py-3 text-sm text-surface-300 transition-colors hover:bg-surface-900 hover:text-accent-400">
<span aria-hidden="true" class="font-mono text-[11px] text-accent-500">{String(index + 1).padStart(2, '0')}</span>
<span class="flex-1">{group.title}</span>
<span class="font-mono text-[11px] text-surface-500" aria-label={`${group.posts.length} articles`}>{group.posts.length}</span>
</a>
</li>
))}
</ol>
<p class="mt-6 border-t border-surface-800 pt-5 text-sm leading-relaxed text-surface-500">
Looking for an overview?
<a href={siteHref('/features/')} class="guide-link mt-1 block">Explore the features →</a>
</p>
</nav>
</aside>
<div class="min-w-0 space-y-16">
{groups.map((group, index) => (
<section id={group.id} aria-labelledby={`${group.id}-title`} class="guide-group scroll-mt-28">
<div class="flex items-baseline gap-4">
<span aria-hidden="true" class="font-mono text-xs text-accent-500">{String(index + 1).padStart(2, '0')}</span>
<h2 id={`${group.id}-title`} class="font-display text-2xl font-semibold tracking-tight text-surface-50 sm:text-3xl">{group.title}</h2>
</div>
<p class="mt-3 text-sm leading-relaxed text-surface-400">{group.description}</p>
<ul class="mt-6 divide-y divide-surface-800 border-y border-surface-800">
{group.posts.map((post) => (
<li>
<a href={siteHref(`/blog/${post.id}/`)} data-guide-article={post.id} class="guide-article group -mx-3 flex gap-5 rounded-lg px-3 py-6 transition-colors hover:bg-surface-900/60">
<div class="min-w-0 flex-1">
{(post.data.draft || post.data.tags.includes('release')) && (
<span class="mb-2 block font-mono text-[10px] uppercase tracking-[0.12em] text-accent-500">
{post.data.draft ? 'Draft preview' : 'Release notes'}
</span>
)}
<h3 class="font-display text-lg font-semibold leading-snug text-surface-100 transition-colors group-hover:text-accent-400">{post.data.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-surface-400">{post.data.description}</p>
</div>
<svg class="mt-1 h-5 w-5 shrink-0 text-surface-600 transition-colors group-hover:text-accent-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7M7 7h10v10" />
</svg>
</a>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>
</BaseLayout>
<style>
/* Clip horizontal decoration without creating a scroll container for the topic nav. */
:global(main:has(.guide-group)) {
overflow-x: clip;
}
.guide-link {
@apply text-accent-400 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-300;
}
.guide-link:focus-visible,
.topic-link:focus-visible,
.guide-article:focus-visible {
outline: 2px solid theme('colors.accent.400');
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
:global(html:has(.guide-group)) {
scroll-behavior: auto;
}
}
</style>
+97
View File
@@ -1,6 +1,9 @@
import { access, readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
import { parse as parseYaml } from 'yaml';
import { launchBrowser, serveDist } from './website-browser-support.mjs';
/**
* Structural checks for guide posts: they must carry FAQPage structured data
@@ -73,6 +76,100 @@ const GUIDES = [
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
const HUB_GROUPS = ['getting-started', 'live-tv-epg', 'playback', 'library', 'updates'];
const DRAFT_GUIDES = [
'm3u-programme-guide', 'stable-nightly-updates-guide', 'fullscreen-channel-episode-guide',
'player-controls-guide', 'stream-info-diagnostics-guide', 'playlist-backup-restore-guide',
'library-organization-guide',
];
test('guides hub groups working article links by task and keeps draft visibility consistent', async () => {
const html = await readDist('guides/index.html');
const doc = new JSDOM(html).window.document;
assert.equal(doc.querySelector('link[rel="canonical"]').href, `${SITE}/guides/`);
assert.equal(doc.querySelectorAll('h1').length, 1);
for (const id of HUB_GROUPS) {
const section = doc.getElementById(id);
assert.ok(section?.querySelector('h2'), `${id}: named section`);
assert.ok(section.querySelector('[data-guide-article]'), `${id}: not an empty category`);
assert.ok(doc.querySelector(`nav[aria-label="Guide topics"] a[href="#${id}"]`));
}
const links = [...doc.querySelectorAll('[data-guide-article]')];
assert.equal(new Set(links.map((link) => link.dataset.guideArticle)).size, links.length);
for (const link of links) {
const slug = link.dataset.guideArticle;
assert.equal(link.getAttribute('href'), `/iptvnator/blog/${slug}/`);
await access(new URL(`blog/${slug}/index.html`, distRoot));
const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8');
const meta = parseYaml(source.split('---')[1]);
assert.equal(link.querySelector('h3').textContent, meta.title);
assert.equal(link.textContent.includes('Draft preview'), meta.draft === true);
}
for (const slug of DRAFT_GUIDES) {
const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8');
const meta = parseYaml(source.split('---')[1]);
const visible = !meta.draft || process.env.PUBLIC_INCLUDE_DRAFTS === 'true';
assert.equal(links.some((link) => link.dataset.guideArticle === slug), visible, `${slug}: draft visibility`);
}
const schema = extractJsonLd(html);
const collection = schema.find((entry) => entry['@type'] === 'CollectionPage');
assert.equal(collection.url, `${SITE}/guides/`);
assert.deepEqual(collection.hasPart.map((entry) => entry.url), links.map((link) => `https://4gray.github.io${link.getAttribute('href')}`));
assert.equal(schema.find((entry) => entry['@type'] === 'BreadcrumbList').itemListElement.at(-1).item, `${SITE}/guides/`);
assert.match(await readDist('sitemap-0.xml'), /<loc>https:\/\/4gray.github.io\/iptvnator\/guides\/<\/loc>/);
});
test('guides are discoverable from the header, footer and blog', async () => {
const home = new JSDOM(await readDist('index.html')).window.document;
assert.ok(home.querySelector('header a[href="/iptvnator/guides/"]'));
assert.ok(home.querySelector('#mobile-menu a[href="/iptvnator/guides/"]'));
assert.ok(home.querySelector('footer a[href="/iptvnator/guides/"]'));
const blog = new JSDOM(await readDist('blog/index.html')).window.document;
assert.ok(blog.querySelector('main a[href="/iptvnator/guides/"]'));
});
test('guides hub: responsive navigation and task anchors work in Chromium', async (t) => {
const browser = await launchBrowser();
if (!browser) return t.skip('Chromium is not installed locally');
const { server, origin } = await serveDist();
try {
for (const width of [390, 768, 1024, 1280]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
try {
await page.goto(`${origin}/iptvnator/guides/`);
await page.evaluate(() => document.fonts.ready);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), `${width}: no horizontal overflow`);
if (width < 1024) {
const button = page.getByRole('button', { name: 'Toggle menu' });
await button.click();
assert.equal(await button.getAttribute('aria-expanded'), 'true');
await page.locator('#mobile-menu').getByRole('link', { name: 'Guides', exact: true }).click();
await page.waitForURL(`${origin}/iptvnator/guides/`);
} else {
const bounds = await page.locator('#site-header a:visible').evaluateAll((links) => links.map((link) => {
const { left, right } = link.getBoundingClientRect();
return { left, right };
}));
for (let index = 1; index < bounds.length; index++) {
assert.ok(bounds[index].left >= bounds[index - 1].right, `${width}: header links do not overlap`);
}
}
await page.getByRole('navigation', { name: 'Guide topics' }).getByRole('link', { name: /Updates/ }).click();
await page.waitForFunction(() => {
const top = document.getElementById('updates').getBoundingClientRect().top;
return top >= 80 && top < 400;
});
assert.equal(new URL(page.url()).hash, '#updates');
} finally {
await page.close();
}
}
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
});
function extractJsonLd(html) {
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');