mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(website): add the Stalker portal setup guide with mock-backed screenshots
Publish "How to Connect a Stalker or Ministra Portal to IPTVnator": the portal URL shapes discovery accepts, MAC normalization, the optional serial/device-ID/signature fields and the pinning rules behind the "generate device IDs" toggle, what endpoint discovery does on Add, the sections a portal source gets, Account info, and a troubleshooting list built from the app's own refusal messages, plus a seven-question FAQ. The guide is cross-linked from the download pages and llms.txt. Guide screenshots come from the capture script. Shots that walk into a Stalker portal start the stalker-mock-server and seed its marketing-demo portal for that run only, so release shots never gain a third source card. The frame guard allowlists exactly that scenario's MAC and keeps rejecting every other MAC-shaped string. To keep the live-TV frame free of third-party images, the fictional live channel list and the channel-logo SVG renderer move into @iptvnator/shared/marketing-fixtures; both mocks now serve /assets/marketing/logo/<slug>.svg, the Stalker marketing-demo scenario builds its ITV categories, channels and schedule from those fixtures instead of faker names with picsum logos, and the mock resolves asset URLs on get_all_channels too, which is the response the app renders the channel list from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9455e0db65
commit
6cfdaf5900
31 files changed
+938
-193
No files matched your search
@@ -337,7 +337,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance
|
||||
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
|
||||
- **shared/m3u-utils** - M3U playlist utilities
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/<slug>.svg`)
|
||||
- **shared/testing** - Shared test helpers
|
||||
- **ui/components** - Reusable UI components (incl. channel list)
|
||||
- **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)
|
||||
|
||||
@@ -84,7 +84,7 @@ actually get wrong:
|
||||
| `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow |
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first, plus the fictional live channels shared with the Xtream mock (logos served from `/assets/marketing/logo/<slug>.svg`, no censored category) — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
||||
| `00:1A:79:AE:<slot>:02` | **login-required** | Parallel-slot-scoped alias range used by concurrent auth E2E runs; it has the same login contract as `00:1A:79:00:00:08` but independent per-MAC state |
|
||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||
@@ -114,6 +114,7 @@ Besides the portal paths above, `src/main.ts` mounts:
|
||||
| `/stalker?url=<portal_url>&macAddress=<mac>&action=<action>&…` | `GET` | CORS-proxy mirror of the IPTVnator web-backend `/stalker` endpoint, used by PWA/Playwright runs. `macAddress`, `token` and `serialNumber` are control params turned into headers and stripped from the portal query (except `handshake`'s candidate token); the response is wrapped as `{ payload }`. Strictness follows the proxied `url`'s shape |
|
||||
| `/stream/gated/:file` | `GET` | `video.mp4` / `audio.mp4` fixtures for the `gated-stream` scenario — 403 without the mac cookie **and** the MAC's current Bearer token |
|
||||
| `/assets/marketing/poster/<slug>.png` | `GET` | The committed screenshot-safe poster catalog shared with the Xtream mock, served from this process so `marketing-demo` needs no second server |
|
||||
| `/assets/marketing/logo/<slug>.svg[?size=WxH]` | `GET` | Generated channel logo (initials on a gradient) for the `marketing-demo` live channels and radio stations, rendered by `@iptvnator/shared/marketing-fixtures` |
|
||||
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
|
||||
| `/reset[?macAddress=<mac>&macAddress=…]` | `POST` | Clear generated data, favorites, session/auth state (including pinned device IDs) and watchdog counters. **Pass the MACs you own**: state is per-MAC and parallel spec files share this process, so a bare `/reset` wipes their state mid-test. Repeated params clear several MACs in one request |
|
||||
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal |
|
||||
|
||||
@@ -3,6 +3,13 @@ import {
|
||||
MarketingMovieCategoryKey,
|
||||
POSTER_SHOWCASE_MOVIES,
|
||||
} from '@iptvnator/shared/marketing-fixtures';
|
||||
import {
|
||||
generateMarketingChannels,
|
||||
generateMarketingEpg,
|
||||
generateMarketingItvCategories,
|
||||
marketingEpgTitlesFor,
|
||||
marketingLogoPath,
|
||||
} from './marketing-live.generator.js';
|
||||
import { ScenarioConfig } from './scenarios.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -235,30 +242,53 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
};
|
||||
|
||||
// ------ ITV categories + channels ------
|
||||
data.itvCategories = generateCategories('itv', config.categoryCount.itv);
|
||||
// Real Ministra portals mark adult genres as censored and EXCLUDE their
|
||||
// channels from get_all_channels / the "*" listing; the channels are only
|
||||
// served by paging the specific genre. Mirror that with one extra
|
||||
// censored category so clients can exercise the fallback path.
|
||||
data.itvCategories.push({
|
||||
id: '1099',
|
||||
title: 'For adults',
|
||||
alias: 'for_adults',
|
||||
censored: '1',
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
channelIndex,
|
||||
config.staticChannelCmd === true
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(ch.id, generateEpg(ch.name));
|
||||
if (config.marketingFixture) {
|
||||
// Screenshot-safe: shared fictional channels, logos served by this
|
||||
// process, no censored category to keep out of a published frame.
|
||||
data.itvCategories = generateMarketingItvCategories();
|
||||
const marketingChannels = generateMarketingChannels();
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = marketingChannels.filter(
|
||||
(channel) => channel.category_id === cat.id
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(
|
||||
ch.id,
|
||||
generateMarketingEpg(ch.name, marketingEpgTitlesFor(ch.name))
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data.itvCategories = generateCategories(
|
||||
'itv',
|
||||
config.categoryCount.itv
|
||||
);
|
||||
// Real Ministra portals mark adult genres as censored and EXCLUDE
|
||||
// their channels from get_all_channels / the "*" listing; the
|
||||
// channels are only served by paging the specific genre. Mirror that
|
||||
// with one extra censored category so clients can exercise the
|
||||
// fallback path.
|
||||
data.itvCategories.push({
|
||||
id: '1099',
|
||||
title: 'For adults',
|
||||
alias: 'for_adults',
|
||||
censored: '1',
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
channelIndex,
|
||||
config.staticChannelCmd === true
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(ch.id, generateEpg(ch.name));
|
||||
}
|
||||
channelIndex += config.itemsPerCategory;
|
||||
}
|
||||
channelIndex += config.itemsPerCategory;
|
||||
}
|
||||
|
||||
// ------ Radio categories + stations ------
|
||||
@@ -271,7 +301,8 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
const stations = generateRadioStations(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
radioIndex
|
||||
radioIndex,
|
||||
config.marketingFixture === true
|
||||
);
|
||||
data.radio.set(cat.id, stations);
|
||||
radioIndex += config.itemsPerCategory;
|
||||
@@ -426,7 +457,8 @@ function generateChannels(
|
||||
function generateRadioStations(
|
||||
categoryId: string,
|
||||
count: number,
|
||||
startIndex: number
|
||||
startIndex: number,
|
||||
localLogos = false
|
||||
): RawRadioStation[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const globalIndex = startIndex + i;
|
||||
@@ -437,7 +469,7 @@ function generateRadioStations(
|
||||
name,
|
||||
o_name: name,
|
||||
cmd: `ffrt4://radio/${id}/index.mp3`,
|
||||
logo: logoUrl(`radio-${id}`),
|
||||
logo: localLogos ? marketingLogoPath(name) : logoUrl(`radio-${id}`),
|
||||
category_id: categoryId,
|
||||
tv_genre_id: categoryId,
|
||||
number: String(globalIndex + 1),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
MARKETING_LIVE_CATEGORIES,
|
||||
MARKETING_LIVE_CHANNELS,
|
||||
MARKETING_LOGO_ASSET_PATH,
|
||||
MarketingLiveCategoryKey,
|
||||
marketingSlug,
|
||||
} from '@iptvnator/shared/marketing-fixtures';
|
||||
import type { RawCategory, RawChannel, RawEpgProgram } from './data-generator.js';
|
||||
|
||||
/**
|
||||
* ITV side of the `marketing-demo` scenario: the provider-neutral channel
|
||||
* list shared with the Xtream mock, logos served by this process from
|
||||
* `/assets/marketing/logo/<slug>.svg`, and a schedule built from the
|
||||
* channels' own fictional programme titles. Nothing here reaches a
|
||||
* third-party host, which is what lets the release screenshot guards accept
|
||||
* a Stalker live-TV frame.
|
||||
*/
|
||||
|
||||
const STALKER_MARKETING_ITV_CATEGORY_IDS: Record<
|
||||
MarketingLiveCategoryKey,
|
||||
string
|
||||
> = {
|
||||
newsroom: '1101',
|
||||
sports: '1102',
|
||||
family: '1103',
|
||||
culture: '1104',
|
||||
};
|
||||
|
||||
const MARKETING_CHANNEL_ID_BASE = 11_000;
|
||||
const SLOT_MINUTES = 60;
|
||||
const TOTAL_DAYS = 7;
|
||||
|
||||
export function marketingLogoPath(name: string): string {
|
||||
return `${MARKETING_LOGO_ASSET_PATH}/${marketingSlug(name)}.svg?size=256x256`;
|
||||
}
|
||||
|
||||
export function generateMarketingItvCategories(): RawCategory[] {
|
||||
return MARKETING_LIVE_CATEGORIES.map((category) => ({
|
||||
id: STALKER_MARKETING_ITV_CATEGORY_IDS[category.key],
|
||||
title: category.name,
|
||||
alias: marketingSlug(category.name).replace(/-/g, '_'),
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateMarketingChannels(): RawChannel[] {
|
||||
return MARKETING_LIVE_CHANNELS.map((channel, index) => {
|
||||
const id = String(MARKETING_CHANNEL_ID_BASE + index);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: channel.name,
|
||||
o_name: channel.name,
|
||||
cmd: `ffrt4://ch/live/${id}/index.m3u8`,
|
||||
logo: marketingLogoPath(channel.name),
|
||||
category_id: STALKER_MARKETING_ITV_CATEGORY_IDS[channel.categoryKey],
|
||||
tv_genre_id: STALKER_MARKETING_ITV_CATEGORY_IDS[channel.categoryKey],
|
||||
xmltv_id: `${marketingSlug(channel.name)}.fictional`,
|
||||
use_http_tmp_link: '1',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Hourly slots for a week, cycling the channel's fixture titles. */
|
||||
export function generateMarketingEpg(
|
||||
channelName: string,
|
||||
titles: readonly string[]
|
||||
): RawEpgProgram[] {
|
||||
const dayStart = new Date();
|
||||
dayStart.setUTCHours(0, 0, 0, 0);
|
||||
const slotsPerDay = (24 * 60) / SLOT_MINUTES;
|
||||
|
||||
return Array.from({ length: slotsPerDay * TOTAL_DAYS }, (_, index) => {
|
||||
const start = new Date(dayStart.getTime() + index * SLOT_MINUTES * 60_000);
|
||||
const stop = new Date(start.getTime() + SLOT_MINUTES * 60_000);
|
||||
const title = titles[index % titles.length] ?? channelName;
|
||||
|
||||
return {
|
||||
id: String(index + 1),
|
||||
name: title,
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
start_timestamp: Math.floor(start.getTime() / 1000),
|
||||
stop_timestamp: Math.floor(stop.getTime() / 1000),
|
||||
descr: `${title} on ${channelName}, part of the fictional IPTVnator demo schedule.`,
|
||||
category: 'Fictional',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function marketingEpgTitlesFor(channelName: string): readonly string[] {
|
||||
return (
|
||||
MARKETING_LIVE_CHANNELS.find((channel) => channel.name === channelName)
|
||||
?.epgTitles ?? [channelName]
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,19 @@ describe('marketing poster URLs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves channel logo paths the same way as posters', () => {
|
||||
const req = request('http', { host: 'localhost:3210' });
|
||||
|
||||
expect(
|
||||
resolveMarketingPosterUrls(
|
||||
{ logo: '/assets/marketing/logo/aurora-news.svg?size=256x256' },
|
||||
buildRequestOrigin(req)
|
||||
)
|
||||
).toEqual({
|
||||
logo: 'http://localhost:3210/assets/marketing/logo/aurora-news.svg?size=256x256',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated URLs and values', () => {
|
||||
const payload = {
|
||||
cover: 'https://picsum.photos/seed/movie/300/450',
|
||||
|
||||
@@ -3,7 +3,8 @@ type RequestOriginSource = {
|
||||
get(name: string): string | undefined;
|
||||
};
|
||||
|
||||
const MARKETING_POSTER_PATH_PREFIX = '/assets/marketing/poster/';
|
||||
/** Posters and channel logos alike: every asset this process serves itself. */
|
||||
const MARKETING_POSTER_PATH_PREFIX = '/assets/marketing/';
|
||||
|
||||
export function buildRequestOrigin(request: RequestOriginSource): string {
|
||||
const protocol =
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './app/auth-store.js';
|
||||
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
|
||||
import { resetAll, resetMac } from './app/data-store.js';
|
||||
import { renderMarketingLogoSvg } from '@iptvnator/shared/marketing-fixtures';
|
||||
import { SCENARIOS } from './app/scenarios.js';
|
||||
import {
|
||||
buildRequestOrigin,
|
||||
@@ -53,9 +54,11 @@ app.use((req, _res, next) => {
|
||||
|
||||
// Marketing fixtures store deployment-neutral asset paths. Resolve them
|
||||
// against the public origin of each request, including reverse-proxy headers.
|
||||
// `get_all_channels` is the full ITV list the app caches per session, so the
|
||||
// live-TV channel logos of the marketing scenario ride on it as well.
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
!['get_ordered_list', 'favorites'].includes(
|
||||
!['get_ordered_list', 'get_all_channels', 'favorites'].includes(
|
||||
String(req.query['action'])
|
||||
)
|
||||
) {
|
||||
@@ -87,6 +90,17 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// Channel logos of the marketing-demo scenario, rendered on the fly with the
|
||||
// same generator the Xtream mock uses, so a live-TV frame never fetches a
|
||||
// third-party image.
|
||||
app.get('/assets/marketing/logo/:slug', (req: Request, res: Response) => {
|
||||
const size =
|
||||
typeof req.query['size'] === 'string' ? req.query['size'] : undefined;
|
||||
res.type('image/svg+xml')
|
||||
.set('Cache-Control', 'public, max-age=3600')
|
||||
.send(renderMarketingLogoSvg(String(req.params['slug'] ?? ''), size));
|
||||
});
|
||||
|
||||
// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check)
|
||||
app.use('/portal.php', portalRouter);
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ 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).
|
||||
(`xtream-codes-setup-guide.mdx` and `stalker-portal-setup-guide.mdx` in
|
||||
`apps/website/src/content/blog/`).
|
||||
Two conventions set them apart:
|
||||
|
||||
- **`faq` frontmatter.** An optional list of `{ q, a }` entries. `BlogPost.astro`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 476 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
@@ -12,6 +12,7 @@
|
||||
- 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/
|
||||
- Guide, Stalker/Ministra portal setup: https://4gray.github.io/iptvnator/blog/stalker-portal-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
|
||||
|
||||
@@ -15,7 +15,11 @@ const { href, label, external = true } = Astro.props;
|
||||
class="not-prose group my-6 inline-flex items-center gap-2 rounded-lg border border-dashed border-accent-500/35 bg-accent-500/[0.08] px-4 py-2.5 text-sm font-medium text-accent-200 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/[0.14] hover:text-accent-100"
|
||||
>
|
||||
<span>{label ?? 'Open link'}</span>
|
||||
<svg class="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5H19.5m0 0v6m0-6L10.5 13.5M6 6h3.75M6 10.5V18a1.5 1.5 0 001.5 1.5H15" />
|
||||
<svg class="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
{external ? (
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5H19.5m0 0v6m0-6L10.5 13.5M6 6h3.75M6 10.5V18a1.5 1.5 0 001.5 1.5H15" />
|
||||
) : (
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
)}
|
||||
</svg>
|
||||
</a>
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: How to Connect a Stalker or Ministra Portal to IPTVnator
|
||||
description: Add a Stalker portal to IPTVnator step by step. Which URL shapes work, how the MAC address is normalized, what device IDs and serial numbers are for, how IPTVnator detects the portal type, and what the usual refusals mean.
|
||||
pubDate: 2026-09-03
|
||||
author: 4gray
|
||||
heroImage: /iptvnator/blog/guides/screenshots/guide-stalker-live-dark.png
|
||||
tags:
|
||||
- guide
|
||||
- stalker-portal
|
||||
- setup
|
||||
- tutorial
|
||||
draft: false
|
||||
faq:
|
||||
- q: What is the difference between a Stalker portal and an Xtream Codes account?
|
||||
a: Both are ways a provider hands you access. Xtream Codes identifies you by username and password against an API. A Stalker or Ministra portal identifies a set-top box by its MAC address against a middleware server, the way MAG boxes and set-top-box emulators work. If your provider gave you a portal URL and a MAC address, you have a Stalker portal.
|
||||
- q: Which portal URL should I enter?
|
||||
a: Whatever your provider gave you, as long as it starts with http or https. A bare host, an address ending in /c, portal.php, or server/load.php all work. IPTVnator probes the usual endpoints behind that address and keeps the one that answers, so you rarely need to guess the exact path.
|
||||
- q: The provider's MAC address is written with dashes or in lowercase. Does it matter?
|
||||
a: No. IPTVnator normalizes hyphens, dots, spaces and lowercase letters into the canonical 00:1A:79:XX:XX:XX form when you leave the field, and again when you click Add. The bytes stay the same, so the portal sees the address it registered.
|
||||
- q: Should I turn on "Generate device IDs from the MAC address"?
|
||||
a: Only for a MAC the portal has never seen, or one you already use in StbEmu with generated IDs. A portal pins the first device ID it receives to the MAC and refuses any other one afterwards, so enabling it for an existing box with a different ID locks the account out. If your provider did not mention device IDs, leave the option off.
|
||||
- q: Why does IPTVnator ask for a username and password on some portals?
|
||||
a: A few Ministra portals require a login on top of the MAC address. IPTVnator reports "This portal requires a login and password" when it sees that; fill in the two optional fields and click Add again. Portals that never ask for it ignore the fields.
|
||||
- q: Does IPTVnator show a program guide and catch-up for portal channels?
|
||||
a: Yes. It loads the portal's seven-day schedule once per session and shows the current program in the channel list and a timeline under the player. Channels the portal marks as archived can be played from the timeline. On the desktop app, XMLTV sources configured in the settings act as a fallback.
|
||||
- q: Where can I see when my portal subscription expires?
|
||||
a: Open Account info from the playlist switcher in the header or from the source card on the dashboard. It shows status, login, tariff plan, expiry date with a days-left counter, and portal details, using the data saved at import when the portal cannot be reached.
|
||||
---
|
||||
|
||||
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';
|
||||
|
||||
Stalker portals, also sold as Ministra portals, are the middleware that MAG set-top boxes talk
|
||||
to. Instead of a username and password, the portal knows your device by its **MAC address**,
|
||||
and everything else, from the channel list to the movie catalog and the program guide, comes
|
||||
from that one identity. IPTVnator behaves like a MAG box towards the portal, so a subscription
|
||||
meant for a set-top box or a set-top-box emulator works on your desktop too.
|
||||
|
||||
This guide covers what to enter, what IPTVnator does with it, and what the portal's usual
|
||||
refusals mean. 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 portals, channels or accounts. The portal URL and MAC address 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 |
|
||||
| --- | --- | --- |
|
||||
| Portal URL | `http://portal.example-provider.net/c` | A bare host, `/c`, `portal.php` or `server/load.php` all work. Must start with `http://` or `https://`. |
|
||||
| MAC address | `00:1A:79:12:34:56` | Twelve hex digits. Dashes, dots and lowercase are corrected for you. |
|
||||
| Username and password | optional | Only some Ministra portals ask for them. |
|
||||
| Serial number, device IDs, signatures | optional | Only if the provider registered a specific box and told you the values. |
|
||||
|
||||
Most portals only accept MAC addresses from Infomir's `00:1A:79` range, the manufacturer of
|
||||
MAG boxes. IPTVnator shows a hint when an address is outside that range but does not refuse
|
||||
it, because some reseller panels are configured differently.
|
||||
|
||||
## Add the portal
|
||||
|
||||
<StepRail
|
||||
title="In IPTVnator"
|
||||
steps={[
|
||||
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add Stalker portal".',
|
||||
'Choose the "Stalker portal" method.',
|
||||
'Fill in a title of your choice, the portal URL and the MAC address. Leave the optional fields empty unless your provider gave you values for them.',
|
||||
'Click "Add". IPTVnator probes the portal, authenticates with your MAC address, and opens the new source. "Validating portal..." can take a few seconds on slow servers.',
|
||||
]}
|
||||
/>
|
||||
|
||||

|
||||
|
||||
<Alert type="success" title="Paste the provider message instead">
|
||||
The Auto-detect method in the same dialog reads a pasted email or chat message. A MAC
|
||||
address is its strongest signal, so a message with a portal link and a MAC address
|
||||
pre-fills the Stalker form for you. You still click Add yourself.
|
||||
</Alert>
|
||||
|
||||
## What happens when you click Add
|
||||
|
||||
Providers write portal addresses in a dozen shapes, and the same host can run a strict
|
||||
Ministra middleware or a tolerant reseller panel. Guessing from the URL used to break real
|
||||
setups, so IPTVnator no longer guesses:
|
||||
|
||||
1. It tries the address you entered, then the usual endpoints behind it (`portal.php`,
|
||||
`server/load.php`, `stalker_portal/server/load.php`) in order.
|
||||
2. For each one it observes how the portal behaves. A portal that hands out data without a
|
||||
token is a **simple panel**; a portal that demands the handshake token is a **full
|
||||
Ministra portal**, and IPTVnator completes the handshake and reads your profile to
|
||||
confirm it.
|
||||
3. The endpoint and mode that worked are saved with the source. Later requests use exactly
|
||||
that address, and if a provider migrates the portal, IPTVnator re-probes once on the
|
||||
first failure and repairs the stored connection.
|
||||
|
||||
On a full portal the profile also carries your subscription facts, and the dialog confirms
|
||||
the import with the expiry date. A portal that cannot be reached during the import is still
|
||||
added when the address looks like a panel, so a temporarily offline provider does not block
|
||||
you; the next successful request completes the setup.
|
||||
|
||||
## After the import
|
||||
|
||||

|
||||
|
||||
The source gets its own sidebar:
|
||||
|
||||
- **Live TV**: categories on the left, channels with the current program and a progress
|
||||
bar, the player and a timeline of the schedule. IPTVnator loads the complete channel list
|
||||
once per session, so search covers every channel and not only the pages the portal has
|
||||
served so far. Channels the portal marks as archived can be played from the timeline.
|
||||
- **Radio**: portal radio stations in a dedicated audio layout, always through the built-in
|
||||
player.
|
||||
- **Movies** and **Series**: poster grids per category and a detail page with description,
|
||||
cast, trailer and similar titles. Series come in the three shapes portals use, regular
|
||||
series, movies with embedded episodes and Ministra's lazily loaded seasons, and all three
|
||||
render as seasons and episodes with resume positions and watched markers. The desktop app
|
||||
adds a download button for offline viewing.
|
||||
- **Search**, **Favorites** and **Recent** 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.
|
||||
|
||||
<Alert type="info" title="Program guide">
|
||||
IPTVnator asks the portal for a seven-day schedule once per session and shows it in the
|
||||
channel list and in the timeline under the player. If a channel shows nothing, the portal
|
||||
did not publish a schedule 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 or from the source card on
|
||||
the dashboard. IPTVnator shows the data saved during the import immediately, marked as saved
|
||||
data, then refreshes it from the portal: status, login, tariff plan, expiry date with a
|
||||
days-left counter, the MAC address and the portal details. The source card on the dashboard
|
||||
carries a small expiry chip that turns amber during the last week of the subscription.
|
||||
|
||||
## Device identity, explained
|
||||
|
||||
A real MAG box reports more than its MAC address: a serial number, two device IDs and two
|
||||
signatures. IPTVnator sends only what you typed. Empty fields stay empty, nothing is invented
|
||||
behind your back, and that matters because of how portals treat device IDs:
|
||||
|
||||
- The portal **pins the first device ID it receives** to the MAC address and refuses every
|
||||
later one. A later empty value locks the MAC out for good.
|
||||
- **Generate device IDs from the MAC address** produces the same pair StbEmu generates, so a
|
||||
MAC you already use there keeps working. It is offered at import only, writes the values
|
||||
into the visible fields, and re-derives them if you correct the MAC before clicking Add.
|
||||
- Once a device ID is stored, the playlist details dialog warns before you change or clear
|
||||
it, because the portal will not accept the new value.
|
||||
|
||||
If your provider registered the box for you and sent a serial number or device IDs, enter
|
||||
exactly those values.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The portal requires a login and password**: fill in the optional username and password
|
||||
fields and click Add again. Some Ministra portals demand a login on top of the MAC address.
|
||||
- **The portal rejected the login and password**: the credentials are wrong or the
|
||||
subscription lapsed; the portal's own message is shown below the headline.
|
||||
- **The portal has a different device ID registered for this MAC address**: another box or
|
||||
emulator authenticated first. Restore the device ID you used there, or ask the provider to
|
||||
reset the device for this MAC.
|
||||
- **The portal refused access for this device**: the account is blocked or expired, or the
|
||||
MAC address is not registered. The portal's own message tells you which; contact the
|
||||
provider.
|
||||
- **Failed to authenticate with the portal. Check the URL and MAC address**: the host did
|
||||
not answer on any of the probed endpoints. Check the address, the network or the VPN, and
|
||||
try again.
|
||||
- **Channels load but nothing plays**: 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. IPTVnator resolves a fresh temporary link from the portal for every playback,
|
||||
so a stream that stopped working usually works again after re-selecting the channel.
|
||||
- **The catalog looks stale**: portals paginate their lists. Use the refresh action in the
|
||||
live TV header to reload the complete channel list, and re-open a category to fetch its
|
||||
content again.
|
||||
|
||||
## Related
|
||||
|
||||
<LinkCards
|
||||
links={[
|
||||
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'Windows, macOS and Linux builds, package managers and the browser version.', icon: 'download' },
|
||||
{ label: 'Add an Xtream Codes account', href: '/iptvnator/blog/xtream-codes-setup-guide/', hint: 'The username-and-password way of connecting a provider.', icon: 'docs' },
|
||||
{ label: 'Why some streams need an external player', href: '/iptvnator/blog/why-external-players-help/', hint: 'Codecs, containers and when MPV or VLC is the right tool.', icon: 'docs' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
|
||||
@@ -298,7 +298,7 @@ sudo emerge iptvnator-bin</TerminalBlock>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'stalker-portal-setup-guide', 'beware-unofficial-iptvnator-websites']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ const jsonLd = buildDownloadPageSchema({
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'macos-arm-app-damaged-fix', 'why-external-players-help']} />
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'stalker-portal-setup-guide', 'macos-arm-app-damaged-fix']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,7 @@ const jsonLd = buildDownloadPageSchema({
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'stalker-portal-setup-guide', 'beware-unofficial-iptvnator-websites']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import {
|
||||
MARKETING_LIVE_CATEGORIES,
|
||||
MARKETING_LIVE_CHANNELS,
|
||||
MarketingLiveCategoryKey,
|
||||
MarketingLiveChannelFixture,
|
||||
MarketingMovieCategoryKey,
|
||||
MarketingMovieFixture,
|
||||
POSTER_SHOWCASE_MOVIES as SHARED_POSTER_SHOWCASE_MOVIES,
|
||||
escapeMarketingSvg,
|
||||
marketingHash,
|
||||
marketingPalette,
|
||||
marketingSlug,
|
||||
marketingSvgDocument,
|
||||
marketingTitleFromSlug,
|
||||
renderMarketingLogoSvg,
|
||||
} from '@iptvnator/shared/marketing-fixtures';
|
||||
import { RawCategory } from './categories.generator.js';
|
||||
import {
|
||||
@@ -38,12 +49,6 @@ type MarketingSeries = {
|
||||
year: number;
|
||||
};
|
||||
|
||||
type MarketingLiveChannel = {
|
||||
categoryId: string;
|
||||
epgTitles: string[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type MarketingArtworkFixture = {
|
||||
contentType: 'movie' | 'series';
|
||||
description: string;
|
||||
@@ -70,12 +75,20 @@ const MARKETING_SERIES_ID_BASE = 72_000;
|
||||
const MARKETING_EPISODE_ID_BASE = 82_000;
|
||||
const ADDED_BASE = 1_777_000_000;
|
||||
|
||||
const LIVE_CATEGORIES: RawCategory[] = [
|
||||
{ category_id: '5101', category_name: 'Newsroom', parent_id: 0 },
|
||||
{ category_id: '5102', category_name: 'Sports & Motion', parent_id: 0 },
|
||||
{ category_id: '5103', category_name: 'Family Channels', parent_id: 0 },
|
||||
{ category_id: '5104', category_name: 'Culture & Docs', parent_id: 0 },
|
||||
];
|
||||
const XTREAM_LIVE_CATEGORY_IDS: Record<MarketingLiveCategoryKey, string> = {
|
||||
newsroom: '5101',
|
||||
sports: '5102',
|
||||
family: '5103',
|
||||
culture: '5104',
|
||||
};
|
||||
|
||||
const LIVE_CATEGORIES: RawCategory[] = MARKETING_LIVE_CATEGORIES.map(
|
||||
(category) => ({
|
||||
category_id: XTREAM_LIVE_CATEGORY_IDS[category.key],
|
||||
category_name: category.name,
|
||||
parent_id: 0,
|
||||
})
|
||||
);
|
||||
|
||||
const VOD_CATEGORIES: RawCategory[] = [
|
||||
{ category_id: '5201', category_name: 'Action & Mystery', parent_id: 0 },
|
||||
@@ -116,48 +129,8 @@ const SERIES_CATEGORIES: RawCategory[] = [
|
||||
{ category_id: '5304', category_name: 'Creative Docs', parent_id: 0 },
|
||||
];
|
||||
|
||||
const LIVE_CHANNELS: MarketingLiveChannel[] = [
|
||||
{
|
||||
categoryId: '5101',
|
||||
name: 'Aurora News',
|
||||
epgTitles: ['Morning Briefing', 'City Desk', 'Global Window'],
|
||||
},
|
||||
{
|
||||
categoryId: '5101',
|
||||
name: 'Civic One',
|
||||
epgTitles: ['Town Hall Live', 'Policy Today', 'Open Forum'],
|
||||
},
|
||||
{
|
||||
categoryId: '5102',
|
||||
name: 'Fieldside Sports',
|
||||
epgTitles: ['Training Ground', 'Matchday Studio', 'Final Whistle'],
|
||||
},
|
||||
{
|
||||
categoryId: '5102',
|
||||
name: 'Motion Arena',
|
||||
epgTitles: ['Court Vision', 'Trackside', 'Night Highlights'],
|
||||
},
|
||||
{
|
||||
categoryId: '5103',
|
||||
name: 'Horizon Kids',
|
||||
epgTitles: ['Rocket Workshop', 'Tiny Explorers', 'Story Lantern'],
|
||||
},
|
||||
{
|
||||
categoryId: '5103',
|
||||
name: 'Kitchen Lab',
|
||||
epgTitles: ['Breakfast Builders', 'Family Table', 'Sweet Science'],
|
||||
},
|
||||
{
|
||||
categoryId: '5104',
|
||||
name: 'Atlas Docs',
|
||||
epgTitles: ['Ocean Notes', 'Museum Hour', 'Wide Angle'],
|
||||
},
|
||||
{
|
||||
categoryId: '5104',
|
||||
name: 'Night Music',
|
||||
epgTitles: ['Studio Session', 'Late Set', 'Ambient City'],
|
||||
},
|
||||
];
|
||||
const LIVE_CHANNELS: readonly MarketingLiveChannelFixture[] =
|
||||
MARKETING_LIVE_CHANNELS;
|
||||
|
||||
const GENERATED_ARTWORK_MOVIES: MarketingMovie[] = [
|
||||
{
|
||||
@@ -570,7 +543,7 @@ export function listMarketingArtworkFixtures(): MarketingArtworkFixture[] {
|
||||
description: movie.description,
|
||||
genre: movie.genre,
|
||||
name: movie.name,
|
||||
slug: slugify(movie.name),
|
||||
slug: marketingSlug(movie.name),
|
||||
tagline: movie.tagline,
|
||||
year: movie.year,
|
||||
})),
|
||||
@@ -579,7 +552,7 @@ export function listMarketingArtworkFixtures(): MarketingArtworkFixture[] {
|
||||
description: series.description,
|
||||
genre: series.genre,
|
||||
name: series.name,
|
||||
slug: slugify(series.name),
|
||||
slug: marketingSlug(series.name),
|
||||
tagline: series.tagline,
|
||||
year: series.year,
|
||||
})),
|
||||
@@ -741,7 +714,7 @@ export function marketingAssetUrl(
|
||||
title: string,
|
||||
size: string
|
||||
): string {
|
||||
return `${marketingAssetOrigin()}/assets/marketing/${kind}/${slugify(
|
||||
return `${marketingAssetOrigin()}/assets/marketing/${kind}/${marketingSlug(
|
||||
title
|
||||
)}.svg?size=${encodeURIComponent(size)}`;
|
||||
}
|
||||
@@ -752,41 +725,19 @@ export function renderMarketingAssetSvg(
|
||||
size: string | undefined
|
||||
): string {
|
||||
const { width, height } = parseSize(size, kind);
|
||||
const title = titleFromSlug(slug);
|
||||
const palette = paletteFor(`${kind}:${slug}`);
|
||||
const initials = title
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
|
||||
const title = marketingTitleFromSlug(slug);
|
||||
const palette = marketingPalette(`${kind}:${slug}`);
|
||||
if (kind === 'logo') {
|
||||
return svgDocument(
|
||||
width,
|
||||
height,
|
||||
`
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="${palette[0]}" />
|
||||
<stop offset="100%" stop-color="${palette[1]}" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" rx="${width * 0.22}" fill="url(#bg)" />
|
||||
<circle cx="${width * 0.72}" cy="${height * 0.25}" r="${width * 0.2}" fill="${palette[2]}" opacity="0.35" />
|
||||
<path d="M ${width * 0.16} ${height * 0.72} C ${width * 0.35} ${height * 0.48}, ${width * 0.58} ${height * 0.92}, ${width * 0.84} ${height * 0.58}" fill="none" stroke="#fff" stroke-width="${Math.max(8, width * 0.05)}" stroke-linecap="round" opacity="0.42" />
|
||||
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="${width * 0.3}" font-weight="800" fill="#fff">${escapeSvg(initials)}</text>
|
||||
`
|
||||
);
|
||||
return renderMarketingLogoSvg(slug, size);
|
||||
}
|
||||
|
||||
if (kind === 'backdrop' || kind === 'episode') {
|
||||
const skylineY = height * 0.7;
|
||||
const heroX = width * (0.34 + (hash(slug) % 20) / 100);
|
||||
const heroX = width * (0.34 + (marketingHash(slug) % 20) / 100);
|
||||
const heroY = height * 0.38;
|
||||
const accentX = width * (0.68 + (hash(`${slug}:light`) % 16) / 100);
|
||||
const accentX = width * (0.68 + (marketingHash(`${slug}:light`) % 16) / 100);
|
||||
|
||||
return svgDocument(
|
||||
return marketingSvgDocument(
|
||||
width,
|
||||
height,
|
||||
`
|
||||
@@ -846,7 +797,7 @@ export function renderMarketingAssetSvg(
|
||||
}, [])
|
||||
.slice(0, 3);
|
||||
|
||||
return svgDocument(
|
||||
return marketingSvgDocument(
|
||||
width,
|
||||
height,
|
||||
`
|
||||
@@ -876,17 +827,17 @@ export function renderMarketingAssetSvg(
|
||||
${titleLines
|
||||
.map(
|
||||
(line, index) =>
|
||||
`<text x="${width * 0.08}" y="${height * 0.58 + index * titleFontSize * 1.05}" font-family="Arial, Helvetica, sans-serif" font-size="${titleFontSize}" font-weight="900" fill="#ffffff">${escapeSvg(line.toUpperCase())}</text>`
|
||||
`<text x="${width * 0.08}" y="${height * 0.58 + index * titleFontSize * 1.05}" font-family="Arial, Helvetica, sans-serif" font-size="${titleFontSize}" font-weight="900" fill="#ffffff">${escapeMarketingSvg(line.toUpperCase())}</text>`
|
||||
)
|
||||
.join('')}
|
||||
<text x="${width * 0.08}" y="${height * 0.84}" font-family="Arial, Helvetica, sans-serif" font-size="${Math.max(11, height * 0.024)}" font-weight="800" fill="#ffffff" opacity="0.76">${escapeSvg(creditLine)}</text>
|
||||
<text x="${width * 0.08}" y="${height * 0.84}" font-family="Arial, Helvetica, sans-serif" font-size="${Math.max(11, height * 0.024)}" font-weight="800" fill="#ffffff" opacity="0.76">${escapeMarketingSvg(creditLine)}</text>
|
||||
<text x="${width * 0.08}" y="${height * 0.9}" font-family="Arial, Helvetica, sans-serif" font-size="${Math.max(12, height * 0.028)}" font-weight="700" fill="#ffffff" opacity="0.72">IPTVnator fictional demo artwork</text>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function buildMarketingLiveStream(
|
||||
channel: MarketingLiveChannel,
|
||||
channel: MarketingLiveChannelFixture,
|
||||
index: number
|
||||
): RawLiveStream {
|
||||
const streamId = MARKETING_LIVE_STREAM_ID_BASE + index;
|
||||
@@ -898,7 +849,7 @@ function buildMarketingLiveStream(
|
||||
stream_icon: marketingAssetUrl('logo', channel.name, '256x256'),
|
||||
epg_channel_id: `marketing-channel-${streamId}`,
|
||||
added: String(ADDED_BASE - index * 4_000),
|
||||
category_id: channel.categoryId,
|
||||
category_id: XTREAM_LIVE_CATEGORY_IDS[channel.categoryKey],
|
||||
custom_sid: '',
|
||||
direct_source: '',
|
||||
tv_archive: 1,
|
||||
@@ -957,7 +908,7 @@ function buildMarketingSeriesItem(
|
||||
|
||||
function buildMarketingEpgListings(
|
||||
stream: RawLiveStream,
|
||||
titles: string[]
|
||||
titles: readonly string[]
|
||||
): RawEpgListing[] {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const slotSeconds = 30 * 60;
|
||||
@@ -984,22 +935,6 @@ function marketingAssetOrigin(): string {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function titleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.replace(/\.svg$/i, '')
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function parseSize(
|
||||
size: string | undefined,
|
||||
kind: MarketingAssetKind
|
||||
@@ -1022,19 +957,6 @@ function parseSize(
|
||||
};
|
||||
}
|
||||
|
||||
function paletteFor(seed: string): [string, string, string] {
|
||||
const palettes: Array<[string, string, string]> = [
|
||||
['#0b1026', '#1b6b77', '#f2a65a'],
|
||||
['#15111f', '#6b3fa0', '#20c7b5'],
|
||||
['#071b2c', '#2457a6', '#f05d5e'],
|
||||
['#1b1b24', '#8d4f2a', '#e9c46a'],
|
||||
['#10251d', '#2a9d8f', '#e76f51'],
|
||||
['#18151f', '#b23a48', '#f4a261'],
|
||||
];
|
||||
const index = hash(seed) % palettes.length;
|
||||
return palettes[index];
|
||||
}
|
||||
|
||||
function creditLineFor(seed: string): string {
|
||||
const credits = [
|
||||
'STARRING MARA SOL / THEO QUILL / IRIS VALE',
|
||||
@@ -1044,23 +966,6 @@ function creditLineFor(seed: string): string {
|
||||
'STARRING NICO REED / TESSA MOON / BRAM COLE',
|
||||
'STARRING ELLE FENN / RAFI NORTH / MIRA STONE',
|
||||
];
|
||||
return credits[hash(seed) % credits.length];
|
||||
return credits[marketingHash(seed) % credits.length];
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
return value.split('').reduce((acc, char) => {
|
||||
return (acc * 31 + char.charCodeAt(0)) >>> 0;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function svgDocument(width: number, height: number, body: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">${body}</svg>`;
|
||||
}
|
||||
|
||||
function escapeSvg(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -163,7 +163,14 @@ into `apps/website/public/blog/guides/screenshots/` instead of a release folder
|
||||
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.
|
||||
any URL carrying query credentials. Shots that walk into a Stalker portal
|
||||
(`open-stalker-live`) make the run start the stalker-mock-server on port 3210
|
||||
and seed its `marketing-demo` portal as a third source, which is why they are
|
||||
never part of a release run. That scenario's MAC, `00:1A:79:00:00:07`, is the
|
||||
one MAC-shaped string G4 accepts (`FICTIONAL_STALKER_MAC`); every other MAC
|
||||
still fails the frame. The scenario's live channels and logos come from
|
||||
`@iptvnator/shared/marketing-fixtures`, served by the mock itself, so no
|
||||
third-party image is ever requested.
|
||||
|
||||
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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './lib/shared-marketing-fixtures';
|
||||
export * from './lib/marketing-live-fixtures';
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
MARKETING_LIVE_CATEGORIES,
|
||||
MARKETING_LIVE_CHANNELS,
|
||||
marketingSlug,
|
||||
marketingTitleFromSlug,
|
||||
renderMarketingLogoSvg,
|
||||
} from './marketing-live-fixtures';
|
||||
|
||||
describe('marketing live fixtures', () => {
|
||||
it('places every channel in a declared category with a schedule', () => {
|
||||
const keys = new Set(MARKETING_LIVE_CATEGORIES.map((c) => c.key));
|
||||
|
||||
for (const channel of MARKETING_LIVE_CHANNELS) {
|
||||
expect(keys.has(channel.categoryKey)).toBe(true);
|
||||
expect(channel.epgTitles.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(new Set(MARKETING_LIVE_CHANNELS.map((c) => c.name)).size).toBe(
|
||||
MARKETING_LIVE_CHANNELS.length
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips a channel name through the slug used in asset URLs', () => {
|
||||
expect(marketingSlug('Aurora News')).toBe('aurora-news');
|
||||
expect(marketingTitleFromSlug('aurora-news.svg')).toBe('Aurora News');
|
||||
});
|
||||
|
||||
it('renders a self-contained SVG logo with the channel initials', () => {
|
||||
const svg = renderMarketingLogoSvg('aurora-news.svg', '128x128');
|
||||
|
||||
expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(
|
||||
true
|
||||
);
|
||||
expect(svg).toContain('width="128"');
|
||||
expect(svg).toContain('>AN</text>');
|
||||
expect(svg).not.toContain('http://localhost');
|
||||
});
|
||||
|
||||
it('escapes markup that leaks into a slug', () => {
|
||||
expect(renderMarketingLogoSvg('<b>x', undefined)).not.toContain('<b>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Provider-neutral fictional live TV fixtures shared by the Xtream and
|
||||
* Stalker marketing mocks, plus the generated logo artwork both serve from
|
||||
* `/assets/marketing/logo/<slug>.svg`. Everything here is invented: no real
|
||||
* broadcaster, programme, or logo is referenced, which is what makes the
|
||||
* screenshots built on top of it publishable.
|
||||
*/
|
||||
|
||||
export type MarketingLiveCategoryKey =
|
||||
| 'newsroom'
|
||||
| 'sports'
|
||||
| 'family'
|
||||
| 'culture';
|
||||
|
||||
export interface MarketingLiveCategoryFixture {
|
||||
key: MarketingLiveCategoryKey;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MarketingLiveChannelFixture {
|
||||
categoryKey: MarketingLiveCategoryKey;
|
||||
name: string;
|
||||
/** Cycled through the generated schedule, so the guide always has titles. */
|
||||
epgTitles: readonly string[];
|
||||
}
|
||||
|
||||
export const MARKETING_LIVE_CATEGORIES: readonly MarketingLiveCategoryFixture[] =
|
||||
[
|
||||
{ key: 'newsroom', name: 'Newsroom' },
|
||||
{ key: 'sports', name: 'Sports & Motion' },
|
||||
{ key: 'family', name: 'Family Channels' },
|
||||
{ key: 'culture', name: 'Culture & Docs' },
|
||||
];
|
||||
|
||||
export const MARKETING_LIVE_CHANNELS: readonly MarketingLiveChannelFixture[] = [
|
||||
{
|
||||
categoryKey: 'newsroom',
|
||||
name: 'Aurora News',
|
||||
epgTitles: ['Morning Briefing', 'City Desk', 'Global Window'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'newsroom',
|
||||
name: 'Civic One',
|
||||
epgTitles: ['Town Hall Live', 'Policy Today', 'Open Forum'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'sports',
|
||||
name: 'Fieldside Sports',
|
||||
epgTitles: ['Training Ground', 'Matchday Studio', 'Final Whistle'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'sports',
|
||||
name: 'Motion Arena',
|
||||
epgTitles: ['Court Vision', 'Trackside', 'Night Highlights'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'family',
|
||||
name: 'Horizon Kids',
|
||||
epgTitles: ['Rocket Workshop', 'Tiny Explorers', 'Story Lantern'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'family',
|
||||
name: 'Kitchen Lab',
|
||||
epgTitles: ['Breakfast Builders', 'Family Table', 'Sweet Science'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'culture',
|
||||
name: 'Atlas Docs',
|
||||
epgTitles: ['Ocean Notes', 'Museum Hour', 'Wide Angle'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'culture',
|
||||
name: 'Night Music',
|
||||
epgTitles: ['Studio Session', 'Late Set', 'Ambient City'],
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKETING_LOGO_ASSET_PATH = '/assets/marketing/logo';
|
||||
|
||||
/** URL-safe slug shared by asset URLs and the renderer that answers them. */
|
||||
export function marketingSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/** Inverse of `marketingSlug` for rendering initials back out of a URL. */
|
||||
export function marketingTitleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.replace(/\.svg$/i, '')
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function marketingHash(value: string): number {
|
||||
return value.split('').reduce((acc, char) => {
|
||||
return (acc * 31 + char.charCodeAt(0)) >>> 0;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function marketingPalette(seed: string): [string, string, string] {
|
||||
const palettes: Array<[string, string, string]> = [
|
||||
['#0b1026', '#1b6b77', '#f2a65a'],
|
||||
['#15111f', '#6b3fa0', '#20c7b5'],
|
||||
['#071b2c', '#2457a6', '#f05d5e'],
|
||||
['#1b1b24', '#8d4f2a', '#e9c46a'],
|
||||
['#10251d', '#2a9d8f', '#e76f51'],
|
||||
['#18151f', '#b23a48', '#f4a261'],
|
||||
];
|
||||
return palettes[marketingHash(seed) % palettes.length];
|
||||
}
|
||||
|
||||
export function marketingSvgDocument(
|
||||
width: number,
|
||||
height: number,
|
||||
body: string
|
||||
): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">${body}</svg>`;
|
||||
}
|
||||
|
||||
export function escapeMarketingSvg(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** `WIDTHxHEIGHT` query value; falls back to a square 256 px logo. */
|
||||
export function parseMarketingLogoSize(size: string | undefined): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) {
|
||||
return { width: 256, height: 256 };
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel logo: gradient tile with the channel initials. Identical output for
|
||||
* both mocks so a screenshot looks the same whichever portal type it shows.
|
||||
*/
|
||||
export function renderMarketingLogoSvg(
|
||||
slug: string,
|
||||
size: string | undefined
|
||||
): string {
|
||||
const { width, height } = parseMarketingLogoSize(size);
|
||||
const title = marketingTitleFromSlug(slug);
|
||||
const palette = marketingPalette(`logo:${slug}`);
|
||||
const initials = title
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
|
||||
return marketingSvgDocument(
|
||||
width,
|
||||
height,
|
||||
`
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="${palette[0]}" />
|
||||
<stop offset="100%" stop-color="${palette[1]}" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" rx="${width * 0.22}" fill="url(#bg)" />
|
||||
<circle cx="${width * 0.72}" cy="${height * 0.25}" r="${width * 0.2}" fill="${palette[2]}" opacity="0.35" />
|
||||
<path d="M ${width * 0.16} ${height * 0.72} C ${width * 0.35} ${height * 0.48}, ${width * 0.58} ${height * 0.92}, ${width * 0.84} ${height * 0.58}" fill="none" stroke="#fff" stroke-width="${Math.max(8, width * 0.05)}" stroke-linecap="round" opacity="0.42" />
|
||||
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="${width * 0.3}" font-weight="800" fill="#fff">${escapeMarketingSvg(initials)}</text>
|
||||
`
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
|
||||
import {
|
||||
M3U_FIXTURE_TITLE,
|
||||
STALKER_FIXTURE_MAC,
|
||||
STALKER_FIXTURE_PORTAL_URL,
|
||||
STALKER_FIXTURE_TITLE,
|
||||
STALKER_MOCK_ORIGIN,
|
||||
XTREAM_FIXTURE_CREDENTIALS,
|
||||
XTREAM_FIXTURE_TITLE,
|
||||
XTREAM_MOCK_ORIGIN,
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
registerPlaylistId,
|
||||
requirePlaylistId,
|
||||
} from './capture-navigation';
|
||||
import type { PlaylistProvider } from './capture-navigation';
|
||||
|
||||
export {
|
||||
M3U_FIXTURE_TITLE,
|
||||
@@ -35,6 +40,13 @@ export {
|
||||
|
||||
/** Synthetic categories that only the marketing fixture generator produces. */
|
||||
const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama'];
|
||||
/**
|
||||
* Live categories of the Stalker mock's marketing-demo scenario, from
|
||||
* `MARKETING_LIVE_CATEGORIES` in `@iptvnator/shared/marketing-fixtures`.
|
||||
* Spelled out here because `pnpm release:screenshots` runs tsx without the
|
||||
* base tsconfig, so workspace path aliases do not resolve in this file.
|
||||
*/
|
||||
const STALKER_MOCK_FIXTURE_CATEGORIES = ['Newsroom', 'Culture & Docs'];
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -117,6 +129,73 @@ export async function ensureXtreamMockServer(
|
||||
throw new Error(`xtream-mock-server did not become healthy at ${healthUrl}`);
|
||||
}
|
||||
|
||||
export async function ensureStalkerMockServer(
|
||||
workspaceRoot: string
|
||||
): Promise<ChildProcess | undefined> {
|
||||
const healthUrl = `${STALKER_MOCK_ORIGIN}/health`;
|
||||
|
||||
if (await isHealthy(healthUrl)) {
|
||||
await assertStalkerMockServerIdentity();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
path.join(workspaceRoot, 'node_modules/.bin/tsx'),
|
||||
['--tsconfig', 'tsconfig.base.json', 'apps/stalker-mock-server/src/main.ts'],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env: { ...process.env, NODE_ENV: 'development', PORT: '3210' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
child.stderr?.on('data', (chunk) =>
|
||||
process.stderr.write(`[stalker-mock] ${chunk}`)
|
||||
);
|
||||
|
||||
const deadline = Date.now() + 20_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await isHealthy(healthUrl)) {
|
||||
return child;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
throw new Error(`stalker-mock-server did not become healthy at ${healthUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same trust boundary as the Xtream check: whatever answers on the Stalker
|
||||
* port must serve the marketing-demo scenario's fictional live categories.
|
||||
*/
|
||||
async function assertStalkerMockServerIdentity(): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${STALKER_MOCK_ORIGIN}/portal.php?type=itv&action=get_genres&JsHttpRequest=1-xml`,
|
||||
{ headers: { Cookie: `mac=${STALKER_FIXTURE_MAC}; stb_lang=en; timezone=UTC` } }
|
||||
).catch(() => null);
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(
|
||||
`Something is listening on ${STALKER_MOCK_ORIGIN} but does not answer the Stalker portal API — stop it and let this script start the mock server itself.`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { js?: { title?: string }[] }
|
||||
| null;
|
||||
const titles = new Set((payload?.js ?? []).map((entry) => entry.title));
|
||||
|
||||
for (const expected of STALKER_MOCK_FIXTURE_CATEGORIES) {
|
||||
if (!titles.has(expected)) {
|
||||
throw new Error(
|
||||
`The server on ${STALKER_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing live category "${expected}"). Refusing to capture screenshots from unknown data.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the service on the mock port is our fixture server: it must serve
|
||||
* the marketing catalog with the exact synthetic titles the shots rely on.
|
||||
@@ -220,9 +299,21 @@ export async function waitForAppReady(page: Page): Promise<void> {
|
||||
/* Seeding */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function seedDemoData(page: Page, m3uPath: string): Promise<void> {
|
||||
export interface SeedOptions {
|
||||
/** Also add the Stalker marketing portal (guide shots only: it changes the dashboard). */
|
||||
stalker?: boolean;
|
||||
}
|
||||
|
||||
export async function seedDemoData(
|
||||
page: Page,
|
||||
m3uPath: string,
|
||||
options: SeedOptions = {}
|
||||
): Promise<void> {
|
||||
await addXtreamPortal(page);
|
||||
await addM3uPlaylist(page, m3uPath);
|
||||
if (options.stalker) {
|
||||
await addStalkerPortal(page);
|
||||
}
|
||||
await seedDashboardActivity(page);
|
||||
}
|
||||
|
||||
@@ -318,6 +409,27 @@ async function addXtreamPortal(page: Page): Promise<void> {
|
||||
.waitFor({ state: 'visible', timeout: 45_000 });
|
||||
}
|
||||
|
||||
async function addStalkerPortal(page: Page): Promise<void> {
|
||||
await openAddPlaylistDialog(page);
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await clickDialogOption(dialog, /stalker portal/i);
|
||||
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
|
||||
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
|
||||
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
|
||||
// Endpoint discovery probes the mock before the row is written.
|
||||
await dialog.getByRole('button', { name: /^add$/i }).last().click();
|
||||
await dialog.waitFor({ state: 'detached', timeout: 60_000 });
|
||||
await page.waitForURL(/\/workspace\/stalker\/[^/]+\/vod/, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
registerPlaylistId('stalker', idFromUrl(page.url(), 'stalker'));
|
||||
await page
|
||||
.locator('.category-content-layout, app-content-card')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 45_000 });
|
||||
}
|
||||
|
||||
async function addM3uPlaylist(page: Page, m3uPath: string): Promise<void> {
|
||||
await openAddPlaylistDialog(page);
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
@@ -342,13 +454,15 @@ async function addM3uPlaylist(page: Page, m3uPath: string): Promise<void> {
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
}
|
||||
|
||||
function idFromUrl(url: string, provider: 'playlists' | 'xtreams'): string {
|
||||
function idFromUrl(url: string, provider: PlaylistProvider): string {
|
||||
// `provider` is a closed union, but build the pattern from a literal
|
||||
// anyway so no future caller can inject regex syntax through it.
|
||||
const pattern =
|
||||
provider === 'playlists'
|
||||
? /\/workspace\/playlists\/([^/]+)\//
|
||||
: /\/workspace\/xtreams\/([^/]+)\//;
|
||||
: provider === 'xtreams'
|
||||
? /\/workspace\/xtreams\/([^/]+)\//
|
||||
: /\/workspace\/stalker\/([^/]+)\//;
|
||||
const match = url.match(pattern);
|
||||
|
||||
if (!match) {
|
||||
|
||||
@@ -10,10 +10,19 @@
|
||||
* instead of a `get.php?username=…` link.
|
||||
*/
|
||||
|
||||
import { FICTIONAL_STALKER_MAC } from './screenshot-guards.mjs';
|
||||
|
||||
export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211';
|
||||
export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo';
|
||||
export const M3U_FIXTURE_TITLE = 'release-demo';
|
||||
|
||||
export const STALKER_MOCK_ORIGIN = 'http://localhost:3210';
|
||||
export const STALKER_FIXTURE_TITLE = 'Fictional Stalker Demo';
|
||||
/** Reseller-panel shape most hand-outs use; discovery classifies the mock's answer itself. */
|
||||
export const STALKER_FIXTURE_PORTAL_URL = `${STALKER_MOCK_ORIGIN}/portal.php`;
|
||||
/** The mock's `marketing-demo` scenario; the only MAC the frame guard lets through. */
|
||||
export const STALKER_FIXTURE_MAC = FICTIONAL_STALKER_MAC;
|
||||
|
||||
/** Credential pair of the mock server's curated `marketing` scenario. */
|
||||
export const XTREAM_FIXTURE_CREDENTIALS = {
|
||||
username: 'marketing',
|
||||
|
||||
@@ -8,33 +8,32 @@ import type { Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
AUTO_DETECT_FIXTURE_MESSAGE,
|
||||
STALKER_FIXTURE_MAC,
|
||||
STALKER_FIXTURE_PORTAL_URL,
|
||||
STALKER_FIXTURE_TITLE,
|
||||
XTREAM_FIXTURE_CREDENTIALS,
|
||||
XTREAM_FIXTURE_TITLE,
|
||||
XTREAM_MOCK_ORIGIN,
|
||||
} from './capture-fixtures';
|
||||
|
||||
let m3uPlaylistId: string | undefined;
|
||||
let xtreamPlaylistId: string | undefined;
|
||||
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
|
||||
export type PlaylistProvider = 'playlists' | 'xtreams' | 'stalker';
|
||||
|
||||
const playlistIds = new Map<PlaylistProvider, string>();
|
||||
|
||||
export function registerPlaylistId(
|
||||
provider: 'playlists' | 'xtreams',
|
||||
provider: PlaylistProvider,
|
||||
id: string
|
||||
): void {
|
||||
if (provider === 'playlists') {
|
||||
m3uPlaylistId = id;
|
||||
} else {
|
||||
xtreamPlaylistId = id;
|
||||
}
|
||||
playlistIds.set(provider, id);
|
||||
}
|
||||
|
||||
export function requirePlaylistId(
|
||||
provider: 'playlists' | 'xtreams'
|
||||
): string {
|
||||
export function requirePlaylistId(provider: PlaylistProvider): string {
|
||||
return requireId(provider);
|
||||
}
|
||||
|
||||
function requireId(provider: 'playlists' | 'xtreams'): string {
|
||||
const id = provider === 'playlists' ? m3uPlaylistId : xtreamPlaylistId;
|
||||
function requireId(provider: PlaylistProvider): string {
|
||||
const id = playlistIds.get(provider);
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`No captured ${provider} playlist id — seeding failed?`);
|
||||
@@ -254,6 +253,52 @@ export async function runAction(
|
||||
await page.waitForTimeout(700);
|
||||
return;
|
||||
}
|
||||
case 'open-add-playlist-stalker': {
|
||||
await goHome(page);
|
||||
await openAddPlaylistDialog(page);
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await clickDialogOption(dialog, /stalker portal/i);
|
||||
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
|
||||
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
|
||||
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
|
||||
// Blur runs the MAC normalization the guide describes.
|
||||
await dialog.locator('#serialNumber').focus();
|
||||
// The form is long; frame the identity fields and the derive
|
||||
// toggle rather than the signature fields at the bottom.
|
||||
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
return;
|
||||
}
|
||||
case 'open-stalker-live': {
|
||||
await goHome(page);
|
||||
await clickHrefSuffix(
|
||||
page,
|
||||
`/workspace/stalker/${requireId('stalker')}/vod`
|
||||
);
|
||||
await clickHrefSuffix(
|
||||
page,
|
||||
`/workspace/stalker/${requireId('stalker')}/itv`
|
||||
);
|
||||
|
||||
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();
|
||||
// No channel click: playback would resolve a create_link to a
|
||||
// public demo stream, and third-party video never enters a 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}`);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,16 @@ async function main(): Promise<void> {
|
||||
// release assets an earlier run already committed.
|
||||
const stagingDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-shots-'));
|
||||
const mockServer = await driver.ensureXtreamMockServer(workspaceRoot);
|
||||
// The Stalker portal is seeded only for shots that walk into it: it adds
|
||||
// a third source card to the dashboard, which release shots must not show.
|
||||
const needsStalker = shots.some((shot: { setup: string[] }) =>
|
||||
shot.setup.some(
|
||||
(step) => parseSetupStep(String(step)).action === 'open-stalker-live'
|
||||
)
|
||||
);
|
||||
const stalkerMockServer = needsStalker
|
||||
? await driver.ensureStalkerMockServer(workspaceRoot)
|
||||
: undefined;
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-'));
|
||||
let app: Awaited<ReturnType<typeof driver.launchApp>> | undefined;
|
||||
let recordedRequests: string[] = [];
|
||||
@@ -204,7 +214,9 @@ async function main(): Promise<void> {
|
||||
await driver.sizeWindow(app, manifest.viewport);
|
||||
await driver.waitForAppReady(page);
|
||||
await assertTmdbDisabled(page); // G5
|
||||
await driver.seedDemoData(page, driver.writeM3uFixture(dataDir));
|
||||
await driver.seedDemoData(page, driver.writeM3uFixture(dataDir), {
|
||||
stalker: needsStalker,
|
||||
});
|
||||
|
||||
for (const theme of themes) {
|
||||
await applyTheme(page, theme);
|
||||
@@ -260,6 +272,7 @@ async function main(): Promise<void> {
|
||||
// may sit in -wal until the worker shuts down and checkpoints.
|
||||
await app?.close().catch(() => undefined);
|
||||
mockServer?.kill('SIGTERM');
|
||||
stalkerMockServer?.kill('SIGTERM');
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export const KNOWN_ACTIONS = [
|
||||
'open-add-playlist-xtream',
|
||||
'open-add-playlist-auto',
|
||||
'open-xtream-live',
|
||||
'open-add-playlist-stalker',
|
||||
'open-stalker-live',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -407,6 +409,15 @@ const CREDENTIAL_TEXT_PATTERNS = [
|
||||
/https?:\/\/(?!localhost|127\.0\.0\.1)[^\s"']+\.m3u8?\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* The one MAC address a published frame may show: the stalker-mock-server's
|
||||
* `marketing-demo` scenario. It exists only in the mock's scenario table, so
|
||||
* it identifies no real subscriber. Any other MAC-shaped text still fails the
|
||||
* shot, which is what keeps the Stalker guide screenshots from ever carrying
|
||||
* a real box identity.
|
||||
*/
|
||||
export const FICTIONAL_STALKER_MAC = '00:1A:79:00:00:07';
|
||||
|
||||
/**
|
||||
* Evaluated against a DOM report collected right before each screenshot:
|
||||
* every image/background URL and the visible text. External resources or
|
||||
@@ -424,8 +435,12 @@ export function evaluateFrameReport(report) {
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = report.bodyText
|
||||
.split(FICTIONAL_STALKER_MAC)
|
||||
.join('[fictional-mac]');
|
||||
|
||||
for (const pattern of CREDENTIAL_TEXT_PATTERNS) {
|
||||
const match = report.bodyText.match(pattern);
|
||||
const match = bodyText.match(pattern);
|
||||
|
||||
if (match) {
|
||||
violations.push(
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
snapshotDatabaseState,
|
||||
stubbedResponseFor,
|
||||
DEFAULT_SHOT_GROUP,
|
||||
FICTIONAL_STALKER_MAC,
|
||||
outputDirectoryFor,
|
||||
shotGroup,
|
||||
validateManifest,
|
||||
@@ -56,6 +57,25 @@ function validManifest() {
|
||||
};
|
||||
}
|
||||
|
||||
describe('frame guard MAC allowlist', () => {
|
||||
it('lets the fictional marketing-demo MAC through but no other MAC', () => {
|
||||
const clean = evaluateFrameReport({
|
||||
resourceUrls: [],
|
||||
bodyText: `Mac Address ${FICTIONAL_STALKER_MAC} Serial Number`,
|
||||
});
|
||||
assert.deepEqual(clean, []);
|
||||
|
||||
const leaked = evaluateFrameReport({
|
||||
resourceUrls: [],
|
||||
bodyText: `Mac Address ${FICTIONAL_STALKER_MAC} and 00:1A:79:12:34:56`,
|
||||
});
|
||||
assert.ok(
|
||||
leaked.some((violation) => /00:1A:79:12:34:56/.test(violation)),
|
||||
'a second MAC must still fail the frame'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest validation', () => {
|
||||
it('accepts the committed manifest shape', () => {
|
||||
assert.deepEqual(validateManifest(validManifest()), []);
|
||||
@@ -161,6 +181,26 @@ describe('manifest validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the Stalker guide actions', () => {
|
||||
const manifest = validManifest();
|
||||
manifest.shots.push(
|
||||
{
|
||||
slug: 'guide-stalker-add-playlist',
|
||||
title: 'Stalker form',
|
||||
group: 'guides',
|
||||
setup: ['open-add-playlist-stalker'],
|
||||
},
|
||||
{
|
||||
slug: 'guide-stalker-live',
|
||||
title: 'Stalker live',
|
||||
group: 'guides',
|
||||
setup: ['open-stalker-live'],
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(validateManifest(manifest), []);
|
||||
});
|
||||
|
||||
it('parses setup steps with and without a parameter', () => {
|
||||
assert.deepEqual(parseSetupStep('open-dashboard'), {
|
||||
action: 'open-dashboard',
|
||||
|
||||
@@ -67,6 +67,22 @@
|
||||
"setup": [
|
||||
"open-xtream-live"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-stalker-add-playlist",
|
||||
"title": "Add playlist: Stalker portal",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-add-playlist-stalker"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-stalker-live",
|
||||
"title": "Stalker Live TV",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-stalker-live"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,13 @@ const GUIDES = [
|
||||
'blog/guides/screenshots/guide-xtream-live-dark.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'stalker-portal-setup-guide',
|
||||
screenshots: [
|
||||
'blog/guides/screenshots/guide-stalker-add-playlist-dark.png',
|
||||
'blog/guides/screenshots/guide-stalker-live-dark.png',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
||||
|
||||
Reference in new issue
Block a user