From 95c32708ee9275ccb709e9197591b8df7089c778 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 21 Feb 2026 08:44:27 +0100 Subject: [PATCH] test: add stalker-mock-server and integrate with e2e tests - Introduced `stalker-mock-server` with TypeScript configuration for local development and testing. - Updated `playwright.config.ts` to run both Angular app and mock server in parallel during e2e tests. - Created comprehensive e2e tests for Stalker portal functionality, including health checks, portal addition, and content loading. - Added detailed architecture documentation for both `stalker-mock-server` and `xtream-mock-server`, outlining design decisions, data flow, and API protocols. - Implemented `xtream-mock-server` to simulate Xtream Codes API for local development and testing, with corresponding e2e tests. - Enhanced test isolation by resetting mock server state before each test run. --- apps/stalker-mock-server/README.md | 131 ++++++ apps/stalker-mock-server/project.json | 38 ++ .../src/app/data-generator.ts | 428 ++++++++++++++++++ .../stalker-mock-server/src/app/data-store.ts | 51 +++ .../src/app/handlers/create-link.handler.ts | 33 ++ .../src/app/handlers/do-auth.handler.ts | 25 + .../src/app/handlers/favorites.handler.ts | 75 +++ .../app/handlers/get-categories.handler.ts | 33 ++ .../src/app/handlers/get-genres.handler.ts | 31 ++ .../app/handlers/get-ordered-list.handler.ts | 80 ++++ .../src/app/handlers/get-seasons.handler.ts | 55 +++ .../src/app/handlers/get-short-epg.handler.ts | 32 ++ .../src/app/handlers/handshake.handler.ts | 24 + .../src/app/routes/dispatch.ts | 55 +++ .../src/app/routes/portal.route.ts | 14 + apps/stalker-mock-server/src/app/scenarios.ts | 121 +++++ apps/stalker-mock-server/src/main.ts | 143 ++++++ apps/stalker-mock-server/tsconfig.json | 16 + apps/web-e2e/playwright.config.ts | 31 +- apps/web-e2e/src/stalker.e2e.ts | 254 +++++++++++ apps/web-e2e/src/xtream.e2e.ts | 357 +++++++++++++++ docs/architecture/stalker-mock-server.md | 250 ++++++++++ docs/architecture/xtream-mock-server.md | 237 ++++++++++ 23 files changed, 2507 insertions(+), 7 deletions(-) create mode 100644 apps/stalker-mock-server/README.md create mode 100644 apps/stalker-mock-server/project.json create mode 100644 apps/stalker-mock-server/src/app/data-generator.ts create mode 100644 apps/stalker-mock-server/src/app/data-store.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/create-link.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/favorites.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/handshake.handler.ts create mode 100644 apps/stalker-mock-server/src/app/routes/dispatch.ts create mode 100644 apps/stalker-mock-server/src/app/routes/portal.route.ts create mode 100644 apps/stalker-mock-server/src/app/scenarios.ts create mode 100644 apps/stalker-mock-server/src/main.ts create mode 100644 apps/stalker-mock-server/tsconfig.json create mode 100644 apps/web-e2e/src/stalker.e2e.ts create mode 100644 apps/web-e2e/src/xtream.e2e.ts create mode 100644 docs/architecture/stalker-mock-server.md create mode 100644 docs/architecture/xtream-mock-server.md diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md new file mode 100644 index 000000000..3067afc5e --- /dev/null +++ b/apps/stalker-mock-server/README.md @@ -0,0 +1,131 @@ +# Stalker Mock Server + +A local mock implementation of the Stalker/Ministra portal API for development and end-to-end testing of IPTVnator. + +## Overview + +The mock server speaks the same `portal.php` HTTP protocol as a real Stalker portal, generating deterministic fake data using `@faker-js/faker` seeded from the connecting MAC address. This means: + +- The **same MAC address always returns the same data** (consistent across page refreshes and test runs). +- **Different MAC addresses produce different datasets** — use predefined scenario MACs for specific test conditions. +- Data is generated once per MAC on first request and cached in memory for the server's lifetime. **Restart to regenerate.** + +## Quick Start + +```bash +# Start the mock server (port 3210) +nx serve stalker-mock-server + +# Or with file watching (auto-restarts on source changes) +nx run stalker-mock-server:serve-with-watch + +# Or run both the mock server + Angular dev server in parallel +nx run-many --targets=serve --projects=stalker-mock-server,web +``` + +Then in IPTVnator, add a new Stalker portal: + +- **Portal URL**: `http://localhost:3210/portal.php` +- **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data) + +## Predefined Scenario MAC Addresses + +| MAC Address | Scenario | Description | +|---|---|---| +| `00:1A:79:00:00:01` | **default** | 8 categories per type, 40 items each — the balanced go-to for daily dev | +| `00:1A:79:FF:FF:FF` | **large** | 20 categories, 200 items each — stress-test pagination and virtual scroll | +| `00:1A:79:00:00:02` | **series-heavy** | 15 series categories with 6 seasons × 10 episodes — test deep series navigation | +| `00:1A:79:00:00:03` | **minimal** | 2 categories, 5 items — edge case testing (empty states, single items) | +| `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow | +| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | +| `` | **auto** | MAC bytes used as seed → deterministic unique dataset | + +## Configuration + +| Environment Variable | Default | Description | +|---|---|---| +| `PORT` | `3210` | HTTP port the server listens on | +| `NODE_ENV` | `development` | Node environment | + +## Utility Endpoints + +| Endpoint | Method | Description | +|---|---|---| +| `/health` | `GET` | Health check — returns `{ status: "ok" }` | +| `/reset` | `POST` | Clear all in-memory data and favorites (useful between test runs) | + +## API Coverage + +All endpoints are served at `GET /portal.php?action=&...` matching the real Stalker protocol: + +| Action | Description | +|---|---| +| `handshake` | Returns a mock Bearer token | +| `do_auth` | Returns a mock user profile | +| `get_categories` | Category list filtered by `type` (itv/vod/series) | +| `get_genres` | Genre list (mirrors categories) | +| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | +| `create_link` | Returns a real public HLS stream URL for playback | +| `favorites` | Add / remove / get favorites (in-memory, resets on restart) | +| `get_short_epg` | EPG program list for a channel (`ch_id`) | +| `get_epg_info` | Alias for `get_short_epg` | + +## Cover Images + +Cover images and logos use [Picsum Photos](https://picsum.photos) (e.g. `https://picsum.photos/seed/{id}/300/200`). These are real images served from a CDN — no local setup required, but an internet connection is needed for images to display. + +## Stream URLs + +`create_link` returns real public HLS test streams so video actually plays: + +- `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` +- `https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8` +- `https://playertest.longtailvideo.com/adaptive/oceans/oceans.m3u8` +- `https://playertest.longtailvideo.com/adaptive/bbbfull/bbbfull.m3u8` + +The stream chosen for a given item is deterministic based on the item's `cmd` string. + +## Using with Playwright E2E Tests + +The Playwright config in `apps/web-e2e/playwright.config.ts` starts the mock server automatically alongside the Angular dev server when running e2e tests. See `apps/web-e2e/src/stalker.e2e.ts` for example stalker tests. + +```bash +# Run all e2e tests (starts mock server automatically) +nx e2e web-e2e + +# Or run only stalker-specific e2e tests +nx e2e web-e2e --grep "@stalker" +``` + +The test suite uses `00:1A:79:00:00:01` (default scenario) for most tests, and calls `POST /reset` in `beforeEach` to ensure a clean state between tests. + +## Architecture + +See [`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker-mock-server.md) for full implementation details. + +## Project Structure + +``` +apps/stalker-mock-server/ +├── src/ +│ ├── main.ts # Express bootstrap +│ └── app/ +│ ├── scenarios.ts # MAC → scenario config mapping +│ ├── data-generator.ts # Seeded faker data generation +│ ├── data-store.ts # Lazy per-MAC in-memory cache +│ └── routes/ +│ ├── portal.route.ts # /portal.php dispatcher +│ └── handlers/ +│ ├── handshake.handler.ts +│ ├── do-auth.handler.ts +│ ├── get-categories.handler.ts +│ ├── get-ordered-list.handler.ts +│ ├── get-seasons.handler.ts +│ ├── create-link.handler.ts +│ ├── favorites.handler.ts +│ ├── get-short-epg.handler.ts +│ └── get-genres.handler.ts +├── project.json +├── tsconfig.json +└── README.md +``` diff --git a/apps/stalker-mock-server/project.json b/apps/stalker-mock-server/project.json new file mode 100644 index 000000000..61e02ecf8 --- /dev/null +++ b/apps/stalker-mock-server/project.json @@ -0,0 +1,38 @@ +{ + "name": "stalker-mock-server", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/stalker-mock-server/src", + "tags": ["scope:dev-tools"], + "targets": { + "serve": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "command": "pnpm tsx apps/stalker-mock-server/src/main.ts", + "cwd": "{workspaceRoot}", + "color": true, + "env": { + "PORT": "3210", + "NODE_ENV": "development" + } + } + }, + "serve-with-watch": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "command": "pnpm tsx watch apps/stalker-mock-server/src/main.ts", + "cwd": "{workspaceRoot}", + "color": true, + "env": { + "PORT": "3210", + "NODE_ENV": "development" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts new file mode 100644 index 000000000..2cdfeea21 --- /dev/null +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -0,0 +1,428 @@ +import { faker } from '@faker-js/faker'; +import { ScenarioConfig } from './scenarios.js'; + +// --------------------------------------------------------------------------- +// Shared types (mirror the Stalker API response shapes) +// --------------------------------------------------------------------------- + +export interface RawCategory { + id: string; + title: string; + alias: string; +} + +export interface RawChannel { + id: string; + name: string; + o_name: string; + cmd: string; + logo: string; + category_id: string; + tv_genre_id: string; + xmltv_id: string; +} + +export interface RawVodItem { + id: string; + name: string; + o_name: string; + title: string; + cmd: string; + screenshot_uri: string; + cover: string; + description: string; + actors: string; + director: string; + year: string; + genre: string; + genres_str: string; + rating_imdb: string; + rating_kinopoisk: string; + category_id: string; + is_series: 0 | 1 | '1'; + has_files: number; + series?: RawEmbeddedEpisode[]; +} + +export interface RawSeriesItem { + id: string; + name: string; + o_name: string; + title: string; + cmd: string; + screenshot_uri: string; + cover: string; + description: string; + actors: string; + director: string; + year: string; + genres_str: string; + rating_imdb: string; + rating_kinopoisk: string; + category_id: string; + is_series: 0; + has_files: 0; +} + +export interface RawSeason { + id: string; + name: string; + cmd: string; + description: string; + director: string; + actors: string; + year: string; + genres_str: string; + age: string; + rating_imdb: string; + rating_kinopoisk: string; + screenshot_uri: string; + added: string; + series: string[]; +} + +export interface RawEmbeddedEpisode { + id: number; + name: string; + cmd: string; +} + +export interface RawEpgProgram { + id: string; + name: string; + start: string; + stop: string; + start_timestamp: number; + stop_timestamp: number; + descr: string; + category: string; +} + +export interface GeneratedPortalData { + itvCategories: RawCategory[]; + vodCategories: RawCategory[]; + seriesCategories: RawCategory[]; + channels: Map; // categoryId -> channels + vod: Map; // categoryId -> items + series: Map; // categoryId -> items + seasons: Map; // seriesItemId -> seasons + epg: Map; // channelId -> programs +} + +// --------------------------------------------------------------------------- +// Public test HLS streams used for create_link responses +// --------------------------------------------------------------------------- +const TEST_HLS_STREAMS = [ + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8', + 'https://playertest.longtailvideo.com/adaptive/oceans/oceans.m3u8', + 'https://playertest.longtailvideo.com/adaptive/bbbfull/bbbfull.m3u8', +]; + +function pickStream(index: number): string { + return TEST_HLS_STREAMS[index % TEST_HLS_STREAMS.length]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function coverUrl(seed: string, width = 300, height = 200): string { + return `https://picsum.photos/seed/${seed}/${width}/${height}`; +} + +function logoUrl(seed: string): string { + return `https://picsum.photos/seed/logo-${seed}/100/100`; +} + +function isoNow(offsetMinutes: number): string { + const d = new Date(Date.now() + offsetMinutes * 60 * 1000); + return d.toISOString(); +} + +function unixNow(offsetMinutes: number): number { + return Math.floor((Date.now() + offsetMinutes * 60 * 1000) / 1000); +} + +// --------------------------------------------------------------------------- +// Generator +// --------------------------------------------------------------------------- + +export function generatePortalData(config: ScenarioConfig): GeneratedPortalData { + faker.seed(config.seed); + + const data: GeneratedPortalData = { + itvCategories: [], + vodCategories: [], + seriesCategories: [], + channels: new Map(), + vod: new Map(), + series: new Map(), + seasons: new Map(), + epg: new Map(), + }; + + // ------ ITV categories + channels ------ + data.itvCategories = generateCategories('itv', config.categoryCount.itv); + let channelIndex = 0; + for (const cat of data.itvCategories) { + const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex); + data.channels.set(cat.id, channels); + for (const ch of channels) { + data.epg.set(ch.id, generateEpg(ch.name)); + } + channelIndex += config.itemsPerCategory; + } + + // ------ VOD categories + items ------ + data.vodCategories = generateCategories('vod', config.categoryCount.vod); + let vodIndex = 0; + for (const cat of data.vodCategories) { + const items = generateVodItems( + cat.id, + config.itemsPerCategory, + vodIndex, + config.isSeriesFraction, + config.embeddedSeriesFraction, + config.seasonsPerSeries, + config.episodesPerSeason + ); + data.vod.set(cat.id, items); + vodIndex += config.itemsPerCategory; + } + + // ------ Series categories + items ------ + data.seriesCategories = generateCategories('series', config.categoryCount.series); + let seriesIndex = 0; + for (const cat of data.seriesCategories) { + const items = generateSeriesItems(cat.id, config.itemsPerCategory, seriesIndex); + data.series.set(cat.id, items); + for (const item of items) { + data.seasons.set(item.id, generateSeasons(item, config.seasonsPerSeries, config.episodesPerSeason)); + } + seriesIndex += config.itemsPerCategory; + } + + return data; +} + +// --------------------------------------------------------------------------- +// Category generators +// --------------------------------------------------------------------------- + +const ITV_GENRE_NAMES = [ + 'News', 'Sports', 'Movies', 'Entertainment', 'Kids', + 'Documentary', 'Music', 'Comedy', 'Drama', 'Reality TV', + 'Lifestyle', 'Travel', 'Food', 'Tech', 'Science', + 'History', 'Nature', 'Animation', 'Gaming', 'Shopping', +]; + +const VOD_GENRE_NAMES = [ + 'Action', 'Comedy', 'Drama', 'Horror', 'Thriller', + 'Romance', 'Sci-Fi', 'Fantasy', 'Animation', 'Documentary', + 'Biography', 'Crime', 'Mystery', 'Adventure', 'Family', + 'War', 'Western', 'Musical', 'Sport', 'History', +]; + +const SERIES_GENRE_NAMES = [ + 'Drama Series', 'Comedy Series', 'Crime Series', 'Sci-Fi Series', + 'Reality Shows', 'Anime', 'Soap Opera', 'Mini Series', + 'Documentary Series', 'Kids Shows', 'Action Series', 'Fantasy Series', + 'Medical', 'Legal', 'Political', 'Romance Series', 'Historical', + 'Thriller Series', 'Horror Series', 'Western Series', +]; + +function getGenreNames(type: 'itv' | 'vod' | 'series'): string[] { + if (type === 'itv') return ITV_GENRE_NAMES; + if (type === 'vod') return VOD_GENRE_NAMES; + return SERIES_GENRE_NAMES; +} + +function generateCategories(type: 'itv' | 'vod' | 'series', count: number): RawCategory[] { + const names = getGenreNames(type); + return Array.from({ length: count }, (_, i) => { + const id = String((type === 'itv' ? 1000 : type === 'vod' ? 2000 : 3000) + i + 1); + const title = names[i % names.length]; + return { id, title, alias: title.toLowerCase().replace(/\s+/g, '_') }; + }); +} + +// --------------------------------------------------------------------------- +// Channel generators +// --------------------------------------------------------------------------- + +function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] { + return Array.from({ length: count }, (_, i) => { + const globalIndex = startIndex + i; + const id = String(10000 + globalIndex); + const name = `${faker.company.name()} TV`; + return { + id, + name, + o_name: name, + cmd: `ffrt4://ch/live/${id}/index.m3u8`, + logo: logoUrl(`ch-${id}`), + category_id: categoryId, + tv_genre_id: categoryId, + xmltv_id: `channel-${id}.example`, + }; + }); +} + +// --------------------------------------------------------------------------- +// VOD generators +// --------------------------------------------------------------------------- + +function generateVodItems( + categoryId: string, + count: number, + startIndex: number, + isSeriesFraction: number, + embeddedSeriesFraction: number, + seasonsPerSeries: number, + episodesPerSeason: number +): RawVodItem[] { + return Array.from({ length: count }, (_, i) => { + const globalIndex = startIndex + i; + const id = String(20000 + globalIndex); + const title = faker.music.songName() + ': ' + faker.lorem.words(2); + const isSeries = i / count < isSeriesFraction; + const hasEmbeddedSeries = !isSeries && (i / count < isSeriesFraction + embeddedSeriesFraction); + + const item: RawVodItem = { + id, + name: title, + o_name: title, + title, + cmd: `ffrt4://vod/${id}/index.m3u8`, + screenshot_uri: coverUrl(`vod-${id}`), + cover: coverUrl(`vod-cover-${id}`, 300, 450), + description: faker.lorem.paragraph(), + actors: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '), + director: faker.person.fullName(), + year: String(faker.date.past({ years: 20 }).getFullYear()), + genre: faker.music.genre(), + genres_str: [faker.music.genre(), faker.music.genre()].join(', '), + rating_imdb: (Math.random() * 4 + 5).toFixed(1), + rating_kinopoisk: (Math.random() * 4 + 5).toFixed(1), + category_id: categoryId, + is_series: isSeries ? '1' : 0, + has_files: isSeries ? 0 : 1, + }; + + if (hasEmbeddedSeries) { + item.series = generateEmbeddedEpisodes(id, seasonsPerSeries * episodesPerSeason); + } + + return item; + }); +} + +function generateEmbeddedEpisodes(parentId: string, count: number): RawEmbeddedEpisode[] { + return Array.from({ length: count }, (_, i) => ({ + id: parseInt(parentId) * 100 + i, + name: `Episode ${i + 1}`, + cmd: `ffrt4://vod/${parentId}/ep${i + 1}/index.m3u8`, + })); +} + +// --------------------------------------------------------------------------- +// Series generators +// --------------------------------------------------------------------------- + +function generateSeriesItems(categoryId: string, count: number, startIndex: number): RawSeriesItem[] { + return Array.from({ length: count }, (_, i) => { + const globalIndex = startIndex + i; + const id = String(30000 + globalIndex); + const title = faker.company.catchPhrase(); + return { + id, + name: title, + o_name: title, + title, + cmd: `ffrt4://series/${id}`, + screenshot_uri: coverUrl(`series-${id}`), + cover: coverUrl(`series-cover-${id}`, 300, 450), + description: faker.lorem.paragraph(), + actors: Array.from({ length: 4 }, () => faker.person.fullName()).join(', '), + director: faker.person.fullName(), + year: String(faker.date.past({ years: 10 }).getFullYear()), + genres_str: [faker.music.genre(), faker.music.genre()].join(', '), + rating_imdb: (Math.random() * 4 + 5).toFixed(1), + rating_kinopoisk: (Math.random() * 4 + 5).toFixed(1), + category_id: categoryId, + is_series: 0, + has_files: 0, + }; + }); +} + +export function generateSeasons( + series: RawSeriesItem, + seasonCount: number, + episodesPerSeason: number +): RawSeason[] { + return Array.from({ length: seasonCount }, (_, s) => { + const seasonId = `${series.id}-s${s + 1}`; + const episodes = Array.from({ length: episodesPerSeason }, (_, e) => + `${series.id}-s${s + 1}-e${e + 1}` + ); + return { + id: seasonId, + name: `Season ${s + 1}`, + cmd: `ffrt4://series/${series.id}/season/${s + 1}`, + description: faker.lorem.sentence(), + director: series.director, + actors: series.actors, + year: series.year, + genres_str: series.genres_str, + age: '16', + rating_imdb: series.rating_imdb, + rating_kinopoisk: series.rating_kinopoisk, + screenshot_uri: coverUrl(`${seasonId}`), + added: new Date(Date.now() - Math.random() * 1e10).toISOString(), + series: episodes, + }; + }); +} + +// --------------------------------------------------------------------------- +// EPG generator +// --------------------------------------------------------------------------- + +const EPG_PROGRAM_TYPES = ['News', 'Movie', 'Documentary', 'Entertainment', 'Sports', 'Kids', 'Series']; + +export function generateEpg(channelName: string): RawEpgProgram[] { + const programs: RawEpgProgram[] = []; + // Generate 12 programs: 6 past, current, 5 future (30-min slots) + const SLOT_MINUTES = 30; + const startOffset = -6 * SLOT_MINUTES; // start 3 hours ago + + for (let i = 0; i < 12; i++) { + const startMin = startOffset + i * SLOT_MINUTES; + const stopMin = startMin + SLOT_MINUTES; + const category = EPG_PROGRAM_TYPES[i % EPG_PROGRAM_TYPES.length]; + programs.push({ + id: String(i + 1), + name: `${channelName}: ${faker.company.catchPhrase()}`, + start: isoNow(startMin), + stop: isoNow(stopMin), + start_timestamp: unixNow(startMin), + stop_timestamp: unixNow(stopMin), + descr: faker.lorem.sentence(), + category, + }); + } + return programs; +} + +// --------------------------------------------------------------------------- +// create_link helper +// --------------------------------------------------------------------------- + +export function resolveStreamUrl(cmd: string, itemIndex: number): string { + if (cmd.startsWith('ffrt4://')) { + return pickStream(itemIndex); + } + return pickStream(0); +} diff --git a/apps/stalker-mock-server/src/app/data-store.ts b/apps/stalker-mock-server/src/app/data-store.ts new file mode 100644 index 000000000..52a16a756 --- /dev/null +++ b/apps/stalker-mock-server/src/app/data-store.ts @@ -0,0 +1,51 @@ +import { generatePortalData, GeneratedPortalData } from './data-generator.js'; +import { getScenario } from './scenarios.js'; + +/** + * Lazy per-MAC in-memory store. + * Data is generated once on first access for each MAC and cached for the + * lifetime of the server process. Restart the server to get fresh data. + */ +const portalCache = new Map(); + +export function getPortalData(mac: string): GeneratedPortalData { + const key = mac.toLowerCase(); + if (!portalCache.has(key)) { + const scenario = getScenario(key); + console.log( + `[DataStore] Generating data for MAC ${mac} (scenario: ${scenario.name}, seed: ${scenario.seed})` + ); + portalCache.set(key, generatePortalData(scenario)); + } + return portalCache.get(key)!; +} + +/** In-memory favorites store per MAC. Resets on server restart. */ +const favoritesStore = new Map>(); + +export function getFavorites(mac: string): Set { + const key = mac.toLowerCase(); + if (!favoritesStore.has(key)) { + favoritesStore.set(key, new Set()); + } + return favoritesStore.get(key)!; +} + +export function addFavorite(mac: string, itemId: string): void { + getFavorites(mac).add(itemId); +} + +export function removeFavorite(mac: string, itemId: string): void { + getFavorites(mac).delete(itemId); +} + +/** Reset favorites for a MAC (exposed via /reset endpoint). */ +export function resetFavorites(mac: string): void { + favoritesStore.delete(mac.toLowerCase()); +} + +/** Reset all cached data (exposed via /reset endpoint). */ +export function resetAll(): void { + portalCache.clear(); + favoritesStore.clear(); +} diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts new file mode 100644 index 000000000..5d083e56b --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -0,0 +1,33 @@ +import { Request, Response } from 'express'; +import { resolveStreamUrl } from '../data-generator.js'; +import { extractMac } from './get-categories.handler.js'; + +/** + * Stalker create_link — returns a playable stream URL. + * + * Query params: + * cmd: the ffrt4:// or similar command from the content item + * type: itv | vod | series + */ +export function handleCreateLink(req: Request, res: Response): void { + const mac = extractMac(req); + const cmd = (req.query['cmd'] as string) ?? ''; + + // Use a stable index derived from the cmd string so the same item + // always returns the same test stream URL. + const itemIndex = cmd + .split('') + .reduce((acc, ch) => acc + ch.charCodeAt(0), 0); + const streamUrl = resolveStreamUrl(cmd, itemIndex); + + console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`); + + res.json({ + js: { + cmd: streamUrl, + streamer_id: '1', + load: '', + error: '', + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts new file mode 100644 index 000000000..51afe2a5b --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; + +/** + * Stalker do_auth — returns user profile / account info. + */ +export function handleDoAuth(req: Request, res: Response): void { + res.json({ + js: { + id: '1', + name: 'Mock User', + login: 'mockuser', + password: '', + status: 'active', + tariff_expired_date: '2099-12-31', + phone: '', + ls: '0', + created: new Date().toISOString(), + updated: new Date().toISOString(), + blocked: '0', + acc_enabled: '1', + max_connections: '1', + active_connections: '0', + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts new file mode 100644 index 000000000..5d9c1e93d --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts @@ -0,0 +1,75 @@ +import { Request, Response } from 'express'; +import { + addFavorite, + getFavorites, + getPortalData, + removeFavorite, +} from '../data-store.js'; +import { extractMac } from './get-categories.handler.js'; +import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js'; + +/** + * Stalker favorites endpoint. + * + * Query params: + * action: favorites + * fav_action: get | add | remove (some portals use set/unset) + * item_id: the item id to add/remove + * type: itv | vod | series + */ +export function handleFavorites(req: Request, res: Response): void { + const mac = extractMac(req); + const favAction = (req.query['fav_action'] as string) ?? 'get'; + const itemId = req.query['item_id'] as string; + + if (favAction === 'add' || favAction === 'set') { + if (itemId) addFavorite(mac, itemId); + res.json({ js: { error: '' } }); + return; + } + + if (favAction === 'remove' || favAction === 'unset') { + if (itemId) removeFavorite(mac, itemId); + res.json({ js: { error: '' } }); + return; + } + + // fav_action === 'get' — return all favorited items + const favIds = getFavorites(mac); + const data = getPortalData(mac); + + type AnyItem = RawChannel | RawVodItem | RawSeriesItem; + const result: AnyItem[] = []; + + for (const id of favIds) { + // Search across all content types + const found = findItemById(data, id); + if (found) result.push(found); + } + + res.json({ + js: { + data: result, + total_items: result.length, + }, + }); +} + +function findItemById( + data: ReturnType, + id: string +): RawChannel | RawVodItem | RawSeriesItem | undefined { + for (const items of data.channels.values()) { + const found = items.find((i) => i.id === id); + if (found) return found; + } + for (const items of data.vod.values()) { + const found = items.find((i) => i.id === id); + if (found) return found; + } + for (const items of data.series.values()) { + const found = items.find((i) => i.id === id); + if (found) return found; + } + return undefined; +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts new file mode 100644 index 000000000..8f57a0e49 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts @@ -0,0 +1,33 @@ +import { Request, Response } from 'express'; +import { getPortalData } from '../data-store.js'; + +/** + * Stalker get_categories — returns category list filtered by type (itv/vod/series). + */ +export function handleGetCategories(req: Request, res: Response): void { + const mac = extractMac(req); + const type = (req.query['type'] as string) ?? 'vod'; + const data = getPortalData(mac); + + let categories; + if (type === 'itv') { + categories = data.itvCategories; + } else if (type === 'series') { + categories = data.seriesCategories; + } else { + categories = data.vodCategories; + } + + res.json({ js: categories }); +} + +export function extractMac(req: Request): string { + const cookie = req.headers['cookie'] ?? ''; + return ( + cookie + .split(';') + .find((c) => c.trim().startsWith('mac=')) + ?.split('=')[1] + ?.trim() ?? '00:00:00:00:00:00' + ); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts new file mode 100644 index 000000000..1c82ad158 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { getPortalData } from '../data-store.js'; +import { extractMac } from './get-categories.handler.js'; + +/** + * Stalker get_genres — returns genre list for a content type. + * Genres mirror the categories for our mock portal. + */ +export function handleGetGenres(req: Request, res: Response): void { + const mac = extractMac(req); + const type = (req.query['type'] as string) ?? 'vod'; + const data = getPortalData(mac); + + let categories; + if (type === 'itv') { + categories = data.itvCategories; + } else if (type === 'series') { + categories = data.seriesCategories; + } else { + categories = data.vodCategories; + } + + const genres = categories.map((cat) => ({ + id: cat.id, + title: cat.title, + alias: cat.alias, + censored: '0', + })); + + res.json({ js: genres }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts new file mode 100644 index 000000000..a7affa37f --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -0,0 +1,80 @@ +import { Request, Response } from 'express'; +import { getPortalData } from '../data-store.js'; +import { extractMac } from './get-categories.handler.js'; +import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js'; + +const PAGE_SIZE = 14; + +type AnyItem = RawChannel | RawVodItem | RawSeriesItem; + +/** + * Stalker get_ordered_list — returns paginated content for a category. + * + * Query params: + * type: itv | vod | series + * category: category_id (or "*" for all) + * genre: same as category for itv + * p: page number (1-based) + * search: optional search phrase + */ +export function handleGetOrderedList(req: Request, res: Response): void { + const mac = extractMac(req); + const type = (req.query['type'] as string) ?? 'vod'; + const categoryId = (req.query['category'] as string) ?? '*'; + const page = parseInt((req.query['p'] as string) ?? '1', 10); + const search = ((req.query['search'] as string) ?? '').toLowerCase(); + const data = getPortalData(mac); + + let allItems: AnyItem[] = []; + + if (type === 'itv') { + if (categoryId === '*') { + for (const items of data.channels.values()) { + allItems.push(...items); + } + } else { + allItems = data.channels.get(categoryId) ?? []; + } + } else if (type === 'series') { + if (categoryId === '*') { + for (const items of data.series.values()) { + allItems.push(...items); + } + } else { + allItems = data.series.get(categoryId) ?? []; + } + } else { + // vod (default) + if (categoryId === '*') { + for (const items of data.vod.values()) { + allItems.push(...items); + } + } else { + allItems = data.vod.get(categoryId) ?? []; + } + } + + // Apply search filter + if (search) { + allItems = allItems.filter((item) => { + const name = ('name' in item ? item.name : '') ?? ''; + return name.toLowerCase().includes(search); + }); + } + + const totalItems = allItems.length; + const totalPages = Math.ceil(totalItems / PAGE_SIZE); + const offset = (page - 1) * PAGE_SIZE; + const pageItems = allItems.slice(offset, offset + PAGE_SIZE); + + res.json({ + js: { + data: pageItems, + total_items: totalItems, + max_page_items: PAGE_SIZE, + cur_page: page, + total_pages: totalPages, + selected_item: 0, + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts new file mode 100644 index 000000000..88a6e375a --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; +import { generateSeasons } from '../data-generator.js'; +import { getPortalData } from '../data-store.js'; +import { getScenario } from '../scenarios.js'; +import { extractMac } from './get-categories.handler.js'; + +/** + * Stalker get_ordered_list with type=series for seasons/episodes. + * When a series item is opened, the frontend requests seasons via + * get_ordered_list?action=get_ordered_list&type=series&movie_id= + * + * This handler is invoked from the main portal router when movie_id is present. + */ +export function handleGetSeasons(req: Request, res: Response): void { + const mac = extractMac(req); + const seriesId = req.query['movie_id'] as string; + const data = getPortalData(mac); + const scenario = getScenario(mac); + + // Try the precomputed seasons map first + if (data.seasons.has(seriesId)) { + res.json({ js: data.seasons.get(seriesId) }); + return; + } + + // Series id might belong to a VOD item with is_series=1 — generate seasons on demand + let foundItem: { id: string; name: string; o_name: string; title: string; cmd: string; screenshot_uri: string; cover: string; description: string; actors: string; director: string; year: string; genres_str: string; rating_imdb: string; rating_kinopoisk: string; category_id: string; is_series: 0; has_files: 0 } | undefined; + + for (const items of data.vod.values()) { + const match = items.find((i) => i.id === seriesId); + if (match) { + foundItem = { + ...match, + genres_str: match.genres_str ?? match.genre ?? '', + is_series: 0, + has_files: 0, + }; + break; + } + } + + if (!foundItem) { + res.json({ js: [] }); + return; + } + + const seasons = generateSeasons( + foundItem, + scenario.seasonsPerSeries, + scenario.episodesPerSeason + ); + data.seasons.set(seriesId, seasons); + + res.json({ js: seasons }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts new file mode 100644 index 000000000..5e27a8872 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { generateEpg } from '../data-generator.js'; +import { getPortalData } from '../data-store.js'; +import { extractMac } from './get-categories.handler.js'; + +/** + * Stalker get_short_epg — returns EPG programs for a channel. + * + * Query params: + * ch_id: channel id + * size: number of programs to return (default 12) + */ +export function handleGetShortEpg(req: Request, res: Response): void { + const mac = extractMac(req); + const channelId = req.query['ch_id'] as string; + const size = parseInt((req.query['size'] as string) ?? '12', 10); + const data = getPortalData(mac); + + let programs = data.epg.get(channelId); + + if (!programs) { + // Channel not found — generate on-the-fly + programs = generateEpg(`Channel ${channelId}`); + data.epg.set(channelId, programs); + } + + res.json({ + js: { + data: programs.slice(0, size), + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts b/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts new file mode 100644 index 000000000..c18354876 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts @@ -0,0 +1,24 @@ +import { Request, Response } from 'express'; + +/** + * Stalker handshake — returns a Bearer token. + * Real portals return a JWT; we return a deterministic fake token. + */ +export function handleHandshake(req: Request, res: Response): void { + const mac = (req.headers['cookie'] ?? '') + .split(';') + .find((c) => c.trim().startsWith('mac=')) + ?.split('=')[1] + ?.trim() ?? 'unknown'; + + res.json({ + js: { + token: `mock-token-${Buffer.from(mac).toString('base64')}`, + keep_alive: 180, + servertime: Math.floor(Date.now() / 1000), + servertimezone: 'Europe/Berlin', + version: '5.6.2', + revision: '1', + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts new file mode 100644 index 000000000..b96ba182f --- /dev/null +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; +import { handleHandshake } from '../handlers/handshake.handler.js'; +import { handleDoAuth } from '../handlers/do-auth.handler.js'; +import { handleGetCategories } from '../handlers/get-categories.handler.js'; +import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js'; +import { handleGetSeasons } from '../handlers/get-seasons.handler.js'; +import { handleCreateLink } from '../handlers/create-link.handler.js'; +import { handleFavorites } from '../handlers/favorites.handler.js'; +import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js'; +import { handleGetGenres } from '../handlers/get-genres.handler.js'; + +/** + * Shared Stalker action dispatcher. + * Used by both the direct /portal.php route and the /stalker CORS proxy route. + */ +export default function dispatchPortalAction(req: Request, res: Response): void { + const action = req.query['action'] as string; + + switch (action) { + case 'handshake': + handleHandshake(req, res); + break; + case 'do_auth': + handleDoAuth(req, res); + break; + case 'get_categories': + case 'get_genres_vod': + case 'get_genres_itv': + handleGetCategories(req, res); + break; + case 'get_genres': + handleGetGenres(req, res); + break; + case 'get_ordered_list': + if (req.query['movie_id']) { + handleGetSeasons(req, res); + } else { + handleGetOrderedList(req, res); + } + break; + case 'create_link': + handleCreateLink(req, res); + break; + case 'favorites': + handleFavorites(req, res); + break; + case 'get_short_epg': + case 'get_epg_info': + handleGetShortEpg(req, res); + break; + default: + console.warn(`[portal] Unknown action: ${action}`); + res.json({ js: { error: `Unknown action: ${action}` } }); + } +} diff --git a/apps/stalker-mock-server/src/app/routes/portal.route.ts b/apps/stalker-mock-server/src/app/routes/portal.route.ts new file mode 100644 index 000000000..e65f1e413 --- /dev/null +++ b/apps/stalker-mock-server/src/app/routes/portal.route.ts @@ -0,0 +1,14 @@ +import { Router, Request, Response } from 'express'; +import dispatchPortalAction from './dispatch.js'; + +const router = Router(); + +/** + * Main Stalker API dispatcher. + * All requests arrive as GET /portal.php?action=&... + */ +router.get('/', (req: Request, res: Response) => { + dispatchPortalAction(req, res); +}); + +export default router; diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts new file mode 100644 index 000000000..3a0454bd8 --- /dev/null +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -0,0 +1,121 @@ +export interface ScenarioConfig { + name: string; + description: string; + seed: number; + categoryCount: { + itv: number; + vod: number; + series: number; + }; + itemsPerCategory: number; + seasonsPerSeries: number; + episodesPerSeason: number; + /** Fraction of VOD items that have is_series=1 (Ministra mode) */ + isSeriesFraction: number; + /** Fraction of VOD items that have embedded series[] array */ + embeddedSeriesFraction: number; +} + +/** + * Predefined scenarios keyed by MAC address (lowercase, colon-separated). + * Any unknown MAC uses its bytes as the seed for deterministic-but-unique data. + */ +export const SCENARIOS: Record = { + '00:1a:79:00:00:01': { + name: 'default', + description: 'Balanced portal — 8 categories, 40 items each', + seed: 1001, + categoryCount: { itv: 8, vod: 8, series: 8 }, + itemsPerCategory: 40, + seasonsPerSeries: 3, + episodesPerSeason: 8, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + }, + '00:1a:79:ff:ff:ff': { + name: 'large', + description: 'Large catalog — 20 categories, 200 items each', + seed: 9999, + categoryCount: { itv: 20, vod: 20, series: 20 }, + itemsPerCategory: 200, + seasonsPerSeries: 5, + episodesPerSeason: 12, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + }, + '00:1a:79:00:00:02': { + name: 'series-heavy', + description: 'Series-heavy portal — many series with deep seasons', + seed: 2002, + categoryCount: { itv: 3, vod: 5, series: 15 }, + itemsPerCategory: 30, + seasonsPerSeries: 6, + episodesPerSeason: 10, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + }, + '00:1a:79:00:00:03': { + name: 'minimal', + description: 'Minimal portal — 2 categories, 5 items (edge case testing)', + seed: 3003, + categoryCount: { itv: 2, vod: 2, series: 2 }, + itemsPerCategory: 5, + seasonsPerSeries: 1, + episodesPerSeason: 3, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + }, + '00:1a:79:00:00:04': { + name: 'is-series', + description: 'VOD with is_series=1 flag (Ministra plugin flow testing)', + seed: 4004, + categoryCount: { itv: 4, vod: 6, series: 4 }, + itemsPerCategory: 20, + seasonsPerSeries: 3, + episodesPerSeason: 6, + isSeriesFraction: 0.6, // 60% of VOD items are is_series=1 + embeddedSeriesFraction: 0, + }, + '00:1a:79:00:00:05': { + name: 'embedded-series', + description: 'VOD with embedded series[] arrays', + seed: 5005, + categoryCount: { itv: 4, vod: 6, series: 4 }, + itemsPerCategory: 20, + seasonsPerSeries: 2, + episodesPerSeason: 5, + isSeriesFraction: 0, + embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[] + }, +}; + +/** + * Convert a MAC address string to a numeric seed for unknown MACs. + * e.g. "AA:BB:CC:DD:EE:FF" -> sum of byte values + */ +export function macToSeed(mac: string): number { + return mac + .toLowerCase() + .split(':') + .reduce((acc, byte) => acc + parseInt(byte, 16), 0); +} + +/** Return the scenario config for a given MAC, falling back to a seeded default. */ +export function getScenario(mac: string): ScenarioConfig { + const normalizedMac = mac.toLowerCase(); + if (SCENARIOS[normalizedMac]) { + return SCENARIOS[normalizedMac]; + } + // Unknown MAC: use MAC bytes as seed, default shape + return { + name: 'auto', + description: `Auto-generated from MAC ${mac}`, + seed: macToSeed(mac), + categoryCount: { itv: 6, vod: 6, series: 6 }, + itemsPerCategory: 30, + seasonsPerSeries: 3, + episodesPerSeason: 8, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + }; +} diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts new file mode 100644 index 000000000..2e8eed602 --- /dev/null +++ b/apps/stalker-mock-server/src/main.ts @@ -0,0 +1,143 @@ +import http from 'http'; +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import portalRouter from './app/routes/portal.route.js'; +import dispatchPortalAction from './app/routes/dispatch.js'; +import { resetAll } from './app/data-store.js'; +import { SCENARIOS } from './app/scenarios.js'; + +const PORT = parseInt(process.env['PORT'] ?? '3210', 10); +const app = express(); + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Log every request +app.use((req, _res, next) => { + const action = req.query['action'] ?? '-'; + const mac = + (req.headers['cookie'] ?? '') + .split(';') + .find((c) => c.trim().startsWith('mac=')) + ?.split('=')[1] + ?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac'; + console.log( + `[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}` + ); + next(); +}); + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +// Stalker portal.php endpoint (direct portal protocol, Electron mode) +app.use('/portal.php', portalRouter); + +/** + * CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape: + * GET /stalker?url=&macAddress=&action=&... + * → { payload: } + * + * The IPTVnator PWA sends Stalker requests to AppConfig.BACKEND_URL/stalker. + * Playwright tests redirect those calls to this endpoint using page.route(), + * so no app code changes are required. + */ +app.get('/stalker', (req: Request, res: Response) => { + const { macAddress, url: _url, ...rest } = req.query as Record; + const mac = macAddress ?? '00:1a:79:00:00:01'; + + // Build a lightweight synthetic request. We need a fresh object with mutable + // `query` and a Cookie header containing the MAC for the handler helpers. + const syntheticReq = { + query: rest, + headers: { cookie: `mac=${mac}` }, + params: {}, + } as unknown as Request; + + // Capture the JSON response and wrap it in the proxy envelope { payload: ... } + let captured: unknown; + const syntheticRes = { + json: (data: unknown) => { + captured = data; + }, + } as unknown as Response; + + dispatchPortalAction(syntheticReq, syntheticRes); + res.json({ payload: captured }); +}); + +// Health check +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Reset all in-memory data (useful between Playwright test runs) +app.post('/reset', (_req: Request, res: Response) => { + resetAll(); + res.json({ status: 'reset', timestamp: new Date().toISOString() }); +}); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- +const server = http.createServer(app); + +server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`[stalker-mock] Port ${PORT} is already in use.`); + } else { + console.error('[stalker-mock] Server error:', err.message); + } + process.exit(1); +}); + +const shutdown = () => { + console.log('\n[stalker-mock] Shutting down...'); + server.close(() => process.exit(0)); +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); +process.on('uncaughtException', (err) => { + console.error('[stalker-mock] Uncaught exception:', err); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + console.error('[stalker-mock] Unhandled rejection:', reason); + process.exit(1); +}); + +// When Nx (or any process manager) closes stdin, prevent auto-exit. +// The HTTP server handle is what keeps the process alive. +process.stdin.resume(); +process.stdin.on('end', () => { /* ignore stdin close */ }); + +server.listen(PORT, () => { + const divider = '─'.repeat(62); + console.log(`\n${divider}`); + console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`); + console.log(divider); + console.log(' Portal URL (Electron/direct):'); + console.log(` http://localhost:${PORT}/portal.php`); + console.log(''); + console.log(' CORS proxy URL (PWA/Playwright e2e):'); + console.log(` http://localhost:${PORT}/stalker?url=...&macAddress=...`); + console.log(''); + console.log(' Predefined scenario MACs:'); + for (const [mac, scenario] of Object.entries(SCENARIOS)) { + console.log( + ` ${mac} → ${scenario.name.padEnd(16)} ${scenario.description}` + ); + } + console.log(''); + console.log(' Any other MAC generates deterministic unique data from MAC bytes.'); + console.log(` Utilities:`); + console.log(` GET http://localhost:${PORT}/health`); + console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`); + console.log(`${divider}\n`); +}); diff --git a/apps/stalker-mock-server/tsconfig.json b/apps/stalker-mock-server/tsconfig.json new file mode 100644 index 000000000..2406aa3d0 --- /dev/null +++ b/apps/stalker-mock-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "../../dist/apps/stalker-mock-server", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 556400d05..578d073f0 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -22,13 +22,30 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, - /* Run your local dev server before starting the tests */ - webServer: { - command: 'pnpm nx run web:serve', - url: 'http://localhost:4200', - reuseExistingServer: true, - cwd: workspaceRoot, - }, + /* Run local dev servers before starting the tests. + * Both the Angular app and the Stalker mock server start in parallel. + * Set MOCK_PORT to override the default mock server port (3210). + */ + webServer: [ + { + command: 'pnpm nx run web:serve', + url: 'http://localhost:4200', + reuseExistingServer: !process.env['CI'], + cwd: workspaceRoot, + }, + { + command: 'pnpm nx run stalker-mock-server:serve', + url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, + reuseExistingServer: !process.env['CI'], + cwd: workspaceRoot, + }, + { + command: 'pnpm nx run xtream-mock-server:serve', + url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, + reuseExistingServer: !process.env['CI'], + cwd: workspaceRoot, + }, + ], projects: [ { name: 'chromium', diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts new file mode 100644 index 000000000..20d5c593c --- /dev/null +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -0,0 +1,254 @@ +import { expect, Page, test } from '@playwright/test'; + +/** + * Stalker Portal E2E Tests + * + * These tests use the stalker-mock-server (apps/stalker-mock-server) to simulate + * a real Stalker portal. The mock server starts automatically alongside the Angular + * dev server when running e2e tests (see playwright.config.ts). + * + * Default scenario MAC (00:1A:79:00:00:01) provides: + * - 8 categories per content type (VOD / Series / ITV) + * - 40 items per category + * - 3 seasons × 8 episodes per series item + * + * Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker" + */ + +const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; +const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; +const PORTAL_URL = `${MOCK_SERVER}/portal.php`; +const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; + +/** Default scenario MAC — balanced catalog, 8 categories, 40 items */ +const DEFAULT_MAC = '00:1A:79:00:00:01'; + +/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */ +const MINIMAL_MAC = '00:1A:79:00:00:03'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Intercept calls to the Angular dev backend (/stalker proxy) and redirect + * them to the mock server. This avoids needing a real backend or changing + * any app environment configuration. + */ +async function interceptStalkerRequests(page: Page): Promise { + await page.route('**/localhost:3000/stalker**', async (route) => { + const originalUrl = new URL(route.request().url()); + const mockUrl = new URL(BACKEND_PROXY); + // Forward all query params unchanged + originalUrl.searchParams.forEach((value, key) => { + mockUrl.searchParams.set(key, value); + }); + await route.continue({ url: mockUrl.toString() }); + }); +} + +/** + * Add a Stalker portal via the UI: + * 1. Click the "add playlist" button + * 2. Click "Stalker Portal Import" in the menu + * 3. Fill in the form and submit + */ +async function addStalkerPortal( + page: Page, + options: { name?: string; mac?: string } = {} +): Promise { + const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options; + + await page.getByTestId('add-playlist').click(); + await page.getByText('Stalker Portal').click(); + + await page.locator('#title').fill(name); + await page.locator('#portalUrl').fill(PORTAL_URL); + await page.locator('#macAddress').fill(mac); + + await page.getByRole('button', { name: 'Add' }).click(); + // Wait for dialog to close + await page.waitForSelector('mat-dialog-container', { state: 'detached' }); +} + +/** + * Clear IndexedDB state between tests so each test starts fresh. + */ +async function clearStorage(page: Page): Promise { + await page.evaluate(async () => { + const dbs = await window.indexedDB.databases(); + await Promise.all( + dbs + .filter((db) => db.name != null) + .map((db) => window.indexedDB.deleteDatabase(db.name!)) + ); + }); +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +test.beforeEach(async ({ page, request }) => { + // Reset mock server state (clears in-memory favorites and cache) + await request.post(`${MOCK_SERVER}/reset`); + + await page.goto('/'); + await clearStorage(page); + await page.reload(); + + // Redirect backend proxy calls to the mock server + await interceptStalkerRequests(page); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('@stalker health check — mock server is running', async ({ request }) => { + const response = await request.get(`${MOCK_SERVER}/health`); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.status).toBe('ok'); +}); + +test('@stalker add a Stalker portal and see it in the playlist list', async ({ + page, +}) => { + await addStalkerPortal(page, { name: 'My Test Portal' }); + + // Portal card should appear on the home page + await expect( + page.getByText('My Test Portal', { exact: false }) + ).toBeVisible(); +}); + +test('@stalker VOD — categories load from mock server', async ({ page }) => { + await addStalkerPortal(page); + + // Navigate into the portal — click the portal card + await page.getByText('Mock Stalker Portal').click(); + + // Wait for VOD route to load + await page.waitForURL(/stalker.*vod/); + + // Default scenario has 8 VOD categories (+ 1 "All categories" prepended by the store) + const categoryItems = page.locator('mat-list-item, [class*="category"]'); + await expect(categoryItems.first()).toBeVisible({ timeout: 10_000 }); + const count = await categoryItems.count(); + expect(count).toBeGreaterThanOrEqual(8); +}); + +test('@stalker VOD — content list loads after selecting a category', async ({ + page, +}) => { + await addStalkerPortal(page); + await page.getByText('Mock Stalker Portal').click(); + await page.waitForURL(/stalker.*vod/); + + // Click the first non-"All" category + const categories = page.locator('mat-list-item'); + await categories.first().click(); + + // Content grid / list should appear with items + const contentItems = page.locator('[class*="content-item"], mat-card'); + await expect(contentItems.first()).toBeVisible({ timeout: 10_000 }); + const itemCount = await contentItems.count(); + expect(itemCount).toBeGreaterThan(0); +}); + +test('@stalker minimal scenario — correct item counts', async ({ page }) => { + await addStalkerPortal(page, { + name: 'Minimal Portal', + mac: MINIMAL_MAC, + }); + await page.getByText('Minimal Portal').click(); + await page.waitForURL(/stalker.*vod/); + + // Minimal scenario: 2 categories (+ "All" = 3 visible) + const categories = page.locator('mat-list-item'); + await expect(categories.first()).toBeVisible({ timeout: 10_000 }); + const count = await categories.count(); + // At least 2 real categories + expect(count).toBeGreaterThanOrEqual(2); +}); + +test('@stalker EPG data loads for ITV channel', async ({ page }) => { + await addStalkerPortal(page); + await page.getByText('Mock Stalker Portal').click(); + await page.waitForURL(/stalker.*vod/); + + // Navigate to ITV tab + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + // Wait for channels to appear + const channels = page.locator('mat-list-item, [class*="channel"]'); + await expect(channels.first()).toBeVisible({ timeout: 10_000 }); + + // Click a channel — EPG info should appear + await channels.first().click(); + // EPG view should become visible + await expect(page.locator('[class*="epg"], app-epg-view')).toBeVisible({ + timeout: 10_000, + }); +}); + +test('@stalker mock server reset clears cached state', async ({ request }) => { + // Generate data for default MAC + const before = await request.get( + `${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}` + ); + expect(before.ok()).toBeTruthy(); + + // Reset + const reset = await request.post(`${MOCK_SERVER}/reset`); + expect(reset.ok()).toBeTruthy(); + + // Data is regenerated identically (deterministic seed) + const after = await request.get( + `${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}` + ); + const beforeBody = (await before.json()).payload.js; + const afterBody = (await after.json()).payload.js; + expect(afterBody).toEqual(beforeBody); +}); + +test('@stalker create_link returns a playable stream URL', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/stalker?action=create_link&cmd=ffrt4://vod/20001/index.m3u8&macAddress=${DEFAULT_MAC}` + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + const streamUrl: string = body.payload.js.cmd; + expect(streamUrl).toMatch(/^https?:\/\//); + expect(streamUrl).toMatch(/\.m3u8$/); +}); + +test('@stalker series — seasons load for a series item', async ({ + request, +}) => { + // First fetch a series item to get its ID + const listResponse = await request.get( + `${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&category=3001&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml` + ); + const listBody = await listResponse.json(); + const firstItem = listBody.payload.js.data[0]; + expect(firstItem).toBeDefined(); + + // Fetch seasons for the first series item + const seasonsResponse = await request.get( + `${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&movie_id=${firstItem.id}&macAddress=${DEFAULT_MAC}` + ); + const seasonsBody = await seasonsResponse.json(); + const seasons = seasonsBody.payload.js; + expect(Array.isArray(seasons)).toBeTruthy(); + // Default scenario has 3 seasons per series + expect(seasons.length).toBe(3); + expect(seasons[0].name).toBe('Season 1'); + expect(Array.isArray(seasons[0].series)).toBeTruthy(); + // Default scenario has 8 episodes per season + expect(seasons[0].series.length).toBe(8); +}); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts new file mode 100644 index 000000000..10b89c9f7 --- /dev/null +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -0,0 +1,357 @@ +import { test, expect, Page } from '@playwright/test'; + +/** + * Xtream Codes E2E Tests + * + * These tests use the xtream-mock-server (apps/xtream-mock-server) to simulate + * a real Xtream Codes API portal. The mock server starts automatically alongside + * the Angular dev server when running e2e tests (see playwright.config.ts). + * + * Default scenario (user1:pass1) provides: + * - 8 categories per content type (live / VOD / series) + * - 40 items per category + * - 3 seasons × 8 episodes per series item + * + * Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream" + */ + +const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211'; +const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; + +/** Default scenario credentials */ +const DEFAULT_USERNAME = 'user1'; +const DEFAULT_PASSWORD = 'pass1'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Intercept calls from the Angular PWA proxy (/xtream) and redirect them + * to the mock server. This avoids any real backend requirement. + */ +async function interceptXtreamRequests(page: Page): Promise { + await page.route('**/localhost:3000/xtream**', async (route) => { + const originalUrl = new URL(route.request().url()); + const mockUrl = new URL(`${MOCK_SERVER}/xtream`); + originalUrl.searchParams.forEach((value, key) => { + mockUrl.searchParams.set(key, value); + }); + await route.continue({ url: mockUrl.toString() }); + }); +} + +/** + * Add an Xtream portal via the UI. + */ +async function addXtreamPortal( + page: Page, + options: { name?: string; username?: string; password?: string } = {} +): Promise { + const { + name = 'Mock Xtream Portal', + username = DEFAULT_USERNAME, + password = DEFAULT_PASSWORD, + } = options; + + await page.getByTestId('add-playlist').click(); + await page.getByText('Xtream').click(); + + await page.locator('#title').fill(name); + await page.locator('#serverUrl').fill(MOCK_SERVER); + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + + await page.getByRole('button', { name: 'Add' }).click(); + await page.waitForSelector('mat-dialog-container', { state: 'detached' }); +} + +/** + * Clear IndexedDB state between tests so each test starts fresh. + */ +async function clearStorage(page: Page): Promise { + await page.evaluate(async () => { + const dbs = await window.indexedDB.databases(); + await Promise.all( + dbs + .filter((db) => db.name != null) + .map((db) => window.indexedDB.deleteDatabase(db.name!)) + ); + }); +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +test.beforeEach(async ({ page, request }) => { + await request.post(`${MOCK_SERVER}/reset`); + + await page.goto('/'); + await clearStorage(page); + await page.reload(); + + await interceptXtreamRequests(page); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('@xtream health check — mock server is running', async ({ request }) => { + const response = await request.get(`${MOCK_SERVER}/health`); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.status).toBe('ok'); + expect(body.server).toBe('xtream-mock-server'); +}); + +test('@xtream get_account_info — active account returns correct fields', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}` + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + expect(body.user_info.username).toBe(DEFAULT_USERNAME); + expect(body.user_info.status).toBe('active'); + expect(body.server_info.url).toBeDefined(); + expect(Array.isArray(body.user_info.allowed_output_formats)).toBeTruthy(); +}); + +test('@xtream get_account_info — expired account returns Expired status', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=expired&password=expired` + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + // Expired scenario: still returns 'Active' but exp_date is in the past + // The app's portal-status.service.ts detects expiry via exp_date, not status string + expect(body.user_info.status).toBe('Active'); + const expDate = new Date(parseInt(body.user_info.exp_date) * 1000); + expect(expDate.getFullYear()).toBe(2020); +}); + +test('@xtream get_live_categories — returns expected category count', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories` + ); + expect(response.ok()).toBeTruthy(); + const categories = await response.json(); + expect(Array.isArray(categories)).toBeTruthy(); + // Default scenario: 8 live categories + expect(categories.length).toBe(8); + expect(categories[0]).toHaveProperty('category_id'); + expect(categories[0]).toHaveProperty('category_name'); +}); + +test('@xtream get_live_streams — streams have required fields', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams` + ); + expect(response.ok()).toBeTruthy(); + const streams = await response.json(); + expect(Array.isArray(streams)).toBeTruthy(); + expect(streams.length).toBeGreaterThan(0); + + const first = streams[0]; + expect(first.stream_type).toBe('live'); + expect(first.stream_id).toBeGreaterThan(0); + expect(first.epg_channel_id).toMatch(/channel-\d+\.mock/); +}); + +test('@xtream get_vod_streams — streams have rating and container_extension', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams` + ); + expect(response.ok()).toBeTruthy(); + const streams = await response.json(); + expect(Array.isArray(streams)).toBeTruthy(); + + const first = streams[0]; + expect(first.stream_type).toBe('movie'); + expect(typeof first.rating).toBe('number'); + expect(['mkv', 'mp4', 'avi']).toContain(first.container_extension); +}); + +test('@xtream get_series — series list has correct structure', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series` + ); + expect(response.ok()).toBeTruthy(); + const series = await response.json(); + expect(Array.isArray(series)).toBeTruthy(); + expect(series.length).toBeGreaterThan(0); + + const first = series[0]; + expect(first.series_id).toBeGreaterThan(0); + expect(typeof first.name).toBe('string'); + expect(typeof first.cover).toBe('string'); + expect(Array.isArray(first.backdrop_path)).toBeTruthy(); +}); + +test('@xtream get_series_info — seasons and episodes present', async ({ + request, +}) => { + // Get a series ID first + const listResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series` + ); + const series = await listResponse.json(); + const firstSeriesId = series[0].series_id; + + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_info&series_id=${firstSeriesId}` + ); + expect(response.ok()).toBeTruthy(); + const info = await response.json(); + + expect(Array.isArray(info.seasons)).toBeTruthy(); + // Default scenario: 3 seasons per series + expect(info.seasons.length).toBe(3); + expect(info.seasons[0].name).toBe('Season 1'); + + // Episodes are keyed by season number string + expect(info.episodes['1']).toBeDefined(); + expect(Array.isArray(info.episodes['1'])).toBeTruthy(); + // Default scenario: 8 episodes per season + expect(info.episodes['1'].length).toBe(8); + expect(info.episodes['1'][0].episode_num).toBe(1); +}); + +test('@xtream get_vod_info — returns full movie details', async ({ + request, +}) => { + // Get a VOD stream ID first + const listResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams` + ); + const streams = await listResponse.json(); + const firstVodId = streams[0].stream_id; + + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_info&vod_id=${firstVodId}` + ); + expect(response.ok()).toBeTruthy(); + const details = await response.json(); + + expect(details.info).toBeDefined(); + expect(details.movie_data).toBeDefined(); + expect(details.info.duration_secs).toBeGreaterThan(0); + expect(details.movie_data.stream_id).toBe(firstVodId); +}); + +test('@xtream get_short_epg — returns base64-encoded listings', async ({ + request, +}) => { + // Get a live stream ID first + const listResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams` + ); + const streams = await listResponse.json(); + const streamId = streams[0].stream_id; + + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_short_epg&stream_id=${streamId}` + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + expect(Array.isArray(body.epg_listings)).toBeTruthy(); + expect(body.epg_listings.length).toBeGreaterThan(0); + + const listing = body.epg_listings[0]; + // Verify base64 encoding — decode and check it's valid text + const decodedTitle = Buffer.from(listing.title, 'base64').toString('utf-8'); + expect(decodedTitle.length).toBeGreaterThan(0); + expect(listing.start_timestamp).toBeDefined(); + expect(listing.stop_timestamp).toBeDefined(); +}); + +test('@xtream reset — data regenerates identically after reset', async ({ + request, +}) => { + const url = `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`; + + const before = await (await request.get(url)).json(); + await request.post(`${MOCK_SERVER}/reset`); + const after = await (await request.get(url)).json(); + + // Data is deterministic: same after reset + expect(after).toEqual(before); +}); + +test('@xtream proxy endpoint — returns payload wrapper', async ({ + request, +}) => { + const response = await request.get( + `${MOCK_SERVER}/xtream?url=${MOCK_SERVER}&username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_categories` + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + expect(body.action).toBe('get_vod_categories'); + expect(Array.isArray(body.payload)).toBeTruthy(); + expect(body.payload.length).toBeGreaterThan(0); +}); + +test('@xtream category filter — get_live_streams filtered by category_id', async ({ + request, +}) => { + // Get categories to find a real ID + const catResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories` + ); + const categories = await catResponse.json(); + const categoryId = categories[0].category_id; + + // Fetch streams filtered by that category + const streamResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams&category_id=${categoryId}` + ); + const streams = await streamResponse.json(); + expect(Array.isArray(streams)).toBeTruthy(); + expect(streams.length).toBeGreaterThan(0); + + // All returned streams should belong to this category + for (const s of streams) { + expect(String(s.category_id)).toBe(String(categoryId)); + } +}); + +test('@xtream add portal and see it in the playlist list', async ({ page }) => { + await addXtreamPortal(page, { name: 'My Xtream Test Portal' }); + + await expect( + page.getByText('My Xtream Test Portal', { exact: false }) + ).toBeVisible(); +}); + +test('@xtream minimal scenario — reduced item count', async ({ request }) => { + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_categories` + ); + const categories = await response.json(); + // Minimal scenario: 2 categories + expect(categories.length).toBe(2); + + const streams = await ( + await request.get( + `${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_streams` + ) + ).json(); + // 2 categories × 5 items = 10 + expect(streams.length).toBe(10); +}); diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md new file mode 100644 index 000000000..a2b59fee4 --- /dev/null +++ b/docs/architecture/stalker-mock-server.md @@ -0,0 +1,250 @@ +# Stalker Mock Server Architecture + +This document describes the design decisions, data flow, and extension points of the `stalker-mock-server` development tool. + +## Related Docs + +- [Stalker Portal Architecture](./stalker-portal.md) +- [Stalker EPG Architecture](./stalker-epg.md) + +## Purpose + +The mock server enables: + +1. **Local development** without access to a real Stalker portal +2. **Playwright E2E testing** with predictable, deterministic data +3. **Scenario-based testing** via predefined MAC addresses that map to specific data shapes + +## Key Design Decisions + +### Seeded Determinism (Not Per-Request Random) + +Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead: + +- Data is generated **once per MAC address** on first request, then cached in memory. +- `@faker-js/faker` is seeded with a numeric value derived from the MAC address before generation. +- Same MAC → identical data on every server restart. +- Restart the server to reshuffle all data. + +### MAC Address as Identity + +Stalker portals use MAC address as the primary credential. The mock server follows the same model: + +- Each unique MAC gets its own isolated dataset. +- Predefined MACs map to specific `ScenarioConfig` shapes (see `src/app/scenarios.ts`). +- Unknown MACs use the sum of their byte values as a seed, producing unique but deterministic data. + +### In-Memory Only + +No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs. + +## Data Generation Pipeline + +``` +faker.seed(macToNumber(mac)) +│ +├── generateCategories('itv', N) → itvCategories[] +│ └── generateChannels() → channels Map +│ └── generateEpg() → epg Map +│ +├── generateCategories('vod', N) → vodCategories[] +│ └── generateVodItems() → vod Map +│ ├── normal VOD items +│ ├── is_series=1 items (fraction, Ministra flow) +│ └── embedded series[] items (fraction) +│ +└── generateCategories('series', N) → seriesCategories[] + └── generateSeriesItems() → series Map + └── generateSeasons() → seasons Map +``` + +## Response Shapes + +All responses follow the Stalker `portal.php` envelope: + +```json +{ "js": } +``` + +### `get_categories` + +```json +{ + "js": [ + { "id": "2001", "title": "Action", "alias": "action" }, + ... + ] +} +``` + +### `get_ordered_list` (content) + +```json +{ + "js": { + "data": [ + { + "id": "20001", + "name": "...", + "cmd": "ffrt4://vod/20001/index.m3u8", + "screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200", + "cover": "https://picsum.photos/seed/vod-cover-20001/300/450", + "description": "...", + "actors": "...", + "director": "...", + "year": "2019", + "rating_imdb": "7.3", + "category_id": "2001", + "is_series": 0, + "has_files": 1 + } + ], + "total_items": 40, + "max_page_items": 14, + "cur_page": 1, + "total_pages": 3 + } +} +``` + +### `get_ordered_list` (seasons — when `movie_id` is present) + +```json +{ + "js": [ + { + "id": "30001-s1", + "name": "Season 1", + "cmd": "ffrt4://series/30001/season/1", + "series": ["30001-s1-e1", "30001-s1-e2", ...], + "screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200", + "director": "...", + "actors": "...", + "year": "2021", + "rating_imdb": "8.1" + } + ] +} +``` + +### `create_link` + +```json +{ + "js": { + "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", + "streamer_id": "1", + "load": "", + "error": "" + } +} +``` + +The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the `cmd` field's character sum, so the same item always returns the same stream. + +### `get_short_epg` + +```json +{ + "js": { + "data": [ + { + "id": "1", + "name": "Channel Name: Program Title", + "start": "2026-02-21T10:00:00.000Z", + "stop": "2026-02-21T10:30:00.000Z", + "start_timestamp": 1740128400, + "stop_timestamp": 1740130200, + "descr": "...", + "category": "News" + } + ] + } +} +``` + +EPG programs are generated as 30-minute slots spanning 3 hours past to 3 hours future relative to the time of generation. + +## Scenarios + +Scenarios are defined in `src/app/scenarios.ts`. Each scenario is a `ScenarioConfig`: + +```typescript +interface ScenarioConfig { + name: string; + description: string; + seed: number; + categoryCount: { itv: number; vod: number; series: number }; + itemsPerCategory: number; + seasonsPerSeries: number; + episodesPerSeason: number; + isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1 + embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[] +} +``` + +### Adding a New Scenario + +1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`. +2. Use any unique MAC address as the key (lowercase, colon-separated). +3. Document it in `README.md` and this file. + +## Favorites + +Favorites are stored in a `Map>` in `src/app/data-store.ts`. They persist for the lifetime of the server process and are shared across all requests for the same MAC. + +Call `POST /reset` to clear all favorites (and regenerated data) between test runs. + +## Playwright Integration + +`apps/web-e2e/playwright.config.ts` registers the mock server as a second `webServer` entry: + +```typescript +webServer: [ + { + command: 'pnpm nx run web:serve', + url: 'http://localhost:4200', + reuseExistingServer: !process.env['CI'], + }, + { + command: 'pnpm nx run stalker-mock-server:serve', + url: 'http://localhost:3210/health', + reuseExistingServer: !process.env['CI'], + }, +] +``` + +Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance. + +### Test Isolation + +Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other. + +The generated content (categories, items) is **not** cleared on reset — it's deterministic and doesn't need to be. Only in-memory favorites are cleared. + +### Recommended Test Structure + +```typescript +import { test, expect } from '@playwright/test'; + +const MOCK_URL = 'http://localhost:3210/portal.php'; +const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario + +test.beforeEach(async ({ request }) => { + await request.post('http://localhost:3210/reset'); +}); + +test('browse VOD categories', async ({ page }) => { + // Add portal via UI or programmatically via IndexedDB + // Navigate to portal + // Assert category list matches expected count (8 for default scenario) +}); +``` + +## Extension Points + +- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`. +- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`. +- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows. +- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store. +- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states. diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md new file mode 100644 index 000000000..19bade2ff --- /dev/null +++ b/docs/architecture/xtream-mock-server.md @@ -0,0 +1,237 @@ +# Xtream Mock Server — Architecture + +## Purpose + +`apps/xtream-mock-server` is a self-contained Express server that emulates the +Xtream Codes API protocol. It is used for: + +- **Local development** — run a full portal without a real Xtream subscription +- **E2E testing** — Playwright spins it up alongside the Angular dev server + +--- + +## Data Pipeline + +``` +credentials (username + password) + │ + ▼ +credentialsToSeed(u, p) ←── deterministic polynomial hash + │ + ▼ +faker.seed(seed) ←── all faker calls use same seed per credentials + │ + ▼ + generateCategories() ←── live / vod / series categories + │ + generateLiveStreams() ←── live TV stream list + generateVodStreams() ←── VOD movie list + generateSeriesItems() ←── series list + generateSeriesInfo() ←── nested seasons + episodes (pre-populated) + │ + ▼ + PortalData (cached) ←── Map<"username:password", PortalData> +``` + +Re-requesting with the same credentials returns the exact same data until +`POST /reset` clears all caches. + +--- + +## File Structure + +``` +apps/xtream-mock-server/ +├── project.json ← Nx targets: serve (port 3211), serve-with-watch +├── tsconfig.json +└── src/ + ├── main.ts ← Express app bootstrap, all routes wired up + └── app/ + ├── scenarios.ts ← Credential → ScenarioConfig mapping + ├── data-store.ts ← Lazy cache, per-credentials generation + ├── generators/ + │ ├── categories.generator.ts + │ ├── live.generator.ts ← Live streams + EPG listings + │ ├── vod.generator.ts ← VOD streams + VodDetails + │ └── series.generator.ts ← Series items + SeriesInfo + ├── handlers/ + │ ├── get-account-info.handler.ts + │ ├── get-categories.handler.ts ← live/vod/series categories + │ ├── get-streams.handler.ts ← live/vod/series stream lists + │ ├── get-vod-info.handler.ts + │ ├── get-series-info.handler.ts + │ └── get-short-epg.handler.ts + └── routes/ + └── dispatch.ts ← Action → handler routing +``` + +--- + +## API Protocol + +### Direct Xtream endpoint + +``` +GET /player_api.php?action=&username=&password=

