Files
iptvnator/apps/xtream-mock-server
4grayandClaude Fable 5.1 01c423ac43 fix(portals): stop treating a slow panel as a dead host; IPv4 fallback budget in Electron (#1621)
* fix(portals): apply the IPv6->IPv4 fallback budget in the Electron process

The 2500 ms happy-eyeballs attempt timeout from #1404 only ever ran in the
web backend. The Electron main process and its playlist-refresh and EPG
workers kept Node's 250 ms default, so a dual-stack panel hostname behind a
VPN or a slow link failed every connection attempt in a row and tripped the
host connectivity guard. The module now lives in `@iptvnator/shared/host-health`
and every Node isolate that opens connections applies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): stop treating a slow panel as a dead one in the host guard

axios raises the same ECONNABORTED whether the SYN went unanswered or the
panel accepted the connection and then thought for longer than the request
budget. Two such timeouts opened the breaker and every request to the panel
was refused for 30 s with "portal is not responding" — the shape behind the
"connection keeps dropping" reports on 0.23 and nightly.

Both transports now report whether the TCP connection was established
(`onConnect`: Electron through a per-request observed agent instead of the
shared keep-alive globalAgent, the web backend through the transport that owns
the ClientRequest), and `classifyHostRequestFailure(error, { connected })`
downgrades a host-level code observed after the handshake to inconclusive.
Redirect attribution keeps precedence. A host that never accepts the
connection trips the guard exactly as before.

The Xtream mock gains a `silent:silent` scenario whose detail actions accept
and never answer, plus a real-socket regression spec for the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): let an accepted connection clear the host-failure streak

Review finding: an unanswered SYN, then an accepted-but-slow timeout, then
another unanswered SYN still reached the two-failure threshold, because the
middle request was merely not counted. An accepted TCP connection is the
reachability the guard measures, so it now reads as `responded` and clears
the streak like an HTTP response would. Regression coverage for the mixed
sequence on one flapping loopback origin (Electron) and through the proxy
route (web backend).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): credit an accepted connection when it happens, not when the request settles

Review findings. A request that connected and then hung for 30 s cleared,
on its eventual timeout, the failures later requests had recorded while it
waited — reopening a host that had just died on evidence older than theirs.
The connect hook now reports the connection the moment it fires through a
new `HostConnectivityGuard.reportConnected`, which clears the failure streak
but closes no open or half-open breaker (the trial keeps its slot until it
settles), and the settled timeout is inconclusive.

Electron also skips the socket observer while an environment proxy
(`http_proxy` / `https_proxy` / `all_proxy`) applies to the request: through
a proxy the socket connects to the proxy, whose handshake proves nothing
about the portal, so those requests keep the pre-observer behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): decide the proxy exemption with axios' own resolution

Review findings. The hand-rolled environment check ignored `no_proxy`, so a
LAN portal exempted from the proxy lost its connect observer and slow
requests to it still tripped the breaker; it also read the variables with
`??`, letting an empty lowercase one mask a populated uppercase one that
axios would honour. The decision now calls `proxy-from-env`'s
`getProxyForUrl`, the same pinned package axios' http adapter uses,
declared as a direct dependency so the packaged app carries it.

The validated-axios spec clears and restores every proxy variable around each
case, so a runner that exports a proxy cannot change what the cases prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:50:50 +02:00
..

Xtream Codes Mock Server

A lightweight Express server that simulates the Xtream Codes API for local development and end-to-end testing. Uses @faker-js/faker with deterministic seeding so every credential pair always produces the same data.


Quick Start

# Start on port 3211
pnpm nx run xtream-mock-server:serve

# Start with file-watch (auto-restart on code changes)
pnpm nx run xtream-mock-server:serve-with-watch

# Start the mock server plus the Electron app
pnpm run serve:marketing-demo

# Start the mock server plus the browser web app
pnpm run serve:marketing-demo:web

Local Performance Mode

The benchmark control plane is opt-in and binds only to an explicit loopback address. Use a dedicated port; do not reuse the normal E2E server on 3211.

HOST=127.0.0.1 \
PORT=3221 \
IPTVNATOR_XTREAM_MOCK_CONTROL=1 \
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN=local-benchmark-token \
pnpm nx run xtream-mock-server:serve

