mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
d7ee25e3aa
commit
95c32708ee
23 files changed
+2507
-7
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
Reference in new issue
Block a user