Files
iptvnator/apps/electron-backend-e2e/src/epg.e2e.ts
T
4grayandClaude Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): add programme guide implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add window-scoped guide programme queries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): separate coverage chunk size in the guide plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): key guide IPC answers by trimmed, present keys only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): guide search hits carry a row id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add a Guide button to the timeline toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): adapt the playlist channel list to the guide contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): document the programme guide and its release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* i18n(epg): translate the programme guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(epg): tidy guide docs, palette gating and the unbound output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): describe guide row ids as scope-local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00

725 lines
28 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Page } from '@playwright/test';
import {
buildM3uContent,
channelItemByTitle,
closeElectronApp,
createMutableTextServer,
expect,
goToDashboard,
importM3uPlaylistFromUrl,
launchElectronApp,
openWorkspaceSection,
openSettings,
openSettingsSection,
saveSettings,
test,
} from './electron-test-fixtures';
const fallbackLogoSvg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#16436b"/><text x="48" y="58" text-anchor="middle" font-size="28" font-family="Arial" fill="#f5f7fb">GN</text></svg>`
).toString('base64');
const fallbackLogoDataUrl = `data:image/svg+xml;base64,${fallbackLogoSvg}`;
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="guide-news">
<display-name>Guide News</display-name>
</channel>
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
<title>Guide Bulletin</title>
<desc>EPG refresh smoke test.</desc>
</programme>
</tv>
`;
function createCurrentXmltvFixture(
channelId: string,
channelName: string,
programTitle: string,
iconUrl?: string
): string {
const start = new Date(Date.now() - 15 * 60 * 1000);
const stop = new Date(Date.now() + 45 * 60 * 1000);
return `<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="${channelId}">
<display-name>${channelName}</display-name>
${iconUrl ? `<icon src="${iconUrl}"/>` : ''}
</channel>
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
<title>${programTitle}</title>
<desc>M3U-declared EPG source smoke test.</desc>
</programme>
</tv>
`;
}
function formatXmltvDate(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return [
date.getUTCFullYear(),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds()),
].join('');
}
test.describe('Electron EPG', () => {
test('@epg @electron saves unrelated settings when EPG reconciliation is unavailable', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'general');
// After startup, make any unexpected reconciliation fail.
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
throw new Error('Synthetic migration failure');
});
});
const guard = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.locator('input');
await guard.uncheck();
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toBeVisible();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).not.toBeVisible();
await goToDashboard(app.mainWindow);
await expect(
app.mainWindow.getByTestId('unsaved-dialog-save')
).not.toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
throw new Error('Synthetic cleanup failure');
});
});
await openSettingsSection(app.mainWindow, 'general');
await app.mainWindow
.getByTestId('portal-connectivity-toggle')
.locator('input')
.uncheck();
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.fill('https://example.invalid/guide.xml');
await app.mainWindow
.getByTestId('save-settings')
.click({ noWaitAfter: true });
await expect(
app.mainWindow.getByText(
'Failed to clear EPG data. Please try again.',
{ exact: true }
)
).toBeVisible();
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toBeVisible();
await expect
.poll(async () => {
const config = JSON.parse(
await readFile(
join(dataDir, 'config', 'config.json'),
'utf8'
)
);
return config.PORTAL_CONNECTIVITY_GUARD;
})
.toBe(false);
// Restore cleanup so the explicit retry can finish and close normally.
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({
success: true,
removedUrls: [],
}));
});
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).not.toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({
dataDir,
}) => {
const server = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml; charset=utf-8',
resourcePath: '/epg-path-secret/guide.xml',
});
const app = await launchElectronApp(dataDir);
const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`;
let diagnostics = '';
const capture = (chunk: Buffer) => {
diagnostics += chunk.toString();
};
const process = app.electronApp.process();
process.stdout?.on('data', capture);
process.stderr?.on('data', capture);
try {
const imported = await app.mainWindow.evaluate(async (url) => {
const result = await window.electron.forceFetchEpg(url);
await window.electron.clearEpgDataForSource(url);
return result;
}, sourceUrl);
expect(imported.success).toBe(true);
await expect
.poll(() => diagnostics)
.toContain('EPG data cleared for source via worker');
const failed = await app.mainWindow.evaluate(async (url) => {
const missing = new URL(url);
missing.pathname += '/missing';
return window.electron.forceFetchEpg(missing.toString());
}, sourceUrl);
expect(failed.success).toBe(false);
await expect
.poll(() => diagnostics)
.toContain('Request failed with status code 404');
// Worker stdout can lose its final buffered line on termination;
// the awaited import result and main-process completion are authoritative.
expect(diagnostics).toContain('[EPG Worker] Fetching EPG source');
expect(diagnostics).toContain('EPG parsing complete:');
expect(diagnostics).not.toContain('epg-path-secret');
expect(diagnostics).not.toContain('epg-query-secret');
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
} finally {
process.stdout?.off('data', capture);
process.stderr?.off('data', capture);
await closeElectronApp(app);
await server.close();
}
});
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guide.xml',
});
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await expect(
app.mainWindow
.locator('.epg-progress-panel .stat-badge')
.first()
).toBeVisible();
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
0
);
// Adding and removing the source row leaves the settings form
// dirty; save so the unsaved-changes close guard has nothing to
// ask about when the app closes below — an unanswered dialog
// would block the close and leak the Electron process.
await saveSettings(app.mainWindow);
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 20000,
})
.toBe(0);
} finally {
await closeElectronApp(app);
await epgServer.close();
}
});
test('@epg @electron removes a retained error row after its import worker has finished', async ({
dataDir,
}) => {
const server = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml',
});
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.fill(server.resourceUrl);
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1, { timeout: 30000 });
// Capture the actual generation; a force-fetch also awaits termination.
const generation = await app.mainWindow.evaluate(async (url) => {
await window.electron.clearEpgDataForSource(url);
const completed = new Promise<number>((resolve) => {
window.electron.onEpgProgress?.((progress) => {
if (
progress.url === url &&
progress.status === 'complete'
)
resolve(progress.generation ?? 0);
});
});
await window.electron.forceFetchEpg(url);
return completed;
}, server.resourceUrl);
await app.electronApp.evaluate(
({ BrowserWindow }, { url, generation }) => {
BrowserWindow.getAllWindows()[0].webContents.send(
'EPG_PROGRESS_UPDATE',
{
url,
status: 'error',
generation,
errorCode: 'invalid-tls-certificate',
error: 'Synthetic retained TLS error',
}
);
},
{ url: server.resourceUrl, generation }
);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-error'
)
).toHaveCount(1);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator('.epg-progress-panel .import-item')
).toHaveCount(0);
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
} finally {
await closeElectronApp(app);
await server.close();
}
});
test('@epg @electron removes only the saved source, preserves shared IDs and mappings, and repairs old orphan data on restart', async ({
dataDir,
}) => {
test.setTimeout(120000);
const first = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-news',
'Shared News',
'Removed Bulletin'
),
{ contentType: 'application/xml', resourcePath: '/first.xml' }
);
const second = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-news',
'Shared News',
'Retained Bulletin'
),
{ contentType: 'application/xml', resourcePath: '/second.xml' }
);
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
for (const [index, url] of [
first.resourceUrl,
second.resourceUrl,
].entries()) {
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
const inputs = app.mainWindow.locator('.epg-source-row input');
await expect(inputs).toHaveCount(index + 1);
await inputs.nth(index).fill(url);
}
await saveSettings(app.mainWindow);
const programs = () =>
app.mainWindow.evaluate(async () =>
(
await window.electron.getChannelPrograms('mapped-news')
).map((p) => p.title)
);
await app.mainWindow.evaluate(() =>
window.electron.setEpgMapping('mapped-news', 'shared-news')
);
await expect
.poll(programs, { timeout: 30000 })
.toEqual(['Removed Bulletin', 'Retained Bulletin']);
await app.mainWindow
.locator('.epg-source-row')
.first()
.locator('button')
.nth(1)
.click();
// A staged removal must not delete data before Save.
expect(await programs()).toContain('Removed Bulletin');
await saveSettings(app.mainWindow);
await expect.poll(programs).toEqual(['Retained Bulletin']);
await closeElectronApp(app);
app = await launchElectronApp(dataDir);
await expect.poll(programs).toEqual(['Retained Bulletin']);
// Simulate a cache left by 0.23: import a source absent from settings.
await app.mainWindow.evaluate(
(url) => window.electron.forceFetchEpg(url),
first.resourceUrl
);
await expect.poll(programs).toContain('Removed Bulletin');
await closeElectronApp(app);
app = await launchElectronApp(dataDir);
await expect.poll(programs).toEqual(['Retained Bulletin']);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
await expect.poll(programs).toEqual([]);
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
expect(
await app.mainWindow.evaluate(() =>
window.electron.getEpgMapping('mapped-news')
)
).toMatchObject({ epgChannelId: 'shared-news' });
} finally {
await closeElectronApp(app);
await first.close();
await second.close();
}
});
test('@epg @electron restores the first source name and logo after removing the last metadata writer', async ({
dataDir,
}) => {
const first = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-logo',
'First News',
'First Bulletin',
'https://example.com/first.png'
),
{ contentType: 'application/xml', resourcePath: '/first.xml' }
);
const second = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-logo',
'Second News',
'Second Bulletin',
'https://example.com/second.png'
),
{ contentType: 'application/xml', resourcePath: '/second.xml' }
);
let app = await launchElectronApp(dataDir);
const metadata = () =>
app.mainWindow.evaluate(
async () =>
(
await window.electron.getEpgChannelMetadata([
'shared-logo',
])
)['shared-logo']
);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
for (const [index, source] of [first, second].entries()) {
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
const inputs = app.mainWindow.locator('.epg-source-row input');
await expect(inputs).toHaveCount(index + 1);
await inputs.nth(index).fill(source.resourceUrl);
await saveSettings(app.mainWindow);
await expect.poll(metadata, { timeout: 30000 }).toMatchObject({
displayName: index === 0 ? 'First News' : 'Second News',
iconUrl: `https://example.com/${index === 0 ? 'first' : 'second'}.png`,
});
}
await app.mainWindow
.locator('.epg-source-row')
.nth(1)
.locator('button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
const firstMetadata = {
displayName: 'First News',
iconUrl: 'https://example.com/first.png',
};
await expect.poll(metadata).toMatchObject(firstMetadata);
await closeElectronApp(app);
app = await launchElectronApp(dataDir);
await expect.poll(metadata).toMatchObject(firstMetadata);
expect(
await app.mainWindow.evaluate(async () =>
(
await window.electron.getChannelPrograms('shared-logo')
).map((p) => p.title)
)
).toEqual(['First Bulletin']);
} finally {
await closeElectronApp(app);
await first.close();
await second.close();
}
});
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
dataDir,
}) => {
test.setTimeout(90000);
const epgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'playlist-guide-news',
'Playlist Guide News',
'Playlist Scoped Bulletin'
),
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guides/ua/playlist-guide.xml',
}
);
const detectedEpgUrls = [
`${epgServer.origin}/guides/us/ignored.xml`,
`${epgServer.origin}/guides/de/ignored.xml`,
epgServer.resourceUrl,
`${epgServer.origin}/guides/fr/ignored.xml`,
`${epgServer.origin}/guides/uk/ignored.xml`,
`${epgServer.origin}/guides/es/ignored.xml`,
];
const playlistContent = buildM3uContent([
{
name: 'Playlist Guide News',
tvgCountry: 'UA',
tvgId: 'playlist-guide-news',
url: 'https://example.com/live/playlist-guide-news.m3u8',
},
]).replace(
'#EXTM3U',
`#EXTM3U x-tvg-url="${detectedEpgUrls.join(',')}"`
);
const playlistServer = await createMutableTextServer(playlistContent, {
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/playlist-with-epg.m3u',
});
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
playlistServer.resourceUrl
);
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
await expect(
app.mainWindow.locator('.epg-progress-panel .import-item')
).toHaveCount(1);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1, { timeout: 30000 });
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle(
app.mainWindow,
'Playlist Guide News'
);
await expect(channelItem).toBeVisible();
await expect(channelItem.locator('.epg-title')).toContainText(
'Playlist Scoped Bulletin',
{ timeout: 30000 }
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.fill(epgServer.resourceUrl);
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
// The same URL still belongs to the saved M3U playlist.
const retained = await app.mainWindow.evaluate(async () =>
window.electron.getChannelPrograms('playlist-guide-news')
);
expect(retained.map((program) => program.title)).toContain(
'Playlist Scoped Bulletin'
);
} finally {
await closeElectronApp(app);
await playlistServer.close();
await epgServer.close();
}
});
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
dataDir,
}) => {
const playlistServer = await createMutableTextServer(
buildM3uContent([
{
name: 'Guide News Live',
tvgId: 'guide-news',
url: 'https://example.com/live/guide-news.m3u8',
},
]),
{
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/playlist.m3u',
}
);
const epgServer = await createMutableTextServer(
`<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="guide-news">
<display-name>Guide News Live</display-name>
<icon src="${fallbackLogoDataUrl}"/>
</channel>
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
<title>Guide Bulletin</title>
<desc>EPG logo fallback test.</desc>
</programme>
</tv>
`,
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guide.xml',
}
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
playlistServer.resourceUrl
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
// The added source is still only staged in the form — leaving
// settings without saving would surface the unsaved-changes
// dialog and block the navigation below.
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle(
app.mainWindow,
'Guide News Live'
);
await expect(channelItem).toBeVisible();
await expect(channelItem.locator('.channel-logo')).toHaveAttribute(
'src',
fallbackLogoDataUrl
);
await expect(
channelItem.locator('.channel-logo-fallback')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
await playlistServer.close();
await epgServer.close();
}
});
});
async function getEpgChannelCount(page: Page): Promise<number> {
return page.evaluate(async () => {
const result = await window.electron?.getEpgChannels?.();
return Array.isArray(result?.channels) ? result.channels.length : 0;
});
}