diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index 6c1979484..0b29d9101 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -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=` | Full movie details | | `get_series_info?series_id=` | Full series info (seasons + episodes) | | `get_short_epg?stream_id=[&limit=N]` | EPG listings for a live channel | +| `get_simple_data_table?stream_id=` | Full per-channel EPG schedule | +| `get_simple_date_table?stream_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+ diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index eb1dbb120..10af189c0 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -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; } const portalCache = new Map(); const vodDetailsCache = new Map(); const seriesInfoCache = new Map(); -const epgCache = new Map(); function generatePortalData(username: string, password: string): PortalData { const scenario = getScenario(username, password); @@ -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(); + 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(); + 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; } diff --git a/apps/xtream-mock-server/src/app/generators/live.generator.ts b/apps/xtream-mock-server/src/app/generators/live.generator.ts index 9d19e76f2..e7a6b67ab 100644 --- a/apps/xtream-mock-server/src/app/generators/live.generator.ts +++ b/apps/xtream-mock-server/src/app/generators/live.generator.ts @@ -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', ''); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-full-epg.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-full-epg.handler.ts new file mode 100644 index 000000000..d57fa0502 --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-full-epg.handler.ts @@ -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; + if (!stream_id) { + res.json({ epg_listings: [] }); + return; + } + + const id = parseInt(stream_id, 10); + res.json({ epg_listings: getEpgListings(username, password, id) }); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts index 95b143ce5..cb53b6d53 100644 --- a/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts +++ b/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -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; + const { + username = '', + password = '', + stream_id, + limit, + } = req.query as Record; if (!stream_id) { res.json({ epg_listings: [] }); return; } const id = parseInt(stream_id, 10); const limitNum = limit ? Math.min(parseInt(limit, 10), 50) : 12; - const listings = getEpgListings(id).slice(0, limitNum); + const listings = getShortEpgListings(username, password, id, limitNum); res.json({ epg_listings: listings }); } diff --git a/apps/xtream-mock-server/src/app/routes/dispatch.ts b/apps/xtream-mock-server/src/app/routes/dispatch.ts index 447cbb1cb..992401874 100644 --- a/apps/xtream-mock-server/src/app/routes/dispatch.ts +++ b/apps/xtream-mock-server/src/app/routes/dispatch.ts @@ -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}` }); } diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 1032872bc..c63762098 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -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 = { 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', diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 2994fb6d0..53fd0fb53 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -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=&action=&username=X&password=Y