Files
iptvnator/docs/architecture/xtream-mock-server.md
T
4grayandClaude Fable 5.1 97b0264dee fix(xtream): render catch-up start times in the panel timezone (#1563)
* fix(xtream): render catch-up start times in the panel timezone

The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).

- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
  ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
  from the `time_now` / `timestamp_now` clock pair, so spellings such as
  `UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
  when unchanged) and project it back from the payload in
  `DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
  read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
  +03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
  Global favorites, and the clock-pair derivation at a UTC-3 viewer.

Closes #1562

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

* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates

Review follow-ups (Greptile):

- A source switch while `get_account_info` is in flight no longer hands
  playlist A's status or clock to playlist B: the store is patched only
  while the asking playlist is still selected, the timezone is persisted
  under the asking playlist's id regardless, and a late failure cannot mark
  the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
  strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
  over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
  by every account-info check and only ever used for non-standard servers.

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

* fix(xtream): drop a panel clock that no longer belongs to the source

Review follow-ups (Codex + Greptile):

- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
  server drops the persisted `serverTimezone` (payload-only) until the next
  account-info check, so Favorites / Recent cannot keep rendering the OLD
  panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
  at the panel it came from — an edit that moved the source during the
  request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
  timezone into the store playlist, so a later response without a usable
  clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.

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

* fix(xtream): drop the stale panel clock inside the UPDATE statement

Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).

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

* refactor(xtream): split the server-clock primitives out of the timezone util

Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.

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

* fix(xtream): offer the learned panel clock to storage on every check

Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.

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

* fix(xtream): apply an account-info answer only to the panel it came from

Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.

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

* fix(xtream): never report another panel's status for the selected playlist

Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.

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

* fix(xtream): persist the panel clock with one conditional UPDATE

Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.

Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:

- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
  that `json_set`s the payload only while the row still points at the
  request's connection and does not already carry the value; a malformed
  payload is never rewritten (CASE, not AND, so json_extract cannot run
  before json_valid). Wired through the worker types, main handler,
  preload, bridge interface, both IPC contract tables and
  `DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
  readwrite cursor transaction, plus the localStorage copy.

The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.

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

* fix(xtream): keep the stored panel clock across clockless full upserts

Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.

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

* refactor(release): split capture-navigation under the max-lines cap

`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:

- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
  navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
  sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
  alternative sources (the two identical live-category flows share one
  helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
  the re-exported API the seeding driver and the capture script import

Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.

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

* refactor(electron): move the panel-clock SQL into its own operations module

Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.

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

---------

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

584 lines
23 KiB
Markdown

# Xtream Mock Server — Architecture
## Purpose
`apps/xtream-mock-server` is a self-contained Express server that emulates the
Xtream Codes API protocol. It is used for:
- **Local development** — run a full portal without a real Xtream subscription
- **E2E testing** — Playwright uses it for both web and Electron workflows
- **Performance investigation** — a locked-down loopback control plane
prepares and identifies a fixed synthetic 100k catalog before capture
---
## Data Pipeline
```
credentials (username + password)
│
▼
credentialsToSeed(u, p) ←── deterministic polynomial hash
│
▼
faker.seed(seed) ←── all faker calls use same seed per credentials
│
├── performance:performance?
│ └── index-derived generator (no Faker/runtime clock)
│
▼
generateCategories() ←── live / vod / series categories
│
generateLiveStreams() ←── live TV stream list
scenario EPG fixture? ←── optional deterministic per-stream EPG override
generateVodStreams() ←── VOD movie list
generateSeriesItems() ←── series list
generateSeriesInfo() ←── nested seasons + episodes (pre-populated)
│
▼
PortalData (cached) ←── Map<"username:password", PortalData>
```
Re-requesting with the same credentials returns the exact same data until
`POST /reset` clears all caches.
---
## File Structure
```
apps/xtream-mock-server/
├── project.json ← Nx serve, watch, lint, and test targets
├── public/
│ └── marketing/ ← committed fictional release artwork PNGs
├── tsconfig.json
└── src/
├── main.ts ← env validation, HTTP lifecycle, safe logging
└── app/
├── server.ts ← side-effect-free Express app factory/routes
├── server-lifecycle.ts ← bounded, idempotent HTTP shutdown
├── scenarios.ts ← Credential → ScenarioConfig mapping
├── data-store.ts ← Lazy cache, per-credentials generation
├── performance-control.ts ← bounded request lifecycle controller
├── performance-interception-lifecycle.ts ← reset/shutdown generation
├── performance-control-routes.ts ← token-gated control HTTP API
├── performance-control-validation.ts ← strict request validation
├── performance-control.types.ts ← safe manifest/state contracts
├── performance-manifest.ts ← fixed-order catalog hash/counts
├── generators/
│ ├── categories.generator.ts
│ ├── live.generator.ts ← Live streams + EPG listings
│ ├── marketing.generator.ts ← Fictional release screenshot fixture
│ ├── performance.generator.ts ← local-only deterministic 100k fixture
│ ├── vod.generator.ts ← VOD streams + VodDetails
│ └── series.generator.ts ← Series items + SeriesInfo
├── handlers/
│ ├── get-account-info.handler.ts
│ ├── get-categories.handler.ts ← live/vod/series categories
│ ├── get-full-epg.handler.ts ← full EPG + legacy typo alias
│ ├── get-streams.handler.ts ← live/vod/series stream lists
│ ├── get-vod-info.handler.ts
│ ├── get-series-info.handler.ts
│ └── get-short-epg.handler.ts
└── routes/
└── dispatch.ts ← Action → handler routing
```
---
## API Protocol
### Direct Xtream endpoint
```
GET /player_api.php?action=<action>&username=<u>&password=<p>[&...]
```
Response: raw JSON (no envelope). Matches the real Xtream Codes API format.
### PWA proxy endpoint
IPTVnator's PWA routes Xtream calls through:
```
GET /xtream?url=<serverUrl>&action=<action>&username=<u>&password=<p>
```
Response: `{ payload: <data>, action: <action> }`
This mirrors the backend proxy in `apps/electron-backend` so the same
Angular service code works in both environments.
### Performance control endpoint
The control plane is absent by default. It is mounted only when
`IPTVNATOR_XTREAM_MOCK_CONTROL=1`; startup then requires a non-empty
`IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST`
(`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value
in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests.
Configuration is validated before the HTTP listener opens. Both modes now
default to `127.0.0.1` when `HOST` is unset — the fixture serves fabricated but
unauthenticated content, so it should not be reachable from other hosts by
accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need
when driving the mock from a phone, an STB, a container, or another machine.
Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell
value reaches the parser; its no-value default remains `3211`.
Use a dedicated port rather than the normal `3211` E2E server:
```bash
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
```
The strict control API is:
```text
POST /__control/prepare
POST /__control/reset
POST /__control/barriers
POST /__control/barriers/:id/release
POST /__control/delays
GET /__control/state
```
`prepare` accepts exactly `{"scenario":"performance-100k"}` and materializes
the fixed `performance:performance` fixture before application capture. It
returns only this manifest:
```json
{
"epoch": 1,
"scenario": "performance-100k",
"seed": 91001,
"counts": {
"categories": { "live": 60, "vod": 20, "series": 20, "total": 100 },
"items": {
"live": 60000,
"vod": 20000,
"series": 20000,
"total": 100000
}
},
"bytes": 123,
"catalogSha256": "64-lowercase-hex-characters"
}
```
`bytes` is the UTF-8 byte length and `catalogSha256` is the SHA-256 of one JSON
object whose property order is fixed as:
1. live categories
2. VOD categories
3. series categories
4. live catalog
5. VOD catalog
6. series catalog
Credentials, server origins, response envelopes, and EPG/detail caches are not
part of the hash input.
`reset` accepts exactly `{"mode":"observations"}` or `{"mode":"all"}`.
Observation reset clears active/held rules, per-identity occurrences, and the
ledger while retaining the prepared manifest and epoch. All-state reset also
calls the data-store reset, clears the manifest, and increments the epoch.
Either mode first advances an internal observation generation and detaches all
pre-reset response listeners, so a late `finish` or `close` cannot repopulate
the freshly cleared ledger. Held barrier/delay clients retain the safe `409`
reset response where possible; unmatched active responses are aborted.
Barrier and delay rules have an ID plus the exact match tuple:
```text
(epoch, scenario, transport, canonicalAction, categoryId, occurrence)
```
`transport` is `direct` or `proxy`. Empty actions canonicalize to
`get_account_info`; the dispatcher's legacy `get_simple_date_table` alias is
allowlisted. Occurrences are counted per tuple excluding occurrence, so
parallel requests for different categories cannot consume each other's rule.
Numeric category IDs must use canonical decimal spelling. Signs, whitespace,
leading zeroes, non-decimal notation, unsafe integers, and values outside the
closed scenario ranges are rejected; invalid incoming aliases collapse to the
single `all` observation identity and cannot expand the bounded state map.
Rules are one-shot and match before dispatch or JSON serialization. Barrier
lifecycle is `arrived → blocked → responded|aborted`; delay lifecycle is
`arrived → delayed → responded|aborted`. Real request abort events release held
state; a normally consumed request's `close` event is not treated as an abort.
The state response contains only epoch, the safe manifest, active rules, held
IDs/count, bounded occurrence counts, and bounded lifecycle entries with
monotonic timestamps. It never stores or returns the token, username, password,
raw URL/query, request/response payload, titles, or catalog arrays. Unknown
incoming actions are represented only as `unknown`.
Control JSON is limited to 16 KiB. Bodies reject unknown/missing/wrong-type
fields. Each observation epoch accepts at most 32 rules, and the serialized
ledger retains at most 128 entries. Occurrence state has 512 slots: enough for
the complete closed scenario/action/category/transport identity domain without
eviction or counter restart; an unexpected overflow fails closed. IDs,
scenarios, transports, actions, categories, occurrences, and delays
(`0..5000` ms) use closed validation. Duplicate IDs/matches and already-past
occurrences fail closed.
The legacy unauthenticated `POST /reset` remains available in normal mode. It
returns `410` while the control plane is enabled, preventing cache invalidation
that would leave the controller's prepared manifest stale. Performance runs
must use the token-authenticated `/__control/reset`.
Barriers and delays exist for deterministic coordination and smoke tests only.
Formal performance captures must prove that both rule sets are empty and must
never add an artificial delay to the timed application path.
`SIGINT` and `SIGTERM` use one bounded, idempotent shutdown path. The
application hook invalidates active observations, settles barriers and delays,
and clears their timers/listeners before the HTTP listener closes. The shared
HTTP helper then closes idle and active connections, so an unreleased control
rule cannot keep the mock process alive.
### M3U fixture endpoint
```
GET /playlist.m3u
```
Returns a small deterministic four-channel playlist. The self-hosted PWA E2E
suite uses this endpoint to verify M3U URL imports through `apps/web-backend`
and the provider target registry.
### Stream stub endpoints
```
GET /live/<username>/<password>/<streamId>.m3u8
GET /movie/<username>/<password>/<streamId>.<ext>
GET /series/<username>/<password>/<streamId>.<ext>
```
In normal mode all redirect to a publicly available HLS test stream
(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). With the control plane
enabled, every stream/timeshift route for the performance credentials returns
`410` without a redirect or outbound request. `/playlist.m3u` is also `410` in
that mode. This keeps formal performance fixtures local-only; normal
control-disabled E2E behavior is unchanged.
---
## Key Response Shapes
### `get_account_info`
```json
{
"user_info": {
"username": "user1",
"password": "pass1",
"status": "active",
"exp_date": "4102444799",
"is_trial": "0",
"active_cons": "1",
"max_connections": "2",
"allowed_output_formats": ["m3u8", "ts", "rtmp"]
},
"server_info": {
"url": "http://localhost:3211",
"port": "3211",
"timezone": "UTC",
"timestamp_now": 1234567890
}
}
```
### `get_live_categories` / `get_vod_categories` / `get_series_categories`
```json
[
{ "category_id": "101", "category_name": "News", "parent_id": 0 },
...
]
```
### `get_live_streams` (sample item)
```json
{
"num": 1,
"name": "Acme Corp TV",
"stream_type": "live",
"stream_id": 10000,
"stream_icon": "https://picsum.photos/seed/live-10000/100/100",
"epg_channel_id": "channel-10000.mock",
"category_id": "101",
"tv_archive": 0,
"tv_archive_duration": 0
}
```
### `get_short_epg` (sample item)
```json
{
"epg_listings": [
{
"id": "1000000",
"epg_id": "channel-10000.mock",
"title": "base64encodedTitle",
"description": "base64encodedDescription",
"start": "2024-01-01 12:00:00",
"end": "2024-01-01 12:30:00",
"start_timestamp": "1704110400",
"stop_timestamp": "1704112200"
}
]
}
```
Note: `title` and `description` are **base64-encoded**, matching the real Xtream API.
### `get_simple_data_table` / `get_simple_date_table`
Both actions return the same full per-channel schedule shape:
```json
{
"epg_listings": [
{
"id": "10000-current",
"epg_id": "channel-10000.mock",
"title": "base64encodedTitle",
"description": "base64encodedDescription",
"start": "2026-04-05 04:30:00",
"end": "2026-04-05 05:00:00",
"start_timestamp": "1775363400",
"stop_timestamp": "1775365200",
"channel_id": "channel-10000.mock"
}
]
}
```
The legacy `get_simple_date_table` typo alias exists because real Xtream panels
sometimes only respond to that misspelled action.
### `get_series_info` (structure)
```json
{
"seasons": [
{
"id": 3000100, "name": "Season 1", "season_number": 1,
"episode_count": 8, "air_date": "2022-05-14",
"cover": "https://picsum.photos/seed/season-30001-1/300/450"
}
],
"info": { "name": "...", "cover": "...", "plot": "...", "cast": "...", ... },
"episodes": {
"1": [
{
"id": "80001", "episode_num": 1, "title": "Series Name S1E1",
"season": 1, "container_extension": "mkv",
"info": { "duration_secs": 2400, "rating": 8.3, ... }
}
]
}
}
```
---
## Scenarios
| Key (`username:password`) | Seed | Categories | Items/cat | Account status |
| ------------------------- | ----- | -------------------------- | --------- | -------------- |
| `user1:pass1` | 1001 | 8 each | 40 | active |
| `large:large` | 9999 | 20 each | 200 | active |
| `stress:stress` | 7777 | 16 each | 120 | active |
| `performance:performance` | 91001 | live:60, vod:20, series:20 | 1,000 | active |
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
| `minimal:minimal` | 3003 | 2 each | 5 | active |
| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
| `<any other>` | hash | 6 each | 30 | active |
### `epg:epg` fixture details
This scenario is reserved for Xtream EPG tests:
- live category `EPG Focus` contains deterministic channels such as `Timezone News`
- `Timezone News` serves a fixed `get_short_epg` window and a full `get_simple_data_table` schedule
- the full schedule includes a program that spans a UTC midnight boundary and another program after midnight
- raw `start` / `end` strings are intentionally offset from `start_timestamp` / `stop_timestamp`
That deliberate mismatch lets Electron tests verify the renderer uses timestamp
fields for sorting, current-program selection, progress bars, and local clock
labels instead of trusting provider-local strings.
### `marketing:marketing` fixture details
This scenario is reserved for release screenshots and marketing materials:
- VOD and series data use 65 curated fictional titles instead of faker-generated
titles or real media metadata
- the core 30-title generated artwork pack keeps matched posters and backdrops;
35 additional movie-poster showcase titles use approved local poster PNGs and
the deterministic SVG fallback for their missing backdrops; the newest 20
appear first in the VOD catalog
- showcase metadata comes from the provider-neutral
`@iptvnator/shared/marketing-fixtures` catalog shared with the Stalker mock
- posters and backdrops are served from committed PNG files in
`apps/xtream-mock-server/public/marketing/{poster,backdrop}/`
- `tools/release/generate-marketing-artwork.ts` generates the PNGs with
`gpt-image-2` only when `OPENAI_API_KEY` is present; it also writes the
prompt/asset manifest at `apps/xtream-mock-server/public/marketing/manifest.json`
- the manifest assigns distinct genre and visual-medium profiles per title,
including documentary, noir, animated family, retro rescue drama, cyberpunk,
anime-inspired space school, and workplace dramedy styles
- screenshot capture remains deterministic and offline because
`tools/release/capture-release-screenshots.ts` consumes the local mock server
assets and never calls OpenAI; its guards additionally verify that the
service answering on the mock port really is this fixture server
- the SVG renderer in `marketing.generator.ts` remains the fallback for missing
assets, live logos, season covers, and episode thumbnails
### `performance:performance` fixture details
This scenario is reserved for local performance captures:
- exactly 60 live categories/60,000 channels, 20 VOD categories/20,000 movies,
and 20 series categories/20,000 series
- exactly 1,000 catalog items in every category
- fixed IDs, timestamps, ratings, names, and one lazy season/episode generated
from indexes rather than Faker, `Date.now()`, or `Math.random()`
- empty/local-only artwork and direct-source fields
- VOD and series detail records are materialized only on request
- preparation and manifest hashing happen before the timed application request
### Electron Xtream performance benchmark
Run a formal benchmark from a clean worktree with the standard CDP endpoint
available at `127.0.0.1:9222`:
```bash
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream"
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
IPTVNATOR_PERF_VARIANT=baseline \
NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \
pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache
```
The target starts an isolated control-enabled mock server on
`127.0.0.1:3221` and uses only the synthetic `performance:performance`
credentials and fixture. It covers initial import, refresh, deletion,
cancellation during import, and navigation/search/UI interaction during a
background operation. Every scenario has one warm-up, five measured
iterations, and one diagnostic iteration. Only the five measured iterations
of a clean formal run can have `validForComparison: true`.
Capture setup and operation measurement are separate boundaries. The harness
first installs and arms the main, renderer, and worker capture infrastructure;
it then starts the main measurement and renderer operation window immediately
before triggering the user operation. Profiler attachment, fixture preparation,
and other setup outside that operation window are not application work. Because
`database.worker` is persistent but created lazily, the harness awaits the
read-only `window.electron.dbGetAppPlaylists()` preload call immediately after
installing main capture. Its result is discarded: this pre-arm only guarantees
that the exact worker can be profiled and happens before seed or measured
capture.
The renderer heartbeat is sampled on a fixed 50 ms deadline grid. One CDP
delivery records every elapsed grid point, and successful capture shutdown
waits for an in-flight delivery before performing one terminal-clipped drain.
The iteration fails closed if the resulting sample count does not cover the
complete operation window; this avoids coordinated omission when renderer work
delays multiple heartbeat deadlines.
Diagnostic profiles are evidence envelopes, not comparison totals. Starting
Chromium tracing and the CPU profilers can add a short pre-trigger setup segment
that contains no application workload; the raw boundary timestamps preserve
that segment explicitly. Formal summaries compare only the five measured runs,
never warm-up or diagnostic totals.
CPU scopes in the summary are intentionally not interchangeable. Electron main
CPU uses `process.threadCpuUsage()` and is labeled
`electron-main-thread`. Database-worker CPU is the sum of thread CPU from valid,
non-overlapping request work and is labeled
`sum-valid-request-thread-cpu`; it is neither process-wide CPU nor a whole-worker
sample.
Scenarios that need an existing portal seed it outside the measured iteration.
Seed completion is authoritative only after a successful
`store.xtream-import-terminal` renderer phase marker, the matching terminal DOM
gate, and two consecutive painted `requestAnimationFrame` frames. The harness
then verifies main-process settlement before rolling over to the measured
capture; merely seeing the catalog or loading overlay disappear is not a seed
terminal.
For an end-to-end wiring check, run smoke mode with one comparison-ineligible
measured iteration per scenario. An unused loopback CDP port may be selected
when the formal port is occupied:
```bash
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream-smoke"
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
IPTVNATOR_PERF_VARIANT=smoke \
IPTVNATOR_PERF_SMOKE=1 \
IPTVNATOR_PERF_CDP_PORT=9322 \
NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \
pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache
```
The manifest, summary, per-iteration JSON, traces, CPU profiles, and heap
snapshots stay under the git-ignored `dist/performance/` tree. Never commit
those artifacts, and never substitute real credentials, portal URLs, or
provider data.
---
## Playwright Integration
### Configuration (`apps/web-e2e/playwright.config.ts`)
The mock server is listed as a third `webServer` entry:
```typescript
{
command: 'pnpm nx run xtream-mock-server:serve',
url: 'http://localhost:3211/health',
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
}
```
### Request Interception
The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these:
```typescript
await page.route('**/localhost:3000/xtream**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL('http://localhost:3211/xtream');
originalUrl.searchParams.forEach((v, k) => mockUrl.searchParams.set(k, v));
await route.continue({ url: mockUrl.toString() });
});
```
---
## Extension Points
- **Add new actions**: Implement a handler function and add a `case` in `routes/dispatch.ts`
- **Add new scenarios**: Add an entry to `SCENARIOS` in `scenarios.ts`
- **Add deterministic EPG fixtures**: Extend `ScenarioConfig.epgFixture` and populate `epgListingsByStreamId` in `data-store.ts`
- **Refresh release artwork**: Run `pnpm release:artwork:dry-run`, then
`OPENAI_API_KEY=... pnpm release:artwork:generate`, inspect the generated PNGs,
and finish with `pnpm release:artwork:validate`
- **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario
- **Custom stream URLs**: Edit the normal-mode HLS stub redirect in
`app/server.ts`; never enable it for the performance fixture