When enabled, every /__control/* request requires the exact x-iptvnator-performance-token header, including OPTIONS preflight requests. The server refuses non-loopback binds and an empty token. The control routes do not exist when the flag is absent or is any value other than 1. The Nx serve targets preserve an explicit shell PORT; when it is omitted, the server parser still defaults to 3211.

Prepare the fixed synthetic fixture before starting a capture:

curl -X POST http://127.0.0.1:3221/__control/prepare \
  -H 'content-type: application/json' \
  -H 'x-iptvnator-performance-token: local-benchmark-token' \
  -d '{"scenario":"performance-100k"}'

The response contains only epoch, scenario, seed, counts, bytes, and catalogSha256. counts.categories is 60/20/20 live/VOD/series (100 total) and counts.items is 60000/20000/20000 (100,000 total). bytes and the SHA-256 cover fixed-order UTF-8 JSON containing, in order, the live, VOD, and series category arrays followed by the live, VOD, and series catalogs. The hash input contains no credentials or server origin.

Control endpoints:

Method Path Purpose
POST /__control/prepare Materialize performance-100k and return its safe manifest
POST /__control/reset Reset observations or all state
POST /__control/barriers Add a one-shot, abort-aware request barrier
POST /__control/barriers/:id/release Release a request that reached a barrier
POST /__control/delays Add a one-shot delay of 0..5000 ms for control/smoke tests only
GET /__control/state Read bounded rules, held IDs, occurrences, lifecycle ledger, and epoch

Rules match the exact tuple (epoch, scenario, transport, action, categoryId, occurrence). Occurrences are counted independently per tuple without occurrence, so parallel category arrival order cannot change which rule matches. Empty Xtream actions are recorded as get_account_info; unknown actions are recorded only as unknown. Numeric category IDs use canonical decimal spelling: signs, whitespace, leading zeroes, non-decimal notation, and unsafe integers are rejected. The state never includes the token, credentials, raw URL/query, response payloads, titles, or catalog arrays.

reset with mode observations clears rules, occurrences, held requests, and the ledger while preserving the prepared manifest and epoch. Mode all also clears fixture caches and the prepared manifest, then increments the epoch. Held clients are settled during either reset. The legacy unauthenticated POST /reset returns 410 in performance mode so it cannot invalidate fixture caches without also invalidating the prepared control manifest; use the token-authenticated control reset instead.

JSON bodies are strict and limited to 16 KiB. A state epoch accepts at most 32 rules; the ledger retains 128 entries, and occurrence state has 512 slots for the complete allowlisted identity domain without eviction or counter restart. Duplicate IDs/matches, past occurrences, arbitrary scenarios/actions/category IDs, unknown fields, and invalid delays are rejected.

Performance mode is local-only: /playlist.m3u and every performance-fixture stream/timeshift URL return 410 without redirecting or contacting external media. Barriers and delays are coordination tools, not timing inputs. Formal benchmark captures must start with zero barrier and delay rules.


Available Scenarios (credential pairs)

Username Password Scenario Live cats VOD cats Series cats Items/cat Status
user1 pass1 default 8 8 8 40 active
large large large catalog 20 20 20 200 active
stress stress stress catalog 16 16 16 120 active
performance performance performance-100k 60 20 20 1,000 active
series series series-heavy 3 4 15 30 active
minimal minimal minimal (edge cases) 2 2 2 5 active
epg epg EPG fixture 2 1 1 3 active
tzoffset tzoffset EPG fixture, UTC+3 clock 2 1 1 3 active
emptyvod emptyvod empty VOD metadata 2 2 2 5 active
marketing marketing fictional release demo 4 4 4 curated active
marketing2 marketing2 same catalog, 2nd copy 4 4 4 curated active
multisrc1 multisrc1 multi-source portal A 1 2 1 5 active
multisrc2 multisrc2 multi-source portal B 1 2 1 5 active
expired expired expired account 4 4 4 10 Expired
inactive inactive disabled account 4 4 4 10 Disabled
silent silent silent detail endpoints 2 2 2 5 active

Any other credential pair is auto-generated using a hash of username:password as the faker seed (6 categories, 30 items each, active account).

silent imports and browses normally, but get_vod_info and get_series_info accept the connection and never answer — the client's own timeout (30 s in the app) ends each request. It models a live but overloaded panel, which is what the host connectivity guard must NOT mistake for a dead host: open two movie details and the guard has to stay closed. A dead host is reproduced with a non-routable address instead (http://10.255.255.1:8080).

multisrc1 and multisrc2 deliberately share one faker seed, so both portals generate an identical catalog. That overlap is what the VOD multi-source E2E needs — the same movie present in two different playlists.


API Endpoints

Direct Xtream Protocol

GET /player_api.php?action=<action>&username=<u>&password=<p>[&...]

Action Description
(none) / get_account_info User info + server info
get_live_categories Live TV categories
get_vod_categories VOD (movie) categories
get_series_categories Series categories
get_live_streams Live streams (optionally filtered by category_id)
get_vod_streams VOD streams (optionally filtered by category_id)
get_series Series list (optionally filtered by category_id)
get_vod_info?vod_id=<id> Full movie details
get_series_info?series_id=<id> Full series info (seasons + episodes)
get_short_epg?stream_id=<id>[&limit=N] EPG listings for a live channel
get_simple_data_table?stream_id=<id> Full per-channel EPG schedule
get_simple_date_table?stream_id=<id> Legacy typo alias for full per-channel EPG schedule

PWA CORS Proxy Endpoint

IPTVnator's PWA routes Xtream calls through a backend proxy:

GET /xtream?url=<serverUrl>&action=<action>&username=<u>&password=<p>

Response: { payload: <data>, action: <action> }

Stream URLs (stub redirects)

GET /live/<username>/<password>/<streamId>.m3u8  → HLS test stream
GET /movie/<username>/<password>/<streamId>.<ext> → HLS test stream
GET /series/<username>/<password>/<streamId>.<ext> → HLS test stream

Utility Endpoints

GET  /health   → { status: "ok", server: "xtream-mock-server", port: 3211 }
POST /reset    → clears all in-memory caches; data regenerates on next request

Example Requests

# Account info (direct)
curl "http://localhost:3211/player_api.php?username=user1&password=pass1"

# Live categories (direct)
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_live_categories"

# VOD details (direct)
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_vod_info&vod_id=20000"

# Series info (direct)
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_series_info&series_id=30000"

# EPG for stream (direct)
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_short_epg&stream_id=10000"

# Full EPG schedule (direct)
curl "http://localhost:3211/player_api.php?username=epg&password=epg&action=get_simple_data_table&stream_id=10000"

# Via PWA proxy
curl "http://localhost:3211/xtream?url=http://localhost:3211&username=user1&password=pass1&action=get_live_categories"

Playwright Integration

The mock server starts automatically with nx e2e web-e2e. Run only Xtream tests using the @xtream tag:

nx e2e web-e2e --grep "@xtream"

Test files: apps/web-e2e/src/xtream.e2e.ts

Electron Xtream EPG coverage lives in apps/electron-backend-e2e/src/xtream-epg.e2e.ts.

The Playwright tests use page.route() to redirect the app's backend proxy calls (localhost:3000/xtream**) to the mock server without modifying any application code.


Data Characteristics

  • Deterministic: Same credentials → same data every time (seeded faker)
  • Cached per session: Data generated once on first request, reused until /reset
  • EPG: Titles and descriptions are base64-encoded (matches real Xtream API)
  • Dedicated EPG fixture: epg:epg returns stable live channels plus deterministic get_short_epg and get_simple_data_table payloads for timezone-focused tests
  • Unusable timezone name: tzoffset:tzoffset serves the same EPG fixture behind a panel whose server_info.timezone is the ICU-unknown spelling UTC+3, while its time_now / timestamp_now clock pair reveals a +03:00 offset — the catch-up URL must be derived from the clock pair (issue #1562)
  • Release screenshot fixture: marketing:marketing returns fictional live, VOD, and series data with local generated artwork under apps/xtream-mock-server/public/marketing
  • Demo XMLTV guide: GET /demo/guide.xml returns an XMLTV file for the marketing live channels (ids such as aurora-news.fictional, eight half-hour programmes around now, logos from the local asset route), so the desktop app can import an EPG source hermetically; guide screenshots use it for the manual channel-mapping flow
  • Alternative-source fixture: marketing2:marketing2 returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
  • Local download media: marketing:marketing also serves /movie/..., /series/... and /live/... stream URLs from generated bytes (downloadStreamFixture: 'local-media'; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
  • Performance fixture: performance:performance returns exactly 100,000 local-only catalog items from index-derived values; it does not use Faker, Date.now(), Math.random(), external artwork, or external media URLs
  • Timestamp precedence coverage: The epg:epg scenario intentionally shifts raw start / end strings away from start_timestamp / stop_timestamp so UI tests can prove timestamps drive rendering
  • Stream IDs: Live 10,000+, VOD 20,000+, Series 30,000+
  • Category IDs: Live 101+, VOD 201+, Series 301+

Release Demo Artwork

The marketing:marketing fixture uses 65 original fictional titles for release screenshots. Its core 30-title artwork pack includes matched posters and backdrops; 35 additional movie-poster showcase titles use approved local poster PNGs and the deterministic SVG fallback for missing backdrops. The newest 20 showcase movies are ordered first so they appear immediately in screenshot catalog grids. Assets are served from:

apps/xtream-mock-server/public/marketing/{poster,backdrop}/

Generate or validate those assets with:

pnpm release:artwork:dry-run
pnpm release:artwork:generate
pnpm release:artwork:validate

release:artwork:generate uses gpt-image-2 through the OpenAI Image API and requires OPENAI_API_KEY. The screenshot capture workflow only reads committed local assets; it does not call OpenAI. If a local PNG is missing, the mock server falls back to its deterministic SVG renderer for development continuity. The prompt manifest deliberately varies genres and visual media across titles so the catalog does not collapse into one superhero/poster style.


Architecture

See docs/architecture/xtream-mock-server.md for a full description of the data pipeline, response shapes, and extension points.