import { APIRequestContext } from '@playwright/test'; import { defaultStalkerMacAddress, expect, stalkerMockServer, xtreamMockServer, } from './electron-test-fixtures'; export type XtreamCategory = { category_id: string; category_name: string; }; export type XtreamLiveStream = { added?: string; category_id?: string; epg_channel_id?: string; name?: string; stream_id?: number | string; stream_type?: string; tv_archive?: number | string; tv_archive_duration?: number | string; }; export type XtreamVodStream = { added?: string; category_id?: string; name?: string; stream_id?: number | string; }; export type XtreamSeriesItem = { added?: string; category_id?: number | string; last_modified?: string; name?: string; releaseDate?: string; series_id?: number | string; }; export type XtreamCategoryFixture = { categoryId: string; categoryName: string; items: T[]; }; export type XtreamRawEpgListing = { channel_id?: string; description?: string; end?: string; epg_id?: string; id?: string; lang?: string; start?: string; start_timestamp?: string; stop_timestamp?: string; title?: string; }; export type XtreamNormalizedEpgListing = { channelId: string; description: string; end: string; id: string; rawDescription: string; rawEnd: string; rawStart: string; rawTitle: string; start: string; startTimestamp: number; stopTimestamp: number; title: string; }; export type XtreamEpgFixture = { categoryId: string; categoryName: string; fullEpg: XtreamNormalizedEpgListing[]; rawFullEpg: XtreamRawEpgListing[]; rawShortEpg: XtreamRawEpgListing[]; shortEpg: XtreamNormalizedEpgListing[]; stream: XtreamLiveStream; }; type StalkerCategory = { id: string; title: string; }; type StalkerProxyPayload = { payload: { js: T; }; }; type StalkerOrderedList = { data: T[]; }; type StalkerSeasonLike = { id?: number | string; }; export type StalkerContentItem = { id?: number | string; name?: string; o_name?: string; }; export type StalkerCategoryFixture = { categoryId: string; categoryName: string; items: StalkerContentItem[]; }; export async function fetchXtreamLiveFixture( request: APIRequestContext, credentials = { username: 'minimal', password: 'minimal' } ): Promise> { return fetchXtreamFixture(request, { categoriesAction: 'get_live_categories', itemsAction: 'get_live_streams', ...credentials, }); } export async function fetchXtreamVodFixture( request: APIRequestContext, credentials = { username: 'minimal', password: 'minimal' } ): Promise> { return fetchXtreamFixture(request, { categoriesAction: 'get_vod_categories', itemsAction: 'get_vod_streams', ...credentials, }); } export async function fetchXtreamSeriesFixture( request: APIRequestContext, credentials = { username: 'minimal', password: 'minimal' } ): Promise> { return fetchXtreamFixture(request, { categoriesAction: 'get_series_categories', itemsAction: 'get_series', ...credentials, }); } export async function fetchXtreamEpgFixture( request: APIRequestContext, credentials = { username: 'epg', password: 'epg' } ): Promise { const liveFixture = await fetchXtreamLiveFixture(request, credentials); const stream = liveFixture.items[0]; if (!stream) { throw new Error('Xtream EPG fixture returned no live stream.'); } const streamId = Number(stream.stream_id); if (!Number.isFinite(streamId) || streamId <= 0) { throw new Error('Xtream EPG fixture returned an invalid stream id.'); } const rawShortResponse = await fetchJson<{ epg_listings?: XtreamRawEpgListing[]; }>( request, `${xtreamMockServer}/player_api.php?action=get_short_epg&username=${encodeURIComponent( credentials.username )}&password=${encodeURIComponent( credentials.password )}&stream_id=${streamId}&limit=3` ); const rawFullResponse = await fetchJson<{ epg_listings?: XtreamRawEpgListing[]; }>( request, `${xtreamMockServer}/player_api.php?action=get_simple_data_table&username=${encodeURIComponent( credentials.username )}&password=${encodeURIComponent( credentials.password )}&stream_id=${streamId}` ); const rawShortEpg = rawShortResponse.epg_listings ?? []; const rawFullEpg = rawFullResponse.epg_listings ?? []; if (rawShortEpg.length === 0 || rawFullEpg.length === 0) { throw new Error('Xtream EPG fixture returned no schedule data.'); } return { categoryId: liveFixture.categoryId, categoryName: liveFixture.categoryName, stream, rawShortEpg, rawFullEpg, shortEpg: normalizeXtreamEpgListings(rawShortEpg), fullEpg: normalizeXtreamEpgListings(rawFullEpg), }; } export async function fetchStalkerCategoryFixture( request: APIRequestContext, type: 'itv' | 'series' | 'vod' ): Promise { const categoriesResponse = await fetchJson< StalkerProxyPayload >( request, buildStalkerProxyUrl('get_categories', { type, }) ); const categories = categoriesResponse.payload.js ?? []; for (const category of categories) { const itemsResponse = await fetchJson< StalkerProxyPayload> >( request, buildStalkerProxyUrl('get_ordered_list', { category: String(category.id), p: '1', type, }) ); const items = itemsResponse.payload.js.data ?? []; if (items.length > 0) { if (type === 'series') { const playableSeries = await findPlayableStalkerSeriesItem( request, items ); if (!playableSeries) { continue; } return { categoryId: String(category.id), categoryName: category.title, items: [ playableSeries, ...items.filter( (item) => String(item.id ?? '') !== String(playableSeries.id ?? '') ), ], }; } return { categoryId: String(category.id), categoryName: category.title, items, }; } } throw new Error(`Stalker mock server returned no ${type} items.`); } export function getXtreamTitle( item: XtreamLiveStream | XtreamVodStream | XtreamSeriesItem ): string { return `${item.name ?? ''}`.trim(); } export function getXtreamDateValue( item: XtreamVodStream | XtreamSeriesItem ): number { if ('last_modified' in item && item.last_modified) { return Number.parseInt(item.last_modified, 10) || 0; } return Number.parseInt(item.added ?? '', 10) || 0; } export function getStalkerTitle(item: StalkerContentItem): string { return `${item.o_name ?? item.name ?? ''}`.trim(); } export function pickDistinctTitles( items: T[], getTitle: (item: T) => string ): [string, string] { const titles = items .map((item) => getTitle(item)) .map((title) => title.trim()) .filter((title, index, allTitles) => { return ( title.length > 0 && allTitles.findIndex((candidate) => candidate === title) === index ); }); if (titles.length < 2) { throw new Error('Expected at least two distinct item titles.'); } return [titles[0], titles[1]]; } async function fetchXtreamFixture( request: APIRequestContext, options: { categoriesAction: string; itemsAction: string; password: string; username: string; } ): Promise> { const { categoriesAction, itemsAction, password, username } = options; const categories = await fetchJson( request, `${xtreamMockServer}/player_api.php?action=${encodeURIComponent( categoriesAction )}&username=${encodeURIComponent(username)}&password=${encodeURIComponent( password )}` ); for (const category of categories) { const items = await fetchJson( request, `${xtreamMockServer}/player_api.php?action=${encodeURIComponent( itemsAction )}&username=${encodeURIComponent(username)}&password=${encodeURIComponent( password )}&category_id=${encodeURIComponent(category.category_id)}` ); if (items.length > 0) { return { categoryId: String(category.category_id), categoryName: category.category_name, items, }; } } throw new Error(`Xtream mock server returned no items for ${itemsAction}.`); } async function fetchJson( request: APIRequestContext, url: string ): Promise { const response = await request.get(url); expect(response.ok()).toBeTruthy(); return (await response.json()) as T; } function normalizeXtreamEpgListings( listings: XtreamRawEpgListing[] ): XtreamNormalizedEpgListing[] { return listings .map((listing, index) => { const startTimestamp = Number.parseInt( String(listing.start_timestamp ?? ''), 10 ); const stopTimestamp = Number.parseInt( String(listing.stop_timestamp ?? ''), 10 ); return { id: String(listing.id ?? index), title: decodeXtreamText(String(listing.title ?? '')), description: decodeXtreamText( String(listing.description ?? '') ), rawTitle: String(listing.title ?? ''), rawDescription: String(listing.description ?? ''), rawStart: String(listing.start ?? ''), rawEnd: String(listing.end ?? ''), start: new Date(startTimestamp * 1000).toISOString(), end: new Date(stopTimestamp * 1000).toISOString(), startTimestamp, stopTimestamp, channelId: String(listing.channel_id ?? listing.epg_id ?? ''), }; }) .sort((left, right) => left.startTimestamp - right.startTimestamp); } function decodeXtreamText(value: string): string { return Buffer.from(value, 'base64').toString('utf-8'); } function buildStalkerProxyUrl( action: string, params: Record ): string { const searchParams = new URLSearchParams({ action, macAddress: defaultStalkerMacAddress, url: `${stalkerMockServer}/portal.php`, ...params, }); return `${stalkerMockServer}/stalker?${searchParams.toString()}`; } async function findPlayableStalkerSeriesItem( request: APIRequestContext, items: StalkerContentItem[] ): Promise { for (const item of items) { const itemId = String(item.id ?? '').trim(); if (!itemId) { continue; } const seasonsResponse = await fetchJson< StalkerProxyPayload< StalkerOrderedList | StalkerSeasonLike[] > >( request, buildStalkerProxyUrl('get_ordered_list', { movie_id: itemId, p: '1', type: 'series', }) ); const seasons = extractStalkerSeriesItems(seasonsResponse.payload.js); if (seasons.length > 0) { return item; } } return null; } function extractStalkerSeriesItems( payload: StalkerOrderedList | T[] | undefined ): T[] { if (Array.isArray(payload)) { return payload; } return payload?.data ?? []; }