diff --git a/CLAUDE.md b/CLAUDE.md index 58bdce569..0f8839d22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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) diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index b77100e1e..f67cf38ff 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -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/.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::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=&macAddress=&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/.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/.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=&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=` | `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 | diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 29d8a73b0..c9311b096 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -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), diff --git a/apps/stalker-mock-server/src/app/marketing-live.generator.ts b/apps/stalker-mock-server/src/app/marketing-live.generator.ts new file mode 100644 index 000000000..232c54c61 --- /dev/null +++ b/apps/stalker-mock-server/src/app/marketing-live.generator.ts @@ -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/.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] + ); +} diff --git a/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts b/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts index e8055efc9..01d462978 100644 --- a/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts +++ b/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts @@ -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', diff --git a/apps/stalker-mock-server/src/app/marketing-poster-url.ts b/apps/stalker-mock-server/src/app/marketing-poster-url.ts index 5ef73e4a9..5cf0de781 100644 --- a/apps/stalker-mock-server/src/app/marketing-poster-url.ts +++ b/apps/stalker-mock-server/src/app/marketing-poster-url.ts @@ -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 = diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 738d94039..0ff74a987 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -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); diff --git a/apps/website/README.md b/apps/website/README.md index 2aece8305..b19341cc2 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -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` diff --git a/apps/website/public/blog/guides/screenshots/guide-stalker-add-playlist-dark.png b/apps/website/public/blog/guides/screenshots/guide-stalker-add-playlist-dark.png new file mode 100644 index 000000000..99c16e3d3 Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-stalker-add-playlist-dark.png differ diff --git a/apps/website/public/blog/guides/screenshots/guide-stalker-add-playlist-light.png b/apps/website/public/blog/guides/screenshots/guide-stalker-add-playlist-light.png new file mode 100644 index 000000000..2f6935e15 Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-stalker-add-playlist-light.png differ diff --git a/apps/website/public/blog/guides/screenshots/guide-stalker-live-dark.png b/apps/website/public/blog/guides/screenshots/guide-stalker-live-dark.png new file mode 100644 index 000000000..bf28e61c0 Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-stalker-live-dark.png differ diff --git a/apps/website/public/blog/guides/screenshots/guide-stalker-live-light.png b/apps/website/public/blog/guides/screenshots/guide-stalker-live-light.png new file mode 100644 index 000000000..e099e4964 Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-stalker-live-light.png differ diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index aa9b3d059..141ddf237 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -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 diff --git a/apps/website/src/components/blog/PostButton.astro b/apps/website/src/components/blog/PostButton.astro index 80fa35d90..71f73f010 100644 --- a/apps/website/src/components/blog/PostButton.astro +++ b/apps/website/src/components/blog/PostButton.astro @@ -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" > {label ?? 'Open link'} - - + diff --git a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx new file mode 100644 index 000000000..b20698ac3 --- /dev/null +++ b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx @@ -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. + + + 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. + + +## 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 + + + +![Add playlist dialog with the Stalker portal method selected, a portal URL and a MAC address filled in](/iptvnator/blog/guides/screenshots/guide-stalker-add-playlist-dark.png) + + + 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. + + +## 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 + +![Live TV of a Stalker portal with categories, the channel list and the program guide](/iptvnator/blog/guides/screenshots/guide-stalker-live-dark.png) + +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. + + + 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. + + +## 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 + + + + diff --git a/apps/website/src/pages/download/linux.astro b/apps/website/src/pages/download/linux.astro index 43baf0db1..6e4a84604 100644 --- a/apps/website/src/pages/download/linux.astro +++ b/apps/website/src/pages/download/linux.astro @@ -298,7 +298,7 @@ sudo emerge iptvnator-bin - +
diff --git a/apps/website/src/pages/download/macos.astro b/apps/website/src/pages/download/macos.astro index 226542dd7..6408dab70 100644 --- a/apps/website/src/pages/download/macos.astro +++ b/apps/website/src/pages/download/macos.astro @@ -207,7 +207,7 @@ const jsonLd = buildDownloadPageSchema({ - +
diff --git a/apps/website/src/pages/download/windows.astro b/apps/website/src/pages/download/windows.astro index dcfa24339..fb59486e3 100644 --- a/apps/website/src/pages/download/windows.astro +++ b/apps/website/src/pages/download/windows.astro @@ -228,7 +228,7 @@ const jsonLd = buildDownloadPageSchema({
- +
diff --git a/apps/xtream-mock-server/src/app/generators/marketing.generator.ts b/apps/xtream-mock-server/src/app/generators/marketing.generator.ts index 855983dc1..bf8aee164 100644 --- a/apps/xtream-mock-server/src/app/generators/marketing.generator.ts +++ b/apps/xtream-mock-server/src/app/generators/marketing.generator.ts @@ -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 = { + 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, - ` - - - - - - - - - - ${escapeSvg(initials)} - ` - ); + 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) => - `${escapeSvg(line.toUpperCase())}` + `${escapeMarketingSvg(line.toUpperCase())}` ) .join('')} - ${escapeSvg(creditLine)} + ${escapeMarketingSvg(creditLine)} IPTVnator fictional demo artwork ` ); } 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 `${body}`; -} - -function escapeSvg(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index f936a5414..190662226 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -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/`, outside version control — keyed by the exact version, because 0.24.0 and 0.24.1 share a blog diff --git a/libs/shared/marketing-fixtures/src/index.ts b/libs/shared/marketing-fixtures/src/index.ts index 7446381e4..585dfef54 100644 --- a/libs/shared/marketing-fixtures/src/index.ts +++ b/libs/shared/marketing-fixtures/src/index.ts @@ -1 +1,2 @@ export * from './lib/shared-marketing-fixtures'; +export * from './lib/marketing-live-fixtures'; diff --git a/libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.spec.ts b/libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.spec.ts new file mode 100644 index 000000000..f00339626 --- /dev/null +++ b/libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.spec.ts @@ -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('AN'); + expect(svg).not.toContain('http://localhost'); + }); + + it('escapes markup that leaks into a slug', () => { + expect(renderMarketingLogoSvg('x', undefined)).not.toContain(''); + }); +}); diff --git a/libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.ts b/libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.ts new file mode 100644 index 000000000..73278cdae --- /dev/null +++ b/libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.ts @@ -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/.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 `${body}`; +} + +export function escapeMarketingSvg(value: string): string { + return value + .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, + ` + + + + + + + + + + ${escapeMarketingSvg(initials)} + ` + ); +} diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts index 3baaf5af9..0ac08fbe7 100644 --- a/tools/release/capture-app-driver.ts +++ b/tools/release/capture-app-driver.ts @@ -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 { + 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 { + 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 { /* Seeding */ /* ------------------------------------------------------------------ */ -export async function seedDemoData(page: Page, m3uPath: string): Promise { +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 { 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 { .waitFor({ state: 'visible', timeout: 45_000 }); } +async function addStalkerPortal(page: Page): Promise { + 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 { await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container').last(); @@ -342,13 +454,15 @@ async function addM3uPlaylist(page: Page, m3uPath: string): Promise { .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) { diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts index 282202029..817625a82 100644 --- a/tools/release/capture-fixtures.ts +++ b/tools/release/capture-fixtures.ts @@ -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', diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts index 14dbaaa8f..d65a5ed07 100644 --- a/tools/release/capture-navigation.ts +++ b/tools/release/capture-navigation.ts @@ -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//`. */ +export type PlaylistProvider = 'playlists' | 'xtreams' | 'stalker'; + +const playlistIds = new Map(); 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}`); } diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts index 613284f98..4a6b80c07 100644 --- a/tools/release/capture-release-screenshots.ts +++ b/tools/release/capture-release-screenshots.ts @@ -150,6 +150,16 @@ async function main(): Promise { // 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> | undefined; let recordedRequests: string[] = []; @@ -204,7 +214,9 @@ async function main(): Promise { 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 { // 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 }); } diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs index cfdb8c44a..bbb9bdbc4 100644 --- a/tools/release/screenshot-guards.mjs +++ b/tools/release/screenshot-guards.mjs @@ -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( diff --git a/tools/release/screenshot-guards.test.mjs b/tools/release/screenshot-guards.test.mjs index 6bec0d1b7..de59fe697 100644 --- a/tools/release/screenshot-guards.test.mjs +++ b/tools/release/screenshot-guards.test.mjs @@ -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', diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json index 8e9da5dbc..2b356c932 100644 --- a/tools/release/screenshots.manifest.json +++ b/tools/release/screenshots.manifest.json @@ -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" + ] } ] } diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs index 3fa623aff..668f98a65 100644 --- a/tools/testing/website-guides.test.mjs +++ b/tools/testing/website-guides.test.mjs @@ -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');