mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat: enhance EPG functionality with new types, fixtures, and tests for Xtream integration
This commit is contained in:
1 parent
0a8a9c9637
commit
c42ddeaef2
7 files changed
+717
-83
No files matched your search
@@ -48,9 +48,9 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(dialog.locator('.category-item mat-checkbox input:checked')).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(
|
||||
dialog.locator('.category-item mat-checkbox input:checked')
|
||||
).toHaveCount(0);
|
||||
|
||||
await dialog
|
||||
.getByRole('button', {
|
||||
@@ -59,19 +59,25 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
dialog.locator('.category-item mat-checkbox input:checked').first()
|
||||
dialog
|
||||
.locator('.category-item mat-checkbox input:checked')
|
||||
.first()
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.locator('.search-field input').fill(targetCategory.name);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory, false);
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(
|
||||
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
||||
).toHaveCount(0);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await refreshSource(app.mainWindow, portalName, { confirm: true });
|
||||
@@ -89,26 +95,34 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(
|
||||
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
||||
).toHaveCount(0);
|
||||
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await dialog.locator('.search-field input').fill(targetCategory.name);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory, true);
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await dialog.locator('.search-field input').fill(targetCategory.name);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
const restoredRows = dialog.locator('.category-item');
|
||||
await expect(restoredRows).toHaveCount(1, { timeout: 15000 });
|
||||
await expect(
|
||||
restoredRows.first().locator('mat-checkbox input')
|
||||
).toBeChecked();
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
@@ -148,9 +162,13 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
dialog.locator('.category-item mat-checkbox input:checked')
|
||||
).toHaveCount(0);
|
||||
|
||||
await dialog.locator('.search-field input').fill(targetCategory.name);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory, true);
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
@@ -165,8 +183,8 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
await expectVisibleSidebarCategoryIds(app.mainWindow, [
|
||||
targetCategory.id,
|
||||
await expectVisibleSidebarCategoryNames(app.mainWindow, [
|
||||
targetCategory.name,
|
||||
]);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
@@ -180,8 +198,8 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
await expectVisibleSidebarCategoryIds(app.mainWindow, [
|
||||
targetCategory.id,
|
||||
await expectVisibleSidebarCategoryNames(app.mainWindow, [
|
||||
targetCategory.name,
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
@@ -245,6 +263,33 @@ async function expectVisibleSidebarCategoryIds(
|
||||
expect(actualIds).toEqual(expectedIds);
|
||||
}
|
||||
|
||||
async function expectVisibleSidebarCategoryNames(
|
||||
page: Page,
|
||||
expectedNames: string[]
|
||||
): Promise<void> {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
);
|
||||
const actualNames: string[] = [];
|
||||
const count = await categories.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const categoryName =
|
||||
(
|
||||
await categories
|
||||
.nth(index)
|
||||
.locator('.nav-item-label')
|
||||
.textContent()
|
||||
)?.trim() ?? '';
|
||||
|
||||
if (categoryName) {
|
||||
actualNames.push(categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
expect(actualNames).toEqual(expectedNames);
|
||||
}
|
||||
|
||||
async function pickSidebarCategory(
|
||||
page: Page
|
||||
): Promise<{ id: string; itemCount: number; name: string }> {
|
||||
@@ -252,13 +297,16 @@ async function pickSidebarCategory(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
);
|
||||
const count = await categories.count();
|
||||
const candidates: Array<{ id: string; itemCount: number; name: string }> = [];
|
||||
const candidates: Array<{ id: string; itemCount: number; name: string }> =
|
||||
[];
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const category = categories.nth(index);
|
||||
const id = (await category.getAttribute('data-category-id'))?.trim() ?? '';
|
||||
const id =
|
||||
(await category.getAttribute('data-category-id'))?.trim() ?? '';
|
||||
const name =
|
||||
(await category.locator('.nav-item-label').textContent())?.trim() ?? '';
|
||||
(await category.locator('.nav-item-label').textContent())?.trim() ??
|
||||
'';
|
||||
const countText =
|
||||
(await category.locator('.item-count').textContent())?.trim() ?? '';
|
||||
const itemCount = Number.parseInt(countText, 10) || 0;
|
||||
@@ -270,7 +318,10 @@ async function pickSidebarCategory(
|
||||
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
|
||||
nameCounts.set(
|
||||
candidate.name,
|
||||
(nameCounts.get(candidate.name) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
const preferredCandidate =
|
||||
|
||||
@@ -28,13 +28,22 @@ test.describe('Dashboard Activation', () => {
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(request, xtreamCredentials);
|
||||
const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials);
|
||||
const liveFixture = await fetchXtreamLiveFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle);
|
||||
const [liveTitle] = pickDistinctTitles(
|
||||
liveFixture.items,
|
||||
getXtreamTitle
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
@@ -50,7 +59,9 @@ test.describe('Dashboard Activation', () => {
|
||||
);
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
|
||||
await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click();
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
@@ -59,7 +70,9 @@ test.describe('Dashboard Activation', () => {
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click();
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
@@ -74,7 +87,9 @@ test.describe('Dashboard Activation', () => {
|
||||
'Global Favorites',
|
||||
liveTitle
|
||||
).click();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/favorites$/);
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/favorites$/
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.player-toolbar .close-btn').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
@@ -87,10 +102,12 @@ test.describe('Dashboard Activation', () => {
|
||||
'Recently Watched',
|
||||
movieTitle
|
||||
).click();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/);
|
||||
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
|
||||
movieTitle
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(movieTitle);
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
|
||||
@@ -100,10 +117,12 @@ test.describe('Dashboard Activation', () => {
|
||||
'Recently Watched',
|
||||
seriesTitle
|
||||
).click();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/);
|
||||
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
|
||||
seriesTitle
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(seriesTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
@@ -130,7 +149,9 @@ function dashboardActivityItemByTitle(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
const backButton = page.locator('app-content-hero .hero__back-button').first();
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
await backButton.click();
|
||||
@@ -145,7 +166,9 @@ async function playCurrentDetail(page: Page): Promise<void> {
|
||||
|
||||
async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
const seasonCard = page.locator('.season-card').first();
|
||||
const episodeCard = page.locator('.episode-card, .episode-list-item').first();
|
||||
const episodeCard = page
|
||||
.locator('.episode-card, .episode-list-item')
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -166,7 +189,10 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
|
||||
async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
const item = page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: title })
|
||||
@@ -175,5 +201,7 @@ async function toggleFavoriteForChannel(page: Page, title: string): Promise<void
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
||||
/star/
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,12 @@ export type XtreamCategory = {
|
||||
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 = {
|
||||
@@ -40,6 +44,44 @@ export type XtreamCategoryFixture<T> = {
|
||||
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;
|
||||
@@ -104,6 +146,60 @@ export async function fetchXtreamSeriesFixture(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchXtreamEpgFixture(
|
||||
request: APIRequestContext,
|
||||
credentials = { username: 'epg', password: 'epg' }
|
||||
): Promise<XtreamEpgFixture> {
|
||||
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'
|
||||
@@ -261,6 +357,44 @@ async function fetchJson<T>(
|
||||
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, string>
|
||||
|
||||
@@ -45,13 +45,17 @@ test.describe('Electron Sources View', () => {
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream', 'stalker']);
|
||||
const localFilePath = writeTemporaryM3uFile(dataDir, localSourceFileName, [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Alpha Local News',
|
||||
url: 'https://streams.example.test/local-alpha.m3u8',
|
||||
},
|
||||
]);
|
||||
const localFilePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
localSourceFileName,
|
||||
[
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Alpha Local News',
|
||||
url: 'https://streams.example.test/local-alpha.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
const urlServer = await createMutableTextServer(
|
||||
`#EXTM3U
|
||||
#EXTINF:-1 group-title="Sports",Omega URL Sports
|
||||
@@ -67,7 +71,10 @@ https://streams.example.test/url-omega.m3u8
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
||||
await importM3uPlaylistFromUrl(app.mainWindow, urlServer.resourceUrl);
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
urlServer.resourceUrl
|
||||
);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: xtreamSourceName,
|
||||
});
|
||||
@@ -223,7 +230,10 @@ https://streams.example.test/url-omega.m3u8
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Editable Xtream Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Editable Xtream Source'
|
||||
);
|
||||
await updateSourceDialog(dialog, {
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
@@ -232,7 +242,10 @@ https://streams.example.test/url-omega.m3u8
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Editable Stalker Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Editable Stalker Source'
|
||||
);
|
||||
await updateSourceDialog(dialog, {
|
||||
macAddress: '00:1A:79:00:00:99',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
@@ -240,30 +253,45 @@ https://streams.example.test/url-omega.m3u8
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Edited Local Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Local Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
autoRefresh: true,
|
||||
title: 'Edited Local Source',
|
||||
userAgent: 'Electron E2E Local/1.0',
|
||||
});
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Edited Xtream Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Xtream Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Edited Xtream Source',
|
||||
username: 'user1',
|
||||
});
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Edited Stalker Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Stalker Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
macAddress: '00:1A:79:00:00:99',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Edited Stalker Source',
|
||||
});
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
@@ -271,24 +299,37 @@ https://streams.example.test/url-omega.m3u8
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Edited Local Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Local Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
autoRefresh: true,
|
||||
title: 'Edited Local Source',
|
||||
userAgent: 'Electron E2E Local/1.0',
|
||||
});
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Edited Xtream Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Xtream Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Edited Xtream Source',
|
||||
username: 'user1',
|
||||
});
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(app.mainWindow, 'Edited Stalker Source');
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Stalker Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
macAddress: '00:1A:79:00:00:99',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
@@ -327,10 +368,7 @@ https://streams.example.test/url-omega.m3u8
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await deleteSource(
|
||||
app.mainWindow,
|
||||
deletableLocalSourceDisplayName
|
||||
);
|
||||
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
|
||||
await expect(
|
||||
sourceRowByTitle(
|
||||
app.mainWindow,
|
||||
@@ -428,6 +466,10 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
);
|
||||
await refreshSource(app.mainWindow, 'refresh-url-source.m3u');
|
||||
await expectPlaylistUpdatedToast(app.mainWindow);
|
||||
await waitForSourceRowIdle(
|
||||
app.mainWindow,
|
||||
'refresh-url-source.m3u'
|
||||
);
|
||||
await sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
XtreamEpgFixture,
|
||||
XtreamNormalizedEpgListing,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
const epgPortalName = 'Xtream EPG Fixture';
|
||||
const epgCredentials = {
|
||||
username: 'epg',
|
||||
password: 'epg',
|
||||
};
|
||||
|
||||
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
test(`renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const currentProgram = fixture.shortEpg[0];
|
||||
if (!currentProgram) {
|
||||
throw new Error('Expected the Xtream EPG fixture to include a current program.');
|
||||
}
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: { TZ: timeZone },
|
||||
});
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: `${epgPortalName} ${timeZone}`,
|
||||
username: epgCredentials.username,
|
||||
password: epgCredentials.password,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
((await channelRow.locator('.epg-title').textContent()) ?? '').trim()
|
||||
)
|
||||
.toBe(currentProgram.title);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return (await channelRow.locator('.epg-time').allInnerTexts()).map(
|
||||
(value) => value.trim()
|
||||
);
|
||||
})
|
||||
.toEqual([
|
||||
formatTimeInZone(currentProgram.startTimestamp, timeZone),
|
||||
formatTimeInZone(currentProgram.stopTimestamp, timeZone),
|
||||
]);
|
||||
await expect
|
||||
.poll(async () => getProgressWidthPercent(channelRow))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await channelRow.click();
|
||||
await expect(app.mainWindow.locator('app-epg-list')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
const currentDateKey = formatDateKeyInZone(Date.now(), timeZone);
|
||||
const currentDayTitles = visibleTitlesForCurrentDate(
|
||||
fixture.fullEpg,
|
||||
currentDateKey,
|
||||
timeZone
|
||||
);
|
||||
|
||||
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
|
||||
currentDayTitles
|
||||
);
|
||||
|
||||
const currentProgramRow = app.mainWindow
|
||||
.locator('app-epg-list .program-item.current-program')
|
||||
.first();
|
||||
await expect(currentProgramRow).toBeVisible();
|
||||
await expect(currentProgramRow.locator('.program-title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(currentProgramRow.locator('.time')).toHaveText(
|
||||
`${formatTimeInZone(
|
||||
currentProgram.startTimestamp,
|
||||
timeZone
|
||||
)} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}`
|
||||
);
|
||||
|
||||
const firstFutureDateKey = getFirstFutureDateKey(
|
||||
fixture.fullEpg,
|
||||
currentDateKey,
|
||||
timeZone
|
||||
);
|
||||
expect(firstFutureDateKey).not.toBeNull();
|
||||
|
||||
const futureDayTitles = titlesForDate(
|
||||
fixture.fullEpg,
|
||||
firstFutureDateKey!,
|
||||
timeZone
|
||||
);
|
||||
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!);
|
||||
|
||||
for (let index = 0; index < nextDayClicks; index += 1) {
|
||||
await app.mainWindow.locator('app-epg-list .next-day').click();
|
||||
}
|
||||
|
||||
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
|
||||
futureDayTitles
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimeInZone(
|
||||
timestampSeconds: number,
|
||||
timeZone: string
|
||||
): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(timestampSeconds * 1000));
|
||||
}
|
||||
|
||||
function formatDateKeyInZone(
|
||||
timestampMilliseconds: number,
|
||||
timeZone: string
|
||||
): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date(timestampMilliseconds));
|
||||
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error(`Failed to format a date key for timezone ${timeZone}.`);
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function titlesForDate(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
dateKey: string,
|
||||
timeZone: string
|
||||
): string[] {
|
||||
return listings
|
||||
.filter(
|
||||
(listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
|
||||
dateKey
|
||||
)
|
||||
.map((listing) => listing.title);
|
||||
}
|
||||
|
||||
function visibleTitlesForCurrentDate(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
currentDateKey: string,
|
||||
timeZone: string
|
||||
): string[] {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
return listings
|
||||
.filter(
|
||||
(listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
|
||||
currentDateKey && listing.stopTimestamp >= nowSeconds
|
||||
)
|
||||
.map((listing) => listing.title);
|
||||
}
|
||||
|
||||
function getFirstFutureDateKey(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
currentDateKey: string,
|
||||
timeZone: string
|
||||
): string | null {
|
||||
const uniqueDateKeys = Array.from(
|
||||
new Set(
|
||||
listings.map((listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone)
|
||||
)
|
||||
)
|
||||
).sort();
|
||||
|
||||
return uniqueDateKeys.find((dateKey) => dateKey > currentDateKey) ?? null;
|
||||
}
|
||||
|
||||
function dayDifference(fromDateKey: string, toDateKey: string): number {
|
||||
const from = Date.parse(`${fromDateKey}T00:00:00.000Z`);
|
||||
const to = Date.parse(`${toDateKey}T00:00:00.000Z`);
|
||||
return Math.round((to - from) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async function visibleProgramTitles(page: Parameters<typeof channelItemByTitle>[0]) {
|
||||
return page
|
||||
.locator('app-epg-list .program-title')
|
||||
.allInnerTexts()
|
||||
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
|
||||
const style = await row.locator('.epg-progress-fill').getAttribute('style');
|
||||
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
|
||||
return Number.parseFloat(width);
|
||||
}
|
||||
@@ -53,10 +53,14 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
username: stressXtreamUsername,
|
||||
});
|
||||
|
||||
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
|
||||
const overlay = app.mainWindow.locator(
|
||||
'.workspace-loading-overlay'
|
||||
);
|
||||
await expect(overlay).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
overlay.getByRole('button', { name: /cancel import/i })
|
||||
overlay.getByRole('button', {
|
||||
name: /stop sync|cancel import/i,
|
||||
})
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
@@ -71,15 +75,14 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await app.mainWindow.evaluate(() =>
|
||||
(await app.mainWindow.evaluate(
|
||||
() =>
|
||||
(window.__dbOperationEvents ?? []).filter(
|
||||
(event) =>
|
||||
event.operation === 'save-content' &&
|
||||
event.status === 'progress'
|
||||
).length
|
||||
)
|
||||
) > 2,
|
||||
)) > 2,
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toBeTruthy();
|
||||
@@ -125,9 +128,11 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
username: stressXtreamUsername,
|
||||
});
|
||||
|
||||
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
|
||||
const overlay = app.mainWindow.locator(
|
||||
'.workspace-loading-overlay'
|
||||
);
|
||||
const cancelButton = overlay.getByRole('button', {
|
||||
name: /cancel import/i,
|
||||
name: /stop sync|cancel import/i,
|
||||
});
|
||||
|
||||
await expect(overlay).toBeVisible({ timeout: 15000 });
|
||||
@@ -145,9 +150,10 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
await cancelButton.click();
|
||||
|
||||
await expect(overlay).toHaveCount(0, { timeout: 20000 });
|
||||
await expect(
|
||||
xtreamBlockedStateTitle(app.mainWindow)
|
||||
).toHaveText('Import cancelled', { timeout: 20000 });
|
||||
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
||||
'Import cancelled',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
|
||||
await app.mainWindow.waitForTimeout(1200);
|
||||
await expect(overlay).toHaveCount(0);
|
||||
@@ -267,7 +273,9 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
|
||||
await playlistRow.locator('.delete-btn').click();
|
||||
|
||||
const confirmDialog = app.mainWindow.locator('mat-dialog-container');
|
||||
const confirmDialog = app.mainWindow.locator(
|
||||
'mat-dialog-container'
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Yes', exact: true })
|
||||
@@ -279,9 +287,9 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
await expect(playlistRow.locator('.busy-state__message')).toBeVisible(
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
await expect(
|
||||
playlistRow.locator('.busy-state__message')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { APIRequestContext, test, expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Xtream Codes E2E Tests
|
||||
@@ -21,6 +21,8 @@ const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
/** Default scenario credentials */
|
||||
const DEFAULT_USERNAME = 'user1';
|
||||
const DEFAULT_PASSWORD = 'pass1';
|
||||
const EPG_USERNAME = 'epg';
|
||||
const EPG_PASSWORD = 'epg';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -282,6 +284,92 @@ test('@xtream get_short_epg — returns base64-encoded listings', async ({
|
||||
expect(listing.stop_timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
test('@xtream epg fixture — short epg starts at the current program, respects limit, and exposes timestamp-vs-string mismatches', async ({
|
||||
request,
|
||||
}) => {
|
||||
const stream = await getEpgFixtureStream(request);
|
||||
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_short_epg&stream_id=${stream.stream_id}&limit=2`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.epg_listings).toHaveLength(2);
|
||||
|
||||
const [currentListing, nextListing] = body.epg_listings;
|
||||
expect(decodeXtreamText(currentListing.title)).toBe('Global Headlines');
|
||||
expect(decodeXtreamText(nextListing.title)).toBe('Market Wrap');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(Number.parseInt(currentListing.start_timestamp, 10)).toBeLessThanOrEqual(
|
||||
now
|
||||
);
|
||||
expect(Number.parseInt(currentListing.stop_timestamp, 10)).toBeGreaterThanOrEqual(
|
||||
now
|
||||
);
|
||||
expect(currentListing.start).not.toBe(
|
||||
formatXtreamDateTime(
|
||||
Number.parseInt(currentListing.start_timestamp, 10)
|
||||
)
|
||||
);
|
||||
expect(currentListing.end).not.toBe(
|
||||
formatXtreamDateTime(
|
||||
Number.parseInt(currentListing.stop_timestamp, 10)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('@xtream epg fixture — full epg and legacy alias return the same ordered schedule with a midnight boundary program', async ({
|
||||
request,
|
||||
}) => {
|
||||
const stream = await getEpgFixtureStream(request);
|
||||
|
||||
const fullResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_data_table&stream_id=${stream.stream_id}`
|
||||
);
|
||||
const aliasResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_date_table&stream_id=${stream.stream_id}`
|
||||
);
|
||||
|
||||
expect(fullResponse.ok()).toBeTruthy();
|
||||
expect(aliasResponse.ok()).toBeTruthy();
|
||||
|
||||
const fullBody = await fullResponse.json();
|
||||
const aliasBody = await aliasResponse.json();
|
||||
|
||||
expect(aliasBody).toEqual(fullBody);
|
||||
|
||||
const titles = fullBody.epg_listings.map((listing: XtreamRawEpgListing) =>
|
||||
decodeXtreamText(listing.title)
|
||||
);
|
||||
expect(titles).toEqual([
|
||||
'Earlier Bulletin',
|
||||
'Global Headlines',
|
||||
'Market Wrap',
|
||||
'Overnight Update',
|
||||
'Late Edition',
|
||||
'After Midnight',
|
||||
]);
|
||||
|
||||
const boundaryListing = fullBody.epg_listings.find(
|
||||
(listing: XtreamRawEpgListing) =>
|
||||
decodeXtreamText(listing.title) === 'Late Edition'
|
||||
);
|
||||
if (!boundaryListing) {
|
||||
throw new Error('Expected the full EPG fixture to include Late Edition.');
|
||||
}
|
||||
|
||||
const boundaryStart = new Date(
|
||||
Number.parseInt(boundaryListing.start_timestamp, 10) * 1000
|
||||
);
|
||||
const boundaryStop = new Date(
|
||||
Number.parseInt(boundaryListing.stop_timestamp, 10) * 1000
|
||||
);
|
||||
|
||||
expect(boundaryStart.getUTCDate()).not.toBe(boundaryStop.getUTCDate());
|
||||
});
|
||||
|
||||
test('@xtream reset — data regenerates identically after reset', async ({
|
||||
request,
|
||||
}) => {
|
||||
@@ -357,3 +445,57 @@ test('@xtream minimal scenario — reduced item count', async ({ request }) => {
|
||||
// 2 categories × 5 items = 10
|
||||
expect(streams.length).toBe(10);
|
||||
});
|
||||
|
||||
type XtreamLiveStream = {
|
||||
category_id: string;
|
||||
name: string;
|
||||
stream_id: number;
|
||||
};
|
||||
|
||||
type XtreamRawEpgListing = {
|
||||
end: string;
|
||||
start: string;
|
||||
start_timestamp: string;
|
||||
stop_timestamp: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
async function getEpgFixtureStream(
|
||||
request: APIRequestContext
|
||||
): Promise<XtreamLiveStream> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
|
||||
const epgCategory = categories.find(
|
||||
(category) => category.category_name === 'EPG Focus'
|
||||
);
|
||||
if (!epgCategory) {
|
||||
throw new Error('Expected the EPG fixture category to exist.');
|
||||
}
|
||||
|
||||
const streams = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_streams&category_id=${epgCategory.category_id}`
|
||||
)
|
||||
).json()) as XtreamLiveStream[];
|
||||
|
||||
const stream = streams.find((item) => item.name === 'Timezone News');
|
||||
if (!stream) {
|
||||
throw new Error('Expected the EPG fixture stream to exist.');
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function decodeXtreamText(value: string): string {
|
||||
return Buffer.from(value, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
return new Date(timestampSeconds * 1000)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace('.000Z', '');
|
||||
}
|
||||
Reference in new issue
Block a user