mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: implement full and short EPG handlers, enhance data generation and scenarios for EPG support
This commit is contained in:
1 parent
2659d52cb9
commit
0a8a9c9637
8 files changed
+390
-42
No files matched your search
@@ -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+
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user