[&...] +``` + +Response: raw JSON (no envelope). Matches the real Xtream Codes API format. + +### PWA proxy endpoint + +IPTVnator's PWA routes Xtream calls through: +``` +GET /xtream?url=&action=&username=&password=

+``` +Response: `{ payload: , action: }` + +This mirrors the backend proxy in `apps/electron-backend` so the same +Angular service code works in both environments. + +### Stream stub endpoints + +``` +GET /live///.m3u8 +GET /movie///. +GET /series///. +``` + +All redirect to a publicly available HLS test stream +(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). + +--- + +## Key Response Shapes + +### `get_account_info` + +```json +{ + "user_info": { + "username": "user1", "password": "pass1", + "status": "active", "exp_date": "4102444799", + "is_trial": "0", "active_cons": "1", "max_connections": "2", + "allowed_output_formats": ["m3u8", "ts", "rtmp"] + }, + "server_info": { + "url": "http://localhost:3211", "port": "3211", + "timezone": "UTC", "timestamp_now": 1234567890 + } +} +``` + +### `get_live_categories` / `get_vod_categories` / `get_series_categories` + +```json +[ + { "category_id": "101", "category_name": "News", "parent_id": 0 }, + ... +] +``` + +### `get_live_streams` (sample item) + +```json +{ + "num": 1, "name": "Acme Corp TV", + "stream_type": "live", "stream_id": 10000, + "stream_icon": "https://picsum.photos/seed/live-10000/100/100", + "epg_channel_id": "channel-10000.mock", + "category_id": "101", "tv_archive": 0, "tv_archive_duration": 0 +} +``` + +### `get_short_epg` (sample item) + +```json +{ + "epg_listings": [ + { + "id": "1000000", "epg_id": "channel-10000.mock", + "title": "base64encodedTitle", + "description": "base64encodedDescription", + "start": "2024-01-01 12:00:00", "end": "2024-01-01 12:30:00", + "start_timestamp": "1704110400", "stop_timestamp": "1704112200" + } + ] +} +``` + +Note: `title` and `description` are **base64-encoded**, matching the real Xtream API. + +### `get_series_info` (structure) + +```json +{ + "seasons": [ + { + "id": 3000100, "name": "Season 1", "season_number": 1, + "episode_count": 8, "air_date": "2022-05-14", + "cover": "https://picsum.photos/seed/season-30001-1/300/450" + } + ], + "info": { "name": "...", "cover": "...", "plot": "...", "cast": "...", ... }, + "episodes": { + "1": [ + { + "id": "80001", "episode_num": 1, "title": "Series Name S1E1", + "season": 1, "container_extension": "mkv", + "info": { "duration_secs": 2400, "rating": 8.3, ... } + } + ] + } +} +``` + +--- + +## Scenarios + +| Key (`username:password`) | Seed | Categories | Items/cat | Account status | +|---------------------------|------|------------|-----------|----------------| +| `user1:pass1` | 1001 | 8 each | 40 | active | +| `large:large` | 9999 | 20 each | 200 | active | +| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | +| `minimal:minimal` | 3003 | 2 each | 5 | active | +| `expired:expired` | 4004 | 4 each | 10 | Expired | +| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | +| `` | hash | 6 each | 30 | active | + +--- + +## Playwright Integration + +### Configuration (`apps/web-e2e/playwright.config.ts`) + +The mock server is listed as a third `webServer` entry: + +```typescript +{ + command: 'pnpm nx run xtream-mock-server:serve', + url: 'http://localhost:3211/health', + reuseExistingServer: !process.env['CI'], + cwd: workspaceRoot, +} +``` + +### Request Interception + +The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these: + +```typescript +await page.route('**/localhost:3000/xtream**', async (route) => { + const originalUrl = new URL(route.request().url()); + const mockUrl = new URL('http://localhost:3211/xtream'); + originalUrl.searchParams.forEach((v, k) => mockUrl.searchParams.set(k, v)); + await route.continue({ url: mockUrl.toString() }); +}); +``` + +--- + +## Extension Points + +- **Add new actions**: Implement a handler function and add a `case` in `routes/dispatch.ts` +- **Add new scenarios**: Add an entry to `SCENARIOS` in `scenarios.ts` +- **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario +- **Custom stream URLs**: Edit the HLS stub redirect in `main.ts`