mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
test: add xtream-mock-server with initial implementation
- Created project structure and configuration for xtream-mock-server. - Implemented data generation for live, VOD, and series content using Faker.js. - Developed API handlers for account info, categories, streams, and series information. - Added caching mechanisms for portal data and details. - Implemented scenario management for user credentials and account status. - Set up Express server with health check and reset endpoints. - Configured TypeScript settings for the project. Entire-Checkpoint: 815a29d0e5a4
This commit is contained in:
1 parent
fae4c44804
commit
0553cd8097
17 files changed
+1175
No files matched your search
@@ -0,0 +1,134 @@
|
||||
# Xtream Codes Mock Server
|
||||
|
||||
A lightweight Express server that simulates the Xtream Codes API for local
|
||||
development and end-to-end testing. Uses `@faker-js/faker` with deterministic
|
||||
seeding so every credential pair always produces the same data.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start on port 3211
|
||||
pnpm nx run xtream-mock-server:serve
|
||||
|
||||
# Start with file-watch (auto-restart on code changes)
|
||||
pnpm nx run xtream-mock-server:serve-with-watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Scenarios (credential pairs)
|
||||
|
||||
| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status |
|
||||
|----------|----------|----------|-----------|----------|-------------|-----------|--------|
|
||||
| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active |
|
||||
| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active |
|
||||
| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active |
|
||||
| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active |
|
||||
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
|
||||
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
|
||||
|
||||
Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account).
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Direct Xtream Protocol
|
||||
|
||||
`GET /player_api.php?action=<action>&username=<u>&password=<p>[&...]`
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| (none) / `get_account_info` | User info + server info |
|
||||
| `get_live_categories` | Live TV categories |
|
||||
| `get_vod_categories` | VOD (movie) categories |
|
||||
| `get_series_categories` | Series categories |
|
||||
| `get_live_streams` | Live streams (optionally filtered by `category_id`) |
|
||||
| `get_vod_streams` | VOD streams (optionally filtered by `category_id`) |
|
||||
| `get_series` | Series list (optionally filtered by `category_id`) |
|
||||
| `get_vod_info?vod_id=<id>` | Full movie details |
|
||||
| `get_series_info?series_id=<id>` | Full series info (seasons + episodes) |
|
||||
| `get_short_epg?stream_id=<id>[&limit=N]` | EPG listings for a live channel |
|
||||
|
||||
### PWA CORS Proxy Endpoint
|
||||
|
||||
IPTVnator's PWA routes Xtream calls through a backend proxy:
|
||||
```
|
||||
GET /xtream?url=<serverUrl>&action=<action>&username=<u>&password=<p>
|
||||
```
|
||||
Response: `{ payload: <data>, action: <action> }`
|
||||
|
||||
### Stream URLs (stub redirects)
|
||||
|
||||
```
|
||||
GET /live/<username>/<password>/<streamId>.m3u8 → HLS test stream
|
||||
GET /movie/<username>/<password>/<streamId>.<ext> → HLS test stream
|
||||
GET /series/<username>/<password>/<streamId>.<ext> → HLS test stream
|
||||
```
|
||||
|
||||
### Utility Endpoints
|
||||
|
||||
```
|
||||
GET /health → { status: "ok", server: "xtream-mock-server", port: 3211 }
|
||||
POST /reset → clears all in-memory caches; data regenerates on next request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Requests
|
||||
|
||||
```bash
|
||||
# Account info (direct)
|
||||
curl "http://localhost:3211/player_api.php?username=user1&password=pass1"
|
||||
|
||||
# Live categories (direct)
|
||||
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_live_categories"
|
||||
|
||||
# VOD details (direct)
|
||||
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_vod_info&vod_id=20000"
|
||||
|
||||
# Series info (direct)
|
||||
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_series_info&series_id=30000"
|
||||
|
||||
# EPG for stream (direct)
|
||||
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_short_epg&stream_id=10000"
|
||||
|
||||
# Via PWA proxy
|
||||
curl "http://localhost:3211/xtream?url=http://localhost:3211&username=user1&password=pass1&action=get_live_categories"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Playwright Integration
|
||||
|
||||
The mock server starts automatically with `nx e2e web-e2e`. Run only Xtream
|
||||
tests using the `@xtream` tag:
|
||||
|
||||
```bash
|
||||
nx e2e web-e2e --grep "@xtream"
|
||||
```
|
||||
|
||||
Test files: `apps/web-e2e/src/xtream.e2e.ts`
|
||||
|
||||
The Playwright tests use `page.route()` to redirect the app's backend proxy
|
||||
calls (`localhost:3000/xtream**`) to the mock server without modifying any
|
||||
application code.
|
||||
|
||||
---
|
||||
|
||||
## Data Characteristics
|
||||
|
||||
- **Deterministic**: Same credentials → same data every time (seeded faker)
|
||||
- **Cached per session**: Data generated once on first request, reused until `/reset`
|
||||
- **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API)
|
||||
- **Stream IDs**: Live 10,000+, VOD 20,000+, Series 30,000+
|
||||
- **Category IDs**: Live 101+, VOD 201+, Series 301+
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
See `docs/architecture/xtream-mock-server.md` for a full description of the
|
||||
data pipeline, response shapes, and extension points.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "xtream-mock-server",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/xtream-mock-server/src",
|
||||
"tags": ["scope:dev-tools"],
|
||||
"targets": {
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm tsx apps/xtream-mock-server/src/main.ts",
|
||||
"cwd": "{workspaceRoot}",
|
||||
"color": true,
|
||||
"env": {
|
||||
"PORT": "3211",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve-with-watch": {
|
||||
"continuous": true,
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm tsx watch apps/xtream-mock-server/src/main.ts",
|
||||
"cwd": "{workspaceRoot}",
|
||||
"color": true,
|
||||
"env": {
|
||||
"PORT": "3211",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { generateCategories } from './generators/categories.generator.js';
|
||||
import { generateLiveStreams, generateEpgListings, RawLiveStream, RawEpgListing } from './generators/live.generator.js';
|
||||
import { generateVodStreams, generateVodDetails, RawVodStream, RawVodDetails } from './generators/vod.generator.js';
|
||||
import { generateSeriesItems, generateSeriesInfo, RawSeriesItem, RawSeriesInfo } from './generators/series.generator.js';
|
||||
import { RawCategory } from './generators/categories.generator.js';
|
||||
import { getScenario, ScenarioConfig } from './scenarios.js';
|
||||
|
||||
export interface PortalData {
|
||||
scenario: ScenarioConfig;
|
||||
liveCategories: RawCategory[];
|
||||
vodCategories: RawCategory[];
|
||||
seriesCategories: RawCategory[];
|
||||
liveStreams: RawLiveStream[];
|
||||
vodStreams: RawVodStream[];
|
||||
seriesItems: RawSeriesItem[];
|
||||
}
|
||||
|
||||
const portalCache = new Map<string, PortalData>();
|
||||
const vodDetailsCache = new Map<number, RawVodDetails>();
|
||||
const seriesInfoCache = new Map<number, RawSeriesInfo>();
|
||||
const epgCache = new Map<number, RawEpgListing[]>();
|
||||
|
||||
function generatePortalData(username: string, password: string): PortalData {
|
||||
const scenario = getScenario(username, password);
|
||||
faker.seed(scenario.seed);
|
||||
|
||||
const { categoryCount, itemsPerCategory, seasonsPerSeries, episodesPerSeason } = scenario;
|
||||
|
||||
const liveCategories = generateCategories('live', categoryCount.live);
|
||||
const vodCategories = generateCategories('vod', categoryCount.vod);
|
||||
const seriesCategories = generateCategories('series', categoryCount.series);
|
||||
|
||||
const liveStreams = generateLiveStreams(liveCategories, itemsPerCategory);
|
||||
const vodStreams = generateVodStreams(vodCategories, itemsPerCategory);
|
||||
const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory);
|
||||
|
||||
// Pre-populate series info cache
|
||||
for (const s of seriesItems) {
|
||||
if (!seriesInfoCache.has(s.series_id)) {
|
||||
seriesInfoCache.set(s.series_id, generateSeriesInfo(s, seasonsPerSeries, episodesPerSeason));
|
||||
}
|
||||
}
|
||||
|
||||
return { scenario, liveCategories, vodCategories, seriesCategories, liveStreams, vodStreams, seriesItems };
|
||||
}
|
||||
|
||||
export function getPortalData(username: string, password: string): PortalData {
|
||||
const key = `${username}:${password}`;
|
||||
if (!portalCache.has(key)) {
|
||||
portalCache.set(key, generatePortalData(username, password));
|
||||
}
|
||||
return portalCache.get(key)!;
|
||||
}
|
||||
|
||||
export function getVodDetails(username: string, password: string, vodId: number): RawVodDetails | null {
|
||||
if (!vodDetailsCache.has(vodId)) {
|
||||
const data = getPortalData(username, password);
|
||||
const stream = data.vodStreams.find(v => v.stream_id === vodId);
|
||||
if (!stream) return null;
|
||||
vodDetailsCache.set(vodId, generateVodDetails(stream));
|
||||
}
|
||||
return vodDetailsCache.get(vodId) ?? null;
|
||||
}
|
||||
|
||||
export function getSeriesInfo(username: string, password: string, seriesId: number): RawSeriesInfo | null {
|
||||
if (!seriesInfoCache.has(seriesId)) {
|
||||
const data = getPortalData(username, password);
|
||||
const series = data.seriesItems.find(s => s.series_id === seriesId);
|
||||
if (!series) return null;
|
||||
const scenario = getScenario(username, password);
|
||||
seriesInfoCache.set(seriesId, generateSeriesInfo(series, scenario.seasonsPerSeries, scenario.episodesPerSeason));
|
||||
}
|
||||
return seriesInfoCache.get(seriesId) ?? null;
|
||||
}
|
||||
|
||||
export function getEpgListings(streamId: number): RawEpgListing[] {
|
||||
if (!epgCache.has(streamId)) {
|
||||
epgCache.set(streamId, generateEpgListings(streamId));
|
||||
}
|
||||
return epgCache.get(streamId)!;
|
||||
}
|
||||
|
||||
export function resetAll(): void {
|
||||
portalCache.clear();
|
||||
vodDetailsCache.clear();
|
||||
seriesInfoCache.clear();
|
||||
epgCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export interface RawCategory {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
parent_id: number;
|
||||
}
|
||||
|
||||
const LIVE_CATEGORY_NAMES = [
|
||||
'News', 'Sports', 'Movies', 'Entertainment', 'Kids',
|
||||
'Documentary', 'Music', 'Comedy', 'Drama', 'Reality TV',
|
||||
'Lifestyle', 'Travel', 'Food', 'Tech', 'Science',
|
||||
'History', 'Nature', 'Animation', 'Gaming', 'Shopping',
|
||||
];
|
||||
|
||||
const VOD_CATEGORY_NAMES = [
|
||||
'Action', 'Comedy', 'Drama', 'Horror', 'Thriller',
|
||||
'Romance', 'Sci-Fi', 'Fantasy', 'Animation', 'Documentary',
|
||||
'Biography', 'Crime', 'Mystery', 'Adventure', 'Family',
|
||||
'War', 'Western', 'Musical', 'Sport', 'History',
|
||||
];
|
||||
|
||||
const SERIES_CATEGORY_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 nameList(type: 'live' | 'vod' | 'series'): string[] {
|
||||
if (type === 'live') return LIVE_CATEGORY_NAMES;
|
||||
if (type === 'vod') return VOD_CATEGORY_NAMES;
|
||||
return SERIES_CATEGORY_NAMES;
|
||||
}
|
||||
|
||||
/** Base ID offsets per content type so IDs never collide. */
|
||||
const BASE_ID: Record<'live' | 'vod' | 'series', number> = {
|
||||
live: 100,
|
||||
vod: 200,
|
||||
series: 300,
|
||||
};
|
||||
|
||||
export function generateCategories(
|
||||
type: 'live' | 'vod' | 'series',
|
||||
count: number
|
||||
): RawCategory[] {
|
||||
const names = nameList(type);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
category_id: String(BASE_ID[type] + i + 1),
|
||||
category_name: names[i % names.length],
|
||||
parent_id: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Stable category ID base for use in item generators. */
|
||||
export { BASE_ID as CATEGORY_ID_BASE };
|
||||
|
||||
/** Deterministic icon URL for a category */
|
||||
export function categoryIcon(type: string, id: string): string {
|
||||
return `https://picsum.photos/seed/cat-${type}-${id}/64/64`;
|
||||
}
|
||||
|
||||
// Re-export faker so generators share the same seeded instance.
|
||||
export { faker };
|
||||
@@ -0,0 +1,93 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { RawCategory } from './categories.generator.js';
|
||||
|
||||
export interface RawLiveStream {
|
||||
num: number;
|
||||
name: string;
|
||||
stream_type: 'live';
|
||||
stream_id: number;
|
||||
stream_icon: string;
|
||||
epg_channel_id: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
tv_archive: number;
|
||||
tv_archive_duration: number;
|
||||
rating_imdb: string;
|
||||
}
|
||||
|
||||
export interface RawEpgListing {
|
||||
id: string;
|
||||
epg_id: string;
|
||||
title: string; // base64-encoded
|
||||
lang: string;
|
||||
start: string;
|
||||
end: string;
|
||||
description: string; // base64-encoded
|
||||
channel_id: string;
|
||||
start_timestamp: string;
|
||||
stop_timestamp: string;
|
||||
}
|
||||
|
||||
const LIVE_STREAM_ID_BASE = 10_000;
|
||||
const EPG_SLOT_MINUTES = 30;
|
||||
const EPG_SLOTS = 12;
|
||||
|
||||
export function generateLiveStreams(
|
||||
categories: RawCategory[],
|
||||
itemsPerCategory: number
|
||||
): RawLiveStream[] {
|
||||
const streams: RawLiveStream[] = [];
|
||||
let num = 1;
|
||||
|
||||
for (const cat of categories) {
|
||||
for (let i = 0; i < itemsPerCategory; i++) {
|
||||
const streamId = LIVE_STREAM_ID_BASE + streams.length;
|
||||
streams.push({
|
||||
num: num++,
|
||||
name: `${faker.company.name()} TV`,
|
||||
stream_type: 'live',
|
||||
stream_id: streamId,
|
||||
stream_icon: `https://picsum.photos/seed/live-${streamId}/100/100`,
|
||||
epg_channel_id: `channel-${streamId}.mock`,
|
||||
added: String(Math.floor(Date.now() / 1000 - Math.random() * 1e7)),
|
||||
category_id: cat.category_id,
|
||||
custom_sid: '',
|
||||
direct_source: '',
|
||||
tv_archive: 0,
|
||||
tv_archive_duration: 0,
|
||||
rating_imdb: (Math.random() * 3 + 6).toFixed(1),
|
||||
});
|
||||
}
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
|
||||
/** Generate EPG listings for a single stream_id. Returns base64-encoded title/description. */
|
||||
export function generateEpgListings(streamId: number): RawEpgListing[] {
|
||||
const listings: RawEpgListing[] = [];
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const startOffset = -(EPG_SLOTS / 2) * EPG_SLOT_MINUTES * 60;
|
||||
|
||||
for (let i = 0; i < EPG_SLOTS; i++) {
|
||||
const startTs = nowSec + startOffset + i * EPG_SLOT_MINUTES * 60;
|
||||
const stopTs = startTs + EPG_SLOT_MINUTES * 60;
|
||||
const title = faker.company.catchPhrase();
|
||||
const description = faker.lorem.sentence();
|
||||
|
||||
listings.push({
|
||||
id: String(streamId * 100 + i),
|
||||
epg_id: `channel-${streamId}.mock`,
|
||||
title: Buffer.from(title).toString('base64'),
|
||||
lang: 'en',
|
||||
start: new Date(startTs * 1000).toISOString().replace('T', ' ').replace('.000Z', ''),
|
||||
end: new Date(stopTs * 1000).toISOString().replace('T', ' ').replace('.000Z', ''),
|
||||
description: Buffer.from(description).toString('base64'),
|
||||
channel_id: `channel-${streamId}`,
|
||||
start_timestamp: String(startTs),
|
||||
stop_timestamp: String(stopTs),
|
||||
});
|
||||
}
|
||||
return listings;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { RawCategory } from './categories.generator.js';
|
||||
|
||||
export interface RawSeriesItem {
|
||||
num: number;
|
||||
name: string;
|
||||
series_id: number;
|
||||
cover: string;
|
||||
plot: string;
|
||||
cast: string;
|
||||
director: string;
|
||||
genre: string;
|
||||
releaseDate: string;
|
||||
last_modified: string;
|
||||
rating: string;
|
||||
rating_5based: number;
|
||||
backdrop_path: string[];
|
||||
youtube_trailer: string;
|
||||
episode_run_time: string;
|
||||
category_id: number;
|
||||
}
|
||||
|
||||
export interface RawSeriesInfo {
|
||||
seasons: RawSeason[];
|
||||
info: RawSeriesInfoMeta;
|
||||
episodes: Record<string, RawEpisode[]>;
|
||||
}
|
||||
|
||||
export interface RawSeason {
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
season_number: number;
|
||||
cover: string;
|
||||
cover_big: string;
|
||||
}
|
||||
|
||||
export interface RawEpisode {
|
||||
id: string;
|
||||
episode_num: number;
|
||||
title: string;
|
||||
container_extension: string;
|
||||
info: {
|
||||
tmdb_id: number;
|
||||
releasedate: string;
|
||||
plot: string;
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
movie_image: string;
|
||||
bitrate: number;
|
||||
rating: number;
|
||||
};
|
||||
custom_sid: string;
|
||||
added: string;
|
||||
season: number;
|
||||
direct_source: string;
|
||||
}
|
||||
|
||||
export interface RawSeriesInfoMeta {
|
||||
name: string;
|
||||
cover: string;
|
||||
plot: string;
|
||||
cast: string;
|
||||
director: string;
|
||||
genre: string;
|
||||
releaseDate: string;
|
||||
last_modified: string;
|
||||
rating: string;
|
||||
rating_5based: number;
|
||||
backdrop_path: string[];
|
||||
youtube_trailer: string;
|
||||
episode_run_time: string;
|
||||
category_id: string;
|
||||
}
|
||||
|
||||
const SERIES_ID_BASE = 30_000;
|
||||
const EPISODE_ID_BASE = 50_000;
|
||||
|
||||
export function generateSeriesItems(
|
||||
categories: RawCategory[],
|
||||
itemsPerCategory: number
|
||||
): RawSeriesItem[] {
|
||||
const items: RawSeriesItem[] = [];
|
||||
let num = 1;
|
||||
|
||||
for (const cat of categories) {
|
||||
for (let i = 0; i < itemsPerCategory; i++) {
|
||||
const seriesId = SERIES_ID_BASE + items.length;
|
||||
const rating = (Math.random() * 4 + 5).toFixed(1);
|
||||
items.push({
|
||||
num: num++,
|
||||
name: faker.company.catchPhrase(),
|
||||
series_id: seriesId,
|
||||
cover: `https://picsum.photos/seed/series-${seriesId}/300/450`,
|
||||
plot: faker.lorem.paragraph(),
|
||||
cast: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '),
|
||||
director: faker.person.fullName(),
|
||||
genre: [faker.music.genre(), faker.music.genre()].join(', '),
|
||||
releaseDate: faker.date.past({ years: 10 }).toISOString().slice(0, 10),
|
||||
last_modified: faker.date.recent({ days: 30 }).toISOString().slice(0, 10),
|
||||
rating,
|
||||
rating_5based: parseFloat((parseFloat(rating) / 2).toFixed(1)),
|
||||
backdrop_path: [
|
||||
`https://picsum.photos/seed/series-bg-${seriesId}/1280/720`,
|
||||
],
|
||||
youtube_trailer: '',
|
||||
episode_run_time: String(faker.number.int({ min: 22, max: 60 })),
|
||||
category_id: parseInt(cat.category_id, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function generateSeriesInfo(
|
||||
series: RawSeriesItem,
|
||||
seasonCount: number,
|
||||
episodesPerSeason: number
|
||||
): RawSeriesInfo {
|
||||
const seasons: RawSeason[] = [];
|
||||
const episodes: Record<string, RawEpisode[]> = {};
|
||||
let episodeCounter = EPISODE_ID_BASE + series.series_id;
|
||||
|
||||
for (let s = 1; s <= seasonCount; s++) {
|
||||
const episodeCount = episodesPerSeason;
|
||||
seasons.push({
|
||||
air_date: faker.date.past({ years: 5 }).toISOString().slice(0, 10),
|
||||
episode_count: episodeCount,
|
||||
id: series.series_id * 100 + s,
|
||||
name: `Season ${s}`,
|
||||
overview: faker.lorem.sentence(),
|
||||
season_number: s,
|
||||
cover: `https://picsum.photos/seed/season-${series.series_id}-${s}/300/450`,
|
||||
cover_big: `https://picsum.photos/seed/season-big-${series.series_id}-${s}/500/750`,
|
||||
});
|
||||
|
||||
const seasonEpisodes: RawEpisode[] = [];
|
||||
for (let e = 1; e <= episodeCount; e++) {
|
||||
const durationSecs = faker.number.int({ min: 1200, max: 3600 });
|
||||
seasonEpisodes.push({
|
||||
id: String(episodeCounter++),
|
||||
episode_num: e,
|
||||
title: `${series.name} S${s}E${e}`,
|
||||
container_extension: 'mkv',
|
||||
info: {
|
||||
tmdb_id: faker.number.int({ min: 100, max: 999999 }),
|
||||
releasedate: faker.date.past({ years: 5 }).toISOString().slice(0, 10),
|
||||
plot: faker.lorem.sentence(),
|
||||
duration_secs: durationSecs,
|
||||
duration: `${Math.floor(durationSecs / 60)}min`,
|
||||
movie_image: `https://picsum.photos/seed/ep-${episodeCounter}/300/200`,
|
||||
bitrate: faker.number.int({ min: 1500, max: 8000 }),
|
||||
rating: parseFloat((Math.random() * 3 + 7).toFixed(1)),
|
||||
},
|
||||
custom_sid: '',
|
||||
added: String(Math.floor(Date.now() / 1000 - Math.random() * 1e7)),
|
||||
season: s,
|
||||
direct_source: '',
|
||||
});
|
||||
}
|
||||
episodes[String(s)] = seasonEpisodes;
|
||||
}
|
||||
|
||||
const info: RawSeriesInfoMeta = {
|
||||
name: series.name,
|
||||
cover: series.cover,
|
||||
plot: series.plot,
|
||||
cast: series.cast,
|
||||
director: series.director,
|
||||
genre: series.genre,
|
||||
releaseDate: series.releaseDate,
|
||||
last_modified: series.last_modified,
|
||||
rating: series.rating,
|
||||
rating_5based: series.rating_5based,
|
||||
backdrop_path: series.backdrop_path,
|
||||
youtube_trailer: '',
|
||||
episode_run_time: series.episode_run_time,
|
||||
category_id: String(series.category_id),
|
||||
};
|
||||
|
||||
return { seasons, info, episodes };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { RawCategory } from './categories.generator.js';
|
||||
|
||||
export interface RawVodStream {
|
||||
num: number;
|
||||
name: string;
|
||||
stream_type: 'movie';
|
||||
stream_id: number;
|
||||
stream_icon: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
rating: number;
|
||||
rating_5based: number;
|
||||
rating_imdb: string;
|
||||
container_extension: string;
|
||||
type: 'movie';
|
||||
}
|
||||
|
||||
export interface RawVodDetails {
|
||||
info: {
|
||||
kinopoisk_url: string;
|
||||
tmdb_id: number;
|
||||
name: string;
|
||||
o_name: string;
|
||||
cover_big: string;
|
||||
movie_image: string;
|
||||
releasedate: string;
|
||||
episode_run_time: number;
|
||||
youtube_trailer: string;
|
||||
director: string;
|
||||
actors: string;
|
||||
cast: string;
|
||||
description: string;
|
||||
plot: string;
|
||||
age: string;
|
||||
mpaa_rating: string;
|
||||
rating_count_kinopoisk: number;
|
||||
country: string;
|
||||
genre: string;
|
||||
backdrop_path: string[];
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
video: string[];
|
||||
audio: string[];
|
||||
bitrate: number;
|
||||
rating: number;
|
||||
rating_kinopoisk: string;
|
||||
rating_imdb: string;
|
||||
};
|
||||
movie_data: {
|
||||
stream_id: number;
|
||||
name: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
container_extension: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
};
|
||||
}
|
||||
|
||||
const VOD_STREAM_ID_BASE = 20_000;
|
||||
const CONTAINER_EXTENSIONS = ['mkv', 'mp4', 'avi'];
|
||||
|
||||
export function generateVodStreams(
|
||||
categories: RawCategory[],
|
||||
itemsPerCategory: number
|
||||
): RawVodStream[] {
|
||||
const streams: RawVodStream[] = [];
|
||||
let num = 1;
|
||||
|
||||
for (const cat of categories) {
|
||||
for (let i = 0; i < itemsPerCategory; i++) {
|
||||
const streamId = VOD_STREAM_ID_BASE + streams.length;
|
||||
const rating = Math.random() * 4 + 5;
|
||||
streams.push({
|
||||
num: num++,
|
||||
name: faker.music.songName() + ': ' + faker.lorem.words(2),
|
||||
stream_type: 'movie',
|
||||
stream_id: streamId,
|
||||
stream_icon: `https://picsum.photos/seed/vod-${streamId}/300/450`,
|
||||
added: String(Math.floor(Date.now() / 1000 - Math.random() * 1e8)),
|
||||
category_id: cat.category_id,
|
||||
custom_sid: '',
|
||||
direct_source: '',
|
||||
rating: parseFloat(rating.toFixed(1)),
|
||||
rating_5based: parseFloat((rating / 2).toFixed(1)),
|
||||
rating_imdb: rating.toFixed(1),
|
||||
container_extension: CONTAINER_EXTENSIONS[streamId % CONTAINER_EXTENSIONS.length],
|
||||
type: 'movie',
|
||||
});
|
||||
}
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
|
||||
export function generateVodDetails(stream: RawVodStream): RawVodDetails {
|
||||
const durationSecs = faker.number.int({ min: 3600, max: 9000 });
|
||||
const hours = Math.floor(durationSecs / 3600);
|
||||
const mins = Math.floor((durationSecs % 3600) / 60);
|
||||
return {
|
||||
info: {
|
||||
kinopoisk_url: '',
|
||||
tmdb_id: faker.number.int({ min: 100, max: 999999 }),
|
||||
name: stream.name,
|
||||
o_name: stream.name,
|
||||
cover_big: `https://picsum.photos/seed/vod-big-${stream.stream_id}/500/750`,
|
||||
movie_image: `https://picsum.photos/seed/vod-img-${stream.stream_id}/300/450`,
|
||||
releasedate: faker.date.past({ years: 20 }).toISOString().slice(0, 10),
|
||||
episode_run_time: durationSecs,
|
||||
youtube_trailer: '',
|
||||
director: faker.person.fullName(),
|
||||
actors: Array.from({ length: 5 }, () => faker.person.fullName()).join(', '),
|
||||
cast: Array.from({ length: 5 }, () => faker.person.fullName()).join(', '),
|
||||
description: faker.lorem.paragraph(),
|
||||
plot: faker.lorem.paragraph(),
|
||||
age: '16',
|
||||
mpaa_rating: 'PG-13',
|
||||
rating_count_kinopoisk: faker.number.int({ min: 100, max: 50000 }),
|
||||
country: faker.location.country(),
|
||||
genre: [faker.music.genre(), faker.music.genre()].join(', '),
|
||||
backdrop_path: [
|
||||
`https://picsum.photos/seed/vod-backdrop-${stream.stream_id}/1280/720`,
|
||||
],
|
||||
duration_secs: durationSecs,
|
||||
duration: `${hours}h ${mins}min`,
|
||||
video: ['H.264'],
|
||||
audio: ['AAC'],
|
||||
bitrate: faker.number.int({ min: 1500, max: 8000 }),
|
||||
rating: stream.rating,
|
||||
rating_kinopoisk: stream.rating_imdb,
|
||||
rating_imdb: stream.rating_imdb,
|
||||
},
|
||||
movie_data: {
|
||||
stream_id: stream.stream_id,
|
||||
name: stream.name,
|
||||
added: stream.added,
|
||||
category_id: stream.category_id,
|
||||
container_extension: stream.container_extension,
|
||||
custom_sid: '',
|
||||
direct_source: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
|
||||
export function handleGetAccountInfo(req: Request, res: Response): void {
|
||||
const { username = '', password = '' } = req.query as Record<string, string>;
|
||||
const data = getPortalData(username, password);
|
||||
const { scenario } = data;
|
||||
|
||||
res.json({
|
||||
user_info: {
|
||||
username,
|
||||
password,
|
||||
message: '',
|
||||
auth: 1,
|
||||
status: scenario.accountStatus,
|
||||
exp_date: String(Math.floor(new Date(scenario.expiryDate).getTime() / 1000)),
|
||||
is_trial: '0',
|
||||
active_cons: '1',
|
||||
created_at: String(Math.floor(Date.now() / 1000 - 86400 * 365)),
|
||||
max_connections: '2',
|
||||
allowed_output_formats: ['m3u8', 'ts', 'rtmp'],
|
||||
},
|
||||
server_info: {
|
||||
url: `http://localhost:3211`,
|
||||
port: '3211',
|
||||
https_port: '',
|
||||
server_protocol: 'http',
|
||||
rtmp_port: '',
|
||||
timezone: 'UTC',
|
||||
timestamp_now: Math.floor(Date.now() / 1000),
|
||||
time_now: new Date().toISOString().replace('T', ' ').replace('.000Z', ''),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
|
||||
export function handleGetLiveCategories(req: Request, res: Response): void {
|
||||
const { username = '', password = '' } = req.query as Record<string, string>;
|
||||
const { liveCategories } = getPortalData(username, password);
|
||||
res.json(liveCategories);
|
||||
}
|
||||
|
||||
export function handleGetVodCategories(req: Request, res: Response): void {
|
||||
const { username = '', password = '' } = req.query as Record<string, string>;
|
||||
const { vodCategories } = getPortalData(username, password);
|
||||
res.json(vodCategories);
|
||||
}
|
||||
|
||||
export function handleGetSeriesCategories(req: Request, res: Response): void {
|
||||
const { username = '', password = '' } = req.query as Record<string, string>;
|
||||
const { seriesCategories } = getPortalData(username, password);
|
||||
res.json(seriesCategories);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getSeriesInfo } from '../data-store.js';
|
||||
|
||||
export function handleGetSeriesInfo(req: Request, res: Response): void {
|
||||
const { username = '', password = '', series_id } = req.query as Record<string, string>;
|
||||
if (!series_id) {
|
||||
res.status(400).json({ error: 'series_id required' });
|
||||
return;
|
||||
}
|
||||
const info = getSeriesInfo(username, password, parseInt(series_id, 10));
|
||||
if (!info) {
|
||||
res.status(404).json({ error: 'Series not found' });
|
||||
return;
|
||||
}
|
||||
res.json(info);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getEpgListings } from '../data-store.js';
|
||||
|
||||
export function handleGetShortEpg(req: Request, res: Response): void {
|
||||
const { stream_id, limit } = req.query as Record<string, string>;
|
||||
if (!stream_id) {
|
||||
res.json({ epg_listings: [] });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(stream_id, 10);
|
||||
const limitNum = limit ? Math.min(parseInt(limit, 10), 50) : 12;
|
||||
const listings = getEpgListings(id).slice(0, limitNum);
|
||||
res.json({ epg_listings: listings });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
|
||||
export function handleGetLiveStreams(req: Request, res: Response): void {
|
||||
const { username = '', password = '', category_id } = req.query as Record<string, string>;
|
||||
let { liveStreams } = getPortalData(username, password);
|
||||
if (category_id) {
|
||||
liveStreams = liveStreams.filter(s => String(s.category_id) === category_id);
|
||||
}
|
||||
res.json(liveStreams);
|
||||
}
|
||||
|
||||
export function handleGetVodStreams(req: Request, res: Response): void {
|
||||
const { username = '', password = '', category_id } = req.query as Record<string, string>;
|
||||
let { vodStreams } = getPortalData(username, password);
|
||||
if (category_id) {
|
||||
vodStreams = vodStreams.filter(s => String(s.category_id) === category_id);
|
||||
}
|
||||
res.json(vodStreams);
|
||||
}
|
||||
|
||||
export function handleGetSeriesStreams(req: Request, res: Response): void {
|
||||
const { username = '', password = '', category_id } = req.query as Record<string, string>;
|
||||
let { seriesItems } = getPortalData(username, password);
|
||||
if (category_id) {
|
||||
seriesItems = seriesItems.filter(s => String(s.category_id) === category_id);
|
||||
}
|
||||
res.json(seriesItems);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getVodDetails } from '../data-store.js';
|
||||
|
||||
export function handleGetVodInfo(req: Request, res: Response): void {
|
||||
const { username = '', password = '', vod_id } = req.query as Record<string, string>;
|
||||
if (!vod_id) {
|
||||
res.status(400).json({ error: 'vod_id required' });
|
||||
return;
|
||||
}
|
||||
const details = getVodDetails(username, password, parseInt(vod_id, 10));
|
||||
if (!details) {
|
||||
res.status(404).json({ error: 'VOD not found' });
|
||||
return;
|
||||
}
|
||||
res.json(details);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { handleGetAccountInfo } from '../handlers/get-account-info.handler.js';
|
||||
import { handleGetLiveCategories, handleGetVodCategories, handleGetSeriesCategories } from '../handlers/get-categories.handler.js';
|
||||
import { handleGetLiveStreams, handleGetVodStreams, handleGetSeriesStreams } from '../handlers/get-streams.handler.js';
|
||||
import { handleGetVodInfo } from '../handlers/get-vod-info.handler.js';
|
||||
import { handleGetSeriesInfo } from '../handlers/get-series-info.handler.js';
|
||||
import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js';
|
||||
|
||||
export function dispatchAction(req: Request, res: Response): void {
|
||||
const action = (req.query['action'] as string) ?? '';
|
||||
|
||||
switch (action) {
|
||||
case '':
|
||||
case 'get_account_info':
|
||||
handleGetAccountInfo(req, res);
|
||||
break;
|
||||
case 'get_live_categories':
|
||||
handleGetLiveCategories(req, res);
|
||||
break;
|
||||
case 'get_vod_categories':
|
||||
handleGetVodCategories(req, res);
|
||||
break;
|
||||
case 'get_series_categories':
|
||||
handleGetSeriesCategories(req, res);
|
||||
break;
|
||||
case 'get_live_streams':
|
||||
handleGetLiveStreams(req, res);
|
||||
break;
|
||||
case 'get_vod_streams':
|
||||
handleGetVodStreams(req, res);
|
||||
break;
|
||||
case 'get_series':
|
||||
handleGetSeriesStreams(req, res);
|
||||
break;
|
||||
case 'get_vod_info':
|
||||
handleGetVodInfo(req, res);
|
||||
break;
|
||||
case 'get_series_info':
|
||||
handleGetSeriesInfo(req, res);
|
||||
break;
|
||||
case 'get_short_epg':
|
||||
handleGetShortEpg(req, res);
|
||||
break;
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown action: ${action}` });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export interface ScenarioConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
seed: number;
|
||||
categoryCount: { live: number; vod: number; series: number };
|
||||
itemsPerCategory: number;
|
||||
seasonsPerSeries: number;
|
||||
episodesPerSeason: number;
|
||||
/** 'Active' | 'Disabled' — mirrors real Xtream user status values.
|
||||
* Expired accounts still have status 'Active' but exp_date is in the past. */
|
||||
accountStatus: 'Active' | 'Disabled';
|
||||
/** ISO date string for subscription expiry */
|
||||
expiryDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined scenarios keyed by "username:password".
|
||||
* Auth is username+password for Xtream (not MAC address like Stalker).
|
||||
* Unknown credential pairs use a hash of "username:password" as seed.
|
||||
*/
|
||||
export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
'user1:pass1': {
|
||||
name: 'default',
|
||||
description: 'Balanced portal — 8 categories, 40 items each',
|
||||
seed: 1001,
|
||||
categoryCount: { live: 8, vod: 8, series: 8 },
|
||||
itemsPerCategory: 40,
|
||||
seasonsPerSeries: 3,
|
||||
episodesPerSeason: 8,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
},
|
||||
'large:large': {
|
||||
name: 'large',
|
||||
description: 'Large catalog — 20 categories, 200 items each',
|
||||
seed: 9999,
|
||||
categoryCount: { live: 20, vod: 20, series: 20 },
|
||||
itemsPerCategory: 200,
|
||||
seasonsPerSeries: 5,
|
||||
episodesPerSeason: 12,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
},
|
||||
'series:series': {
|
||||
name: 'series-heavy',
|
||||
description: 'Series-heavy — 15 series categories, 6 seasons × 10 episodes',
|
||||
seed: 2002,
|
||||
categoryCount: { live: 3, vod: 4, series: 15 },
|
||||
itemsPerCategory: 30,
|
||||
seasonsPerSeries: 6,
|
||||
episodesPerSeason: 10,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
},
|
||||
'minimal:minimal': {
|
||||
name: 'minimal',
|
||||
description: 'Minimal — 2 categories, 5 items (edge case testing)',
|
||||
seed: 3003,
|
||||
categoryCount: { live: 2, vod: 2, series: 2 },
|
||||
itemsPerCategory: 5,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
},
|
||||
'expired:expired': {
|
||||
name: 'expired',
|
||||
description: 'Expired account — tests subscription expiry UI flow',
|
||||
seed: 4004,
|
||||
categoryCount: { live: 4, vod: 4, series: 4 },
|
||||
itemsPerCategory: 10,
|
||||
seasonsPerSeries: 2,
|
||||
episodesPerSeason: 5,
|
||||
accountStatus: 'Active', // Active but exp_date is in the past → service treats as expired
|
||||
expiryDate: '2020-01-01',
|
||||
},
|
||||
'inactive:inactive': {
|
||||
name: 'inactive',
|
||||
description: 'Disabled account — tests account disabled UI flow',
|
||||
seed: 5005,
|
||||
categoryCount: { live: 4, vod: 4, series: 4 },
|
||||
itemsPerCategory: 10,
|
||||
seasonsPerSeries: 2,
|
||||
episodesPerSeason: 5,
|
||||
accountStatus: 'Disabled',
|
||||
expiryDate: '2020-01-01',
|
||||
},
|
||||
};
|
||||
|
||||
/** Convert credential pair to a numeric seed for unknown credentials. */
|
||||
export function credentialsToSeed(username: string, password: string): number {
|
||||
const str = `${username}:${password}`;
|
||||
return str.split('').reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) | 0, 0) >>> 0;
|
||||
}
|
||||
|
||||
/** Return the scenario config for a given username+password pair. */
|
||||
export function getScenario(username: string, password: string): ScenarioConfig {
|
||||
const key = `${username}:${password}`;
|
||||
if (SCENARIOS[key]) return SCENARIOS[key];
|
||||
return {
|
||||
name: 'auto',
|
||||
description: `Auto-generated for ${username}`,
|
||||
seed: credentialsToSeed(username, password),
|
||||
categoryCount: { live: 6, vod: 6, series: 6 },
|
||||
itemsPerCategory: 30,
|
||||
seasonsPerSeries: 3,
|
||||
episodesPerSeason: 8,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { dispatchAction } from './app/routes/dispatch.js';
|
||||
import { resetAll } from './app/data-store.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3211', 10);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// ─── Health check ──────────────────────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', server: 'xtream-mock-server', port: PORT });
|
||||
});
|
||||
|
||||
// ─── Reset all cached data ─────────────────────────────────────────────────────
|
||||
app.post('/reset', (_req, res) => {
|
||||
resetAll();
|
||||
res.json({ status: 'reset' });
|
||||
});
|
||||
|
||||
// ─── Direct Xtream player_api.php endpoint ─────────────────────────────────────
|
||||
app.get('/player_api.php', dispatchAction);
|
||||
|
||||
// ─── Stream stub endpoints (Xtream stream URLs reference these) ────────────────
|
||||
// Returns a valid HLS playlist redirect for any stream type.
|
||||
// In production these would be actual video streams.
|
||||
const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
|
||||
app.get('/live/:username/:password/:streamId.m3u8', (_req, res) => {
|
||||
res.redirect(HLS_STUB);
|
||||
});
|
||||
|
||||
app.get('/live/:username/:password/:streamId.ts', (_req, res) => {
|
||||
res.redirect(HLS_STUB);
|
||||
});
|
||||
|
||||
app.get('/movie/:username/:password/:streamId.:ext', (_req, res) => {
|
||||
res.redirect(HLS_STUB);
|
||||
});
|
||||
|
||||
app.get('/series/:username/:password/:streamId.:ext', (_req, res) => {
|
||||
res.redirect(HLS_STUB);
|
||||
});
|
||||
|
||||
// ─── PWA CORS proxy endpoint ────────────────────────────────────────────────────
|
||||
// IPTVnator PWA routes Xtream calls through:
|
||||
// GET /xtream?url=<serverUrl>&action=<action>&username=X&password=Y
|
||||
// and expects: { payload: <data>, action: <action> }
|
||||
app.get('/xtream', async (req, res) => {
|
||||
const { url: _url, action, ...rest } = req.query as Record<string, string>;
|
||||
|
||||
const syntheticReq = {
|
||||
query: { action, ...rest },
|
||||
headers: req.headers,
|
||||
params: {},
|
||||
} as unknown as express.Request;
|
||||
|
||||
// Capture response via monkey-patched json method
|
||||
let payload: unknown;
|
||||
let statusCode = 200;
|
||||
|
||||
const syntheticRes = {
|
||||
json(data: unknown) {
|
||||
payload = data;
|
||||
return this;
|
||||
},
|
||||
status(code: number) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
} as unknown as express.Response;
|
||||
|
||||
dispatchAction(syntheticReq, syntheticRes);
|
||||
|
||||
if (statusCode !== 200) {
|
||||
res.status(statusCode).json({ error: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ payload, action });
|
||||
});
|
||||
|
||||
// ─── Server lifecycle ──────────────────────────────────────────────────────────
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[xtream-mock] Port ${PORT} is already in use.`);
|
||||
} else {
|
||||
console.error('[xtream-mock] Server error:', err.message);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[xtream-mock] Listening on http://localhost:${PORT}`);
|
||||
console.log(`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`);
|
||||
console.log(`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`);
|
||||
console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`);
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
console.log('\n[xtream-mock] Shutting down...');
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[xtream-mock] Uncaught exception:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[xtream-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 */ });
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "../../dist/apps/xtream-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