Files
iptvnator/docs/architecture/stalker-mock-server.md

13 KiB
Raw Permalink Blame History

Stalker Mock Server Architecture

This document describes the design decisions, data flow, and extension points of the stalker-mock-server development tool.

Purpose

The mock server enables:

  1. Local development without access to a real Stalker portal
  2. Playwright E2E testing with predictable scenario shapes and stable IDs
  3. Scenario-based testing via predefined MAC addresses that map to specific data shapes
  4. Stateful authentication replay with exact request order, cookie, redirect, classifier, refresh, and terminal-state assertions
  5. Screenshot-safe marketing capture with committed fictional posters shared with the Xtream mock

The generated catalog server and authentication replay are deliberately separate. Port 3210 remains a convenient long-running development portal. Authentication E2E creates isolated ephemeral loopback listeners and never shares their state with that server.

Key Design Decisions

Seeded Determinism (Not Per-Request Random)

Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead:

  • Data is generated once per MAC address on first request, then cached in memory.
  • @faker-js/faker is seeded with the scenario's seed value before generation: predefined scenario MACs use fixed seeds from scenarios.ts; unknown MACs derive the seed from the MAC via macToSeed().
  • The same MAC keeps the same scenario, IDs, and seed-driven catalog structure across regeneration, and the in-memory cache keeps requests internally consistent between /reset or process-restart events.
  • A restart is not byte-for-byte deterministic: ratings and some dates use runtime randomness, while EPG is anchored to the current day. Change the scenario seed to intentionally reshuffle the seed-driven fields.

MAC Address as Identity

Stalker portals use MAC address as the primary credential. The mock server follows the same model:

  • Each unique MAC gets its own isolated dataset.
  • Predefined MACs map to specific ScenarioConfig shapes (see src/app/scenarios.ts).
  • Unknown MACs use the sum of their byte values as a seed for their catalog structure. Different MACs still have isolated in-memory state; seed sums can collide, so uniqueness is not promised.

In-Memory Only

No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.

Data Generation Pipeline

faker.seed(config.seed)   // scenario seed; unknown MACs: macToSeed(mac)
│
├── generateCategories('itv', N)  → itvCategories[]
│     └── generateChannels()      → channels Map<categoryId, channel[]>
│           └── generateEpg()     → epg Map<channelId, program[]>
│
├── generateCategories('radio', N) → radioCategories[]
│     └── generateRadioStations()  → radio Map<categoryId, station[]>
│
├── generateCategories('vod', N)  → vodCategories[]
│     └── generateVodItems()      → vod Map<categoryId, item[]>
│           ├── normal VOD items
│           ├── is_series=1 items (fraction, Ministra flow)
│           └── embedded series[] items (fraction)
│
├── marketingFixture?             → shared curated VOD catalog
│     ├── @iptvnator/shared/marketing-fixtures
│     ├── vod Map<categoryId, item[]>
│     └── vodOrder[] preserves screenshot catalog order
│
└── generateCategories('series', N) → seriesCategories[]
      └── generateSeriesItems()    → series Map<categoryId, item[]>
            └── generateSeasons()  → seasons Map<seriesItemId, season[]>

Response Shapes

All responses follow the Stalker portal.php envelope:

{ "js": <action-specific payload> }

get_categories

{
  "js": [
    { "id": "2001", "title": "Action", "alias": "action" },
    ...
  ]
}

get_ordered_list (content)

{
  "js": {
    "data": [
      {
        "id": "20001",
        "name": "...",
        "cmd": "ffrt4://vod/20001/index.m3u8",
        "screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200",
        "cover": "https://picsum.photos/seed/vod-cover-20001/300/450",
        "description": "...",
        "actors": "...",
        "director": "...",
        "year": "2019",
        "rating_imdb": "7.3",
        "category_id": "2001",
        "is_series": 0,
        "has_files": 1
      }
    ],
    "total_items": 40,
    "max_page_items": 14,
    "cur_page": 1,
    "total_pages": 3
  }
}

For type=radio, items use the same paginated response envelope as live TV channels, but each item is generated as a radio station with a radio: true marker and an ffrt4://radio/... command.

get_ordered_list (seasons — when movie_id is present)

{
  "js": [
    {
      "id": "30001-s1",
      "name": "Season 1",
      "cmd": "ffrt4://series/30001/season/1",
      "series": ["1", "2", "3", ...],
      "screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
      "director": "...",
      "actors": "...",
      "year": "2021",
      "rating_imdb": "8.1"
    }
  ]
}
{
  "js": {
    "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
    "streamer_id": "1",
    "load": "",
    "error": ""
  }
}

The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the cmd field's character sum, so the same item always returns the same stream.

get_short_epg

{
  "js": {
    "data": [
      {
        "id": "1",
        "name": "Channel Name: Program Title",
        "start": "2026-02-21T10:00:00.000Z",
        "stop": "2026-02-21T12:00:00.000Z",
        "start_timestamp": 1740128400,
        "stop_timestamp": 1740135600,
        "descr": "...",
        "category": "News"
      }
    ]
  }
}

get_short_epg returns the current program and upcoming items from the generated schedule, limited by the requested size.

get_epg_info

{
  "js": {
    "data": {
      "10000": [
        {
          "id": "1",
          "name": "Channel Name: Program Title",
          "start": "2026-02-21T10:00:00.000Z",
          "stop": "2026-02-21T12:00:00.000Z",
          "start_timestamp": 1740128400,
          "stop_timestamp": 1740135600,
          "descr": "...",
          "category": "News"
        }
      ]
    }
  }
}

