Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
# 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` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) |
| `get_epg_info` | Bulk EPG keyed by channel id for a requested `period` window |
## 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.
## EPG Behavior
The mock server generates a 7-day EPG schedule for every ITV channel using
2-hour slots starting at the current UTC day boundary.
- `get_short_epg` returns the current program and upcoming items from that
schedule, limited by `size`
- `get_epg_info` returns bulk data in the shape
`{ js: { data: Record<channelId, program[]> } }`
- `get_epg_info` filters the bulk response from the current UTC day start through
`now + period`
## 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-epg-info.handler.ts
│ ├── get-short-epg.handler.ts
│ └── get-genres.handler.ts
├── project.json
├── tsconfig.json
└── README.md
```
+38
View File
@@ -0,0 +1,38 @@
{
"name": "stalker-mock-server",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/stalker-mock-server/src",
"tags": ["scope:dev-tools"],
"targets": {
"serve": {
"continuous": true,
"executor": "nx:run-commands",
"options": {
"command": "pnpm tsx apps/stalker-mock-server/src/main.ts",
"cwd": "{workspaceRoot}",
"color": true,
"env": {
"PORT": "3210",
"NODE_ENV": "development"
}
}
},
"serve-with-watch": {
"continuous": true,
"executor": "nx:run-commands",
"options": {
"command": "pnpm tsx watch apps/stalker-mock-server/src/main.ts",
"cwd": "{workspaceRoot}",
"color": true,
"env": {
"PORT": "3210",
"NODE_ENV": "development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,434 @@
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`;
}
// ---------------------------------------------------------------------------
// 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) =>
String(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[] = [];
const SLOT_MINUTES = 120;
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
const TOTAL_DAYS = 7;
const dayStart = new Date();
dayStart.setUTCHours(0, 0, 0, 0);
for (let i = 0; i < SLOTS_PER_DAY * TOTAL_DAYS; i++) {
const startDate = new Date(
dayStart.getTime() + i * SLOT_MINUTES * 60 * 1000
);
const stopDate = new Date(
startDate.getTime() + SLOT_MINUTES * 60 * 1000
);
const category = EPG_PROGRAM_TYPES[i % EPG_PROGRAM_TYPES.length];
programs.push({
id: String(i + 1),
name: `${channelName}: ${faker.company.catchPhrase()}`,
start: startDate.toISOString(),
stop: stopDate.toISOString(),
start_timestamp: Math.floor(startDate.getTime() / 1000),
stop_timestamp: Math.floor(stopDate.getTime() / 1000),
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,37 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker get_epg_info — returns bulk EPG keyed by channel id.
*
* Query params:
* period: number of future hours to include (default 168)
*/
export function handleGetEpgInfo(req: Request, res: Response): void {
const mac = extractMac(req);
const period = parseInt((req.query['period'] as string) ?? '168', 10);
const data = getPortalData(mac);
const now = Math.floor(Date.now() / 1000);
const currentDayStart = new Date();
currentDayStart.setUTCHours(0, 0, 0, 0);
const startTimestamp = Math.floor(currentDayStart.getTime() / 1000);
const endTimestamp = now + Math.max(period, 1) * 60 * 60;
const epgByChannel = Object.fromEntries(
[...data.epg.entries()].map(([channelId, programs]) => [
channelId,
programs.filter(
(program) =>
program.stop_timestamp >= startTimestamp &&
program.start_timestamp <= endTimestamp
),
])
);
res.json({
js: {
data: epgByChannel,
},
});
}
@@ -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,38 @@
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);
}
const now = Math.floor(Date.now() / 1000);
const currentIndex = programs.findIndex(
(program) => program.stop_timestamp > now
);
const startIndex = currentIndex >= 0 ? currentIndex : 0;
res.json({
js: {
data: programs.slice(startIndex, startIndex + 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,58 @@
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 { handleGetEpgInfo } from '../handlers/get-epg-info.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':
handleGetShortEpg(req, res);
break;
case 'get_epg_info':
handleGetEpgInfo(req, res);
break;
default:
console.warn(`[portal] Unknown action: ${action}`);
res.json({ js: { error: `Unknown action: ${action}` } });
}
}
@@ -0,0 +1,14 @@
import { Router, Request, Response } from 'express';
import dispatchPortalAction from './dispatch.js';
const router = Router();
/**
* Main Stalker API dispatcher.
* All requests arrive as GET /portal.php?action=<action>&...
*/
router.get('/', (req: Request, res: Response) => {
dispatchPortalAction(req, res);
});
export default router;
@@ -0,0 +1,121 @@
export interface ScenarioConfig {
name: string;
description: string;
seed: number;
categoryCount: {
itv: number;
vod: number;
series: number;
};
itemsPerCategory: number;
seasonsPerSeries: number;
episodesPerSeason: number;
/** Fraction of VOD items that have is_series=1 (Ministra mode) */
isSeriesFraction: number;
/** Fraction of VOD items that have embedded series[] array */
embeddedSeriesFraction: number;
}
/**
* Predefined scenarios keyed by MAC address (lowercase, colon-separated).
* Any unknown MAC uses its bytes as the seed for deterministic-but-unique data.
*/
export const SCENARIOS: Record<string, ScenarioConfig> = {
'00:1a:79:00:00:01': {
name: 'default',
description: 'Balanced portal — 8 categories, 40 items each',
seed: 1001,
categoryCount: { itv: 8, vod: 8, series: 8 },
itemsPerCategory: 40,
seasonsPerSeries: 3,
episodesPerSeason: 8,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:ff:ff:ff': {
name: 'large',
description: 'Large catalog — 20 categories, 200 items each',
seed: 9999,
categoryCount: { itv: 20, vod: 20, series: 20 },
itemsPerCategory: 200,
seasonsPerSeries: 5,
episodesPerSeason: 12,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:02': {
name: 'series-heavy',
description: 'Series-heavy portal — many series with deep seasons',
seed: 2002,
categoryCount: { itv: 3, vod: 5, series: 15 },
itemsPerCategory: 30,
seasonsPerSeries: 6,
episodesPerSeason: 10,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:03': {
name: 'minimal',
description: 'Minimal portal — 2 categories, 5 items (edge case testing)',
seed: 3003,
categoryCount: { itv: 2, vod: 2, series: 2 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:04': {
name: 'is-series',
description: 'VOD with is_series=1 flag (Ministra plugin flow testing)',
seed: 4004,
categoryCount: { itv: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 3,
episodesPerSeason: 6,
isSeriesFraction: 0.6, // 60% of VOD items are is_series=1
embeddedSeriesFraction: 0,
},
'00:1a:79:00:00:05': {
name: 'embedded-series',
description: 'VOD with embedded series[] arrays',
seed: 5005,
categoryCount: { itv: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 2,
episodesPerSeason: 5,
isSeriesFraction: 0,
embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[]
},
};
/**
* Convert a MAC address string to a numeric seed for unknown MACs.
* e.g. "AA:BB:CC:DD:EE:FF" -> sum of byte values
*/
export function macToSeed(mac: string): number {
return mac
.toLowerCase()
.split(':')
.reduce((acc, byte) => acc + parseInt(byte, 16), 0);
}
/** Return the scenario config for a given MAC, falling back to a seeded default. */
export function getScenario(mac: string): ScenarioConfig {
const normalizedMac = mac.toLowerCase();
if (SCENARIOS[normalizedMac]) {
return SCENARIOS[normalizedMac];
}
// Unknown MAC: use MAC bytes as seed, default shape
return {
name: 'auto',
description: `Auto-generated from MAC ${mac}`,
seed: macToSeed(mac),
categoryCount: { itv: 6, vod: 6, series: 6 },
itemsPerCategory: 30,
seasonsPerSeries: 3,
episodesPerSeason: 8,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
};
}
+143
View File
@@ -0,0 +1,143 @@
import http from 'http';
import express, { Request, Response } from 'express';
import cors from 'cors';
import portalRouter from './app/routes/portal.route.js';
import dispatchPortalAction from './app/routes/dispatch.js';
import { resetAll } from './app/data-store.js';
import { SCENARIOS } from './app/scenarios.js';
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
const app = express();
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Log every request
app.use((req, _res, next) => {
const action = req.query['action'] ?? '-';
const mac =
(req.headers['cookie'] ?? '')
.split(';')
.find((c) => c.trim().startsWith('mac='))
?.split('=')[1]
?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac';
console.log(
`[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}`
);
next();
});
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Stalker portal.php endpoint (direct portal protocol, Electron mode)
app.use('/portal.php', portalRouter);
/**
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
* GET /stalker?url=<portal_url>&macAddress=<mac>&action=<action>&...
* → { payload: <stalker_response> }
*
* The IPTVnator PWA sends Stalker requests to AppConfig.BACKEND_URL/stalker.
* Playwright tests redirect those calls to this endpoint using page.route(),
* so no app code changes are required.
*/
app.get('/stalker', (req: Request, res: Response) => {
const { macAddress, url: _url, ...rest } = req.query as Record<string, string>;
const mac = macAddress ?? '00:1a:79:00:00:01';
// Build a lightweight synthetic request. We need a fresh object with mutable
// `query` and a Cookie header containing the MAC for the handler helpers.
const syntheticReq = {
query: rest,
headers: { cookie: `mac=${mac}` },
params: {},
} as unknown as Request;
// Capture the JSON response and wrap it in the proxy envelope { payload: ... }
let captured: unknown;
const syntheticRes = {
json: (data: unknown) => {
captured = data;
},
} as unknown as Response;
dispatchPortalAction(syntheticReq, syntheticRes);
res.json({ payload: captured });
});
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Reset all in-memory data (useful between Playwright test runs)
app.post('/reset', (_req: Request, res: Response) => {
resetAll();
res.json({ status: 'reset', timestamp: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const server = http.createServer(app);
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[stalker-mock] Port ${PORT} is already in use.`);
} else {
console.error('[stalker-mock] Server error:', err.message);
}
process.exit(1);
});
const shutdown = () => {
console.log('\n[stalker-mock] Shutting down...');
server.close(() => process.exit(0));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => {
console.error('[stalker-mock] Uncaught exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[stalker-mock] Unhandled rejection:', reason);
process.exit(1);
});
// When Nx (or any process manager) closes stdin, prevent auto-exit.
// The HTTP server handle is what keeps the process alive.
process.stdin.resume();
process.stdin.on('end', () => { /* ignore stdin close */ });
server.listen(PORT, () => {
const divider = '─'.repeat(62);
console.log(`\n${divider}`);
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
console.log(divider);
console.log(' Portal URL (Electron/direct):');
console.log(` http://localhost:${PORT}/portal.php`);
console.log('');
console.log(' CORS proxy URL (PWA/Playwright e2e):');
console.log(` http://localhost:${PORT}/stalker?url=...&macAddress=...`);
console.log('');
console.log(' Predefined scenario MACs:');
for (const [mac, scenario] of Object.entries(SCENARIOS)) {
console.log(
` ${mac} → ${scenario.name.padEnd(16)} ${scenario.description}`
);
}
console.log('');
console.log(' Any other MAC generates deterministic unique data from MAC bytes.');
console.log(` Utilities:`);
console.log(` GET http://localhost:${PORT}/health`);
console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`);
console.log(`${divider}\n`);
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "../../dist/apps/stalker-mock-server",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}