feat(website): add the Xtream Codes setup guide with FAQ and guide screenshots

Publish "How to Add an Xtream Codes Account to IPTVnator" as the first
evergreen guide: what the server URL, username and password are, the
Add playlist flow with the connection test and its four verdicts, the
Auto-detect method for pasted provider messages, what the import syncs,
Account info, refresh, troubleshooting and a seven-question FAQ. The
guide is cross-linked from the three download pages and llms.txt.

Blog posts gain an optional `faq` frontmatter list: BlogPost.astro
renders it as an accordion after the body and emits FAQPage JSON-LD
next to the BlogPosting entry. LinkCards and PostButton keep internal
links in the same tab.

Guide screenshots come from the release capture script: manifest shots
may carry a `group`, `--group guides` captures only those into
apps/website/public/blog/guides/screenshots/, and a release run skips
them. New setup actions open the Add playlist dialog with the mock's
fictional Xtream credentials (connection test shown), the Auto-detect
method with a labeled hand-out, and the Xtream Live TV view. Dialog
helpers and fixture identities move into shared modules so the driver
and the navigation actions cannot import each other cyclically.

tools/testing/website-guides.test.mjs checks the FAQPage schema, the
download-hub link and the shipped screenshots of every guide;
screenshot-guards.test.mjs covers group validation and output routing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-03 21:14:59 +02:00
1 parent 81ce8e8c90
commit 90d26d499f
27 files changed
+661 -53

No files matched your search

+1 -1
View File
@@ -41,7 +41,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`.
- Final task summaries should state whether a release note was added or why it was skipped.
## Regression Prevention And Test Updates
+19
View File
@@ -62,3 +62,22 @@ resolved version.
`tools/testing/website-download-pages.test.mjs`, which checks titles,
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
without depending on a specific version.
## Guides
Evergreen how-to posts live in the blog collection next to release notes
(`apps/website/src/content/blog/xtream-codes-setup-guide.mdx` is the first).
Two conventions set them apart:
- **`faq` frontmatter.** An optional list of `{ q, a }` entries. `BlogPost.astro`
renders it as an accordion after the body and emits a `FAQPage` JSON-LD block
next to the `BlogPosting` one, so the answers can surface as rich results.
- **Screenshots from the capture script.** Guide frames are captured by
`pnpm release:screenshots --group guides` into
`apps/website/public/blog/guides/screenshots/<slug>-<theme>.png`; the shots are
declared in `tools/release/screenshots.manifest.json` with `"group": "guides"`
and never appear in a release run.
`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.
+1 -1
View File
@@ -18,7 +18,7 @@
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs"
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs"
}
},
"serve": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

