diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md new file mode 100644 index 000000000..6c1979484 --- /dev/null +++ b/apps/xtream-mock-server/README.md @@ -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=&username=&password=

[&...]` + +| 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=` | Full movie details | +| `get_series_info?series_id=` | Full series info (seasons + episodes) | +| `get_short_epg?stream_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=&action=&username=&password=

+``` +Response: `{ payload: , action: }` + +### Stream URLs (stub redirects) + +``` +GET /live///.m3u8 → HLS test stream +GET /movie///. → HLS test stream +GET /series///. → 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. diff --git a/apps/xtream-mock-server/project.json b/apps/xtream-mock-server/project.json new file mode 100644 index 000000000..9f1c5d30e --- /dev/null +++ b/apps/xtream-mock-server/project.json @@ -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" + } + } +} diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts new file mode 100644 index 000000000..eb1dbb120 --- /dev/null +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -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(); +const vodDetailsCache = new Map(); +const seriesInfoCache = new Map(); +const epgCache = new Map(); + +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(); +} diff --git a/apps/xtream-mock-server/src/app/generators/categories.generator.ts b/apps/xtream-mock-server/src/app/generators/categories.generator.ts new file mode 100644 index 000000000..84238f74c --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/categories.generator.ts @@ -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 }; diff --git a/apps/xtream-mock-server/src/app/generators/live.generator.ts b/apps/xtream-mock-server/src/app/generators/live.generator.ts new file mode 100644 index 000000000..9d19e76f2 --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/live.generator.ts @@ -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; +} diff --git a/apps/xtream-mock-server/src/app/generators/series.generator.ts b/apps/xtream-mock-server/src/app/generators/series.generator.ts new file mode 100644 index 000000000..d9de8715a --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/series.generator.ts @@ -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; +} + +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 = {}; + 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 }; +} diff --git a/apps/xtream-mock-server/src/app/generators/vod.generator.ts b/apps/xtream-mock-server/src/app/generators/vod.generator.ts new file mode 100644 index 000000000..2eba20abe --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/vod.generator.ts @@ -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: '', + }, + }; +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts new file mode 100644 index 000000000..681668b4d --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts @@ -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; + 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', ''), + }, + }); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-categories.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-categories.handler.ts new file mode 100644 index 000000000..0491da9b1 --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-categories.handler.ts @@ -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; + const { liveCategories } = getPortalData(username, password); + res.json(liveCategories); +} + +export function handleGetVodCategories(req: Request, res: Response): void { + const { username = '', password = '' } = req.query as Record; + const { vodCategories } = getPortalData(username, password); + res.json(vodCategories); +} + +export function handleGetSeriesCategories(req: Request, res: Response): void { + const { username = '', password = '' } = req.query as Record; + const { seriesCategories } = getPortalData(username, password); + res.json(seriesCategories); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-series-info.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-series-info.handler.ts new file mode 100644 index 000000000..957d90d5c --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-series-info.handler.ts @@ -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; + 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); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts new file mode 100644 index 000000000..95b143ce5 --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -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; + 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 }); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-streams.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-streams.handler.ts new file mode 100644 index 000000000..6d0bad04e --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-streams.handler.ts @@ -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; + 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; + 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; + let { seriesItems } = getPortalData(username, password); + if (category_id) { + seriesItems = seriesItems.filter(s => String(s.category_id) === category_id); + } + res.json(seriesItems); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-vod-info.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-vod-info.handler.ts new file mode 100644 index 000000000..72d54b9cd --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-vod-info.handler.ts @@ -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; + 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); +} diff --git a/apps/xtream-mock-server/src/app/routes/dispatch.ts b/apps/xtream-mock-server/src/app/routes/dispatch.ts new file mode 100644 index 000000000..447cbb1cb --- /dev/null +++ b/apps/xtream-mock-server/src/app/routes/dispatch.ts @@ -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}` }); + } +} diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts new file mode 100644 index 000000000..babb1010a --- /dev/null +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -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 = { + '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', + }; +} diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts new file mode 100644 index 000000000..2994fb6d0 --- /dev/null +++ b/apps/xtream-mock-server/src/main.ts @@ -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=&action=&username=X&password=Y +// and expects: { payload: , action: } +app.get('/xtream', async (req, res) => { + const { url: _url, action, ...rest } = req.query as Record; + + 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 */ }); diff --git a/apps/xtream-mock-server/tsconfig.json b/apps/xtream-mock-server/tsconfig.json new file mode 100644 index 000000000..b94d5fe8b --- /dev/null +++ b/apps/xtream-mock-server/tsconfig.json @@ -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"] +}