Proyecto App Linux
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user