+1
View File
@@ -11,6 +11,7 @@
- Download for Windows: https://4gray.github.io/iptvnator/download/windows/
- Download for macOS: https://4gray.github.io/iptvnator/download/macos/
- Download for Linux: https://4gray.github.io/iptvnator/download/linux/
- Guide, Xtream Codes setup: https://4gray.github.io/iptvnator/blog/xtream-codes-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
@@ -29,8 +29,8 @@ const iconPaths = {
{links.map((item) => (
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
target={item.href.startsWith('/') ? undefined : '_blank'}
rel={item.href.startsWith('/') ? undefined : 'noopener noreferrer'}
class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
>
<div class="flex items-center gap-2.5">
+2
View File
@@ -13,6 +13,8 @@ const blog = defineCollection({
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
/** Rendered as an accordion after the post and emitted as FAQPage JSON-LD. */
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
}),
});
@@ -0,0 +1,187 @@
---
title: How to Add an Xtream Codes Account to IPTVnator
description: Connect an Xtream Codes API provider to IPTVnator step by step. What the server URL, username and password are, how to test the login, how to paste the message from your provider, and what to do when the portal refuses to connect.
pubDate: 2026-09-03
author: 4gray
heroImage: /iptvnator/blog/guides/screenshots/guide-xtream-live-dark.png
tags:
- guide
- xtream-codes
- setup
- tutorial
draft: false
faq:
- q: Is an Xtream Codes login the same thing as an M3U link?
a: Usually both come from the same account. An M3U link that contains get.php with username and password in its query string is generated by an Xtream Codes panel, so you can add it as Xtream credentials instead. You get the same channels plus movies, series, provider metadata, EPG and catch-up, which the flat M3U file cannot carry.
- q: Where do I find my Xtream server URL?
a: It is the host part of any link your provider gave you, including the scheme and the port, for example http followed by the hostname and a port number. If you only have a get.php or player_api.php link, paste it into the Server URL field. IPTVnator strips the endpoint and reads the username and password from the query string.
- q: Can I use the same Xtream account on several devices?
a: Your provider decides that through the connection limit of the account. The Account info dialog in IPTVnator shows the active and maximum connections. Playing on more devices than allowed produces authentication errors or streams that stop after a few seconds.
- q: Does IPTVnator show a program guide and catch-up for Xtream channels?
a: Yes. IPTVnator asks the portal for the schedule of each channel and shows the current program in the channel list and a timeline under the player. Channels that the portal marks as archived get a catch-up badge, and past programs can be played from the timeline. On the desktop app, XMLTV sources configured in the settings are used as a fallback.
- q: Can I add more than one Xtream account?
a: Yes. Every account becomes its own source with separate favorites, history and cached catalog. The dashboard and the playlist switcher in the header list all of them, and global favorites, recent items and search work across sources.
- q: Where are my credentials stored, and are they sent anywhere else?
a: Credentials stay on your device, in the local database of the desktop app or in browser storage of the web version, and are only sent to the server URL you entered. IPTVnator has no account system and no analytics. The self-hosted web version routes requests through your own backend container.
- q: My provider only gave me a MAC address and a portal URL. Is that Xtream Codes?
a: No, that is a Stalker or Ministra portal. Choose the Stalker portal method in the same Add playlist dialog. The Auto-detect method recognizes a MAC address in the pasted message and pre-fills the Stalker form for you.
---
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';
Most IPTV providers hand out three pieces of data: a **server URL**, a **username** and a
**password**. That trio is an Xtream Codes account, and it is the richest way to connect a
provider to IPTVnator. Instead of a flat channel list you get live TV, movies and series with
posters and descriptions, a program guide, catch-up where the provider offers it, and an
account page that tells you when the subscription ends.
This guide walks through adding such an account in IPTVnator, checking that it works, and
fixing the usual problems. It applies to the desktop app on Windows, macOS and Linux and to the
self-hosted browser version.
<Alert type="info" title="IPTVnator is a player, not a service">
It does not include channels, playlists or accounts. The credentials in this guide come from
the provider you already have a contract with.
</Alert>
## What you need from your provider
| Field | What it looks like | Notes |
| --- | --- | --- |
| Server URL | `http://tv.example-provider.net:8080` | Scheme, host and port. Some panels live under a subpath such as `/panel`; keep it. |
| Username | `janedoe` | Case-sensitive. |
| Password | `s3cret` | Case-sensitive. |
Providers rarely label the data this cleanly. Often you receive one long link that ends in
`get.php` or `player_api.php` with the username and password inside the query string. That link
is enough: the part before `/get.php` is the server URL, and the two query parameters are your
credentials. IPTVnator can read them out of the link for you, as shown below.
## Add the account
<StepRail
title="In IPTVnator"
steps={[
'Open the Add playlist dialog. You will find the button on the dashboard, in the header playlist switcher, and in the command palette under "Add Xtream Codes playlist".',
'Choose the "Xtream credentials" method.',
'Fill in a playlist title of your choice, the server URL, the username and the password.',
'Click "Test Connection". IPTVnator asks the portal for the account status and shows the result under the form.',
'Click "Add". IPTVnator downloads the categories for live TV, movies and series and opens the new source.',
]}
/>
![Add playlist dialog with the Xtream credentials method selected and a successful connection test](/iptvnator/blog/guides/screenshots/guide-xtream-add-playlist-dark.png)
The connection test reports one of four states. **Connection successful! Portal is active** is
the one you want. **Portal subscription has expired** and **Portal is inactive** mean the
portal answered but refuses the account; check the subscription with your provider. **Could
not connect to the portal** means no answer at all: a typo in the server URL, a wrong port, a
missing `http://` or `https://`, or a host that is down.
<Alert type="success" title="Paste the whole link">
If you paste a complete `get.php` or `player_api.php` link into the Server URL field while the
username and password fields are still empty, IPTVnator cuts the link down to the server
URL and fills in the credentials from the query string. Trailing slashes and surrounding
whitespace are removed as well.
</Alert>
## Or paste the message from your provider
Provider messages are messy: a link, a username and a password somewhere in a chat message
or an email, sometimes dressed up in fancy Unicode letters to get past spam filters. The
**Auto-detect** method in the same dialog takes the whole message and finds the source for
you.
![Auto-detect method with a pasted provider message and a recognized Xtream account](/iptvnator/blog/guides/screenshots/guide-xtream-auto-detect-dark.png)
Paste everything, then click **Fill the form** on the candidate that looks right. IPTVnator
switches to the matching method with the fields pre-filled, and you continue with the
connection test as above. Detection runs inside the app; nothing you paste is sent anywhere.
The detector ranks what it finds: a MAC address means a Stalker portal, a `get.php` or
`player_api.php` link means Xtream Codes, a labeled username and password without a MAC
address leans Xtream, and a link ending in `.m3u` or `.m3u8` is a plain playlist URL. It never
invents or "fixes" a value, so a password that is almost right stays wrong, and the connection
test remains the authority on whether the account works.
## After the import
Right after you click Add, IPTVnator syncs the catalog: the categories for live TV, movies and
series, then their content. A progress panel shows what is being fetched. On the desktop app
the result is stored in a local library, so the next time you open the source it appears
instantly and only changed data is downloaded.
![Live TV of an Xtream source with categories, the channel list and the program guide](/iptvnator/blog/guides/screenshots/guide-xtream-live-dark.png)
The source gets its own sidebar with these sections:
- **Live TV**: categories on the left, channels with the current program and a progress bar,
the player and a timeline of the schedule. Channels that the portal marks as archived carry
a **Catchup available** badge with the number of days, and past programs play straight from
the timeline.
- **Movies** and **Series**: poster grids per category, a detail page with description, cast,
trailer and similar titles, resume positions, watched markers, and on the desktop app a
download button for offline viewing.
- **Search**, **Favorites**, **Recent** and **Recently added** for the source. Global
favorites, recent items and search on the dashboard combine all your sources.
- **Discover**, when TMDB metadata is enabled in the settings: browse the portal by year,
genre and country.
<Alert type="info" title="Program guide">
Xtream portals ship their own schedule, so the guide works without any extra setup. If a
channel shows no program information, the portal did not provide any for it. The desktop
app additionally uses XMLTV sources configured under Settings › EPG as a fallback, and a
right-click on a channel offers "Map EPG channel" to link it to an XMLTV channel by hand.
</Alert>
## Check the subscription
Open **Account info** from the playlist switcher in the header (the menu next to the source)
or from the source card on the dashboard. The dialog shows the account status, the expiry
date, active and maximum connections, whether the account is a trial, the stream formats the
portal allows, and the server details. The source card on the dashboard also carries a small
expiry chip that turns amber during the last week of the subscription.
## Keep the catalog fresh
Providers add and remove content all the time. **Refresh Xtream playlist from remote** in the
source menu downloads the categories and content again while keeping your favorites,
watch history, hidden categories and playback positions. To change the server URL or
credentials after a provider migration, open the playlist details of the source and edit
them there; the next request uses the new connection.
## Troubleshooting
- **Unauthorized, Account inactive or Account expired** after the import worked before:
the portal now rejects the credentials. Open Account info, then check the subscription with
your provider or correct the credentials in the playlist details.
- **Portal not found**: the server URL is wrong. It must start with `http://` or `https://`,
carry the right port, and keep any subpath the provider uses. Try the exact host from the
link you were given.
- **Portal unavailable**: the host did not answer. Check your network or VPN, then use
**Retry**. After two failed connection attempts IPTVnator pauses requests to that host for
30 seconds to keep the app responsive; a manual retry lifts the pause immediately.
- **The catalog loads but streams 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. Settings › Playback also has a **Stream Format** option for Xtream
live streams; `auto` picks HLS when the portal allows it and falls back to MPEG-TS.
- **Streams stop after a few seconds or refuse to start on a second device**: the account's
connection limit is reached. Account info shows the active and maximum connections.
- **The provider blocks the app**: some panels only answer clients with a specific
User-Agent. On the desktop app you can set one in the playlist details; otherwise IPTVnator
identifies itself with a common IPTV player signature.
## Related
<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' },
]}
/>
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
+34 -2
View File
@@ -1,6 +1,12 @@
---
import BaseLayout from './BaseLayout.astro';
import GiscusComments from '../components/GiscusComments.astro';
import FaqAccordion from '../components/blog/FaqAccordion.astro';
interface FaqEntry {
q: string;
a: string;
}
interface Props {
title: string;
@@ -10,9 +16,11 @@ interface Props {
author: string;
tags?: string[];
heroImage?: string;
/** Optional FAQ from the post frontmatter: rendered after the body and emitted as FAQPage schema. */
faq?: FaqEntry[];
}
const { title, description, pubDate, updatedDate, author, tags = [], heroImage } = Astro.props;
const { title, description, pubDate, updatedDate, author, tags = [], heroImage, faq = [] } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric',
@@ -52,6 +60,19 @@ const blogPostSchema = {
mainEntityOfPage: canonicalURL,
image: heroImage ? new URL(heroImage, Astro.site).toString() : undefined,
};
const faqSchema =
faq.length > 0
? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faq.map((entry) => ({
'@type': 'Question',
name: entry.q,
acceptedAnswer: { '@type': 'Answer', text: entry.a },
})),
}
: null;
const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
---
<BaseLayout
@@ -62,7 +83,7 @@ const blogPostSchema = {
publishedTime={pubDate.toISOString()}
modifiedTime={(updatedDate ?? pubDate).toISOString()}
keywords={tags}
jsonLd={blogPostSchema}
jsonLd={jsonLd}
>
<article class="pt-36 pb-24">
<div class="relative mx-auto max-w-3xl px-6">
@@ -152,6 +173,17 @@ const blogPostSchema = {
<slot />
</div>
{faq.length > 0 && (
<section class="mt-16" aria-labelledby="post-faq-heading">
<hr class="section-divider" />
<span class="mt-12 block font-mono text-xs uppercase tracking-[0.2em] text-accent-500">FAQ</span>
<h2 id="post-faq-heading" class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
Frequently asked questions
</h2>
<FaqAccordion items={faq} />
</section>
)}
<GiscusComments />
</div>
</article>
@@ -25,6 +25,7 @@ const { Content } = await render(post);
author={post.data.author}
tags={post.data.tags}
heroImage={post.data.heroImage}
faq={post.data.faq}
>
<Content />
</BlogPost>
+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={['why-external-players-help', 'epg-guide', 'beware-unofficial-iptvnator-websites']} />
<RelatedPosts slugs={['xtream-codes-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
<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={['macos-arm-app-damaged-fix', 'embedded-mpv-macos-experiment', 'why-external-players-help']} />
<RelatedPosts slugs={['xtream-codes-setup-guide', 'macos-arm-app-damaged-fix', 'why-external-players-help']} />
<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={['why-external-players-help', 'epg-guide', 'beware-unofficial-iptvnator-websites']} />
<RelatedPosts slugs={['xtream-codes-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
+10
View File
@@ -155,6 +155,16 @@ Screenshots come only from the capture script running against the mock servers.
Never publish one taken from a real playlist or account — streams, logos and
metadata are copyrighted, and credentials must never reach a published image.
The same script also produces the evergreen screenshots of the website guides.
Manifest shots that carry `"group": "guides"` are skipped by a release run and
captured only with `pnpm release:screenshots --group guides`, which publishes
into `apps/website/public/blog/guides/screenshots/` instead of a release folder
(`outputDirectoryFor` in `screenshot-guards.mjs`). Guide shots go through every
guard a release shot does; the add-playlist dialog shots fill the form with the
mock's fictional `marketing` credentials and use a labeled hand-out for the
Auto-detect method rather than a `get.php?username=…` link, because G4 rejects
any URL carrying query credentials.
Output lands in `dist/release-highlight-cards/v<version>/`, outside version
control — keyed by the exact version, because 0.24.0 and 0.24.1 share a blog
post but not a card set. A run first removes the cards a previous run left in
+19 -33
View File
@@ -14,11 +14,24 @@ import {
type Page,
} from '@playwright/test';
import { registerPlaylistId, requirePlaylistId } from './capture-navigation';
import {
M3U_FIXTURE_TITLE,
XTREAM_FIXTURE_CREDENTIALS,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
import {
clickDialogOption,
openAddPlaylistDialog,
registerPlaylistId,
requirePlaylistId,
} from './capture-navigation';
export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211';
export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo';
export const M3U_FIXTURE_TITLE = 'release-demo';
export {
M3U_FIXTURE_TITLE,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
/** Synthetic categories that only the marketing fixture generator produces. */
const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama'];
@@ -288,8 +301,8 @@ async function addXtreamPortal(page: Page): Promise<void> {
await clickDialogOption(dialog, /xtream credentials/i);
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
await dialog.locator('#username').fill('marketing');
await dialog.locator('#password').fill('marketing');
await dialog.locator('#username').fill(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog.locator('#password').fill(XTREAM_FIXTURE_CREDENTIALS.password);
await dialog
.getByRole('button', { name: /^(add|add playlist)$/i })
.last()
@@ -329,33 +342,6 @@ async function addM3uPlaylist(page: Page, m3uPath: string): Promise<void> {
.waitFor({ state: 'visible', timeout: 60_000 });
}
async function openAddPlaylistDialog(page: Page): Promise<void> {
await page.getByRole('button', { name: /add playlist/i }).first().click();
await page
.locator('mat-dialog-container')
.last()
.waitFor({ state: 'visible', timeout: 15_000 });
}
async function clickDialogOption(
dialog: ReturnType<Page['locator']>,
label: RegExp
): Promise<void> {
// The add-playlist dialog has changed shape across releases: source
// methods were tabs, then plain buttons, now a radio group.
for (const role of ['radio', 'tab', 'button'] as const) {
const option = dialog.getByRole(role, { name: label }).first();
if ((await option.count()) > 0) {
await option.click();
return;
}
}
throw new Error(`Dialog option matching ${label} not found`);
}
function idFromUrl(url: string, provider: 'playlists' | 'xtreams'): string {
// `provider` is a closed union, but build the pattern from a literal
// anyway so no future caller can inject regex syntax through it.
+32
View File
@@ -0,0 +1,32 @@
/**
* Fixture identities shared by the capture driver (seeding) and the named
* setup actions (guide shots that re-enter the add-playlist dialog). Kept in
* a leaf module so capture-navigation.ts can import them without pulling in
* the driver, which itself imports the navigation module.
*
* Everything here is fictional and resolves only against the local Xtream
* mock server; the G4 frame guard still rejects any URL carrying query
* credentials, so the auto-detect hand-out deliberately uses labeled lines
* instead of a `get.php?username=…` link.
*/
export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211';
export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo';
export const M3U_FIXTURE_TITLE = 'release-demo';
/** Credential pair of the mock server's curated `marketing` scenario. */
export const XTREAM_FIXTURE_CREDENTIALS = {
username: 'marketing',
password: 'marketing',
} as const;
/** 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.',
'',
`Host: ${XTREAM_MOCK_ORIGIN}`,
`Username: ${XTREAM_FIXTURE_CREDENTIALS.username}`,
`Password: ${XTREAM_FIXTURE_CREDENTIALS.password}`,
'',
'Use these details in any Xtream Codes compatible player.',
].join('\n');
+134
View File
@@ -6,6 +6,13 @@
import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
XTREAM_FIXTURE_CREDENTIALS,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
let m3uPlaylistId: string | undefined;
let xtreamPlaylistId: string | undefined;
@@ -76,6 +83,11 @@ 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.
await dismissDialogs(page);
switch (action) {
case 'open-settings': {
await page.locator('a[href$="/workspace/settings"]').first().click();
@@ -167,11 +179,133 @@ export async function runAction(
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-xtream': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /xtream credentials/i);
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
await dialog
.locator('#username')
.fill(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog
.locator('#password')
.fill(XTREAM_FIXTURE_CREDENTIALS.password);
// The status probe only talks to the local mock, so the frame can
// show the successful "portal is active" verdict the guide explains.
await dialog
.getByRole('button', { name: /test connection/i })
.first()
.click();
const status = dialog.locator('.connection-status');
await status.waitFor({ state: 'visible', timeout: 30_000 });
// The dialog body scrolls; bring the verdict the guide explains
// into frame together with the credential fields above it.
await status.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-auto': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /auto-detect/i);
await dialog
.locator('[data-test-id="auto-detect-textarea"]')
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
const candidate = dialog
.locator('[data-test-id="auto-detect-candidate"]')
.first();
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
await candidate.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-xtream-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/live`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// Deliberately no channel click: playback would pull the mock's
// redirect to a public demo stream, and third-party video frames
// must never enter a published shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
default:
throw new Error(`Unknown setup action: ${action}`);
}
}
/* ------------------------------------------------------------------ */
/* Dialog helpers (shared with the seeding driver) */
/* ------------------------------------------------------------------ */
export async function openAddPlaylistDialog(page: Page): Promise<void> {
await page.getByRole('button', { name: /add playlist/i }).first().click();
await page
.locator('mat-dialog-container')
.last()
.waitFor({ state: 'visible', timeout: 15_000 });
}
export async function clickDialogOption(
dialog: ReturnType<Page['locator']>,
label: RegExp
): Promise<void> {
// The add-playlist dialog has changed shape across releases: source
// methods were tabs, then plain buttons, now a radio group.
for (const role of ['radio', 'tab', 'button'] as const) {
const option = dialog.getByRole(role, { name: label }).first();
if ((await option.count()) > 0) {
await option.click();
return;
}
}
throw new Error(`Dialog option matching ${label} not found`);
}
async function dismissDialogs(page: Page): Promise<void> {
const dialogs = page.locator('mat-dialog-container');
if ((await dialogs.count()) === 0) {
return;
}
await page.keyboard.press('Escape');
await dialogs
.first()
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(async () => {
await page.getByRole('button', { name: /^cancel$/i }).last().click();
await dialogs.first().waitFor({ state: 'detached', timeout: 10_000 });
});
}
/** Returns to the dashboard via the always-visible brand link. */
async function goHome(page: Page): Promise<void> {
if (/\/workspace\/dashboard/.test(page.url())) {
+23 -4
View File
@@ -4,10 +4,14 @@
* pnpm release:screenshots # release slug from package.json
* pnpm release:screenshots --release v0-24 # explicit
* pnpm release:screenshots --only dashboard --theme dark
* pnpm release:screenshots --group guides # evergreen guide shots
*
* Reads tools/release/screenshots.manifest.json and writes
* apps/website/public/blog/<release>/screenshots/<slug>-<theme>.png against
* dist builds + the xtream mock server. Guards (screenshot-guards.mjs):
* dist builds + the xtream mock server. Shots carrying a `group` (for
* example `guides`) are skipped by a release run and land in
* apps/website/public/blog/<group>/screenshots/ when that group is selected.
* Guards (screenshot-guards.mjs):
*
* G1 the real ~/.iptvnator/databases directory — including the SQLite WAL
* sidecars, compared after Electron exits and checkpoints — is proven
@@ -30,6 +34,7 @@ import process from 'node:process';
import type { Page } from '@playwright/test';
import {
DEFAULT_SHOT_GROUP,
buildCaptureEnv,
compareDatabaseStates,
evaluateFrameReport,
@@ -37,8 +42,10 @@ import {
HOST_RESOLVER_RULES,
isAllowedRequestUrl,
networkPolicy,
outputDirectoryFor,
parseSetupStep,
publishDirectory,
shotGroup,
snapshotDatabaseState,
stubbedResponseFor,
validateManifest,
@@ -90,14 +97,26 @@ async function main(): Promise<void> {
throw new Error(`--release rejected: ${releaseError}`);
}
const group = flag('group') ?? DEFAULT_SHOT_GROUP;
const groupError = validateReleaseSlug(group);
if (groupError) {
throw new Error(`--group rejected: ${groupError}`);
}
const only = flag('only');
const themeFilter = flag('theme');
const shots = manifest.shots.filter(
(shot: { slug: string }) => !only || shot.slug === only
(shot: { slug: string; group?: string }) =>
shotGroup(shot) === group && (!only || shot.slug === only)
);
if (shots.length === 0) {
throw new Error(`--only ${only} matches no manifest slug`);
throw new Error(
only
? `--only ${only} matches no manifest slug in group ${group}`
: `--group ${group} matches no manifest shots`
);
}
// Without this an erased cast would accept `--theme --only`, and every
@@ -112,7 +131,7 @@ async function main(): Promise<void> {
? [themeFilter as Theme]
: manifest.themes;
const blogRoot = path.join(workspaceRoot, 'apps/website/public/blog');
const outputRoot = path.join(blogRoot, release, 'screenshots');
const outputRoot = outputDirectoryFor({ blogRoot, group, release });
// Belt and braces: the slug is validated above, but assert the resolved
// path really lands inside the blog tree before anything deletes there.
+31
View File
@@ -45,8 +45,35 @@ export const KNOWN_ACTIONS = [
'open-xtream-vod',
'open-xtream-series',
'open-m3u-groups',
'open-add-playlist-xtream',
'open-add-playlist-auto',
'open-xtream-live',
];
/**
* Shots without a `group` belong to the release post and land in
* `blog/<release>/screenshots/`. Any other group (for example `guides`) is
* captured only when asked for with `--group` and lands in its own
* `blog/<group>/screenshots/` directory, so a release run can never publish
* guide frames into a release folder or the other way round.
*/
export const DEFAULT_SHOT_GROUP = 'release';
/** @param {{ group?: unknown }} shot */
export function shotGroup(shot) {
return typeof shot?.group === 'string' ? shot.group : DEFAULT_SHOT_GROUP;
}
/**
* @param {{ blogRoot: string, group: string, release: string }} input
* @returns {string} the directory a run of `group` publishes into
*/
export function outputDirectoryFor({ blogRoot, group, release }) {
const folder = group === DEFAULT_SHOT_GROUP ? release : group;
return path.join(blogRoot, folder, 'screenshots');
}
/** @param {string} step e.g. `open-xtream-vod=Hero Premieres` */
export function parseSetupStep(step) {
const separator = step.indexOf('=');
@@ -104,6 +131,10 @@ export function validateManifest(manifest) {
seen.add(shot?.slug);
if (shot?.group !== undefined && !SLUG_PATTERN.test(String(shot.group))) {
errors.push(`shot "${label}": group must be a lowercase slug`);
}
if (!Array.isArray(shot?.setup) || shot.setup.length === 0) {
errors.push(`shot "${label}": setup must be a non-empty array`);
continue;
+58
View File
@@ -25,6 +25,9 @@ import {
parseSetupStep,
snapshotDatabaseState,
stubbedResponseFor,
DEFAULT_SHOT_GROUP,
outputDirectoryFor,
shotGroup,
validateManifest,
validateReleaseSlug,
} from './screenshot-guards.mjs';
@@ -103,6 +106,61 @@ describe('manifest validation', () => {
);
});
it('accepts guide shots that name a group and the actions they use', () => {
const manifest = validManifest();
manifest.shots.push(
{
slug: 'guide-xtream-add-playlist',
title: 'Add playlist',
group: 'guides',
setup: ['open-add-playlist-xtream'],
},
{
slug: 'guide-xtream-auto-detect',
title: 'Auto-detect',
group: 'guides',
setup: ['open-add-playlist-auto'],
},
{
slug: 'guide-xtream-live',
title: 'Live TV',
group: 'guides',
setup: ['open-xtream-live=News'],
}
);
assert.deepEqual(validateManifest(manifest), []);
});
it('rejects a group that is not a lowercase slug', () => {
const manifest = validManifest();
manifest.shots.push({
slug: 'guide',
title: 'x',
group: '../v0-24',
setup: ['open-dashboard'],
});
assert.ok(
validateManifest(manifest).some((error) =>
/group must be a lowercase slug/.test(error)
)
);
});
it('routes release shots by release and grouped shots by group', () => {
assert.equal(shotGroup({ slug: 'dashboard' }), DEFAULT_SHOT_GROUP);
assert.equal(shotGroup({ slug: 'guide', group: 'guides' }), 'guides');
assert.equal(
outputDirectoryFor({ blogRoot: '/blog', group: DEFAULT_SHOT_GROUP, release: 'v0-24' }),
path.join('/blog', 'v0-24', 'screenshots')
);
assert.equal(
outputDirectoryFor({ blogRoot: '/blog', group: 'guides', release: 'v0-24' }),
path.join('/blog', 'guides', 'screenshots')
);
});
it('parses setup steps with and without a parameter', () => {
assert.deepEqual(parseSetupStep('open-dashboard'), {
action: 'open-dashboard',
+47 -7
View File
@@ -1,32 +1,72 @@
{
"version": 1,
"viewport": { "width": 1280, "height": 720 },
"themes": ["dark", "light"],
"viewport": {
"width": 1280,
"height": 720
},
"themes": [
"dark",
"light"
],
"shots": [
{
"slug": "dashboard",
"title": "Dashboard",
"setup": ["open-dashboard"]
"setup": [
"open-dashboard"
]
},
{
"slug": "settings",
"title": "Settings",
"setup": ["open-settings"]
"setup": [
"open-settings"
]
},
{
"slug": "xtream-vod-details",
"title": "Movie details",
"setup": ["open-xtream-vod=Action & Mystery"]
"setup": [
"open-xtream-vod=Action & Mystery"
]
},
{
"slug": "xtream-series-season-open",
"title": "Series season view",
"setup": ["open-xtream-series=Urban Drama"]
"setup": [
"open-xtream-series=Urban Drama"
]
},
{
"slug": "m3u-live-groups-two-column",
"title": "Live TV groups",
"setup": ["open-m3u-groups"]
"setup": [
"open-m3u-groups"
]
},
{
"slug": "guide-xtream-add-playlist",
"title": "Add playlist: Xtream credentials",
"group": "guides",
"setup": [
"open-add-playlist-xtream"
]
},
{
"slug": "guide-xtream-auto-detect",
"title": "Add playlist: Auto-detect",
"group": "guides",
"setup": [
"open-add-playlist-auto"
]
},
{
"slug": "guide-xtream-live",
"title": "Xtream Live TV",
"group": "guides",
"setup": [
"open-xtream-live"
]
}
]
}
+56
View File
@@ -0,0 +1,56 @@
import { access, readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
/**
* Structural checks for guide posts: they must carry FAQPage structured data
* next to the BlogPosting entry, link to the download hub, and reference only
* screenshots that the build actually shipped.
*/
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
const SITE = 'https://4gray.github.io/iptvnator';
const GUIDES = [
{
slug: 'xtream-codes-setup-guide',
screenshots: [
'blog/guides/screenshots/guide-xtream-add-playlist-dark.png',
'blog/guides/screenshots/guide-xtream-auto-detect-dark.png',
'blog/guides/screenshots/guide-xtream-live-dark.png',
],
},
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
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.');
return blocks.flatMap((match) => JSON.parse(match[1]));
}
for (const guide of GUIDES) {
test(`${guide.slug}: BlogPosting and FAQPage structured data`, async () => {
const html = await readDist(`blog/${guide.slug}/index.html`);
const schema = extractJsonLd(html);
assert.ok(schema.some((entry) => entry['@type'] === 'BlogPosting'), 'Expected a BlogPosting entry.');
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
assert.ok(faq, 'Expected a FAQPage entry.');
assert.ok(faq.mainEntity.length >= 5, 'Expected at least five FAQ questions.');
assert.match(html, /Frequently asked questions/);
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/blog/${guide.slug}/"`));
});
test(`${guide.slug}: links to the download hub and ships its screenshots`, async () => {
const html = await readDist(`blog/${guide.slug}/index.html`);
assert.match(html, /href="\/iptvnator\/download\/"/);
for (const screenshot of guide.screenshots) {
assert.match(html, new RegExp(`src="/iptvnator/${screenshot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
await access(new URL(screenshot, distRoot));
}
});
}