get_epg_info returns bulk EPG keyed by channel id and filters the generated 7-day schedule from the current UTC day start through now + period.

EPG programs are generated as 2-hour slots across 7 days for each channel, starting at the current UTC day boundary.

Scenarios

Scenarios are defined in src/app/scenarios.ts. Each scenario is a ScenarioConfig:

interface ScenarioConfig {
  name: string;
  description: string;
  seed: number;
  categoryCount: { itv: number; radio: number; vod: number; series: number };
  itemsPerCategory: number;
  seasonsPerSeries: number;
  episodesPerSeason: number;
  isSeriesFraction: number;      // 0–1: fraction of VOD with is_series=1
  embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[]
  supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
                                    // without the ITV get_all_channels action
  marketingFixture?: true;          // replace generated VOD with shared posters
}

The legacy-pagination scenario (00:1A:79:00:00:06) sets supportsGetAllChannels: false: get_all_channels then answers with an error payload so clients fall back to the paginated get_ordered_list crawl. For supporting scenarios, get_all_channels (get-all-channels.handler.ts, type=itv only) returns the complete ITV channel list in one { js: { data, total_items } } response, excluding channels from censored (adult) genres.

The marketing-demo scenario (00:1A:79:00:00:07) replaces faker-generated VOD with the 35-movie provider-neutral showcase catalog from @iptvnator/shared/marketing-fixtures. Its newest 20 movies are returned first for the wildcard VOD listing. Fixtures keep deployment-neutral poster paths, then the JSON middleware resolves them against the request origin (including forwarded host/protocol) as <portal-origin>/assets/marketing/poster/<slug>.png. main.ts serves the committed PNG directory directly, so the Xtream server does not need to run.

Adding a New Scenario

  1. Add an entry to the SCENARIOS map in src/app/scenarios.ts.
  2. Use any unique MAC address as the key (lowercase, colon-separated).
  3. Document it in README.md and this file.

Favorites

Favorites are stored in a Map<mac, Set<itemId>> in src/app/data-store.ts. They persist for the lifetime of the server process and are shared across all requests for the same MAC.

Call POST /reset to clear all favorites (and regenerated data) between test runs.

Playwright Integration

apps/web-e2e/playwright.config.ts registers the mock server as a second webServer entry:

webServer: [
  {
    command: 'pnpm nx run web:serve',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env['CI'],
  },
  {
    command: 'pnpm nx run stalker-mock-server:serve',
    url: 'http://localhost:3210/health',
    reuseExistingServer: !process.env['CI'],
  },
]

Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance.

Test Isolation

Each stalker e2e test calls POST http://localhost:3210/reset in beforeEach to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.

resetAll() clears both the generated-content cache and in-memory favorites (data-store.ts). The next request regenerates the same seeded IDs and scenario shape, but volatile ratings, dates, and current-day EPG may differ. Tests should assert the intended structure or behavior rather than byte equality of the full payload.

Authentication Replay Architecture

Replay fixtures live under apps/stalker-mock-server/fixtures/replay/. A version-1 fixture declares:

  • named origins and an entry URL;
  • ordered or unordered phases;
  • exact request method, path, headers, query, cookies, and body expectations;
  • typed generated inputs and exact references to them;
  • request cardinality, barriers, and the required terminal state.

createReplayServerRun() binds one ephemeral 127.0.0.1 listener per named origin. Every public URL includes a run-specific route prefix, which the listener strips before matching. Symbols, cookies, counters, and ledgers are isolated per run.

Electron E2E does not call the listeners directly through an ambient global server. withStalkerReplayRun() starts a process-local control plane whose capability is never passed to the app. The control plane accepts only repository-allowlisted fixture IDs, exact loopback Host headers, bounded JSON bodies, and create/finalize/dispose operations. Finalization verifies exact cardinality, barriers, the expected endpoint, and terminal state; the ledger contains sanitized operation and mismatch counts only.

Committed fixtures are a security boundary:

  • use only generated locally administered MACs, safe credentials, tokens, and cookies;
  • never include real portal origins, accounts, catalogs, artwork, or streams;
  • validate the whole corpus with pnpm run stalker:fixtures:validate;
  • run pnpm nx test stalker-mock-server and pnpm nx test stalker-fixture-tools after schema/runtime changes.

pnpm run stalker:fixtures:draft -- <absolute-har> <absolute-output> creates a sanitized draft outside the repository. It is not a substitute for review: the converter fails closed on unsafe files, repository inputs, unknown origins, oversized/deep structures, and secret-like evidence.

import { test, expect } from '@playwright/test';

const MOCK_URL = 'http://localhost:3210/portal.php';
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario

test.beforeEach(async ({ request }) => {
  await request.post('http://localhost:3210/reset');
});

test('browse VOD categories', async ({ page }) => {
  // Add portal via UI or programmatically via IndexedDB
  // Navigate to portal
  // Assert category list matches expected count (8 for default scenario)
});

Extension Points

  • New content types: Add a new generator function in data-generator.ts and a new handler in handlers/.
  • New scenarios: Add to SCENARIOS in scenarios.ts.
  • Authentication/session behavior: Add a validated replay fixture instead of overloading generated scenario MACs or introducing test-only query switches.
  • Catalog-only errors: Add a generated scenario only when the behavior belongs to the long-running development portal rather than session replay.
  • Slow responses: Add a MOCK_DELAY_MS env var and apply it in middleware for testing loading states.