feat: implement full and short EPG handlers, enhance data generation and scenarios for EPG support

This commit is contained in:
4gray committed 2026-04-05 19:04:59 +02:00
1 parent 2659d52cb9
commit 0a8a9c9637
8 files changed
+390 -42

No files matched your search

+11
View File
@@ -26,6 +26,7 @@ pnpm nx run xtream-mock-server:serve-with-watch
| `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 |
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
@@ -51,6 +52,8 @@ Any other credential pair is auto-generated using a hash of `username:password`
| `get_vod_info?vod_id=<id>` | Full movie details |
| `get_series_info?series_id=<id>` | Full series info (seasons + episodes) |
| `get_short_epg?stream_id=<id>[&limit=N]` | EPG listings for a live channel |
| `get_simple_data_table?stream_id=<id>` | Full per-channel EPG schedule |
| `get_simple_date_table?stream_id=<id>` | Legacy typo alias for full per-channel EPG schedule |
### PWA CORS Proxy Endpoint
@@ -95,6 +98,9 @@ curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=
# EPG for stream (direct)
curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_short_epg&stream_id=10000"
# Full EPG schedule (direct)
curl "http://localhost:3211/player_api.php?username=epg&password=epg&action=get_simple_data_table&stream_id=10000"
# Via PWA proxy
curl "http://localhost:3211/xtream?url=http://localhost:3211&username=user1&password=pass1&action=get_live_categories"
```
@@ -112,6 +118,9 @@ nx e2e web-e2e --grep "@xtream"
Test files: `apps/web-e2e/src/xtream.e2e.ts`
Electron Xtream EPG coverage lives in
`apps/electron-backend-e2e/src/xtream-epg.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.
@@ -123,6 +132,8 @@ application code.
- **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)
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
- **Timestamp precedence coverage**: The `epg:epg` scenario intentionally shifts raw `start` / `end` strings away from `start_timestamp` / `stop_timestamp` so UI tests can prove timestamps drive rendering
- **Stream IDs**: Live 10,000+, VOD 20,000+, Series 30,000+
- **Category IDs**: Live 101+, VOD 201+, Series 301+
+209 -10
View File
@@ -1,6 +1,13 @@
import { faker } from '@faker-js/faker';
import { generateCategories } from './generators/categories.generator.js';
import { generateLiveStreams, generateEpgListings, RawLiveStream, RawEpgListing } from './generators/live.generator.js';
import {
buildEpgListing,
buildLiveStream,
generateEpgListings,
generateLiveStreams,
RawEpgListing,
RawLiveStream,
} 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';
@@ -14,12 +21,12 @@ export interface PortalData {
liveStreams: RawLiveStream[];
vodStreams: RawVodStream[];
seriesItems: RawSeriesItem[];
epgListingsByStreamId: Map<number, RawEpgListing[]>;
}
const portalCache = new Map<string, PortalData>();
const vodDetailsCache = new Map<number, RawVodDetails>();
const seriesInfoCache = new Map<number, RawSeriesInfo>();
const epgCache = new Map<number, RawEpgListing[]>();
function generatePortalData(username: string, password: string): PortalData {
const scenario = getScenario(username, password);
@@ -27,14 +34,24 @@ function generatePortalData(username: string, password: string): PortalData {
const { categoryCount, itemsPerCategory, seasonsPerSeries, episodesPerSeason } = scenario;
const liveCategories = generateCategories('live', categoryCount.live);
let liveCategories = generateCategories('live', categoryCount.live);
const vodCategories = generateCategories('vod', categoryCount.vod);
const seriesCategories = generateCategories('series', categoryCount.series);
const liveStreams = generateLiveStreams(liveCategories, itemsPerCategory);
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
let liveStreams = generateLiveStreams(liveCategories, itemsPerCategory);
const vodStreams = generateVodStreams(vodCategories, itemsPerCategory);
const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory);
if (scenario.epgFixture === 'timezone-focus') {
const timezoneFixture = buildTimezoneFixture();
liveCategories = timezoneFixture.liveCategories;
liveStreams = timezoneFixture.liveStreams;
timezoneFixture.epgListingsByStreamId.forEach((listings, streamId) => {
epgListingsByStreamId.set(streamId, listings);
});
}
// Pre-populate series info cache
for (const s of seriesItems) {
if (!seriesInfoCache.has(s.series_id)) {
@@ -42,7 +59,16 @@ function generatePortalData(username: string, password: string): PortalData {
}
}
return { scenario, liveCategories, vodCategories, seriesCategories, liveStreams, vodStreams, seriesItems };
return {
scenario,
liveCategories,
vodCategories,
seriesCategories,
liveStreams,
vodStreams,
seriesItems,
epgListingsByStreamId,
};
}
export function getPortalData(username: string, password: string): PortalData {
@@ -74,16 +100,189 @@ export function getSeriesInfo(username: string, password: string, seriesId: numb
return seriesInfoCache.get(seriesId) ?? null;
}
export function getEpgListings(streamId: number): RawEpgListing[] {
if (!epgCache.has(streamId)) {
epgCache.set(streamId, generateEpgListings(streamId));
export function getEpgListings(
username: string,
password: string,
streamId: number
): RawEpgListing[] {
const data = getPortalData(username, password);
if (!data.epgListingsByStreamId.has(streamId)) {
data.epgListingsByStreamId.set(streamId, generateEpgListings(streamId));
}
return epgCache.get(streamId)!;
return data.epgListingsByStreamId.get(streamId)!;
}
export function getShortEpgListings(
username: string,
password: string,
streamId: number,
limit = 12
): RawEpgListing[] {
const listings = getEpgListings(username, password, streamId);
const now = Math.floor(Date.now() / 1000);
const currentIndex = listings.findIndex((listing) => {
const stopTimestamp = Number.parseInt(listing.stop_timestamp, 10);
return Number.isFinite(stopTimestamp) && stopTimestamp >= now;
});
const startIndex = currentIndex >= 0 ? currentIndex : 0;
return listings.slice(startIndex, startIndex + Math.max(1, limit));
}
export function resetAll(): void {
portalCache.clear();
vodDetailsCache.clear();
seriesInfoCache.clear();
epgCache.clear();
}
function buildTimezoneFixture(): Pick<
PortalData,
'liveCategories' | 'liveStreams' | 'epgListingsByStreamId'
> {
const primaryCategoryId = '101';
const secondaryCategoryId = '102';
const liveCategories: RawCategory[] = [
{
category_id: primaryCategoryId,
category_name: 'EPG Focus',
parent_id: 0,
},
{
category_id: secondaryCategoryId,
category_name: 'Overflow Live',
parent_id: 0,
},
];
const liveStreams: RawLiveStream[] = [
buildLiveStream({
num: 1,
name: 'Timezone News',
streamId: 10_000,
categoryId: primaryCategoryId,
tvArchive: 1,
tvArchiveDuration: 3,
}),
buildLiveStream({
num: 2,
name: 'Night Sports',
streamId: 10_001,
categoryId: primaryCategoryId,
}),
buildLiveStream({
num: 3,
name: 'Archive Cinema',
streamId: 10_002,
categoryId: primaryCategoryId,
}),
buildLiveStream({
num: 4,
name: 'Overflow Live 1',
streamId: 10_003,
categoryId: secondaryCategoryId,
}),
buildLiveStream({
num: 5,
name: 'Overflow Live 2',
streamId: 10_004,
categoryId: secondaryCategoryId,
}),
];
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000));
return {
liveCategories,
liveStreams,
epgListingsByStreamId,
};
}
function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] {
const now = Math.floor(Date.now() / 1000);
const roundedNow = now - (now % (15 * 60));
const nextUtcMidnight = getFutureUtcMidnight(now);
const rawStringOffsetSeconds = -2 * 60 * 60;
const channelId = `channel-${streamId}.mock`;
const listings = [
{
id: `${streamId}-past`,
title: 'Earlier Bulletin',
description: 'Past schedule item used to anchor current-program detection.',
startTimestamp: roundedNow - 75 * 60,
stopTimestamp: roundedNow - 15 * 60,
},
{
id: `${streamId}-current`,
title: 'Global Headlines',
description: 'Current program for list-row and detail EPG assertions.',
startTimestamp: roundedNow - 15 * 60,
stopTimestamp: roundedNow + 15 * 60,
},
{
id: `${streamId}-next`,
title: 'Market Wrap',
description: 'Immediate next program for short-EPG preview assertions.',
startTimestamp: roundedNow + 15 * 60,
stopTimestamp: roundedNow + 45 * 60,
},
{
id: `${streamId}-later`,
title: 'Overnight Update',
description: 'Later same-day program for selected-channel list coverage.',
startTimestamp: roundedNow + 45 * 60,
stopTimestamp: roundedNow + 75 * 60,
},
{
id: `${streamId}-boundary-1`,
title: 'Late Edition',
description: 'Boundary program spanning UTC midnight for timezone edge cases.',
startTimestamp: nextUtcMidnight - 30 * 60,
stopTimestamp: nextUtcMidnight + 30 * 60,
},
{
id: `${streamId}-boundary-2`,
title: 'After Midnight',
description: 'Post-midnight program used for next-day navigation checks.',
startTimestamp: nextUtcMidnight + 30 * 60,
stopTimestamp: nextUtcMidnight + 90 * 60,
},
];
return listings
.sort((left, right) => left.startTimestamp - right.startTimestamp)
.map((listing) =>
buildEpgListing({
id: listing.id,
epgId: channelId,
title: listing.title,
description: listing.description,
startTimestamp: listing.startTimestamp,
stopTimestamp: listing.stopTimestamp,
channelId,
rawStringOffsetSeconds,
})
);
}
function getFutureUtcMidnight(nowSeconds: number): number {
const nowDate = new Date(nowSeconds * 1000);
let midnightSeconds = Math.floor(
Date.UTC(
nowDate.getUTCFullYear(),
nowDate.getUTCMonth(),
nowDate.getUTCDate() + 1,
0,
0,
0
) / 1000
);
while (midnightSeconds - nowSeconds < 3 * 60 * 60) {
midnightSeconds += 24 * 60 * 60;
}
return midnightSeconds;
}
@@ -32,7 +32,32 @@ export interface RawEpgListing {
const LIVE_STREAM_ID_BASE = 10_000;
const EPG_SLOT_MINUTES = 30;
const EPG_SLOTS = 12;
const EPG_PREVIOUS_DAYS = 2;
const EPG_NEXT_DAYS = 3;
const EPG_SLOTS =
((EPG_PREVIOUS_DAYS + EPG_NEXT_DAYS) * 24 * 60) / EPG_SLOT_MINUTES;
type BuildLiveStreamOptions = {
num: number;
name: string;
streamId: number;
categoryId: string;
epgChannelId?: string;
tvArchive?: number;
tvArchiveDuration?: number;
};
type BuildEpgListingOptions = {
id: string;
epgId: string;
title: string;
description: string;
startTimestamp: number;
stopTimestamp: number;
channelId: string;
rawStringOffsetSeconds?: number;
lang?: string;
};
export function generateLiveStreams(
categories: RawCategory[],
@@ -44,21 +69,14 @@ export function generateLiveStreams(
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),
});
streams.push(
buildLiveStream({
num: num++,
name: `${faker.company.name()} TV`,
streamId,
categoryId: cat.category_id,
})
);
}
}
return streams;
@@ -68,7 +86,7 @@ export function generateLiveStreams(
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;
const startOffset = -EPG_PREVIOUS_DAYS * 24 * 60 * 60;
for (let i = 0; i < EPG_SLOTS; i++) {
const startTs = nowSec + startOffset + i * EPG_SLOT_MINUTES * 60;
@@ -76,18 +94,85 @@ export function generateEpgListings(streamId: number): RawEpgListing[] {
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),
});
listings.push(
buildEpgListing({
id: String(streamId * 100 + i),
epgId: `channel-${streamId}.mock`,
title,
description,
startTimestamp: startTs,
stopTimestamp: stopTs,
channelId: `channel-${streamId}.mock`,
})
);
}
return listings;
}
export function buildLiveStream(
options: BuildLiveStreamOptions
): RawLiveStream {
const {
num,
name,
streamId,
categoryId,
epgChannelId = `channel-${streamId}.mock`,
tvArchive = 1,
tvArchiveDuration = 3,
} = options;
return {
num,
name,
stream_type: 'live',
stream_id: streamId,
stream_icon: `https://picsum.photos/seed/live-${streamId}/100/100`,
epg_channel_id: epgChannelId,
added: String(Math.floor(Date.now() / 1000 - Math.random() * 1e7)),
category_id: categoryId,
custom_sid: '',
direct_source: '',
tv_archive: tvArchive,
tv_archive_duration: tvArchiveDuration,
rating_imdb: (Math.random() * 3 + 6).toFixed(1),
};
}
export function buildEpgListing(
options: BuildEpgListingOptions
): RawEpgListing {
const {
id,
epgId,
title,
description,
startTimestamp,
stopTimestamp,
channelId,
rawStringOffsetSeconds = 0,
lang = 'en',
} = options;
return {
id,
epg_id: epgId,
title: Buffer.from(title).toString('base64'),
lang,
start: formatXtreamDateTime(
startTimestamp + rawStringOffsetSeconds
),
end: formatXtreamDateTime(stopTimestamp + rawStringOffsetSeconds),
description: Buffer.from(description).toString('base64'),
channel_id: channelId,
start_timestamp: String(startTimestamp),
stop_timestamp: String(stopTimestamp),
};
}
function formatXtreamDateTime(timestampSeconds: number): string {
return new Date(timestampSeconds * 1000)
.toISOString()
.replace('T', ' ')
.replace('.000Z', '');
}
@@ -0,0 +1,17 @@
import { Request, Response } from 'express';
import { getEpgListings } from '../data-store.js';
export function handleGetFullEpg(req: Request, res: Response): void {
const {
username = '',
password = '',
stream_id,
} = req.query as Record<string, string>;
if (!stream_id) {
res.json({ epg_listings: [] });
return;
}
const id = parseInt(stream_id, 10);
res.json({ epg_listings: getEpgListings(username, password, id) });
}
@@ -1,14 +1,19 @@
import { Request, Response } from 'express';
import { getEpgListings } from '../data-store.js';
import { getShortEpgListings } from '../data-store.js';
export function handleGetShortEpg(req: Request, res: Response): void {
const { stream_id, limit } = req.query as Record<string, string>;
const {
username = '',
password = '',
stream_id,
limit,
} = req.query as Record<string, string>;
if (!stream_id) {
res.json({ epg_listings: [] });
return;
}
const id = parseInt(stream_id, 10);
const limitNum = limit ? Math.min(parseInt(limit, 10), 50) : 12;
const listings = getEpgListings(id).slice(0, limitNum);
const listings = getShortEpgListings(username, password, id, limitNum);
res.json({ epg_listings: listings });
}
@@ -4,6 +4,7 @@ import { handleGetLiveCategories, handleGetVodCategories, handleGetSeriesCategor
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 { handleGetFullEpg } from '../handlers/get-full-epg.handler.js';
import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js';
export function dispatchAction(req: Request, res: Response): void {
@@ -41,6 +42,10 @@ export function dispatchAction(req: Request, res: Response): void {
case 'get_short_epg':
handleGetShortEpg(req, res);
break;
case 'get_simple_data_table':
case 'get_simple_date_table':
handleGetFullEpg(req, res);
break;
default:
res.status(400).json({ error: `Unknown action: ${action}` });
}
@@ -11,6 +11,8 @@ export interface ScenarioConfig {
accountStatus: 'Active' | 'Disabled';
/** ISO date string for subscription expiry */
expiryDate: string;
/** Optional deterministic EPG fixture profile for scenario-specific tests. */
epgFixture?: 'timezone-focus';
}
/**
@@ -75,6 +77,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
accountStatus: 'Active',
expiryDate: '2099-12-31',
},
'epg:epg': {
name: 'epg-fixture',
description:
'Deterministic Xtream EPG fixture — stable live channels and timezone-focused guide data',
seed: 6006,
categoryCount: { live: 2, vod: 1, series: 1 },
itemsPerCategory: 3,
seasonsPerSeries: 1,
episodesPerSeason: 3,
accountStatus: 'Active',
expiryDate: '2099-12-31',
epgFixture: 'timezone-focus',
},
'expired:expired': {
name: 'expired',
description: 'Expired account — tests subscription expiry UI flow',
+11
View File
@@ -45,6 +45,17 @@ app.get('/series/:username/:password/:streamId.:ext', (_req, res) => {
res.redirect(HLS_STUB);
});
app.all(
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
(_req, res) => {
res.redirect(HLS_STUB);
}
);
app.all('/streaming/timeshift.php', (_req, res) => {
res.redirect(HLS_STUB);
});
// ─── PWA CORS proxy endpoint ────────────────────────────────────────────────────
// IPTVnator PWA routes Xtream calls through:
// GET /xtream?url=<serverUrl>&action=<action>&username=X&password=Y