test: add stalker-mock-server and integrate with e2e tests

- Introduced `stalker-mock-server` with TypeScript configuration for local development and testing.
- Updated `playwright.config.ts` to run both Angular app and mock server in parallel during e2e tests.
- Created comprehensive e2e tests for Stalker portal functionality, including health checks, portal addition, and content loading.
- Added detailed architecture documentation for both `stalker-mock-server` and `xtream-mock-server`, outlining design decisions, data flow, and API protocols.
- Implemented `xtream-mock-server` to simulate Xtream Codes API for local development and testing, with corresponding e2e tests.
- Enhanced test isolation by resetting mock server state before each test run.
This commit is contained in:
4gray committed 2026-02-21 08:44:27 +01:00
1 parent d7ee25e3aa
commit 95c32708ee
23 files changed
+2507 -7

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
# Stalker Mock Server
A local mock implementation of the Stalker/Ministra portal API for development and end-to-end testing of IPTVnator.
## Overview
The mock server speaks the same `portal.php` HTTP protocol as a real Stalker portal, generating deterministic fake data using `@faker-js/faker` seeded from the connecting MAC address. This means:
- The **same MAC address always returns the same data** (consistent across page refreshes and test runs).
- **Different MAC addresses produce different datasets** — use predefined scenario MACs for specific test conditions.
- Data is generated once per MAC on first request and cached in memory for the server's lifetime. **Restart to regenerate.**
## Quick Start
```bash
# Start the mock server (port 3210)
nx serve stalker-mock-server
# Or with file watching (auto-restarts on source changes)
nx run stalker-mock-server:serve-with-watch
# Or run both the mock server + Angular dev server in parallel
nx run-many --targets=serve --projects=stalker-mock-server,web
```
Then in IPTVnator, add a new Stalker portal:
- **Portal URL**: `http://localhost:3210/portal.php`
- **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data)
## Predefined Scenario MAC Addresses
| MAC Address | Scenario | Description |
|---|---|---|
| `00:1A:79:00:00:01` | **default** | 8 categories per type, 40 items each — the balanced go-to for daily dev |
| `00:1A:79:FF:FF:FF` | **large** | 20 categories, 200 items each — stress-test pagination and virtual scroll |
| `00:1A:79:00:00:02` | **series-heavy** | 15 series categories with 6 seasons × 10 episodes — test deep series navigation |
| `00:1A:79:00:00:03` | **minimal** | 2 categories, 5 items — edge case testing (empty states, single items) |
| `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 |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
## Configuration
| Environment Variable | Default | Description |
|---|---|---|
| `PORT` | `3210` | HTTP port the server listens on |
| `NODE_ENV` | `development` | Node environment |
## Utility Endpoints
| Endpoint | Method | Description |
|---|---|---|
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
| `/reset` | `POST` | Clear all in-memory data and favorites (useful between test runs) |
## API Coverage
All endpoints are served at `GET /portal.php?action=<action>&...` matching the real Stalker protocol:
| Action | Description |
|---|---|
| `handshake` | Returns a mock Bearer token |
| `do_auth` | Returns a mock user profile |
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
| `get_genres` | Genre list (mirrors categories) |
| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons |
| `create_link` | Returns a real public HLS stream URL for playback |
| `favorites` | Add / remove / get favorites (in-memory, resets on restart) |
| `get_short_epg` | EPG program list for a channel (`ch_id`) |
| `get_epg_info` | Alias for `get_short_epg` |
## Cover Images
Cover images and logos use [Picsum Photos](https://picsum.photos) (e.g. `https://picsum.photos/seed/{id}/300/200`). These are real images served from a CDN — no local setup required, but an internet connection is needed for images to display.
## Stream URLs
`create_link` returns real public HLS test streams so video actually plays:
- `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`
- `https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8`
- `https://playertest.longtailvideo.com/adaptive/oceans/oceans.m3u8`
- `https://playertest.longtailvideo.com/adaptive/bbbfull/bbbfull.m3u8`
The stream chosen for a given item is deterministic based on the item's `cmd` string.
## Using with Playwright E2E Tests
The Playwright config in `apps/web-e2e/playwright.config.ts` starts the mock server automatically alongside the Angular dev server when running e2e tests. See `apps/web-e2e/src/stalker.e2e.ts` for example stalker tests.
```bash
# Run all e2e tests (starts mock server automatically)
nx e2e web-e2e
# Or run only stalker-specific e2e tests
nx e2e web-e2e --grep "@stalker"
```
The test suite uses `00:1A:79:00:00:01` (default scenario) for most tests, and calls `POST /reset` in `beforeEach` to ensure a clean state between tests.
## Architecture
See [`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker-mock-server.md) for full implementation details.
## Project Structure
```
apps/stalker-mock-server/
├── src/
│ ├── main.ts # Express bootstrap
│ └── app/
│ ├── scenarios.ts # MAC → scenario config mapping
│ ├── data-generator.ts # Seeded faker data generation
│ ├── data-store.ts # Lazy per-MAC in-memory cache
│ └── routes/
│ ├── portal.route.ts # /portal.php dispatcher
│ └── handlers/
│ ├── handshake.handler.ts
│ ├── do-auth.handler.ts
│ ├── get-categories.handler.ts
│ ├── get-ordered-list.handler.ts
│ ├── get-seasons.handler.ts
│ ├── create-link.handler.ts
│ ├── favorites.handler.ts
│ ├── get-short-epg.handler.ts
│ └── get-genres.handler.ts
├── project.json
├── tsconfig.json
└── README.md
```
+38
View File
@@ -0,0 +1,38 @@
{
"name": "stalker-mock-server",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/stalker-mock-server/src",
"tags": ["scope:dev-tools"],
"targets": {
"serve": {
"continuous": true,
"executor": "nx:run-commands",
"options": {
"command": "pnpm tsx apps/stalker-mock-server/src/main.ts",
"cwd": "{workspaceRoot}",
"color": true,
"env": {
"PORT": "3210",
"NODE_ENV": "development"
}
}
},
"serve-with-watch": {
"continuous": true,
"executor": "nx:run-commands",
"options": {
"command": "pnpm tsx watch apps/stalker-mock-server/src/main.ts",
"cwd": "{workspaceRoot}",
"color": true,
"env": {
"PORT": "3210",
"NODE_ENV": "development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,428 @@
import { faker } from '@faker-js/faker';
import { ScenarioConfig } from './scenarios.js';
// ---------------------------------------------------------------------------
// Shared types (mirror the Stalker API response shapes)
// ---------------------------------------------------------------------------
export interface RawCategory {
id: string;
title: string;
alias: string;
}
export interface RawChannel {
id: string;
name: string;
o_name: string;
cmd: string;
logo: string;
category_id: string;
tv_genre_id: string;
xmltv_id: string;
}
export interface RawVodItem {
id: string;
name: string;
o_name: string;
title: string;
cmd: string;
screenshot_uri: string;
cover: string;
description: string;
actors: string;
director: string;
year: string;
genre: string;
genres_str: string;
rating_imdb: string;
rating_kinopoisk: string;
category_id: string;
is_series: 0 | 1 | '1';
has_files: number;
series?: RawEmbeddedEpisode[];
}
export interface RawSeriesItem {
id: string;
name: string;
o_name: string;
title: string;
cmd: string;
screenshot_uri: string;
cover: string;
description: string;
actors: string;
director: string;
year: string;
genres_str: string;
rating_imdb: string;
rating_kinopoisk: string;
category_id: string;
is_series: 0;
has_files: 0;
}
export interface RawSeason {
id: string;
name: string;
cmd: string;
description: string;
director: string;
actors: string;
year: string;
genres_str: string;
age: string;
rating_imdb: string;
rating_kinopoisk: string;
screenshot_uri: string;
added: string;
series: string[];
}
export interface RawEmbeddedEpisode {
id: number;
name: string;
cmd: string;
}
export interface RawEpgProgram {
id: string;
name: string;
start: string;
stop: string;
start_timestamp: number;
stop_timestamp: number;
descr: string;
category: string;
}
export interface GeneratedPortalData {
itvCategories: RawCategory[];
vodCategories: RawCategory[];
seriesCategories: RawCategory[];
channels: Map<string, RawChannel[]>; // categoryId -> channels
vod: Map<string, RawVodItem[]>; // categoryId -> items
series: Map<string, RawSeriesItem[]>; // categoryId -> items
seasons: Map<string, RawSeason[]>; // seriesItemId -> seasons
epg: Map<string, RawEpgProgram[]>; // channelId -> programs
}
// ---------------------------------------------------------------------------
// Public test HLS streams used for create_link responses
// ---------------------------------------------------------------------------
const TEST_HLS_STREAMS = [
'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8',
'https://playertest.longtailvideo.com/adaptive/oceans/oceans.m3u8',
'https://playertest.longtailvideo.com/adaptive/bbbfull/bbbfull.m3u8',
];
function pickStream(index: number): string {
return TEST_HLS_STREAMS[index % TEST_HLS_STREAMS.length];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function coverUrl(seed: string, width = 300, height = 200): string {
return `https://picsum.photos/seed/${seed}/${width}/${height}`;
}
function logoUrl(seed: string): string {
return `https://picsum.photos/seed/logo-${seed}/100/100`;
}
function isoNow(offsetMinutes: number): string {
const d = new Date(Date.now() + offsetMinutes * 60 * 1000);
return d.toISOString();
}
function unixNow(offsetMinutes: number): number {
return Math.floor((Date.now() + offsetMinutes * 60 * 1000) / 1000);
}
// ---------------------------------------------------------------------------
// Generator
// ---------------------------------------------------------------------------
export function generatePortalData(config: ScenarioConfig): GeneratedPortalData {
faker.seed(config.seed);
const data: GeneratedPortalData = {
itvCategories: [],
vodCategories: [],
seriesCategories: [],
channels: new Map(),
vod: new Map(),
series: new Map(),
seasons: new Map(),
epg: new Map(),
};
// ------ ITV categories + channels ------
data.itvCategories = generateCategories('itv', config.categoryCount.itv);
let channelIndex = 0;
for (const cat of data.itvCategories) {
const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex);
data.channels.set(cat.id, channels);
for (const ch of channels) {
data.epg.set(ch.id, generateEpg(ch.name));
}
channelIndex += config.itemsPerCategory;
}
// ------ VOD categories + items ------
data.vodCategories = generateCategories('vod', config.categoryCount.vod);
let vodIndex = 0;
for (const cat of data.vodCategories) {
const items = generateVodItems(
cat.id,
config.itemsPerCategory,
vodIndex,
config.isSeriesFraction,
config.embeddedSeriesFraction,
config.seasonsPerSeries,
config.episodesPerSeason
);
data.vod.set(cat.id, items);
vodIndex += config.itemsPerCategory;
}
// ------ Series categories + items ------
data.seriesCategories = generateCategories('series', config.categoryCount.series);
let seriesIndex = 0;
for (const cat of data.seriesCategories) {
const items = generateSeriesItems(cat.id, config.itemsPerCategory, seriesIndex);
data.series.set(cat.id, items);
for (const item of items) {
data.seasons.set(item.id, generateSeasons(item, config.seasonsPerSeries, config.episodesPerSeason));
}
seriesIndex += config.itemsPerCategory;
}
return data;
}
// ---------------------------------------------------------------------------
// Category generators
// ---------------------------------------------------------------------------
const ITV_GENRE_NAMES = [
'News', 'Sports', 'Movies', 'Entertainment', 'Kids',
'Documentary', 'Music', 'Comedy', 'Drama', 'Reality TV',
'Lifestyle', 'Travel', 'Food', 'Tech', 'Science',
'History', 'Nature', 'Animation', 'Gaming', 'Shopping',
];
const VOD_GENRE_NAMES = [
'Action', 'Comedy', 'Drama', 'Horror', 'Thriller',
'Romance', 'Sci-Fi', 'Fantasy', 'Animation', 'Documentary',
'Biography', 'Crime', 'Mystery', 'Adventure', 'Family',
'War', 'Western', 'Musical', 'Sport', 'History',
];
const SERIES_GENRE_NAMES = [
'Drama Series', 'Comedy Series', 'Crime Series', 'Sci-Fi Series',
'Reality Shows', 'Anime', 'Soap Opera', 'Mini Series',
'Documentary Series', 'Kids Shows', 'Action Series', 'Fantasy Series',
'Medical', 'Legal', 'Political', 'Romance Series', 'Historical',
'Thriller Series', 'Horror Series', 'Western Series',
];
function getGenreNames(type: 'itv' | 'vod' | 'series'): string[] {
if (type === 'itv') return ITV_GENRE_NAMES;
if (type === 'vod') return VOD_GENRE_NAMES;
return SERIES_GENRE_NAMES;
}
function generateCategories(type: 'itv' | 'vod' | 'series', count: number): RawCategory[] {
const names = getGenreNames(type);
return Array.from({ length: count }, (_, i) => {
const id = String((type === 'itv' ? 1000 : type === 'vod' ? 2000 : 3000) + i + 1);
const title = names[i % names.length];
return { id, title, alias: title.toLowerCase().replace(/\s+/g, '_') };
});
}
// ---------------------------------------------------------------------------
// Channel generators
// ---------------------------------------------------------------------------
function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] {
return Array.from({ length: count }, (_, i) => {
const globalIndex = startIndex + i;
const id = String(10000 + globalIndex);
const name = `${faker.company.name()} TV`;
return {
id,
name,
o_name: name,
cmd: `ffrt4://ch/live/${id}/index.m3u8`,
logo: logoUrl(`ch-${id}`),
category_id: categoryId,
tv_genre_id: categoryId,
xmltv_id: `channel-${id}.example`,
};
});
}
// ---------------------------------------------------------------------------
// VOD generators
// ---------------------------------------------------------------------------
function generateVodItems(
categoryId: string,
count: number,
startIndex: number,
isSeriesFraction: number,
embeddedSeriesFraction: number,
seasonsPerSeries: number,
episodesPerSeason: number
): RawVodItem[] {
return Array.from({ length: count }, (_, i) => {
const globalIndex = startIndex + i;
const id = String(20000 + globalIndex);
const title = faker.music.songName() + ': ' + faker.lorem.words(2);
const isSeries = i / count < isSeriesFraction;
const hasEmbeddedSeries = !isSeries && (i / count < isSeriesFraction + embeddedSeriesFraction);
const item: RawVodItem = {
id,
name: title,
o_name: title,
title,
cmd: `ffrt4://vod/${id}/index.m3u8`,
screenshot_uri: coverUrl(`vod-${id}`),
cover: coverUrl(`vod-cover-${id}`, 300, 450),
description: faker.lorem.paragraph(),
actors: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '),
director: faker.person.fullName(),
year: String(faker.date.past({ years: 20 }).getFullYear()),
genre: faker.music.genre(),
genres_str: [faker.music.genre(), faker.music.genre()].join(', '),
rating_imdb: (Math.random() * 4 + 5).toFixed(1),
rating_kinopoisk: (Math.random() * 4 + 5).toFixed(1),
category_id: categoryId,
is_series: isSeries ? '1' : 0,
has_files: isSeries ? 0 : 1,
};
if (hasEmbeddedSeries) {
item.series = generateEmbeddedEpisodes(id, seasonsPerSeries * episodesPerSeason);
}
return item;
});
}
function generateEmbeddedEpisodes(parentId: string, count: number): RawEmbeddedEpisode[] {
return Array.from({ length: count }, (_, i) => ({
id: parseInt(parentId) * 100 + i,
name: `Episode ${i + 1}`,
cmd: `ffrt4://vod/${parentId}/ep${i + 1}/index.m3u8`,
}));
}
// ---------------------------------------------------------------------------
// Series generators
// ---------------------------------------------------------------------------
function generateSeriesItems(categoryId: string, count: number, startIndex: number): RawSeriesItem[] {
return Array.from({ length: count }, (_, i) => {
const globalIndex = startIndex + i;
const id = String(30000 + globalIndex);
const title = faker.company.catchPhrase();
return {
id,
name: title,
o_name: title,
title,
cmd: `ffrt4://series/${id}`,
screenshot_uri: coverUrl(`series-${id}`),
cover: coverUrl(`series-cover-${id}`, 300, 450),
description: faker.lorem.paragraph(),
actors: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '),
director: faker.person.fullName(),
year: String(faker.date.past({ years: 10 }).getFullYear()),
genres_str: [faker.music.genre(), faker.music.genre()].join(', '),
rating_imdb: (Math.random() * 4 + 5).toFixed(1),
rating_kinopoisk: (Math.random() * 4 + 5).toFixed(1),
category_id: categoryId,
is_series: 0,
has_files: 0,
};
});
}
export function generateSeasons(
series: RawSeriesItem,
seasonCount: number,
episodesPerSeason: number
): RawSeason[] {
return Array.from({ length: seasonCount }, (_, s) => {
const seasonId = `${series.id}-s${s + 1}`;
const episodes = Array.from({ length: episodesPerSeason }, (_, e) =>
`${series.id}-s${s + 1}-e${e + 1}`
);
return {
id: seasonId,
name: `Season ${s + 1}`,
cmd: `ffrt4://series/${series.id}/season/${s + 1}`,
description: faker.lorem.sentence(),
director: series.director,
actors: series.actors,
year: series.year,
genres_str: series.genres_str,
age: '16',
rating_imdb: series.rating_imdb,
rating_kinopoisk: series.rating_kinopoisk,
screenshot_uri: coverUrl(`${seasonId}`),
added: new Date(Date.now() - Math.random() * 1e10).toISOString(),
series: episodes,
};
});
}
// ---------------------------------------------------------------------------
// EPG generator
// ---------------------------------------------------------------------------
const EPG_PROGRAM_TYPES = ['News', 'Movie', 'Documentary', 'Entertainment', 'Sports', 'Kids', 'Series'];
export function generateEpg(channelName: string): RawEpgProgram[] {
const programs: RawEpgProgram[] = [];
// Generate 12 programs: 6 past, current, 5 future (30-min slots)
const SLOT_MINUTES = 30;
const startOffset = -6 * SLOT_MINUTES; // start 3 hours ago
for (let i = 0; i < 12; i++) {
const startMin = startOffset + i * SLOT_MINUTES;
const stopMin = startMin + SLOT_MINUTES;
const category = EPG_PROGRAM_TYPES[i % EPG_PROGRAM_TYPES.length];
programs.push({
id: String(i + 1),
name: `${channelName}: ${faker.company.catchPhrase()}`,
start: isoNow(startMin),
stop: isoNow(stopMin),
start_timestamp: unixNow(startMin),
stop_timestamp: unixNow(stopMin),
descr: faker.lorem.sentence(),
category,
});
}
return programs;
}
// ---------------------------------------------------------------------------
// create_link helper
// ---------------------------------------------------------------------------
export function resolveStreamUrl(cmd: string, itemIndex: number): string {
if (cmd.startsWith('ffrt4://')) {
return pickStream(itemIndex);
}
return pickStream(0);
}
@@ -0,0 +1,51 @@
import { generatePortalData, GeneratedPortalData } from './data-generator.js';
import { getScenario } from './scenarios.js';
/**
* Lazy per-MAC in-memory store.
* Data is generated once on first access for each MAC and cached for the
* lifetime of the server process. Restart the server to get fresh data.
*/
const portalCache = new Map<string, GeneratedPortalData>();
export function getPortalData(mac: string): GeneratedPortalData {
const key = mac.toLowerCase();
if (!portalCache.has(key)) {
const scenario = getScenario(key);
console.log(
`[DataStore] Generating data for MAC ${mac} (scenario: ${scenario.name}, seed: ${scenario.seed})`
);
portalCache.set(key, generatePortalData(scenario));
}
return portalCache.get(key)!;
}
/** In-memory favorites store per MAC. Resets on server restart. */
const favoritesStore = new Map<string, Set<string>>();
export function getFavorites(mac: string): Set<string> {
const key = mac.toLowerCase();
if (!favoritesStore.has(key)) {
favoritesStore.set(key, new Set());
}
return favoritesStore.get(key)!;
}
export function addFavorite(mac: string, itemId: string): void {
getFavorites(mac).add(itemId);
}
export function removeFavorite(mac: string, itemId: string): void {
getFavorites(mac).delete(itemId);
}
/** Reset favorites for a MAC (exposed via /reset endpoint). */
export function resetFavorites(mac: string): void {
favoritesStore.delete(mac.toLowerCase());
}
/** Reset all cached data (exposed via /reset endpoint). */
export function resetAll(): void {
portalCache.clear();
favoritesStore.clear();
}
@@ -0,0 +1,33 @@
import { Request, Response } from 'express';
import { resolveStreamUrl } from '../data-generator.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker create_link — returns a playable stream URL.
*
* Query params:
* cmd: the ffrt4:// or similar command from the content item
* type: itv | vod | series
*/
export function handleCreateLink(req: Request, res: Response): void {
const mac = extractMac(req);
const cmd = (req.query['cmd'] as string) ?? '';
// Use a stable index derived from the cmd string so the same item
// always returns the same test stream URL.
const itemIndex = cmd
.split('')
.reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
const streamUrl = resolveStreamUrl(cmd, itemIndex);
console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`);
res.json({
js: {
cmd: streamUrl,
streamer_id: '1',
load: '',
error: '',
},
});
}
@@ -0,0 +1,25 @@
import { Request, Response } from 'express';
/**
* Stalker do_auth — returns user profile / account info.
*/
export function handleDoAuth(req: Request, res: Response): void {
res.json({
js: {
id: '1',
name: 'Mock User',
login: 'mockuser',
password: '',
status: 'active',
tariff_expired_date: '2099-12-31',
phone: '',
ls: '0',
created: new Date().toISOString(),
updated: new Date().toISOString(),
blocked: '0',
acc_enabled: '1',
max_connections: '1',
active_connections: '0',
},
});
}
@@ -0,0 +1,75 @@
import { Request, Response } from 'express';
import {
addFavorite,
getFavorites,
getPortalData,
removeFavorite,
} from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
/**
* Stalker favorites endpoint.
*
* Query params:
* action: favorites
* fav_action: get | add | remove (some portals use set/unset)
* item_id: the item id to add/remove
* type: itv | vod | series
*/
export function handleFavorites(req: Request, res: Response): void {
const mac = extractMac(req);
const favAction = (req.query['fav_action'] as string) ?? 'get';
const itemId = req.query['item_id'] as string;
if (favAction === 'add' || favAction === 'set') {
if (itemId) addFavorite(mac, itemId);
res.json({ js: { error: '' } });
return;
}
if (favAction === 'remove' || favAction === 'unset') {
if (itemId) removeFavorite(mac, itemId);
res.json({ js: { error: '' } });
return;
}
// fav_action === 'get' — return all favorited items
const favIds = getFavorites(mac);
const data = getPortalData(mac);
type AnyItem = RawChannel | RawVodItem | RawSeriesItem;
const result: AnyItem[] = [];
for (const id of favIds) {
// Search across all content types
const found = findItemById(data, id);
if (found) result.push(found);
}
res.json({
js: {
data: result,
total_items: result.length,
},
});
}
function findItemById(
data: ReturnType<typeof getPortalData>,
id: string
): RawChannel | RawVodItem | RawSeriesItem | undefined {
for (const items of data.channels.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
for (const items of data.vod.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
for (const items of data.series.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
return undefined;
}
@@ -0,0 +1,33 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
/**
* Stalker get_categories — returns category list filtered by type (itv/vod/series).
*/
export function handleGetCategories(req: Request, res: Response): void {
const mac = extractMac(req);
const type = (req.query['type'] as string) ?? 'vod';
const data = getPortalData(mac);
let categories;
if (type === 'itv') {
categories = data.itvCategories;
} else if (type === 'series') {
categories = data.seriesCategories;
} else {
categories = data.vodCategories;
}
res.json({ js: categories });
}
export function extractMac(req: Request): string {
const cookie = req.headers['cookie'] ?? '';
return (
cookie
.split(';')
.find((c) => c.trim().startsWith('mac='))
?.split('=')[1]
?.trim() ?? '00:00:00:00:00:00'
);
}
@@ -0,0 +1,31 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker get_genres — returns genre list for a content type.
* Genres mirror the categories for our mock portal.
*/
export function handleGetGenres(req: Request, res: Response): void {
const mac = extractMac(req);
const type = (req.query['type'] as string) ?? 'vod';
const data = getPortalData(mac);
let categories;
if (type === 'itv') {
categories = data.itvCategories;
} else if (type === 'series') {
categories = data.seriesCategories;
} else {
categories = data.vodCategories;
}
const genres = categories.map((cat) => ({
id: cat.id,
title: cat.title,
alias: cat.alias,
censored: '0',
}));
res.json({ js: genres });
}
@@ -0,0 +1,80 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
const PAGE_SIZE = 14;
type AnyItem = RawChannel | RawVodItem | RawSeriesItem;
/**
* Stalker get_ordered_list — returns paginated content for a category.
*
* Query params:
* type: itv | vod | series
* category: category_id (or "*" for all)
* genre: same as category for itv
* p: page number (1-based)
* search: optional search phrase
*/
export function handleGetOrderedList(req: Request, res: Response): void {
const mac = extractMac(req);
const type = (req.query['type'] as string) ?? 'vod';
const categoryId = (req.query['category'] as string) ?? '*';
const page = parseInt((req.query['p'] as string) ?? '1', 10);
const search = ((req.query['search'] as string) ?? '').toLowerCase();
const data = getPortalData(mac);
let allItems: AnyItem[] = [];
if (type === 'itv') {
if (categoryId === '*') {
for (const items of data.channels.values()) {
allItems.push(...items);
}
} else {
allItems = data.channels.get(categoryId) ?? [];
}
} else if (type === 'series') {
if (categoryId === '*') {
for (const items of data.series.values()) {
allItems.push(...items);
}
} else {
allItems = data.series.get(categoryId) ?? [];
}
} else {
// vod (default)
if (categoryId === '*') {
for (const items of data.vod.values()) {
allItems.push(...items);
}
} else {
allItems = data.vod.get(categoryId) ?? [];
}
}
// Apply search filter
if (search) {
allItems = allItems.filter((item) => {
const name = ('name' in item ? item.name : '') ?? '';
return name.toLowerCase().includes(search);
});
}
const totalItems = allItems.length;
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
const offset = (page - 1) * PAGE_SIZE;
const pageItems = allItems.slice(offset, offset + PAGE_SIZE);
res.json({
js: {
data: pageItems,
total_items: totalItems,
max_page_items: PAGE_SIZE,
cur_page: page,
total_pages: totalPages,
selected_item: 0,
},
});
}
@@ -0,0 +1,55 @@
import { Request, Response } from 'express';
import { generateSeasons } from '../data-generator.js';
import { getPortalData } from '../data-store.js';
import { getScenario } from '../scenarios.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker get_ordered_list with type=series for seasons/episodes.
* When a series item is opened, the frontend requests seasons via
* get_ordered_list?action=get_ordered_list&type=series&movie_id=<id>
*
* This handler is invoked from the main portal router when movie_id is present.
*/
export function handleGetSeasons(req: Request, res: Response): void {
const mac = extractMac(req);
const seriesId = req.query['movie_id'] as string;
const data = getPortalData(mac);
const scenario = getScenario(mac);
// Try the precomputed seasons map first
if (data.seasons.has(seriesId)) {
res.json({ js: data.seasons.get(seriesId) });
return;
}
// Series id might belong to a VOD item with is_series=1 — generate seasons on demand
let foundItem: { id: string; name: string; o_name: string; title: string; cmd: string; screenshot_uri: string; cover: string; description: string; actors: string; director: string; year: string; genres_str: string; rating_imdb: string; rating_kinopoisk: string; category_id: string; is_series: 0; has_files: 0 } | undefined;
for (const items of data.vod.values()) {
const match = items.find((i) => i.id === seriesId);
if (match) {
foundItem = {
...match,
genres_str: match.genres_str ?? match.genre ?? '',
is_series: 0,
has_files: 0,
};
break;
}
}
if (!foundItem) {
res.json({ js: [] });
return;
}
const seasons = generateSeasons(
foundItem,
scenario.seasonsPerSeries,
scenario.episodesPerSeason
);
data.seasons.set(seriesId, seasons);
res.json({ js: seasons });
}
@@ -0,0 +1,32 @@
import { Request, Response } from 'express';
import { generateEpg } from '../data-generator.js';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker get_short_epg — returns EPG programs for a channel.
*
* Query params:
* ch_id: channel id
* size: number of programs to return (default 12)
*/
export function handleGetShortEpg(req: Request, res: Response): void {
const mac = extractMac(req);
const channelId = req.query['ch_id'] as string;
const size = parseInt((req.query['size'] as string) ?? '12', 10);
const data = getPortalData(mac);
let programs = data.epg.get(channelId);
if (!programs) {
// Channel not found — generate on-the-fly
programs = generateEpg(`Channel ${channelId}`);
data.epg.set(channelId, programs);
}
res.json({
js: {
data: programs.slice(0, size),
},
});
}
@@ -0,0 +1,24 @@
import { Request, Response } from 'express';
/**
* Stalker handshake — returns a Bearer token.
* Real portals return a JWT; we return a deterministic fake token.
*/
export function handleHandshake(req: Request, res: Response): void {
const mac = (req.headers['cookie'] ?? '')
.split(';')
.find((c) => c.trim().startsWith('mac='))
?.split('=')[1]
?.trim() ?? 'unknown';
res.json({
js: {
token: `mock-token-${Buffer.from(mac).toString('base64')}`,
keep_alive: 180,
servertime: Math.floor(Date.now() / 1000),
servertimezone: 'Europe/Berlin',
version: '5.6.2',
revision: '1',
},
});
}
@@ -0,0 +1,55 @@
import { Request, Response } from 'express';
import { handleHandshake } from '../handlers/handshake.handler.js';
import { handleDoAuth } from '../handlers/do-auth.handler.js';
import { handleGetCategories } from '../handlers/get-categories.handler.js';
import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js';
import { handleGetSeasons } from '../handlers/get-seasons.handler.js';
import { handleCreateLink } from '../handlers/create-link.handler.js';
import { handleFavorites } from '../handlers/favorites.handler.js';
import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js';
import { handleGetGenres } from '../handlers/get-genres.handler.js';
/**
* Shared Stalker action dispatcher.
* Used by both the direct /portal.php route and the /stalker CORS proxy route.
*/
export default function dispatchPortalAction(req: Request, res: Response): void {
const action = req.query['action'] as string;
switch (action) {
case 'handshake':
handleHandshake(req, res);
break;
case 'do_auth':
handleDoAuth(req, res);
break;
case 'get_categories':
case 'get_genres_vod':
case 'get_genres_itv':
handleGetCategories(req, res);
break;
case 'get_genres':
handleGetGenres(req, res);
break;
case 'get_ordered_list':
if (req.query['movie_id']) {
handleGetSeasons(req, res);
} else {
handleGetOrderedList(req, res);
}
break;
case 'create_link':
handleCreateLink(req, res);
break;
case 'favorites':
handleFavorites(req, res);
break;
case 'get_short_epg':
case 'get_epg_info':
handleGetShortEpg(req, res);
break;
default:
console.warn(`[portal] Unknown action: ${action}`);
res.json({ js: { error: `Unknown action: ${action}` } });
}
}
@@ -0,0 +1,14 @@
import { Router, Request, Response } from 'express';
import dispatchPortalAction from './dispatch.js';
const router = Router();
/**
* Main Stalker API dispatcher.
* All requests arrive as GET /portal.php?action=<action>&...
*/
router.get('/', (req: Request, res: Response) => {
dispatchPortalAction(req, res);
});
export default router;
@@ -0,0 +1,121 @@
export interface ScenarioConfig {
name: string;
description: string;
seed: number;
categoryCount: {
itv: number;
vod: number;
series: number;
};
itemsPerCategory: number;
seasonsPerSeries: number;
episodesPerSeason: number;
/** Fraction of VOD items that have is_series=1 (Ministra mode) */
isSeriesFraction: number;
/** Fraction of VOD items that have embedded series[] array */
embeddedSeriesFraction: number;
}
/**
* Predefined scenarios keyed by MAC address (lowercase, colon-separated).
* Any unknown MAC uses its bytes as the seed for deterministic-but-unique data.
*/
export const SCENARIOS: Record<string, ScenarioConfig> = {
'00:1a:79:00:00:01': {
name: 'default',
description: 'Balanced portal — 8 categories, 40 items each',
seed: 1001,
categoryCount: { itv: 8, vod: 8, series: 8 },
itemsPerCategory: 40,
seasonsPerSeries: 3,
episodesPerSeason: 8,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:ff:ff:ff': {
name: 'large',
description: 'Large catalog — 20 categories, 200 items each',
seed: 9999,
categoryCount: { itv: 20, vod: 20, series: 20 },
itemsPerCategory: 200,
seasonsPerSeries: 5,
episodesPerSeason: 12,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:02': {
name: 'series-heavy',
description: 'Series-heavy portal — many series with deep seasons',
seed: 2002,
categoryCount: { itv: 3, vod: 5, series: 15 },
itemsPerCategory: 30,
seasonsPerSeries: 6,
episodesPerSeason: 10,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:03': {
name: 'minimal',
description: 'Minimal portal — 2 categories, 5 items (edge case testing)',
seed: 3003,
categoryCount: { itv: 2, vod: 2, series: 2 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:04': {
name: 'is-series',
description: 'VOD with is_series=1 flag (Ministra plugin flow testing)',
seed: 4004,
categoryCount: { itv: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 3,
episodesPerSeason: 6,
isSeriesFraction: 0.6, // 60% of VOD items are is_series=1
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:05': {
name: 'embedded-series',
description: 'VOD with embedded series[] arrays',
seed: 5005,
categoryCount: { itv: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 2,
episodesPerSeason: 5,
isSeriesFraction: 0,
embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[]
},
};
/**
* Convert a MAC address string to a numeric seed for unknown MACs.
* e.g. "AA:BB:CC:DD:EE:FF" -> sum of byte values
*/
export function macToSeed(mac: string): number {
return mac
.toLowerCase()
.split(':')
.reduce((acc, byte) => acc + parseInt(byte, 16), 0);
}
/** Return the scenario config for a given MAC, falling back to a seeded default. */
export function getScenario(mac: string): ScenarioConfig {
const normalizedMac = mac.toLowerCase();
if (SCENARIOS[normalizedMac]) {
return SCENARIOS[normalizedMac];
}
// Unknown MAC: use MAC bytes as seed, default shape
return {
name: 'auto',
description: `Auto-generated from MAC ${mac}`,
seed: macToSeed(mac),
categoryCount: { itv: 6, vod: 6, series: 6 },
itemsPerCategory: 30,
seasonsPerSeries: 3,
episodesPerSeason: 8,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
};
}
+143
View File
@@ -0,0 +1,143 @@
import http from 'http';
import express, { Request, Response } from 'express';
import cors from 'cors';
import portalRouter from './app/routes/portal.route.js';
import dispatchPortalAction from './app/routes/dispatch.js';
import { resetAll } from './app/data-store.js';
import { SCENARIOS } from './app/scenarios.js';
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
const app = express();
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Log every request
app.use((req, _res, next) => {
const action = req.query['action'] ?? '-';
const mac =
(req.headers['cookie'] ?? '')
.split(';')
.find((c) => c.trim().startsWith('mac='))
?.split('=')[1]
?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac';
console.log(
`[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}`
);
next();
});
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Stalker portal.php endpoint (direct portal protocol, Electron mode)
app.use('/portal.php', portalRouter);
/**
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
* GET /stalker?url=<portal_url>&macAddress=<mac>&action=<action>&...
* → { payload: <stalker_response> }
*
* The IPTVnator PWA sends Stalker requests to AppConfig.BACKEND_URL/stalker.
* Playwright tests redirect those calls to this endpoint using page.route(),
* so no app code changes are required.
*/
app.get('/stalker', (req: Request, res: Response) => {
const { macAddress, url: _url, ...rest } = req.query as Record<string, string>;
const mac = macAddress ?? '00:1a:79:00:00:01';
// Build a lightweight synthetic request. We need a fresh object with mutable
// `query` and a Cookie header containing the MAC for the handler helpers.
const syntheticReq = {
query: rest,
headers: { cookie: `mac=${mac}` },
params: {},
} as unknown as Request;
// Capture the JSON response and wrap it in the proxy envelope { payload: ... }
let captured: unknown;
const syntheticRes = {
json: (data: unknown) => {
captured = data;
},
} as unknown as Response;
dispatchPortalAction(syntheticReq, syntheticRes);
res.json({ payload: captured });
});
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Reset all in-memory data (useful between Playwright test runs)
app.post('/reset', (_req: Request, res: Response) => {
resetAll();
res.json({ status: 'reset', timestamp: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const server = http.createServer(app);
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[stalker-mock] Port ${PORT} is already in use.`);
} else {
console.error('[stalker-mock] Server error:', err.message);
}
process.exit(1);
});
const shutdown = () => {
console.log('\n[stalker-mock] Shutting down...');
server.close(() => process.exit(0));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => {
console.error('[stalker-mock] Uncaught exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[stalker-mock] Unhandled rejection:', reason);
process.exit(1);
});
// When Nx (or any process manager) closes stdin, prevent auto-exit.
// The HTTP server handle is what keeps the process alive.
process.stdin.resume();
process.stdin.on('end', () => { /* ignore stdin close */ });
server.listen(PORT, () => {
const divider = '─'.repeat(62);
console.log(`\n${divider}`);
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
console.log(divider);
console.log(' Portal URL (Electron/direct):');
console.log(` http://localhost:${PORT}/portal.php`);
console.log('');
console.log(' CORS proxy URL (PWA/Playwright e2e):');
console.log(` http://localhost:${PORT}/stalker?url=...&macAddress=...`);
console.log('');
console.log(' Predefined scenario MACs:');
for (const [mac, scenario] of Object.entries(SCENARIOS)) {
console.log(
` ${mac} → ${scenario.name.padEnd(16)} ${scenario.description}`
);
}
console.log('');
console.log(' Any other MAC generates deterministic unique data from MAC bytes.');
console.log(` Utilities:`);
console.log(` GET http://localhost:${PORT}/health`);
console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`);
console.log(`${divider}\n`);
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "../../dist/apps/stalker-mock-server",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+24 -7
View File
@@ -22,13 +22,30 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: true,
cwd: workspaceRoot,
},
/* Run local dev servers before starting the tests.
* Both the Angular app and the Stalker mock server start in parallel.
* Set MOCK_PORT to override the default mock server port (3210).
*/
webServer: [
{
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run stalker-mock-server:serve',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run xtream-mock-server:serve',
url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
],
projects: [
{
name: 'chromium',
+254
View File
@@ -0,0 +1,254 @@
import { expect, Page, test } from '@playwright/test';
/**
* Stalker Portal E2E Tests
*
* These tests use the stalker-mock-server (apps/stalker-mock-server) to simulate
* a real Stalker portal. The mock server starts automatically alongside the Angular
* dev server when running e2e tests (see playwright.config.ts).
*
* Default scenario MAC (00:1A:79:00:00:01) provides:
* - 8 categories per content type (VOD / Series / ITV)
* - 40 items per category
* - 3 seasons × 8 episodes per series item
*
* Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker"
*/
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Intercept calls to the Angular dev backend (/stalker proxy) and redirect
* them to the mock server. This avoids needing a real backend or changing
* any app environment configuration.
*/
async function interceptStalkerRequests(page: Page): Promise<void> {
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(BACKEND_PROXY);
// Forward all query params unchanged
originalUrl.searchParams.forEach((value, key) => {
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button
* 2. Click "Stalker Portal Import" in the menu
* 3. Fill in the form and submit
*/
async function addStalkerPortal(
page: Page,
options: { name?: string; mac?: string } = {}
): Promise<void> {
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByTestId('add-playlist').click();
await page.getByText('Stalker Portal').click();
await page.locator('#title').fill(name);
await page.locator('#portalUrl').fill(PORTAL_URL);
await page.locator('#macAddress').fill(mac);
await page.getByRole('button', { name: 'Add' }).click();
// Wait for dialog to close
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
/**
* Clear IndexedDB state between tests so each test starts fresh.
*/
async function clearStorage(page: Page): Promise<void> {
await page.evaluate(async () => {
const dbs = await window.indexedDB.databases();
await Promise.all(
dbs
.filter((db) => db.name != null)
.map((db) => window.indexedDB.deleteDatabase(db.name!))
);
});
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
test.beforeEach(async ({ page, request }) => {
// Reset mock server state (clears in-memory favorites and cache)
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await clearStorage(page);
await page.reload();
// Redirect backend proxy calls to the mock server
await interceptStalkerRequests(page);
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('@stalker health check — mock server is running', async ({ request }) => {
const response = await request.get(`${MOCK_SERVER}/health`);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('ok');
});
test('@stalker add a Stalker portal and see it in the playlist list', async ({
page,
}) => {
await addStalkerPortal(page, { name: 'My Test Portal' });
// Portal card should appear on the home page
await expect(
page.getByText('My Test Portal', { exact: false })
).toBeVisible();
});
test('@stalker VOD — categories load from mock server', async ({ page }) => {
await addStalkerPortal(page);
// Navigate into the portal — click the portal card
await page.getByText('Mock Stalker Portal').click();
// Wait for VOD route to load
await page.waitForURL(/stalker.*vod/);
// Default scenario has 8 VOD categories (+ 1 "All categories" prepended by the store)
const categoryItems = page.locator('mat-list-item, [class*="category"]');
await expect(categoryItems.first()).toBeVisible({ timeout: 10_000 });
const count = await categoryItems.count();
expect(count).toBeGreaterThanOrEqual(8);
});
test('@stalker VOD — content list loads after selecting a category', async ({
page,
}) => {
await addStalkerPortal(page);
await page.getByText('Mock Stalker Portal').click();
await page.waitForURL(/stalker.*vod/);
// Click the first non-"All" category
const categories = page.locator('mat-list-item');
await categories.first().click();
// Content grid / list should appear with items
const contentItems = page.locator('[class*="content-item"], mat-card');
await expect(contentItems.first()).toBeVisible({ timeout: 10_000 });
const itemCount = await contentItems.count();
expect(itemCount).toBeGreaterThan(0);
});
test('@stalker minimal scenario — correct item counts', async ({ page }) => {
await addStalkerPortal(page, {
name: 'Minimal Portal',
mac: MINIMAL_MAC,
});
await page.getByText('Minimal Portal').click();
await page.waitForURL(/stalker.*vod/);
// Minimal scenario: 2 categories (+ "All" = 3 visible)
const categories = page.locator('mat-list-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
const count = await categories.count();
// At least 2 real categories
expect(count).toBeGreaterThanOrEqual(2);
});
test('@stalker EPG data loads for ITV channel', async ({ page }) => {
await addStalkerPortal(page);
await page.getByText('Mock Stalker Portal').click();
await page.waitForURL(/stalker.*vod/);
// Navigate to ITV tab
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
// Wait for channels to appear
const channels = page.locator('mat-list-item, [class*="channel"]');
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
// Click a channel — EPG info should appear
await channels.first().click();
// EPG view should become visible
await expect(page.locator('[class*="epg"], app-epg-view')).toBeVisible({
timeout: 10_000,
});
});
test('@stalker mock server reset clears cached state', async ({ request }) => {
// Generate data for default MAC
const before = await request.get(
`${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}`
);
expect(before.ok()).toBeTruthy();
// Reset
const reset = await request.post(`${MOCK_SERVER}/reset`);
expect(reset.ok()).toBeTruthy();
// Data is regenerated identically (deterministic seed)
const after = await request.get(
`${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}`
);
const beforeBody = (await before.json()).payload.js;
const afterBody = (await after.json()).payload.js;
expect(afterBody).toEqual(beforeBody);
});
test('@stalker create_link returns a playable stream URL', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/stalker?action=create_link&cmd=ffrt4://vod/20001/index.m3u8&macAddress=${DEFAULT_MAC}`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
const streamUrl: string = body.payload.js.cmd;
expect(streamUrl).toMatch(/^https?:\/\//);
expect(streamUrl).toMatch(/\.m3u8$/);
});
test('@stalker series — seasons load for a series item', async ({
request,
}) => {
// First fetch a series item to get its ID
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&category=3001&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const firstItem = listBody.payload.js.data[0];
expect(firstItem).toBeDefined();
// Fetch seasons for the first series item
const seasonsResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&movie_id=${firstItem.id}&macAddress=${DEFAULT_MAC}`
);
const seasonsBody = await seasonsResponse.json();
const seasons = seasonsBody.payload.js;
expect(Array.isArray(seasons)).toBeTruthy();
// Default scenario has 3 seasons per series
expect(seasons.length).toBe(3);
expect(seasons[0].name).toBe('Season 1');
expect(Array.isArray(seasons[0].series)).toBeTruthy();
// Default scenario has 8 episodes per season
expect(seasons[0].series.length).toBe(8);
});
+357
View File
@@ -0,0 +1,357 @@
import { test, expect, Page } from '@playwright/test';
/**
* Xtream Codes E2E Tests
*
* These tests use the xtream-mock-server (apps/xtream-mock-server) to simulate
* a real Xtream Codes API portal. The mock server starts automatically alongside
* the Angular dev server when running e2e tests (see playwright.config.ts).
*
* Default scenario (user1:pass1) provides:
* - 8 categories per content type (live / VOD / series)
* - 40 items per category
* - 3 seasons × 8 episodes per series item
*
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
*/
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
/** Default scenario credentials */
const DEFAULT_USERNAME = 'user1';
const DEFAULT_PASSWORD = 'pass1';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
* to the mock server. This avoids any real backend requirement.
*/
async function interceptXtreamRequests(page: Page): Promise<void> {
await page.route('**/localhost:3000/xtream**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
originalUrl.searchParams.forEach((value, key) => {
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/**
* Add an Xtream portal via the UI.
*/
async function addXtreamPortal(
page: Page,
options: { name?: string; username?: string; password?: string } = {}
): Promise<void> {
const {
name = 'Mock Xtream Portal',
username = DEFAULT_USERNAME,
password = DEFAULT_PASSWORD,
} = options;
await page.getByTestId('add-playlist').click();
await page.getByText('Xtream').click();
await page.locator('#title').fill(name);
await page.locator('#serverUrl').fill(MOCK_SERVER);
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
await page.getByRole('button', { name: 'Add' }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
/**
* Clear IndexedDB state between tests so each test starts fresh.
*/
async function clearStorage(page: Page): Promise<void> {
await page.evaluate(async () => {
const dbs = await window.indexedDB.databases();
await Promise.all(
dbs
.filter((db) => db.name != null)
.map((db) => window.indexedDB.deleteDatabase(db.name!))
);
});
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await clearStorage(page);
await page.reload();
await interceptXtreamRequests(page);
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('@xtream health check — mock server is running', async ({ request }) => {
const response = await request.get(`${MOCK_SERVER}/health`);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('ok');
expect(body.server).toBe('xtream-mock-server');
});
test('@xtream get_account_info — active account returns correct fields', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.user_info.username).toBe(DEFAULT_USERNAME);
expect(body.user_info.status).toBe('active');
expect(body.server_info.url).toBeDefined();
expect(Array.isArray(body.user_info.allowed_output_formats)).toBeTruthy();
});
test('@xtream get_account_info — expired account returns Expired status', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=expired&password=expired`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
// Expired scenario: still returns 'Active' but exp_date is in the past
// The app's portal-status.service.ts detects expiry via exp_date, not status string
expect(body.user_info.status).toBe('Active');
const expDate = new Date(parseInt(body.user_info.exp_date) * 1000);
expect(expDate.getFullYear()).toBe(2020);
});
test('@xtream get_live_categories — returns expected category count', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`
);
expect(response.ok()).toBeTruthy();
const categories = await response.json();
expect(Array.isArray(categories)).toBeTruthy();
// Default scenario: 8 live categories
expect(categories.length).toBe(8);
expect(categories[0]).toHaveProperty('category_id');
expect(categories[0]).toHaveProperty('category_name');
});
test('@xtream get_live_streams — streams have required fields', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams`
);
expect(response.ok()).toBeTruthy();
const streams = await response.json();
expect(Array.isArray(streams)).toBeTruthy();
expect(streams.length).toBeGreaterThan(0);
const first = streams[0];
expect(first.stream_type).toBe('live');
expect(first.stream_id).toBeGreaterThan(0);
expect(first.epg_channel_id).toMatch(/channel-\d+\.mock/);
});
test('@xtream get_vod_streams — streams have rating and container_extension', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams`
);
expect(response.ok()).toBeTruthy();
const streams = await response.json();
expect(Array.isArray(streams)).toBeTruthy();
const first = streams[0];
expect(first.stream_type).toBe('movie');
expect(typeof first.rating).toBe('number');
expect(['mkv', 'mp4', 'avi']).toContain(first.container_extension);
});
test('@xtream get_series — series list has correct structure', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series`
);
expect(response.ok()).toBeTruthy();
const series = await response.json();
expect(Array.isArray(series)).toBeTruthy();
expect(series.length).toBeGreaterThan(0);
const first = series[0];
expect(first.series_id).toBeGreaterThan(0);
expect(typeof first.name).toBe('string');
expect(typeof first.cover).toBe('string');
expect(Array.isArray(first.backdrop_path)).toBeTruthy();
});
test('@xtream get_series_info — seasons and episodes present', async ({
request,
}) => {
// Get a series ID first
const listResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series`
);
const series = await listResponse.json();
const firstSeriesId = series[0].series_id;
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_info&series_id=${firstSeriesId}`
);
expect(response.ok()).toBeTruthy();
const info = await response.json();
expect(Array.isArray(info.seasons)).toBeTruthy();
// Default scenario: 3 seasons per series
expect(info.seasons.length).toBe(3);
expect(info.seasons[0].name).toBe('Season 1');
// Episodes are keyed by season number string
expect(info.episodes['1']).toBeDefined();
expect(Array.isArray(info.episodes['1'])).toBeTruthy();
// Default scenario: 8 episodes per season
expect(info.episodes['1'].length).toBe(8);
expect(info.episodes['1'][0].episode_num).toBe(1);
});
test('@xtream get_vod_info — returns full movie details', async ({
request,
}) => {
// Get a VOD stream ID first
const listResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams`
);
const streams = await listResponse.json();
const firstVodId = streams[0].stream_id;
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_info&vod_id=${firstVodId}`
);
expect(response.ok()).toBeTruthy();
const details = await response.json();
expect(details.info).toBeDefined();
expect(details.movie_data).toBeDefined();
expect(details.info.duration_secs).toBeGreaterThan(0);
expect(details.movie_data.stream_id).toBe(firstVodId);
});
test('@xtream get_short_epg — returns base64-encoded listings', async ({
request,
}) => {
// Get a live stream ID first
const listResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams`
);
const streams = await listResponse.json();
const streamId = streams[0].stream_id;
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_short_epg&stream_id=${streamId}`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(Array.isArray(body.epg_listings)).toBeTruthy();
expect(body.epg_listings.length).toBeGreaterThan(0);
const listing = body.epg_listings[0];
// Verify base64 encoding — decode and check it's valid text
const decodedTitle = Buffer.from(listing.title, 'base64').toString('utf-8');
expect(decodedTitle.length).toBeGreaterThan(0);
expect(listing.start_timestamp).toBeDefined();
expect(listing.stop_timestamp).toBeDefined();
});
test('@xtream reset — data regenerates identically after reset', async ({
request,
}) => {
const url = `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`;
const before = await (await request.get(url)).json();
await request.post(`${MOCK_SERVER}/reset`);
const after = await (await request.get(url)).json();
// Data is deterministic: same after reset
expect(after).toEqual(before);
});
test('@xtream proxy endpoint — returns payload wrapper', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/xtream?url=${MOCK_SERVER}&username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_categories`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.action).toBe('get_vod_categories');
expect(Array.isArray(body.payload)).toBeTruthy();
expect(body.payload.length).toBeGreaterThan(0);
});
test('@xtream category filter — get_live_streams filtered by category_id', async ({
request,
}) => {
// Get categories to find a real ID
const catResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`
);
const categories = await catResponse.json();
const categoryId = categories[0].category_id;
// Fetch streams filtered by that category
const streamResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams&category_id=${categoryId}`
);
const streams = await streamResponse.json();
expect(Array.isArray(streams)).toBeTruthy();
expect(streams.length).toBeGreaterThan(0);
// All returned streams should belong to this category
for (const s of streams) {
expect(String(s.category_id)).toBe(String(categoryId));
}
});
test('@xtream add portal and see it in the playlist list', async ({ page }) => {
await addXtreamPortal(page, { name: 'My Xtream Test Portal' });
await expect(
page.getByText('My Xtream Test Portal', { exact: false })
).toBeVisible();
});
test('@xtream minimal scenario — reduced item count', async ({ request }) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_categories`
);
const categories = await response.json();
// Minimal scenario: 2 categories
expect(categories.length).toBe(2);
const streams = await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_streams`
)
).json();
// 2 categories × 5 items = 10
expect(streams.length).toBe(10);
});
+250
View File
@@ -0,0 +1,250 @@
# Stalker Mock Server Architecture
This document describes the design decisions, data flow, and extension points of the `stalker-mock-server` development tool.
## Related Docs
- [Stalker Portal Architecture](./stalker-portal.md)
- [Stalker EPG Architecture](./stalker-epg.md)
## Purpose
The mock server enables:
1. **Local development** without access to a real Stalker portal
2. **Playwright E2E testing** with predictable, deterministic data
3. **Scenario-based testing** via predefined MAC addresses that map to specific data shapes
## 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 a numeric value derived from the MAC address before generation.
- Same MAC → identical data on every server restart.
- Restart the server to reshuffle all data.
### 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, producing unique but deterministic data.
### 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(macToNumber(mac))
│
├── generateCategories('itv', N) → itvCategories[]
│ └── generateChannels() → channels Map<categoryId, channel[]>
│ └── generateEpg() → epg Map<channelId, program[]>
│
├── generateCategories('vod', N) → vodCategories[]
│ └── generateVodItems() → vod Map<categoryId, item[]>
│ ├── normal VOD items
│ ├── is_series=1 items (fraction, Ministra flow)
│ └── embedded series[] items (fraction)
│
└── generateCategories('series', N) → seriesCategories[]
└── generateSeriesItems() → series Map<categoryId, item[]>
└── generateSeasons() → seasons Map<seriesItemId, season[]>
```
## Response Shapes
All responses follow the Stalker `portal.php` envelope:
```json
{ "js": <action-specific payload> }
```
### `get_categories`
```json
{
"js": [
{ "id": "2001", "title": "Action", "alias": "action" },
...
]
}
```
### `get_ordered_list` (content)
```json
{
"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
}
}
```
### `get_ordered_list` (seasons — when `movie_id` is present)
```json
{
"js": [
{
"id": "30001-s1",
"name": "Season 1",
"cmd": "ffrt4://series/30001/season/1",
"series": ["30001-s1-e1", "30001-s1-e2", ...],
"screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
"director": "...",
"actors": "...",
"year": "2021",
"rating_imdb": "8.1"
}
]
}
```
### `create_link`
```json
{
"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`
```json
{
"js": {
"data": [
{
"id": "1",
"name": "Channel Name: Program Title",
"start": "2026-02-21T10:00:00.000Z",
"stop": "2026-02-21T10:30:00.000Z",
"start_timestamp": 1740128400,
"stop_timestamp": 1740130200,
"descr": "...",
"category": "News"
}
]
}
}
```
EPG programs are generated as 30-minute slots spanning 3 hours past to 3 hours future relative to the time of generation.
## Scenarios
Scenarios are defined in `src/app/scenarios.ts`. Each scenario is a `ScenarioConfig`:
```typescript
interface ScenarioConfig {
name: string;
description: string;
seed: number;
categoryCount: { itv: 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[]
}
```
### 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:
```typescript
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.
The generated content (categories, items) is **not** cleared on reset — it's deterministic and doesn't need to be. Only in-memory favorites are cleared.
### Recommended Test Structure
```typescript
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`.
- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows.
- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store.
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.
+237
View File
@@ -0,0 +1,237 @@
# 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 spins it up alongside the Angular dev server
---
## Data Pipeline
```
credentials (username + password)
│
▼
credentialsToSeed(u, p) ←── deterministic polynomial hash
│
▼
faker.seed(seed) ←── all faker calls use same seed per credentials
│
▼
generateCategories() ←── live / vod / series categories
│
generateLiveStreams() ←── live TV stream list
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 targets: serve (port 3211), serve-with-watch
├── tsconfig.json
└── src/
├── main.ts ← Express app bootstrap, all routes wired up
└── app/
├── scenarios.ts ← Credential → ScenarioConfig mapping
├── data-store.ts ← Lazy cache, per-credentials generation
├── generators/
│ ├── categories.generator.ts
│ ├── live.generator.ts ← Live streams + EPG listings
│ ├── 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-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.
### Stream stub endpoints
```
GET /live/<username>/<password>/<streamId>.m3u8
GET /movie/<username>/<password>/<streamId>.<ext>
GET /series/<username>/<password>/<streamId>.<ext>
```
All redirect to a publicly available HLS test stream
(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`).
---
## 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_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 |
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
| `minimal:minimal` | 3003 | 2 each | 5 | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
| `<any other>` | hash | 6 each | 30 | active |
---
## 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`
- **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario
- **Custom stream URLs**: Edit the HLS stub redirect in `main.ts`