Files
iptvnator/apps/electron-backend-e2e/src/xtream-epg.e2e.ts
T
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +02:00

697 lines
25 KiB
TypeScript

import type { Locator } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
fetchXtreamEpgFixture,
} 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(`@epg @xtream @electron renders Xtream EPG previews and the timeline 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 });
// Provider-declared catch-up (tv_archive=1 in the fixture) is
// surfaced as a badge on the sidebar row (#1128).
await expect(
channelRow.getByTestId('catchup-badge')
).toBeVisible();
// Sidebar channel list shows the per-channel "now" programme line.
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-timeline')
).toBeVisible({ timeout: 20000 });
// The timeline renders the full multi-day window as blocks,
// sorted by start time (no per-day filtering — it scrolls).
const allTitles = [...fixture.fullEpg]
.sort((a, b) => a.startTimestamp - b.startTimestamp)
.map((listing) => listing.title);
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toEqual(allTitles);
// The current programme is highlighted as the "now" block.
await expect(
app.mainWindow
.locator(
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
)
.first()
).toHaveText(currentProgram.title);
// The date stepper advances the day label.
const dayLabel = app.mainWindow
.locator('app-epg-timeline .epg-timeline__day small')
.first();
const beforeLabel = (await dayLabel.textContent())?.trim();
await app.mainWindow
.locator(
'app-epg-timeline .epg-timeline__stepper .epg-timeline__nav'
)
.last()
.click();
await expect
.poll(async () => (await dayLabel.textContent())?.trim())
.not.toBe(beforeLabel);
} finally {
await closeElectronApp(app);
}
});
}
test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row widths', 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: 'UTC' },
});
try {
await addXtreamPortal(app.mainWindow, {
name: `${epgPortalName} Narrow`,
username: epgCredentials.username,
password: epgCredentials.password,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const currentProgramRow = channelItemByTitle(
app.mainWindow,
fixture.stream.name ?? ''
).first();
const noProgramRow = channelItemByTitle(
app.mainWindow,
'Night Sports'
).first();
await expect(currentProgramRow).toBeVisible({ timeout: 20000 });
await expect(noProgramRow).toBeVisible({ timeout: 20000 });
const currentTitle = currentProgramRow.locator('.epg-title');
const progressTrack = currentProgramRow.locator('.epg-progress-track');
const favoriteAction = currentProgramRow.locator('.favorite-button');
const placeholder = noProgramRow.locator('.epg-placeholder');
const logo = currentProgramRow.locator('.channel-logo-shell');
const timeLabels = currentProgramRow.locator('.epg-time');
await setPortalChannelItemWidth(app.mainWindow, 300);
await expect(currentTitle).toHaveText(currentProgram.title);
await expect(currentTitle).toBeVisible();
await expect(progressTrack).toBeVisible();
await expect(favoriteAction).toBeVisible();
await expect(placeholder).toBeVisible();
await expect(logo).toBeVisible();
await expect(timeLabels).toHaveCount(2);
await expect(timeLabels.first()).toBeVisible();
await expect(timeLabels.last()).toBeHidden();
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
await expectTimelineStartAndProgressAligned(currentProgramRow);
await expectNarrowRowContentFits(currentProgramRow);
await expectNarrowRowContentFits(noProgramRow);
await setPortalChannelItemWidth(app.mainWindow, 232);
await expect(currentTitle).toHaveText(currentProgram.title);
await expect(currentTitle).toBeVisible();
await expect(progressTrack).toBeVisible();
await expect(favoriteAction).toBeVisible();
await expect(placeholder).toBeVisible();
await expect(logo).toBeHidden();
await expect(timeLabels).toHaveCount(2);
await expect(timeLabels.first()).toBeVisible();
await expect(timeLabels.last()).toBeHidden();
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
await expectTimelineStartAndProgressAligned(currentProgramRow);
await expectNarrowRowContentFits(currentProgramRow);
await expectNarrowRowContentFits(noProgramRow);
await setPortalChannelItemWidth(app.mainWindow, 200);
await expect(currentTitle).toHaveText(currentProgram.title);
await expect(currentTitle).toBeVisible();
await expect(progressTrack).toBeVisible();
await expect(favoriteAction).toBeVisible();
await expect(placeholder).toBeVisible();
await expect(logo).toBeHidden();
await expect(timeLabels.first()).toBeHidden();
await expect(timeLabels.last()).toBeHidden();
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
await expectNarrowRowContentFits(currentProgramRow);
await expectNarrowRowContentFits(noProgramRow);
} finally {
await closeElectronApp(app);
}
});
test('@radio @stalker @electron keeps radio rows compact at narrow widths', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const fixture = await fetchStalkerCategoryFixture(request, 'radio');
const [firstItem, secondItem] = fixture.items;
const firstTitle = firstItem?.o_name || firstItem?.name;
const secondTitle = secondItem?.o_name || secondItem?.name;
if (!firstTitle || !secondTitle) {
throw new Error(
'Expected the Stalker radio fixture to include two named stations.'
);
}
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: 'Stalker Radio Row Fixture',
});
await waitForStalkerCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Radio');
const categoryButton = app.mainWindow.getByRole('button', {
name: fixture.categoryName,
exact: true,
});
await expect(categoryButton).toBeVisible();
await categoryButton.click();
const radioRow = channelItemByTitle(
app.mainWindow,
firstTitle
).first();
await expect(radioRow).toBeVisible({ timeout: 20000 });
await expect(radioRow).toHaveClass(/compact/);
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
await expect(radioRow.locator('.epg-title')).toHaveCount(0);
await expect(radioRow.locator('.epg-timeline')).toHaveCount(0);
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
await setStalkerChannelItemWidth(app.mainWindow, 232);
await expect(radioRow.locator('.channel-logo-shell')).toBeVisible();
await expect(radioRow.locator('.favorite-button')).toBeVisible();
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
await setStalkerChannelItemWidth(app.mainWindow, 200);
await expect(radioRow.locator('.channel-name')).toBeVisible();
await expect(radioRow.locator('.channel-logo-shell')).toBeHidden();
await expect(radioRow.locator('.favorite-button')).toBeHidden();
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
} finally {
await closeElectronApp(app);
}
});
test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" block by the EPG display offset', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
const offsetMinutes = 60;
// The guide runs an hour ahead of the real schedule, so the programme
// actually on air is the one the provider files under "an hour ago" —
// and every surface must agree on that same programme.
const providerNowSeconds = Math.floor(Date.now() / 1000) - offsetMinutes * 60;
const expectedProgram = fixture.fullEpg.find(
(listing) =>
listing.startTimestamp <= providerNowSeconds &&
providerNowSeconds < listing.stopTimestamp
);
if (!expectedProgram) {
throw new Error(
'Expected the Xtream EPG fixture to cover the shifted current time.'
);
}
const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('[data-test-id="epg-offset-minutes"]')
.fill(String(offsetMinutes));
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: `${epgPortalName} Offset`,
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 });
// Sidebar preview: the shifted programme, with shifted times.
await expect
.poll(async () =>
(
(await channelRow.locator('.epg-title').textContent()) ??
''
).trim()
)
.toBe(expectedProgram.title);
await expect
.poll(async () =>
(await channelRow.locator('.epg-time').allInnerTexts()).map(
(value) => value.trim()
)
)
.toEqual([
formatTimeInZone(
expectedProgram.startTimestamp + offsetMinutes * 60,
'UTC'
),
formatTimeInZone(
expectedProgram.stopTimestamp + offsetMinutes * 60,
'UTC'
),
]);
// Timeline: the same programme is the highlighted "now" block.
await channelRow.click();
await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible({
timeout: 20000,
});
await expect(
app.mainWindow
.locator(
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
)
.first()
).toHaveText(expectedProgram.title);
} finally {
await closeElectronApp(app);
}
});
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', 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);
try {
// Opt into the list view first (from the fresh workspace) so the portal
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('[data-test-id="epg-view-mode-list"]')
.click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: `${epgPortalName} List`,
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 channelRow.click();
// The list view renders instead of the timeline.
await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({
timeout: 20000,
});
await expect(app.mainWindow.locator('app-epg-timeline')).toHaveCount(0);
// The on-air programme is the highlighted "now" row.
await expect(
app.mainWindow
.locator('app-epg-list-view .g-row[data-when="now"] .title')
.first()
).toHaveText(currentProgram.title);
} 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));
}
async function setPortalChannelItemWidth(
page: Parameters<typeof channelItemByTitle>[0],
width: number
) {
const channelItems = page.locator(
'app-portal-channels-list app-channel-list-item'
);
await channelItems.evaluateAll((elements, itemWidth) => {
for (const element of elements) {
(element as HTMLElement).style.width = `${itemWidth}px`;
}
}, width);
await expect
.poll(() =>
channelItems
.first()
.evaluate((element) =>
Math.round(element.getBoundingClientRect().width)
)
)
.toBe(width);
}
async function setStalkerChannelItemWidth(
page: Parameters<typeof channelItemByTitle>[0],
width: number
) {
const channelItems = page.locator(
'app-stalker-live-stream-layout app-channel-list-item'
);
await channelItems.evaluateAll((elements, itemWidth) => {
for (const element of elements) {
(element as HTMLElement).style.width = `${itemWidth}px`;
}
}, width);
await expect
.poll(() =>
channelItems
.first()
.evaluate((element) =>
Math.round(element.getBoundingClientRect().width)
)
)
.toBe(width);
}
const portalChannelItemSize = 68;
const compactRadioItemSize = 52;
const stalkerChannelItemGap = 2;
const rowGeometryTolerance = 0.25;
const contentGeometryTolerance = 1;
type ElementBox = {
x: number;
y: number;
width: number;
height: number;
};
async function expectPortalRowHeightAndStride(
row: ReturnType<typeof channelItemByTitle>,
expectedFollowingTitle: string
) {
const followingRow = row
.locator('xpath=ancestor::app-channel-list-item')
.locator('xpath=following-sibling::app-channel-list-item[1]')
.getByTestId('channel-item');
await expect(followingRow.locator('.channel-name')).toHaveText(
expectedFollowingTitle
);
const [rowBox, followingRowBox] = await Promise.all([
row.boundingBox(),
followingRow.boundingBox(),
]);
if (!rowBox || !followingRowBox) {
throw new Error(
`Expected visible channel-row geometry before "${expectedFollowingTitle}" for the virtual-stride assertion.`
);
}
expect(Math.abs(rowBox.height - portalChannelItemSize)).toBeLessThanOrEqual(
rowGeometryTolerance
);
expect(
Math.abs(followingRowBox.y - rowBox.y - portalChannelItemSize)
).toBeLessThanOrEqual(rowGeometryTolerance);
}
async function expectCompactRadioRowHeightAndStride(
row: ReturnType<typeof channelItemByTitle>,
expectedFollowingTitle: string
) {
const followingRow = row
.locator('xpath=ancestor::app-channel-list-item')
.locator('xpath=following-sibling::app-channel-list-item[1]')
.getByTestId('channel-item');
await expect(followingRow.locator('.channel-name')).toHaveText(
expectedFollowingTitle
);
const [rowBox, followingRowBox] = await Promise.all([
row.boundingBox(),
followingRow.boundingBox(),
]);
if (!rowBox || !followingRowBox) {
throw new Error(
`Expected compact radio-row geometry before "${expectedFollowingTitle}".`
);
}
expect(Math.abs(rowBox.height - compactRadioItemSize)).toBeLessThanOrEqual(
rowGeometryTolerance
);
expect(
Math.abs(
followingRowBox.y -
rowBox.y -
compactRadioItemSize -
stalkerChannelItemGap
)
).toBeLessThanOrEqual(rowGeometryTolerance);
}
async function expectTimelineStartAndProgressAligned(
row: ReturnType<typeof channelItemByTitle>
) {
const [startTimeBox, progressBox] = await Promise.all([
row.locator('.epg-time').first().boundingBox(),
row.locator('.epg-progress-track').first().boundingBox(),
]);
if (!startTimeBox || !progressBox) {
throw new Error(
'Expected visible start-time and progress geometry in the narrow channel row.'
);
}
expect(
Math.abs(boxCenterY(startTimeBox) - boxCenterY(progressBox))
).toBeLessThanOrEqual(contentGeometryTolerance);
}
async function expectNarrowRowContentFits(
row: ReturnType<typeof channelItemByTitle>
) {
const [rowBox, detailsBox, actionsBox] = await Promise.all([
row.boundingBox(),
row.locator('.channel-details').boundingBox(),
row.locator('.action-buttons').boundingBox(),
]);
if (!rowBox || !detailsBox || !actionsBox) {
throw new Error(
'Expected visible narrow channel-row, details, and action geometry.'
);
}
expectBoxContainedWithin(detailsBox, rowBox);
expectBoxContainedWithin(actionsBox, rowBox);
expect(boxRight(detailsBox)).toBeLessThanOrEqual(
actionsBox.x + contentGeometryTolerance
);
await expectVisibleElementsContainedWithin(
row.locator(
'.channel-details > .channel-name:visible, .channel-details > .epg-title:visible, .channel-details > .epg-placeholder:visible, .channel-details > .epg-timeline:visible'
),
detailsBox,
'channel-details child'
);
await expectVisibleElementsContainedWithin(
row.locator('.action-buttons > button:visible'),
actionsBox,
'channel action button'
);
const progressTrack = row.locator('.epg-progress-track');
if ((await progressTrack.count()) > 0) {
const progressBox = await progressTrack.first().boundingBox();
if (!progressBox) {
throw new Error(
'Expected visible progress-track geometry in the narrow channel row.'
);
}
expectBoxContainedWithin(progressBox, detailsBox);
expect(progressBox.width).toBeGreaterThanOrEqual(24);
}
}
async function expectVisibleElementsContainedWithin(
elements: Locator,
outer: ElementBox,
description: string
) {
const count = await elements.count();
if (count === 0) {
throw new Error(`Expected at least one visible ${description}.`);
}
for (let index = 0; index < count; index += 1) {
const elementBox = await elements.nth(index).boundingBox();
if (!elementBox) {
throw new Error(
`Expected visible ${description} geometry at index ${index}.`
);
}
expectBoxContainedWithin(elementBox, outer);
}
}
function expectBoxContainedWithin(inner: ElementBox, outer: ElementBox) {
expect(inner.x).toBeGreaterThanOrEqual(outer.x - contentGeometryTolerance);
expect(inner.y).toBeGreaterThanOrEqual(outer.y - contentGeometryTolerance);
expect(boxRight(inner)).toBeLessThanOrEqual(
boxRight(outer) + contentGeometryTolerance
);
expect(boxBottom(inner)).toBeLessThanOrEqual(
boxBottom(outer) + contentGeometryTolerance
);
}
function boxRight(box: ElementBox) {
return box.x + box.width;
}
function boxBottom(box: ElementBox) {
return box.y + box.height;
}
function boxCenterY(box: ElementBox) {
return box.y + box.height / 2;
}
async function timelineBlockTitles(
page: Parameters<typeof channelItemByTitle>[0]
) {
return page
.locator('app-epg-timeline .epg-timeline__block-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);
}