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:
4gray committed 2026-02-21 08:40:51 +01:00
1 parent fae4c44804
commit 0553cd8097
17 files changed
+1175

No files matched your search

+134
View File
@@ -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.
+38
View File
@@ -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',
};
}
+124
View File
@@ -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 */ });
+16
View File
@@ -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"]
}