feat(epg): support playlist-scoped sources

Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
4gray authored and GitHub committed 2026-06-21 22:53:07 +02:00
1 parent 36a7ce9f3d
commit e801028005
71 files changed
+4876 -373

No files matched your search

+5 -4
View File
@@ -323,10 +323,11 @@ Useful narrower flags:
Security-sensitive network compatibility flags are opt-in:
- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits strict EPG fetches from
playlist metadata (`url-tvg`) to resolve to localhost, LAN, or other private
addresses. Directly configured Xtream/Stalker portals and private playlist
servers remain supported without this flag. Prefer the in-app source-scoped
“Allow source” action for a trusted EPG URL.
playlist metadata (`x-tvg-url`, `url-tvg`, or `tvg-url`) to resolve to
localhost, LAN, or other private addresses. Directly configured
Xtream/Stalker portals and private playlist servers remain supported without
this flag. Prefer the in-app source-scoped “Allow source” action for a trusted
EPG URL.
- `IPTVNATOR_ALLOW_INSECURE_TLS=1` disables certificate validation for remote
playlist imports and refreshes for the whole Electron process. Prefer the
in-app host-scoped trust action for a trusted provider with a self-signed or
@@ -48,7 +48,9 @@ export type M3uTestChannel = {
logo?: string;
name: string;
radio?: boolean;
tvgCountry?: string;
tvgId?: string;
tvgLanguage?: string;
tvgName?: string;
url: string;
};
@@ -614,6 +616,10 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
for (const channel of channels) {
const attributes = [
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
channel.tvgLanguage
? `tvg-language="${channel.tvgLanguage}"`
: '',
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
@@ -1140,7 +1146,10 @@ export async function saveSourceDialog(
page: Page,
dialog: Locator
): Promise<void> {
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true })
.click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
await expectPlaylistUpdatedToast(page);
}
+135 -6
View File
@@ -28,6 +28,40 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
</tv>
`;
function createCurrentXmltvFixture(
channelId: string,
channelName: string,
programTitle: 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>
</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 adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
dataDir,
@@ -48,7 +82,10 @@ test.describe('Electron EPG', () => {
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow.locator('.epg-source-row button').first().click();
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
@@ -58,18 +95,27 @@ test.describe('Electron EPG', () => {
})
.toBeGreaterThan(0);
await expect(
app.mainWindow.locator('.epg-progress-panel .stat-badge').first()
app.mainWindow
.locator('.epg-progress-panel .stat-badge')
.first()
).toBeVisible();
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(0);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
0
);
await app.mainWindow
.getByRole('button', { name: 'Clear EPG data' })
.click();
const dialog = app.mainWindow.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await dialog
.getByRole('button', { name: 'Yes', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
@@ -85,6 +131,86 @@ test.describe('Electron EPG', () => {
}
});
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
dataDir,
}) => {
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 }
);
} 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,
}) => {
@@ -135,7 +261,10 @@ test.describe('Electron EPG', () => {
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow.locator('.epg-source-row button').first().click();
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
@@ -405,6 +405,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
channel: 'EPG_CLEAR_ALL',
forwardedArgs: [],
},
{
method: 'clearEpgDataForSource',
args: ['https://example.com/guide.xml'],
channel: 'EPG_CLEAR_SOURCE',
forwardedArgs: [{ sourceUrl: 'https://example.com/guide.xml' }],
},
{
method: 'checkEpgFreshness',
args: [epgUrls, 24],
@@ -7,6 +7,7 @@ import type {
ElectronBridgeApi,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
ElectronBridgeEpgLookupOptions,
ElectronBridgeEpgProgress,
ElectronBridgePlaybackPositionInput,
ElectronBridgePlaylistInput,
@@ -460,18 +461,31 @@ const electronApi: ElectronBridgeApi = {
) => ipcRenderer.invoke('AUTO_UPDATE', playlists, options),
fetchEpg: (urls: string[], options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('FETCH_EPG', { url: urls, options }),
getChannelPrograms: (channelId: string) =>
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
getCurrentProgramsBatch: (channelIds: string[]) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds }),
getEpgChannelMetadata: (channelIds: string[]) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
getChannelPrograms: (
channelId: string,
options?: ElectronBridgeEpgLookupOptions
) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId, options }),
getCurrentProgramsBatch: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', {
channelIds,
options,
}),
getEpgChannelMetadata: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds, options }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
getEpgChannelsByRange: (skip: number, limit: number) =>
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
clearEpgDataForSource: (sourceUrl: string) =>
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
@@ -68,6 +68,17 @@ describe('playlist.operations', () => {
importDate: '2026-04-01T00:00:00.000Z',
favorites: JSON.stringify(['channel-1']),
recentlyViewed: JSON.stringify([{ id: 'recent-1' }]),
epgUrls: JSON.stringify(['https://example.com/enabled.xml']),
detectedEpgUrls: JSON.stringify([
'https://example.com/enabled.xml',
'https://example.com/detected-only.xml',
]),
manualEpgUrls: JSON.stringify([
'https://example.com/manual.xml',
]),
disabledEpgUrls: JSON.stringify([
'https://example.com/disabled.xml',
]),
autoRefresh: false,
url: 'https://example.com/list.m3u',
},
@@ -84,6 +95,13 @@ describe('playlist.operations', () => {
count: 2,
favorites: ['channel-1'],
recentlyViewed: [{ id: 'recent-1' }],
epgUrls: ['https://example.com/enabled.xml'],
detectedEpgUrls: [
'https://example.com/enabled.xml',
'https://example.com/detected-only.xml',
],
manualEpgUrls: ['https://example.com/manual.xml'],
disabledEpgUrls: ['https://example.com/disabled.xml'],
url: 'https://example.com/list.m3u',
}),
]);
@@ -59,6 +59,16 @@ function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
}
}
function getStringArrayValue(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
function getFavoriteChannelMatch(
channel: Channel,
favoritePositions: ReadonlyMap<string, number>
@@ -149,6 +159,22 @@ function buildPlaylistRow(
origin: getStringValue(playlist.origin),
referrer: getStringValue(playlist.referrer),
filePath: getStringValue(playlist.filePath),
epgUrls:
playlist.epgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.epgUrls))
: undefined,
detectedEpgUrls:
playlist.detectedEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.detectedEpgUrls))
: undefined,
manualEpgUrls:
playlist.manualEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.manualEpgUrls))
: undefined,
disabledEpgUrls:
playlist.disabledEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.disabledEpgUrls))
: undefined,
autoRefresh: Boolean(playlist.autoRefresh),
macAddress: getStringValue(playlist.macAddress),
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
@@ -170,7 +196,9 @@ function buildPlaylistRow(
};
}
export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
export function parseAppPlaylist(
row: schema.Playlist
): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
@@ -178,11 +206,28 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const epgUrls = getStringArrayValue(
row.epgUrls ? parseJsonValue<unknown[]>(row.epgUrls, []) : base.epgUrls
);
const detectedEpgUrls = getStringArrayValue(
row.detectedEpgUrls
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
: (base.detectedEpgUrls ?? epgUrls)
);
const manualEpgUrls = getStringArrayValue(
row.manualEpgUrls
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
: base.manualEpgUrls
);
const disabledEpgUrls = getStringArrayValue(
row.disabledEpgUrls
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
: base.disabledEpgUrls
);
const importDate =
row.importDate ?? row.dateCreated ?? new Date().toISOString();
const portalUrl =
row.portalUrl ??
(row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
@@ -191,9 +236,7 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
...base,
_id: row.id,
title:
getStringValue(base.title) ??
getStringValue(base.name) ??
row.name,
getStringValue(base.title) ?? getStringValue(base.name) ?? row.name,
count: row.count ?? getNumericValue(base.count) ?? 0,
importDate: getStringValue(base.importDate) ?? importDate,
lastUsage:
@@ -206,9 +249,13 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? row.url ?? getStringValue(base.url)
? (row.url ?? getStringValue(base.url))
: getStringValue(base.url),
filePath: row.filePath ?? getStringValue(base.filePath),
epgUrls,
detectedEpgUrls,
manualEpgUrls,
disabledEpgUrls,
userAgent: row.userAgent ?? getStringValue(base.userAgent),
referrer: row.referrer ?? getStringValue(base.referrer),
origin: row.origin ?? getStringValue(base.origin),
@@ -260,13 +307,10 @@ export async function upsertAppPlaylist(
throw new Error('Playlist ID is required for upsert');
}
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
return { success: true };
}
@@ -289,8 +333,7 @@ export async function upsertAppPlaylists(
await db.transaction((tx) => {
for (const row of rows) {
tx
.insert(schema.playlists)
tx.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
@@ -323,6 +366,10 @@ export async function getAppPlaylistMetas(db: AppDatabase) {
origin: schema.playlists.origin,
referrer: schema.playlists.referrer,
filePath: schema.playlists.filePath,
epgUrls: schema.playlists.epgUrls,
detectedEpgUrls: schema.playlists.detectedEpgUrls,
manualEpgUrls: schema.playlists.manualEpgUrls,
disabledEpgUrls: schema.playlists.disabledEpgUrls,
autoRefresh: schema.playlists.autoRefresh,
macAddress: schema.playlists.macAddress,
url: schema.playlists.url,
@@ -453,25 +500,29 @@ export async function deletePlaylist(
playlistId: string,
control?: OperationControl
): Promise<{ success: boolean }> {
const [favoriteRows, recentlyViewedRows, playbackPositionRows, downloadRows] =
await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const [
favoriteRows,
recentlyViewedRows,
playbackPositionRows,
downloadRows,
] = await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const categoryRows = await db
.select({ id: schema.categories.id })
@@ -543,7 +594,9 @@ export async function deletePlaylist(
}
await checkpointOperation(control);
await db.delete(schema.playlists).where(eq(schema.playlists.id, playlistId));
await db
.delete(schema.playlists)
.where(eq(schema.playlists.id, playlistId));
await reportOperationProgress(control, {
phase: 'deleting-playlist',
current: 1,
@@ -0,0 +1,83 @@
const getDatabase = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('../../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown {
const { ipcMain } = jest.requireMock('electron') as {
ipcMain: { handle: jest.Mock };
};
const calls = ipcMain.handle.mock.calls as Array<
[string, (...args: unknown[]) => unknown]
>;
const match = calls.find(
([registeredChannel]) => registeredChannel === channel
);
if (!match) {
throw new Error(`Missing ipcMain handler for ${channel}`);
}
return match[1];
}
describe('epg-db.events', () => {
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(async () => {
jest.resetModules();
getDatabase.mockReset();
const { ipcMain } = jest.requireMock('electron') as {
ipcMain: { handle: jest.Mock };
};
ipcMain.handle.mockClear();
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
await import('./epg-db.events');
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('clears a source in a single database transaction', async () => {
const runOrder: string[] = [];
const txDelete = jest
.fn()
.mockReturnValueOnce({
where: jest.fn(() => ({
run: jest.fn(() => runOrder.push('programs')),
})),
})
.mockReturnValueOnce({
where: jest.fn(() => ({
run: jest.fn(() => runOrder.push('channels')),
})),
});
const transaction = jest.fn((callback: (tx: unknown) => void) =>
callback({ delete: txDelete })
);
getDatabase.mockResolvedValue({ transaction });
await expect(
getIpcMainHandler('EPG_DB_CLEAR_SOURCE')(
{},
'https://playlist.example.com/guide.xml'
)
).resolves.toEqual({ success: true });
expect(transaction).toHaveBeenCalledTimes(1);
expect(txDelete).toHaveBeenCalledTimes(2);
expect(runOrder).toEqual(['programs', 'channels']);
});
});
@@ -298,10 +298,21 @@ ipcMain.handle('EPG_DB_CLEAR_SOURCE', async (_event, sourceUrl: string) => {
try {
const db = await getDatabase();
// Deleting channels will cascade delete programs due to foreign key
await db
.delete(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, sourceUrl));
await db.transaction((tx) => {
tx.delete(schema.epgPrograms)
.where(eq(schema.epgPrograms.sourceUrl, sourceUrl))
.run();
tx.delete(schema.epgChannels)
.where(sql`
${schema.epgChannels.sourceUrl} = ${sourceUrl}
AND NOT EXISTS (
SELECT 1
FROM ${schema.epgPrograms}
WHERE ${schema.epgPrograms.channelId} = ${schema.epgChannels.id}
)
`)
.run();
});
console.log(loggerLabel, `Cleared EPG data for source: ${sourceUrl}`);
return { success: true };
@@ -0,0 +1,257 @@
import { EpgQueryService } from './epg-query.service';
const getDatabase = jest.fn();
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
function flattenSql(value: unknown, seen = new Set<unknown>()): string {
if (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return String(value ?? '');
}
if (seen.has(value)) {
return '';
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => flattenSql(item, seen)).join(' ');
}
const sqlLike = value as {
name?: unknown;
queryChunks?: unknown[];
value?: unknown[];
};
if (Array.isArray(sqlLike.queryChunks)) {
return sqlLike.queryChunks
.map((chunk) => flattenSql(chunk, seen))
.join(' ');
}
if (Array.isArray(sqlLike.value)) {
return sqlLike.value.join(' ');
}
if (typeof sqlLike.name === 'string') {
return sqlLike.name;
}
return '';
}
function createSelectChain(
whereResult: unknown,
whereCalls: unknown[]
): { from: jest.Mock } {
const where = jest.fn((condition: unknown) => {
whereCalls.push(condition);
return whereResult;
});
return {
from: jest.fn(() => ({ where })),
};
}
function createLimitedSelectChain(
limitResult: unknown,
whereCalls: unknown[]
): { from: jest.Mock } {
const limit = jest.fn().mockResolvedValue(limitResult);
const where = jest.fn((condition: unknown) => {
whereCalls.push(condition);
return { limit };
});
return {
from: jest.fn(() => ({ where })),
};
}
describe('EpgQueryService', () => {
let service: EpgQueryService;
beforeEach(() => {
getDatabase.mockReset();
service = new EpgQueryService('[Test EPG]');
});
it('scopes display-name current program fallback by program source ownership for shared channels', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(createSelectChain([], whereCalls))
.mockReturnValueOnce(createSelectChain([], whereCalls));
getDatabase.mockResolvedValue({ select });
await service.getCurrentProgramsBatch(['Shared Channel'], {
sourceUrls: ['https://second.example.com/guide.xml'],
});
const channelScopeSql = whereCalls.map((condition) =>
flattenSql(condition)
);
expect(
channelScopeSql.some(
(condition) =>
condition.includes('EXISTS') &&
condition.includes('channel_id') &&
condition.includes('source_url')
)
).toBe(true);
});
it('falls back to legacy unscoped current programs after a scoped batch miss', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(
createLimitedSelectChain(
[
{
id: 1,
channelId: 'legacy-channel',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'Legacy Guide Program',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: null,
},
],
whereCalls
)
);
getDatabase.mockResolvedValue({ select });
const result = await service.getCurrentProgramsBatch(
['legacy-channel'],
{
sourceUrls: ['https://playlist.example.com/guide.xml'],
}
);
expect(result['legacy-channel']?.title).toBe('Legacy Guide Program');
expect(
whereCalls
.map((condition) => flattenSql(condition).toLowerCase())
.some(
(condition) =>
condition.includes('source_url') &&
condition.includes('is null')
)
).toBe(true);
});
it('resolves display-name current program fallback in batched queries', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(
createSelectChain(
[
{
id: 'guide-news',
displayName: 'News Channel',
iconUrl: null,
},
{
id: 'guide-sports',
displayName: 'Sports Channel',
iconUrl: null,
},
],
whereCalls
)
)
.mockReturnValueOnce(
createLimitedSelectChain(
[
{
id: 1,
channelId: 'guide-news',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'News Now',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: 'https://playlist.example.com/guide.xml',
},
{
id: 2,
channelId: 'guide-sports',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'Sports Now',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: 'https://playlist.example.com/guide.xml',
},
],
whereCalls
)
);
getDatabase.mockResolvedValue({ select });
const result = await service.getCurrentProgramsBatch(
['News Channel', 'Sports Channel'],
{
sourceUrls: ['https://playlist.example.com/guide.xml'],
}
);
expect(result['News Channel']?.title).toBe('News Now');
expect(result['Sports Channel']?.title).toBe('Sports Now');
expect(select).toHaveBeenCalledTimes(4);
});
it('scopes channel metadata by program source ownership for shared channels', async () => {
const whereCalls: unknown[] = [];
const select = jest.fn(() => createSelectChain([], whereCalls));
getDatabase.mockResolvedValue({ select });
await service.getChannelMetadata(['Shared Channel'], {
sourceUrls: ['https://second.example.com/guide.xml'],
});
const metadataScopeSql = whereCalls.map((condition) =>
flattenSql(condition)
);
expect(
metadataScopeSql.some(
(condition) =>
condition.includes('EXISTS') &&
condition.includes('channel_id') &&
condition.includes('source_url')
)
).toBe(true);
});
});
@@ -1,4 +1,14 @@
import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm';
import {
and,
eq,
gte,
inArray,
isNull,
lte,
or,
sql,
type SQL,
} from 'drizzle-orm';
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
@@ -16,24 +26,37 @@ interface EpgProgramRow {
episodeNum: string | null;
}
type EpgDatabase = Awaited<ReturnType<typeof getDatabase>>;
type EpgChannelRow = typeof schema.epgChannels.$inferSelect;
export class EpgQueryService {
constructor(private readonly loggerLabel = '[EPG Events]') {}
async getChannelPrograms(channelId: string): Promise<EpgProgram[]> {
async getChannelPrograms(
channelId: string,
options: { sourceUrls?: string[] } = {}
): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const trimmedChannelId = channelId.trim();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
if (!trimmedChannelId) {
return [];
}
let results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, trimmedChannelId))
.orderBy(schema.epgPrograms.start)
.limit(500);
let results = await this.selectChannelPrograms(
db,
trimmedChannelId,
sourceUrls
);
if (results.length === 0) {
results = await this.selectLegacyChannelPrograms(
db,
trimmedChannelId,
sourceUrls
);
}
if (results.length > 0) {
return results
@@ -41,21 +64,32 @@ export class EpgQueryService {
.filter(this.isValidEpgProgram);
}
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${trimmedChannelId} COLLATE NOCASE`
)
.limit(1);
let channel = await this.selectChannelById(
db,
trimmedChannelId,
sourceUrls
);
if (channel.length === 0) {
channel = await this.selectLegacyChannelById(
db,
trimmedChannelId,
sourceUrls
);
}
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel[0].id))
.orderBy(schema.epgPrograms.start)
.limit(500);
results = await this.selectChannelPrograms(
db,
channel[0].id,
sourceUrls
);
if (results.length === 0) {
results = await this.selectLegacyChannelPrograms(
db,
channel[0].id,
sourceUrls
);
}
if (results.length > 0) {
return results
@@ -64,29 +98,51 @@ export class EpgQueryService {
}
}
channel = await db
.select()
.from(schema.epgChannels)
.where(eq(schema.epgChannels.displayName, trimmedChannelId))
.limit(1);
channel = await this.selectChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls
);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${trimmedChannelId} COLLATE NOCASE`
)
.limit(1);
channel = await this.selectChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls,
{ caseInsensitive: true }
);
}
if (channel.length === 0) {
channel = await this.selectLegacyChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls
);
}
if (channel.length === 0) {
channel = await this.selectLegacyChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls,
{ caseInsensitive: true }
);
}
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel[0].id))
.orderBy(schema.epgPrograms.start)
.limit(500);
results = await this.selectChannelPrograms(
db,
channel[0].id,
sourceUrls
);
if (results.length === 0) {
results = await this.selectLegacyChannelPrograms(
db,
channel[0].id,
sourceUrls
);
}
return results
.map(this.transformDbRowToEpgProgram)
@@ -105,7 +161,8 @@ export class EpgQueryService {
}
async getCurrentProgramsBatch(
channelIds: string[]
channelIds: string[],
options: { sourceUrls?: string[] } = {}
): Promise<Record<string, EpgProgram | null>> {
const result: Record<string, EpgProgram | null> = {};
if (!Array.isArray(channelIds) || channelIds.length === 0) {
@@ -126,75 +183,102 @@ export class EpgQueryService {
try {
const db = await getDatabase();
const now = new Date().toISOString();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
const rows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
inArray(schema.epgPrograms.channelId, validIds),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
this.assignCurrentProgramRows(
result,
await this.selectCurrentProgramsForChannelIds(
db,
validIds,
now,
sourceUrls
)
);
let unmatchedIds = validIds.filter((id) => !(id in result));
if (unmatchedIds.length > 0) {
this.assignCurrentProgramRows(
result,
await this.selectCurrentProgramsForChannelIds(
db,
unmatchedIds,
now,
sourceUrls,
{ legacyOnly: true }
)
);
for (const row of rows) {
if (!result[row.channelId]) {
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
result[row.channelId] = program;
}
}
}
const unmatchedIds = validIds.filter((id) => !(id in result));
for (const channelId of unmatchedIds) {
result[channelId] = null;
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`
)
.limit(1);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${channelId} COLLATE NOCASE`
unmatchedIds = validIds.filter((id) => !(id in result));
if (unmatchedIds.length > 0) {
const scopedCandidates = await this.selectChannelLookupCandidates(
db,
unmatchedIds,
sourceUrls
);
const unresolvedIds = unmatchedIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(
channelId,
scopedCandidates
)
.limit(1);
}
if (channel.length === 0) {
continue;
}
const programRows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channel[0].id),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.limit(1);
if (programRows.length > 0) {
const program = this.transformDbRowToEpgProgram(
programRows[0]
);
const candidates =
unresolvedIds.length > 0
? [
...scopedCandidates,
...(await this.selectChannelLookupCandidates(
db,
unresolvedIds,
sourceUrls,
{ legacyOnly: true }
)),
]
: scopedCandidates;
const candidateIdsByRequestedId = new Map<string, string>();
for (const channelId of unmatchedIds) {
const candidate = this.resolveChannelMetadataCandidate(
channelId,
candidates
);
if (this.isValidEpgProgram(program)) {
result[channelId] = program;
if (candidate) {
candidateIdsByRequestedId.set(channelId, candidate.id);
}
}
const candidateIds = Array.from(
new Set(candidateIdsByRequestedId.values())
);
const matchedCandidateIds =
this.assignCandidateCurrentProgramRows(
result,
candidateIdsByRequestedId,
await this.selectCurrentProgramsForChannelIds(
db,
candidateIds,
now,
sourceUrls
)
);
const unresolvedCandidateIds = candidateIds.filter(
(candidateId) => !matchedCandidateIds.has(candidateId)
);
this.assignCandidateCurrentProgramRows(
result,
candidateIdsByRequestedId,
await this.selectCurrentProgramsForChannelIds(
db,
unresolvedCandidateIds,
now,
sourceUrls,
{ legacyOnly: true }
)
);
}
for (const channelId of validIds) {
result[channelId] ??= null;
}
return result;
} catch (error) {
console.error(
@@ -232,7 +316,8 @@ export class EpgQueryService {
}
async getChannelMetadata(
channelIds: string[]
channelIds: string[],
options: { sourceUrls?: string[] } = {}
): Promise<Record<string, EpgChannelMetadata | null>> {
try {
const normalizedChannelIds =
@@ -243,6 +328,7 @@ export class EpgQueryService {
}
const db = await getDatabase();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
const lowerKeys = Array.from(
new Set(
normalizedChannelIds.map((channelId) =>
@@ -251,17 +337,33 @@ export class EpgQueryService {
)
);
const lowerKeyValues = lowerKeys.map((key) => sql`${key}`);
const candidates = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels).where(sql`
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
`);
let candidates = await this.selectChannelMetadataCandidates(
db,
lowerKeyValues,
sourceUrls
);
if (sourceUrls.length > 0) {
const missingChannelIds = normalizedChannelIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(
channelId,
candidates
)
);
if (missingChannelIds.length > 0) {
candidates = [
...candidates,
...(await this.selectChannelMetadataCandidates(
db,
missingChannelIds
.map((channelId) => channelId.toLowerCase())
.map((key) => sql`${key}`),
sourceUrls,
{ legacyOnly: true }
)),
];
}
}
return Object.fromEntries(
normalizedChannelIds.map((channelId) => [
@@ -337,6 +439,332 @@ export class EpgQueryService {
);
}
private normalizeSourceUrls(sourceUrls?: string[]): string[] {
return Array.from(
new Set(
(sourceUrls ?? [])
.map((sourceUrl) => sourceUrl.trim())
.filter((sourceUrl) => sourceUrl.length > 0)
)
);
}
private assignCurrentProgramRows(
result: Record<string, EpgProgram | null>,
rows: EpgProgramRow[]
): Set<string> {
const matchedChannelIds = new Set<string>();
for (const row of rows) {
if (result[row.channelId]) {
continue;
}
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
result[row.channelId] = program;
matchedChannelIds.add(row.channelId);
}
}
return matchedChannelIds;
}
private assignCandidateCurrentProgramRows(
result: Record<string, EpgProgram | null>,
candidateIdsByRequestedId: Map<string, string>,
rows: EpgProgramRow[]
): Set<string> {
const matchedCandidateIds = new Set<string>();
for (const row of rows) {
const program = this.transformDbRowToEpgProgram(row);
if (!this.isValidEpgProgram(program)) {
continue;
}
for (const [
requestedId,
candidateId,
] of candidateIdsByRequestedId) {
if (candidateId !== row.channelId || result[requestedId]) {
continue;
}
result[requestedId] = program;
matchedCandidateIds.add(candidateId);
}
}
return matchedCandidateIds;
}
private async selectChannelPrograms(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgProgramRow[]> {
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
sourceUrls
)
)
.orderBy(schema.epgPrograms.start)
.limit(500);
}
private async selectLegacyChannelPrograms(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgProgramRow[]> {
if (sourceUrls.length === 0) {
return [];
}
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
sourceUrls,
{ legacyOnly: true }
)
)
.orderBy(schema.epgPrograms.start)
.limit(500);
}
private async selectCurrentProgramsForChannelIds(
db: EpgDatabase,
channelIds: string[],
now: string,
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): Promise<EpgProgramRow[]> {
if (channelIds.length === 0) {
return [];
}
if (options.legacyOnly && sourceUrls.length === 0) {
return [];
}
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
and(
inArray(schema.epgPrograms.channelId, channelIds),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
) as SQL,
sourceUrls,
options
)
)
.limit(channelIds.length);
}
private async selectChannelById(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgChannelRow[]> {
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`,
sourceUrls
)
)
.limit(1);
}
private async selectLegacyChannelById(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgChannelRow[]> {
if (sourceUrls.length === 0) {
return [];
}
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`,
sourceUrls,
{ legacyOnly: true }
)
)
.limit(1);
}
private async selectChannelByDisplayName(
db: EpgDatabase,
displayName: string,
sourceUrls: string[],
options: { caseInsensitive?: boolean } = {}
): Promise<EpgChannelRow[]> {
const condition = options.caseInsensitive
? (sql`${schema.epgChannels.displayName} = ${displayName} COLLATE NOCASE` as SQL)
: eq(schema.epgChannels.displayName, displayName);
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(condition, sourceUrls)
)
.limit(1);
}
private async selectLegacyChannelByDisplayName(
db: EpgDatabase,
displayName: string,
sourceUrls: string[],
options: { caseInsensitive?: boolean } = {}
): Promise<EpgChannelRow[]> {
if (sourceUrls.length === 0) {
return [];
}
const condition = options.caseInsensitive
? (sql`${schema.epgChannels.displayName} = ${displayName} COLLATE NOCASE` as SQL)
: eq(schema.epgChannels.displayName, displayName);
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
condition,
sourceUrls,
{ legacyOnly: true }
)
)
.limit(1);
}
private async selectChannelLookupCandidates(
db: EpgDatabase,
channelIds: string[],
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): Promise<EpgChannelMetadata[]> {
const lowerKeyValues = Array.from(
new Set(channelIds.map((channelId) => channelId.toLowerCase()))
).map((key) => sql`${key}`);
return this.selectChannelMetadataCandidates(
db,
lowerKeyValues,
sourceUrls,
options
);
}
private async selectChannelMetadataCandidates(
db: EpgDatabase,
lowerKeyValues: SQL[],
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): Promise<EpgChannelMetadata[]> {
if (options.legacyOnly && sourceUrls.length === 0) {
return [];
}
const lookupCondition = sql`
(
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
)
`;
return db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
lookupCondition as SQL,
sourceUrls,
options
)
);
}
private withProgramSourceScope(
condition: SQL,
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): SQL {
if (options.legacyOnly) {
return and(condition, this.legacyProgramSourceCondition()) as SQL;
}
return sourceUrls.length > 0
? (and(
condition,
inArray(schema.epgPrograms.sourceUrl, sourceUrls)
) as SQL)
: condition;
}
private withChannelSourceScope(
condition: SQL,
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): SQL {
if (options.legacyOnly) {
return and(condition, this.legacyChannelSourceCondition()) as SQL;
}
return sourceUrls.length > 0
? (and(
condition,
or(
inArray(schema.epgChannels.sourceUrl, sourceUrls),
this.channelHasProgramsForSourceScope(sourceUrls)
) as SQL
) as SQL)
: condition;
}
private legacyProgramSourceCondition(): SQL {
return or(
isNull(schema.epgPrograms.sourceUrl),
eq(schema.epgPrograms.sourceUrl, '')
) as SQL;
}
private legacyChannelSourceCondition(): SQL {
return or(
isNull(schema.epgChannels.sourceUrl),
eq(schema.epgChannels.sourceUrl, '')
) as SQL;
}
private channelHasProgramsForSourceScope(sourceUrls: string[]): SQL {
const sourceUrlValues = sourceUrls.map((sourceUrl) => sql`${sourceUrl}`);
return sql`EXISTS (
SELECT 1
FROM ${schema.epgPrograms}
WHERE ${schema.epgPrograms.channelId} = ${schema.epgChannels.id}
AND ${schema.epgPrograms.sourceUrl} IN (${sql.join(sourceUrlValues, sql`, `)})
)`;
}
private resolveChannelMetadataCandidate(
channelId: string,
candidates: EpgChannelMetadata[]
@@ -24,6 +24,11 @@ interface EpgWorkerMessage {
stats?: EpgProgressStats;
}
interface ClearWorkerMessage {
type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
sourceUrl?: string;
}
export class EpgWorkerService {
private readonly fetchedUrls = new Set<string>();
private readonly workers = new Map<string, Worker>();
@@ -127,17 +132,54 @@ export class EpgWorkerService {
// Guards against double-settling and keeps the outer loop moving
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let lastProgressStats: EpgProgressStats = {
totalChannels: 0,
totalPrograms: 0,
};
const clearFetchTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
clearFetchTimeout();
fn();
};
const timeoutId = setTimeout(() => {
const scheduleFetchTimeout = () => {
clearFetchTimeout();
timeoutId = setTimeout(() => {
handleFetchTimeout();
}, this.fetchTimeoutMs);
};
const hasProgressMoved = (stats: EpgProgressStats): boolean =>
stats.totalChannels > lastProgressStats.totalChannels ||
stats.totalPrograms > lastProgressStats.totalPrograms;
const recordProgress = (stats: EpgProgressStats): void => {
lastProgressStats = {
totalChannels: Math.max(
lastProgressStats.totalChannels,
stats.totalChannels
),
totalPrograms: Math.max(
lastProgressStats.totalPrograms,
stats.totalPrograms
),
};
};
const handleFetchTimeout = () => {
const errorMessage = `EPG fetch timed out after ${
this.fetchTimeoutMs / 1000
}s`;
}s without progress`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
url,
@@ -154,12 +196,15 @@ export class EpgWorkerService {
() => reject(new Error(errorMessage))
);
});
}, this.fetchTimeoutMs);
};
scheduleFetchTimeout();
worker.on('message', async (message: EpgWorkerMessage) => {
try {
switch (message.type) {
case 'READY':
scheduleFetchTimeout();
this.sendProgressToRenderer(url, 'loading', {
totalChannels: 0,
totalPrograms: 0,
@@ -173,6 +218,10 @@ export class EpgWorkerService {
case 'EPG_PROGRESS':
if (message.stats) {
if (hasProgressMoved(message.stats)) {
recordProgress(message.stats);
scheduleFetchTimeout();
}
this.sendProgressToRenderer(
url,
'loading',
@@ -287,6 +336,77 @@ export class EpgWorkerService {
}
async clearEpgData(): Promise<void> {
return this.runClearWorker({
timeoutLabel: 'EPG clear',
exitLabel: 'Clear worker',
readyMessage: { type: 'CLEAR_EPG' },
completeWorkerLabel: 'completed clear',
failedWorkerLabel: 'failed clear',
erroredWorkerLabel: 'errored clear',
onComplete: async (worker) => {
console.log(this.loggerLabel, 'EPG data cleared via worker');
this.fetchedUrls.clear();
// Resolve only after every interrupted fetch worker has exited
// too — they may still hold the SQLite lock the caller expects
// to be free.
const terminations = [...this.workers.values()].map(
(runningWorker) =>
this.terminateWorker(
runningWorker,
'fetch during clear'
)
);
this.workers.clear();
terminations.push(
this.terminateWorker(worker, 'completed clear')
);
await Promise.all(terminations);
},
});
}
async clearEpgDataForSource(sourceUrl: string): Promise<void> {
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return;
}
const runningWorker = this.workers.get(normalizedSourceUrl);
if (runningWorker) {
this.workers.delete(normalizedSourceUrl);
await this.terminateWorker(runningWorker, 'source clear');
}
return this.runClearWorker({
timeoutLabel: 'EPG source clear',
exitLabel: 'Source clear worker',
readyMessage: {
type: 'CLEAR_EPG_SOURCE',
sourceUrl: normalizedSourceUrl,
},
completeWorkerLabel: 'completed source clear',
failedWorkerLabel: 'failed source clear',
erroredWorkerLabel: 'errored source clear',
onComplete: async (worker) => {
console.log(
this.loggerLabel,
`EPG data cleared for source via worker: ${normalizedSourceUrl}`
);
this.fetchedUrls.delete(normalizedSourceUrl);
await this.terminateWorker(worker, 'completed source clear');
},
});
}
private runClearWorker(options: {
timeoutLabel: string;
exitLabel: string;
readyMessage: ClearWorkerMessage;
completeWorkerLabel: string;
failedWorkerLabel: string;
erroredWorkerLabel: string;
onComplete: (worker: Worker) => Promise<void>;
}): Promise<void> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
@@ -302,6 +422,7 @@ export class EpgWorkerService {
}
let settled = false;
let timeoutId: ReturnType<typeof setTimeout>;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
@@ -309,15 +430,16 @@ export class EpgWorkerService {
fn();
};
const timeoutId = setTimeout(() => {
const errorMessage = `EPG clear timed out after ${
timeoutId = setTimeout(() => {
const errorMessage = `${options.timeoutLabel} timed out after ${
this.fetchTimeoutMs / 1000
}s`;
console.error(this.loggerLabel, errorMessage);
settle(() => {
void this.terminateWorker(worker, 'timed out clear').then(
() => reject(new Error(errorMessage))
);
void this.terminateWorker(
worker,
`timed out ${options.timeoutLabel}`
).then(() => reject(new Error(errorMessage)));
});
}, this.fetchTimeoutMs);
@@ -325,31 +447,12 @@ export class EpgWorkerService {
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEAR_EPG' });
worker.postMessage(options.readyMessage);
} else if (message.type === 'CLEAR_COMPLETE') {
settle(() => {
console.log(
this.loggerLabel,
'EPG data cleared via worker'
);
this.fetchedUrls.clear();
// Resolve only after every interrupted fetch
// worker has exited too — they may still hold the
// SQLite lock the caller expects to be free.
const terminations = [...this.workers.values()].map(
(runningWorker) =>
this.terminateWorker(
runningWorker,
'fetch during clear'
)
);
this.workers.clear();
terminations.push(
this.terminateWorker(worker, 'completed clear')
);
void Promise.all(terminations).then(() =>
resolve()
);
void options
.onComplete(worker)
.then(() => resolve(), reject);
});
} else if (message.type === 'EPG_ERROR') {
console.error(
@@ -360,7 +463,7 @@ export class EpgWorkerService {
settle(() => {
void this.terminateWorker(
worker,
'failed clear'
options.failedWorkerLabel
).then(() =>
reject(
new Error(message.error || 'Clear failed')
@@ -378,15 +481,16 @@ export class EpgWorkerService {
error
);
settle(() => {
void this.terminateWorker(worker, 'errored clear').then(
() => reject(error)
);
void this.terminateWorker(
worker,
options.erroredWorkerLabel
).then(() => reject(error));
});
});
worker.on('exit', (code) => {
if (settled) return;
const errorMessage = `Clear worker exited unexpectedly (code ${code})`;
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
console.error(this.loggerLabel, errorMessage);
settle(() => reject(new Error(errorMessage)));
});
@@ -282,6 +282,30 @@ describe('EpgEvents', () => {
expect(cleared).toBe(true);
});
it('clears one EPG source through a worker and allows it to be fetched again', async () => {
const workerService = new EpgWorkerService('[Test EPG]', 1000);
const sourceUrl = 'https://playlist.example.com/guide.xml';
workerService.markFetchedUrl(sourceUrl);
const clearPromise = workerService.clearEpgDataForSource(
` ${sourceUrl} `
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'READY' });
await flushPromises();
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'CLEAR_EPG_SOURCE',
sourceUrl,
});
worker.emit('message', { type: 'CLEAR_COMPLETE' });
await expect(clearPromise).resolves.toBeUndefined();
expect(workerService.hasFetchedUrl(sourceUrl)).toBe(false);
});
it('rejects a timed-out fetch with the timeout error after the worker has terminated', async () => {
jest.useFakeTimers();
@@ -307,6 +331,39 @@ describe('EpgEvents', () => {
expect(terminated).toBe(true);
});
it('keeps an active EPG fetch alive when worker progress keeps moving', async () => {
jest.useFakeTimers();
const workerService = new EpgWorkerService('[Test EPG]', 25);
const fetchPromise = workerService.fetchEpgFromUrl(
'https://example.com/large-guide.xml'
);
const fetchOutcome = fetchPromise.then(
() => 'resolved' as const,
(error) => error
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'READY' });
jest.advanceTimersByTime(20);
worker.emit('message', {
type: 'EPG_PROGRESS',
stats: { totalChannels: 100, totalPrograms: 500000 },
});
jest.advanceTimersByTime(20);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit('message', {
type: 'EPG_COMPLETE',
stats: { totalChannels: 100, totalPrograms: 510000 },
});
await expect(fetchOutcome).resolves.toBe('resolved');
});
it('falls back to case-insensitive channel id lookup for EPG programs', async () => {
const select = jest.fn();
const programLimitExact = jest.fn().mockResolvedValue([]);
@@ -33,15 +33,30 @@ export default class EpgEvents {
ipcMain.handle(
'GET_CHANNEL_PROGRAMS',
async (_event, args: { channelId: string }) => {
return this.handleGetChannelPrograms(args.channelId);
async (
_event,
args: { channelId: string; options?: { sourceUrls?: string[] } }
) => {
return this.handleGetChannelPrograms(
args.channelId,
args.options
);
}
);
ipcMain.handle(
'GET_CURRENT_PROGRAMS_BATCH',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetCurrentProgramsBatch(args.channelIds);
async (
_event,
args: {
channelIds: string[];
options?: { sourceUrls?: string[] };
}
) => {
return this.handleGetCurrentProgramsBatch(
args.channelIds,
args.options
);
}
);
@@ -51,8 +66,17 @@ export default class EpgEvents {
ipcMain.handle(
'EPG_GET_CHANNEL_METADATA',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetChannelMetadata(args.channelIds);
async (
_event,
args: {
channelIds: string[];
options?: { sourceUrls?: string[] };
}
) => {
return this.handleGetChannelMetadata(
args.channelIds,
args.options
);
}
);
@@ -84,6 +108,14 @@ export default class EpgEvents {
return { success: true };
});
ipcMain.handle(
'EPG_CLEAR_SOURCE',
async (_event, args: { sourceUrl: string }) => {
await this.clearEpgDataForSource(args.sourceUrl);
return { success: true };
}
);
ipcMain.handle(
'EPG_CHECK_FRESHNESS',
async (
@@ -252,15 +284,17 @@ export default class EpgEvents {
}
private static async handleGetChannelPrograms(
channelId: string
channelId: string,
options?: { sourceUrls?: string[] }
): Promise<EpgProgram[]> {
return epgQueryService.getChannelPrograms(channelId);
return epgQueryService.getChannelPrograms(channelId, options);
}
private static async handleGetCurrentProgramsBatch(
channelIds: string[]
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgProgram | null>> {
return epgQueryService.getCurrentProgramsBatch(channelIds);
return epgQueryService.getCurrentProgramsBatch(channelIds, options);
}
private static async handleGetAllChannels(): Promise<{
@@ -271,9 +305,10 @@ export default class EpgEvents {
}
private static async handleGetChannelMetadata(
channelIds: string[]
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgChannelMetadata | null>> {
return epgQueryService.getChannelMetadata(channelIds);
return epgQueryService.getChannelMetadata(channelIds, options);
}
private static async handleGetChannelsByRange(
@@ -293,4 +328,8 @@ export default class EpgEvents {
static async clearEpgData(): Promise<void> {
return epgWorkerService.clearEpgData();
}
static async clearEpgDataForSource(sourceUrl: string): Promise<void> {
return epgWorkerService.clearEpgDataForSource(sourceUrl);
}
}
@@ -1,5 +1,10 @@
import type BetterSqlite3 from 'better-sqlite3';
import { EpgDatabaseClearOperation } from './epg-database';
import {
EpgDatabase,
EpgDatabaseClearOperation,
EpgDatabaseSourceClearOperation,
} from './epg-database';
import type { ParsedChannel } from './epg-streaming-parser';
function createDatabaseMock(exec: jest.Mock) {
const database = {
@@ -12,6 +17,92 @@ function createDatabaseMock(exec: jest.Mock) {
return { Database, database };
}
function normalizeSql(sql: unknown): string {
return String(sql).replace(/\s+/g, ' ').trim();
}
function createEpgDatabaseMock() {
const statements = new Map<string, jest.Mock>();
const prepare = jest.fn((statement: string) => {
const run = jest.fn();
statements.set(normalizeSql(statement), run);
return { run };
});
const transaction = jest.fn((callback: (rows: unknown[]) => void) => {
return (rows: unknown[]) => callback(rows);
});
const database = {
close: jest.fn(),
pragma: jest.fn(),
prepare,
transaction,
};
const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3;
return { Database, database, statements };
}
describe('EpgDatabase', () => {
it('refreshes a source without cascading shared channel programs from other sources', () => {
const sourceUrl = 'https://example.com/playlist-guide.xml';
const { Database, database, statements } = createEpgDatabaseMock();
const epgDb = new EpgDatabase(Database);
const channels: ParsedChannel[] = [
{
id: 'shared.channel',
displayName: [{ lang: '', value: 'Shared Channel' }],
icon: [],
url: [],
},
];
epgDb.insertChannels(channels, sourceUrl, true);
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const deleteProgramsSql =
'DELETE FROM epg_programs WHERE source_url = ?';
const deleteOrphanChannelsSql = normalizeSql(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
expect(preparedSql).toContain(deleteOrphanChannelsSql);
expect(preparedSql).not.toContain(
'DELETE FROM epg_channels WHERE source_url = ?'
);
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
sourceUrl
);
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
sourceUrl
);
});
it('does not overwrite a shared channel source URL when another EPG source reuses the same channel ID', () => {
const { Database, database } = createEpgDatabaseMock();
new EpgDatabase(Database);
const insertChannelSql = database.prepare.mock.calls
.map(([sql]) => normalizeSql(sql))
.find((sql) => sql.startsWith('INSERT INTO epg_channels'));
expect(insertChannelSql).toBeDefined();
expect(insertChannelSql).not.toContain(
'source_url = excluded.source_url'
);
});
});
describe('EpgDatabaseClearOperation', () => {
it('clears programs and channels in one transaction', () => {
const exec = jest.fn();
@@ -48,3 +139,39 @@ describe('EpgDatabaseClearOperation', () => {
]);
});
});
describe('EpgDatabaseSourceClearOperation', () => {
it('clears programs for one source and prunes only orphan channels from that source', () => {
const sourceUrl = 'https://playlist.example.com/guide.xml';
const { Database, database, statements } = createEpgDatabaseMock();
new EpgDatabaseSourceClearOperation(Database).run(sourceUrl);
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const deleteProgramsSql =
'DELETE FROM epg_programs WHERE source_url = ?';
const deleteOrphanChannelsSql = normalizeSql(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
expect(preparedSql).toContain(deleteOrphanChannelsSql);
expect(preparedSql).not.toContain(
'DELETE FROM epg_channels WHERE source_url = ?'
);
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
sourceUrl
);
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
sourceUrl
);
});
});
@@ -11,7 +11,8 @@ export class EpgDatabase {
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteChannelsStmt: BetterSqlite3.Statement;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
@@ -26,17 +27,26 @@ export class EpgDatabase {
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
}
@@ -52,7 +62,8 @@ export class EpgDatabase {
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.deleteProgramsForSourceStmt.run(sourceUrl);
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
@@ -79,7 +90,7 @@ export class EpgDatabase {
/**
* Insert programs for channels already seen during the current parse.
*/
insertPrograms(programs: ParsedProgram[]): number {
insertPrograms(programs: ParsedProgram[], sourceUrl: string): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
@@ -103,7 +114,8 @@ export class EpgDatabase {
category,
iconUrl,
rating,
episodeNum
episodeNum,
sourceUrl
);
insertedCount++;
} catch {
@@ -145,3 +157,46 @@ export class EpgDatabaseClearOperation {
this.db.close();
}
}
export class EpgDatabaseSourceClearOperation {
private readonly db: BetterSqlite3.Database;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('busy_timeout = 5000');
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
}
run(sourceUrl: string): void {
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return;
}
const clearSource = this.db.transaction((url: string) => {
this.deleteProgramsForSourceStmt.run(url);
this.deleteOrphanChannelsForSourceStmt.run(url);
});
clearSource(normalizedSourceUrl);
}
close(): void {
this.db.close();
}
}
@@ -6,7 +6,11 @@ import {
} from '@iptvnator/shared/interfaces';
import { Readable } from 'stream';
import { parentPort, workerData } from 'worker_threads';
import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database';
import {
EpgDatabase,
EpgDatabaseClearOperation,
EpgDatabaseSourceClearOperation,
} from './epg-database';
import { createDecodedEpgStream } from './epg-stream-decoder';
import { StreamingEpgParser } from './epg-streaming-parser';
import {
@@ -61,8 +65,9 @@ const Database = loadBetterSqlite3();
*/
interface WorkerMessage {
type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG';
type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
url?: string;
sourceUrl?: string;
options?: ElectronBridgeTrustOptions;
}
@@ -133,9 +138,7 @@ async function fetchAndParseEpgStreaming(
headers: response.headers,
url: responseUrl,
});
const contentEncoding = getEpgResponseContentEncoding(
response.headers
);
const contentEncoding = getEpgResponseContentEncoding(response.headers);
if (responseUrl && responseUrl !== url) {
console.log(
@@ -172,7 +175,7 @@ async function fetchAndParseEpgStreaming(
},
(programs) => {
// Insert programs directly into database
epgDb.insertPrograms(programs);
epgDb.insertPrograms(programs, url);
},
(totalChannels, totalPrograms) => {
// Send progress to main thread (lightweight)
@@ -332,6 +335,33 @@ function clearAllEpgData(): void {
}
}
function clearEpgDataForSource(sourceUrl: string): void {
const clearOperation = new EpgDatabaseSourceClearOperation(Database);
try {
console.log(
loggerLabel,
`Clearing EPG data for source ${sourceUrl}...`
);
clearOperation.run(sourceUrl);
console.log(loggerLabel, `EPG data cleared for source ${sourceUrl}`);
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG source data:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
};
parentPort?.postMessage(errorResponse);
} finally {
clearOperation.close();
}
}
/**
* Worker message handler
*/
@@ -345,6 +375,8 @@ if (parentPort) {
await fetchAndParseEpgStreaming(message.url!, message.options);
} else if (message.type === 'CLEAR_EPG') {
clearAllEpgData();
} else if (message.type === 'CLEAR_EPG_SOURCE') {
clearEpgDataForSource(message.sourceUrl ?? '');
}
} catch (error) {
console.error(loggerLabel, 'Worker error:', error);
+6 -4
View File
@@ -210,10 +210,12 @@ export async function updateSourceDialog(
}
export async function saveSourceDialog(dialog: Locator): Promise<void> {
await expect(
dialog.getByRole('button', { name: 'Save', exact: true })
).toBeEnabled();
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const saveButton = dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true });
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(dialog).toBeHidden();
}
+4 -1
View File
@@ -492,7 +492,10 @@ test('@xtream playlist details edit is retained in the PWA browser context', asy
dialog.locator('input[formcontrolname="password"]'),
'minimal'
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).toBeHidden();
await expect(
@@ -375,6 +375,7 @@ export class ElectronService extends DataService {
_id: data.id,
},
playlistId: data.id,
refreshEpg: true,
})
);
+1
View File
@@ -171,6 +171,7 @@ export class PwaService extends DataService {
PlaylistActions.updatePlaylist({
playlist,
playlistId,
refreshEpg: true,
})
);
@@ -194,6 +194,9 @@ describe('SettingsComponent', () => {
beforeEach(waitForAsync(() => {
epgBridge = {
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
supportsDataManagement: true,
supportsImport: true,
@@ -291,6 +294,9 @@ describe('SettingsComponent', () => {
staleUrls: [],
}),
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
@@ -1175,9 +1181,9 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(dashboardRails?.enabled).toBe(true);
expect(
component.settingsForm.get('dashboardRails.hero')?.enabled
).toBe(true);
expect(component.settingsForm.get('dashboardRails.hero')?.enabled).toBe(
true
);
});
it('should save settings on submit', async () => {
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "داكن",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "داكن",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Цёмная",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Zuletzt verwendete Quellen",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Dunkel",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Σκούρο",
+1
View File
@@ -218,6 +218,7 @@
"PLAYBACK": "Playback",
"EPG_SOURCES": "EPG sources",
"EPG_SOURCES_DESCRIPTION": "Add one or more URLs as EPG sources",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Prefer your EPG sources for Xtream playlists",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Oscuro",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Sombre",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Scuro",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "ダーク",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "어둡게",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Donker",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Ciemny",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Escuro",
+1
View File
@@ -218,6 +218,7 @@
"PLAYBACK": "Воспроизведение",
"EPG_SOURCES": "Источники EPG",
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько URL-адресов источников EPG",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} из {{detected}} найденных источников EPG плейлиста выбрано для импорта автоматически.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Предпочитать ваши источники EPG для плейлистов Xtream",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Koyu",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "深色",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "深色",
+97 -3
View File
@@ -251,6 +251,98 @@ EPG lookup keys use the same precedence in both program and icon paths:
2. `tvg-name`
3. channel name
### Playlist-Declared EPG Sources
Some M3U providers declare XMLTV sources in the playlist header instead of
requiring the user to add them in Settings. The importer extracts EPG URLs from
`#EXTM3U` header attributes `x-tvg-url`, `url-tvg`, and `tvg-url` in
`@iptvnator/shared/m3u-utils`, then stores the normalized, deduplicated
candidates on `Playlist.detectedEpgUrls`.
`Playlist.epgUrls` is the enabled playlist-scoped subset used for automatic
import and lookup. Two additional lists preserve user edits:
- `Playlist.manualEpgUrls` stores URLs the user explicitly added for this
playlist, including detected catalog URLs the user manually enabled.
- `Playlist.disabledEpgUrls` stores detected URLs the user removed from this
playlist so playlist refreshes do not silently re-enable them.
- Up to five detected URLs are enabled automatically.
- Larger header lists are treated as provider catalogs. The importer keeps all
candidates in `detectedEpgUrls`, but auto-enables only recommended URLs whose
`guides/<country>` path matches playlist hints such as `tvg-country` or the
country suffix in `tvg-id` (`channel.ua`). Language hints are used only when no
country hints are present. If no recommendation can be made, the importer
falls back to the first five detected URLs so generic provider catalogs still
produce usable local EPG sources instead of silently enabling none.
- Recommendations are capped so a malformed or global provider list cannot
start dozens of XMLTV downloads during playlist import.
These URLs are playlist-scoped by default:
- `libs/m3u-state` auto-fetches enabled `epgUrls` when M3U playlists are
loaded, added, or refreshed, using the same EPG progress/import pipeline as
Settings-managed XMLTV URLs. Before fetching, playlist URLs already present in
global Settings are filtered out so the same XMLTV URL is not downloaded
twice. Within a running session, the effect remembers the last fetchable URL
set per playlist and only re-fetches when that URL set changes; metadata-only
edits such as renaming a playlist or hiding groups do not re-download local
EPG sources. When the local URL set expands, only newly added fetchable URLs
are downloaded; disabling or removing one source does not re-download the
remaining sources. Explicit playlist refreshes bypass that session fetch key
and re-download the current fetchable local EPG URLs. Partial metadata updates
that omit `epgUrls` preserve the previous fetch key, while an explicit empty
`epgUrls` list clears it. Add/update metadata effects trigger playlist-local
EPG fetches only after the playlist persistence call succeeds, and metadata
updates that do not include any EPG source fields do not evaluate the fetch
plan.
- The Electron EPG database stores `source_url` on imported programs so current
program lookups can ask for the active playlist's EPG sources first. Existing
databases backfill this column from `epg_channels.source_url` once, in bounded
batches, after the scoped indexes are created. When multiple EPG files reuse
the same XMLTV channel id, the channel row keeps its original `source_url`
attribution instead of being overwritten by the last imported source; program
scoping remains source-specific through `epg_programs.source_url`.
- `ChannelListContainerComponent` enables EPG rows when either global settings
URLs or the active M3U playlist has `epgUrls`. EPG availability refreshes are
debounced so several playlist-local XMLTV imports completing close together
coalesce into one visible-channel EPG refresh. The visible channel list also
refreshes when the effective EPG source context changes, so a playlist whose
`epgUrls` arrive after the channels are rendered does not wait for the next
periodic refresh before showing current programs. A successful EPG import
clears current-program lookup caches before publishing availability, so an
early "no current program" lookup cannot mask freshly imported rows until the
TTL expires.
- Scoped lookups fall back only to Settings-managed EPG URLs for channels
missing from the playlist-declared source. Playlist-local sources from other
playlists are not treated as global fallback sources. Single-channel current
program lookups include the source URL set in their cache and in-flight keys,
so playlist-local and global lookups deduplicate without reusing the wrong
source scope. Batch current-program lookups use the same source-scoped
per-channel TTL cache and order-insensitive in-flight batch deduplication
before reaching IPC; missing exact channel-id matches are resolved with batched
case-insensitive id/display-name candidate queries rather than a per-channel
fallback loop.
When upgrading an existing database whose historical programs have no
`source_url`, scoped program and metadata queries try those legacy unscoped
rows only after the requested source scope returns no result, so old EPG data
remains visible without taking precedence over freshly imported scoped data.
Channel metadata lookups use the same playlist-first, Settings-managed
fallback strategy so icons and display names can still come from global EPG
sources when the playlist-local guide only supplies programs. If multiple EPG
sources reuse the same XMLTV channel id, channel metadata and display-name
fallback lookups treat a channel as source-scoped when either the channel row
itself or matching programs are tagged with the requested `source_url`.
- The playlist details dialog shows enabled EPG URLs with explicit actions to
refresh, remove, or add a source to global Settings. It also allows adding one
or more manual playlist-local sources and indicates when additional detected
candidates were not auto-enabled. Removing a playlist-local source also
clears programs tagged with that `source_url` and prunes only orphaned channel
rows for that same source before saving the playlist metadata change, so a
failed cleanup keeps the source enabled and visible. Shared XMLTV channel ids
from other sources are preserved. Detected playlist sources are not silently
promoted to global settings.
### Performance Optimizations
| Optimization | Implementation |
@@ -291,7 +383,7 @@ EPG lookup keys use the same precedence in both program and icon paths:
## EPG Integration
### EpgService (libs/services/)
### EpgService (`@iptvnator/epg/data-access`)
```typescript
class EpgService {
@@ -303,12 +395,14 @@ class EpgService {
// Batch fetch current programs
getCurrentProgramsForChannels(
channelIds: string[]
channelIds: string[],
options?: { sourceUrls?: string[] }
): Observable<Map<string, EpgProgram>>;
// Batch fetch XMLTV channel metadata for logo fallback
getChannelMetadataForChannels(
channelIds: string[]
channelIds: string[],
options?: { sourceUrls?: string[] }
): Observable<Map<string, EpgChannelMetadata | null>>;
// Observables
+9
View File
@@ -346,6 +346,14 @@ Keep worker lifecycle state out of the IPC registration layer. Add new EPG DB
lookup behavior to `epg-query.service.ts`; add new EPG worker/progress behavior
to `epg-worker.service.ts`.
EPG fetch workers use an inactivity watchdog, not a fixed maximum import
duration. `EpgWorkerService` starts the watchdog when the worker is created,
refreshes it when the worker becomes ready, and refreshes it again whenever an
`EPG_PROGRESS` event increases the channel or program counters. This lets very
large XMLTV imports continue for longer than the nominal timeout as long as the
parser/database pipeline is still making progress, while still terminating a
worker that stops emitting progress.
## UI Behavior Changes
### Search
@@ -511,6 +519,7 @@ covers:
5. case-insensitive EPG program lookup fallbacks
6. metadata lookup precedence for exact/case-insensitive id and display name
7. malformed EPG row filtering
8. active EPG fetches keep running when worker progress keeps moving
`apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts` covers:
@@ -66,11 +66,15 @@ describe('EpgRuntimeBridgeService', () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const forceFetchEpg = jest.fn().mockResolvedValue({ success: true });
const clearEpgData = jest.fn().mockResolvedValue({ success: true });
const clearEpgDataForSource = jest
.fn()
.mockResolvedValue({ success: true });
window.electron = {
...window.electron,
fetchEpg,
forceFetchEpg,
clearEpgData,
clearEpgDataForSource,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgImport = true;
runtimeCapabilities.supportsEpgDataManagement = true;
@@ -84,6 +88,13 @@ describe('EpgRuntimeBridgeService', () => {
await expect(service.clearEpgData()).resolves.toEqual({
success: true,
});
await expect(
service.clearEpgDataForSource(
' https://playlist.example.com/guide.xml '
)
).resolves.toEqual({
success: true,
});
expect(fetchEpg).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
@@ -94,6 +105,9 @@ describe('EpgRuntimeBridgeService', () => {
undefined
);
expect(clearEpgData).toHaveBeenCalledTimes(1);
expect(clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/guide.xml'
);
});
it('delegates read-side EPG calls through the typed Electron bridge', async () => {
@@ -127,15 +141,23 @@ describe('EpgRuntimeBridgeService', () => {
runtimeCapabilities.supportsEpgProgramSearch = true;
await service.getChannelPrograms('channel-1');
await service.getCurrentProgramsBatch(['channel-1']);
await service.getChannelMetadata(['channel-1']);
await service.getCurrentProgramsBatch(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.getChannelMetadata(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.checkFreshness(['https://example.com/epg.xml'], 12);
await service.getChannelsByRange(0, 20);
await service.searchPrograms('news', 20);
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1']);
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1']);
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
expect(checkEpgFreshness).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
12
@@ -7,6 +7,7 @@ import {
ElectronBridgeEpgProgressStatus,
ElectronBridgeEpgChannelWithPrograms,
ElectronBridgeEpgFreshnessResult,
ElectronBridgeEpgLookupOptions,
ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES,
ElectronBridgeResult,
ElectronBridgeTrustOptions,
@@ -22,11 +23,13 @@ export type EpgImportProgress = ElectronBridgeEpgProgress;
export type EpgFetchResult = ElectronBridgeEpgFetchResult;
export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult;
export type EpgClearResult = ElectronBridgeResult;
export type EpgLookupOptions = ElectronBridgeEpgLookupOptions;
type EpgElectronBridge = Pick<
Partial<ElectronBridgeApi>,
| 'checkEpgFreshness'
| 'clearEpgData'
| 'clearEpgDataForSource'
| 'fetchEpg'
| 'forceFetchEpg'
| 'getChannelPrograms'
@@ -109,41 +112,71 @@ export class EpgRuntimeBridgeService {
return this.bridge?.clearEpgData?.() ?? Promise.resolve(null);
}
getChannelPrograms(channelId: string): Promise<EpgProgram[] | null> {
if (!this.supportsProgramLookup) {
clearEpgDataForSource(sourceUrl: string): Promise<EpgClearResult | null> {
if (!this.supportsDataManagement) {
return Promise.resolve(null);
}
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return Promise.resolve(null);
}
return (
this.bridge?.getChannelPrograms?.(channelId) ??
this.bridge?.clearEpgDataForSource?.(normalizedSourceUrl) ??
Promise.resolve(null)
);
}
getChannelPrograms(
channelId: string,
options?: EpgLookupOptions
): Promise<EpgProgram[] | null> {
if (!this.supportsProgramLookup) {
return Promise.resolve(null);
}
if (!this.bridge?.getChannelPrograms) {
return Promise.resolve(null);
}
return options
? this.bridge.getChannelPrograms(channelId, options)
: this.bridge.getChannelPrograms(channelId);
}
getCurrentProgramsBatch(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Promise<Record<string, EpgProgram | null> | null> {
if (!this.supportsCurrentProgramBatch) {
return Promise.resolve(null);
}
return (
this.bridge?.getCurrentProgramsBatch?.(channelIds) ??
Promise.resolve(null)
);
if (!this.bridge?.getCurrentProgramsBatch) {
return Promise.resolve(null);
}
return options
? this.bridge.getCurrentProgramsBatch(channelIds, options)
: this.bridge.getCurrentProgramsBatch(channelIds);
}
getChannelMetadata(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Promise<Record<string, EpgChannelMetadata | null> | null> {
if (!this.supportsChannelMetadata) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgChannelMetadata?.(channelIds) ??
Promise.resolve(null)
);
if (!this.bridge?.getEpgChannelMetadata) {
return Promise.resolve(null);
}
return options
? this.bridge.getEpgChannelMetadata(channelIds, options)
: this.bridge.getEpgChannelMetadata(channelIds);
}
checkFreshness(
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, skip } from 'rxjs';
import { SettingsStore } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import { EpgService } from './epg.service';
@@ -26,6 +26,7 @@ describe('EpgService', () => {
};
settingsStore = {
getSettings: jest.fn(() => ({
epgUrl: [],
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
trustedInsecureTlsHosts: ['playlist.local'],
})),
@@ -75,6 +76,7 @@ describe('EpgService', () => {
'https://example.com/epg.xml',
'',
'https://example.com/other.xml',
' https://example.com/epg.xml ',
]);
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
@@ -132,4 +134,484 @@ describe('EpgService', () => {
expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith('channel-1');
jest.useRealTimers();
});
it('caches current program lookups separately by EPG source URL scope', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.getChannelPrograms = jest
.fn()
.mockResolvedValueOnce([
{
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
])
.mockResolvedValueOnce([
{
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global News Bulletin',
},
])
.mockResolvedValue([]);
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
try {
const playlistResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const globalResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news')
);
const cachedPlaylistResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
const cachedGlobalResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news')
);
expect(playlistResult?.title).toBe('Playlist Guide Bulletin');
expect(globalResult?.title).toBe('Global News Bulletin');
expect(cachedPlaylistResult?.title).toBe('Playlist Guide Bulletin');
expect(cachedGlobalResult?.title).toBe('Global News Bulletin');
expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(2);
expect(epgBridge.getChannelPrograms).toHaveBeenNthCalledWith(
1,
'guide-news',
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgBridge.getChannelPrograms).toHaveBeenNthCalledWith(
2,
'guide-news',
{ sourceUrls: ['https://global.example.com/guide.xml'] }
);
} finally {
jest.useRealTimers();
}
});
it('deduplicates concurrent scoped current program lookups for the same source scope', async () => {
epgBridge.supportsProgramLookup = true;
let resolvePrograms:
| ((
programs: {
channel: string;
start: string;
stop: string;
title: string;
}[]
) => void)
| undefined;
epgBridge.getChannelPrograms = jest.fn(
() =>
new Promise((resolve) => {
resolvePrograms = resolve;
})
);
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
try {
const firstLookup = firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const secondLookup = firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(1);
resolvePrograms?.([
{
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
]);
await expect(
Promise.all([firstLookup, secondLookup])
).resolves.toEqual([
expect.objectContaining({
title: 'Playlist Guide Bulletin',
}),
expect.objectContaining({
title: 'Playlist Guide Bulletin',
}),
]);
} finally {
jest.useRealTimers();
}
});
it('queries playlist-scoped current programs first and falls back to global EPG for missing channels', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: [
'https://global.example.com/guide.xml',
' https://global.example.com/guide.xml ',
],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
'guide-sports': null,
})
.mockResolvedValueOnce({
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global Sports Bulletin',
},
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
expect(result.get('guide-news')?.title).toBe('Playlist Guide Bulletin');
expect(result.get('guide-sports')?.title).toBe(
'Global Sports Bulletin'
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith(
1,
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith(
2,
['guide-sports'],
{ sourceUrls: ['https://global.example.com/guide.xml'] }
);
});
it('does not fall back to the unscoped EPG pool when no global EPG URLs are configured', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest.fn().mockResolvedValue({
'guide-sports': null,
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-sports'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(result.get('guide-sports')).toBeNull();
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith(
['guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
});
it('caches scoped batch current programs by EPG source URL scope', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValue({})
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
});
const firstResult = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const secondResult = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
expect(firstResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(secondResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
});
it('clears cached null current programs after a successful EPG import', async () => {
epgBridge.supportsImport = true;
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({
'guide-news': null,
})
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
});
const beforeImport = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(beforeImport.get('guide-news')).toBeNull();
const availability = firstValueFrom(service.epgAvailable$.pipe(skip(1)));
service.fetchEpg(['https://playlist.example.com/guide.xml']);
await expect(availability).resolves.toBe(true);
const afterImport = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(afterImport.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2);
});
it('deduplicates concurrent scoped batch current program lookups for the same source scope', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
const batchResolvers: Array<
(programs: Record<string, unknown>) => void
> = [];
epgBridge.getCurrentProgramsBatch = jest.fn(
() =>
new Promise((resolve) => {
batchResolvers.push(resolve);
})
);
const firstLookup = firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const secondLookup = firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
try {
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
batchResolvers.forEach((resolve) =>
resolve({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
})
);
const [firstResult, secondResult] = await Promise.all([
firstLookup,
secondLookup,
]);
expect(firstResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(secondResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
} finally {
batchResolvers.forEach((resolve) => resolve({}));
}
});
it('deduplicates concurrent scoped batch current program lookups regardless of channel order', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
let resolveBatch:
| ((programs: Record<string, unknown>) => void)
| undefined;
epgBridge.getCurrentProgramsBatch = jest.fn(
() =>
new Promise((resolve) => {
resolveBatch = resolve;
})
);
const firstLookup = firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
const secondLookup = firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-sports', 'guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
try {
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
resolveBatch?.({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Sports Bulletin',
},
});
const [firstResult, secondResult] = await Promise.all([
firstLookup,
secondLookup,
]);
expect(firstResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(secondResult.get('guide-sports')?.title).toBe(
'Playlist Sports Bulletin'
);
} finally {
resolveBatch?.({});
}
});
it('returns null scoped current programs instead of re-entering global lookup when scoped batch lookup fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockRejectedValueOnce(new Error('ipc down'))
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global News Bulletin',
},
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(result.get('guide-news')).toBeNull();
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
} finally {
consoleError.mockRestore();
}
});
it('falls back to global EPG metadata for channels missing from playlist-scoped sources', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsChannelMetadata = true;
epgBridge.getChannelMetadata = jest
.fn()
.mockResolvedValueOnce({
'guide-news': {
id: 'guide-news',
displayName: 'Playlist News',
iconUrl: 'https://playlist.example.com/news.png',
},
'guide-sports': null,
})
.mockResolvedValueOnce({
'guide-sports': {
id: 'guide-sports',
displayName: 'Global Sports',
iconUrl: 'https://global.example.com/sports.png',
},
});
const result = await firstValueFrom(
service.getChannelMetadataForChannels(
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
expect(result.get('guide-news')).toMatchObject({
displayName: 'Playlist News',
});
expect(result.get('guide-sports')).toMatchObject({
displayName: 'Global Sports',
iconUrl: 'https://global.example.com/sports.png',
});
expect(epgBridge.getChannelMetadata).toHaveBeenNthCalledWith(
1,
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgBridge.getChannelMetadata).toHaveBeenNthCalledWith(
2,
['guide-sports'],
{ sourceUrls: ['https://global.example.com/guide.xml'] }
);
});
});
+427 -48
View File
@@ -2,15 +2,27 @@ import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
import { catchError, map, tap, timeout } from 'rxjs/operators';
import {
catchError,
finalize,
map,
shareReplay,
switchMap,
tap,
timeout,
} from 'rxjs/operators';
import {
createDevLogger,
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import {
EpgLookupOptions,
EpgRuntimeBridgeService,
} from './epg-runtime-bridge.service';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
interface CachedProgram {
program: EpgProgram | null;
@@ -33,6 +45,14 @@ export class EpgService {
// Cache for channel programs with 60-second TTL
private programCache = new Map<string, CachedProgram>();
private fetchingCurrentPrograms = new Map<
string,
Observable<EpgProgram | null>
>();
private fetchingCurrentProgramBatches = new Map<
string,
Observable<Map<string, EpgProgram | null>>
>();
private readonly CACHE_TTL = 60000; // 60 seconds
readonly epgAvailable$ = this.epgAvailable.asObservable();
@@ -44,8 +64,8 @@ export class EpgService {
fetchEpg(urls: string[]): void {
if (!this.epgBridge.supportsImport) return;
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
// Filter out empty and duplicate URLs and send all URLs at once.
const validUrls = normalizeEpgUrls(urls);
if (validUrls.length === 0) return;
from(
@@ -59,6 +79,7 @@ export class EpgService {
if (result === null) return;
if (result.success) {
this.clearCache();
this.epgAvailable.next(true);
} else {
this.epgAvailable.next(false);
@@ -117,50 +138,46 @@ export class EpgService {
* @returns Observable of current program or null
*/
getCurrentProgramForChannel(
channelId: string
channelId: string,
options?: EpgLookupOptions
): Observable<EpgProgram | null> {
if (!this.epgBridge.supportsProgramLookup || !channelId) {
return of(null);
}
// Check cache first
const cached = this.programCache.get(channelId);
const now = Date.now();
if (cached && now - cached.timestamp < this.CACHE_TTL) {
return of(cached.program);
const sourceUrls = this.normalizeSourceUrls(options);
if (sourceUrls.length > 0) {
return this.getScopedCurrentProgramForChannel(
channelId,
sourceUrls,
this.getGlobalEpgSourceUrls(sourceUrls)
);
}
const globalSourceUrls = this.getGlobalEpgSourceUrls();
if (globalSourceUrls.length > 0) {
return this.getScopedCurrentProgramForChannel(
channelId,
globalSourceUrls,
[]
);
}
// Check cache first
const cacheKey = this.createProgramCacheKey(channelId);
// Fetch from backend
return from(this.epgBridge.getChannelPrograms(channelId)).pipe(
map((programs) => normalizeEpgPrograms(programs ?? [])),
map((programs: EpgProgram[]) => {
if (!programs.length) {
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return null;
}
const currentProgram = this.findCurrentProgram(programs);
// Cache the result
this.programCache.set(channelId, {
program: currentProgram,
timestamp: now,
});
return currentProgram;
}),
catchError((err) => {
console.error('EPG get current program error:', err);
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return of(null);
})
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
from(this.epgBridge.getChannelPrograms(channelId)).pipe(
map((programs) => normalizeEpgPrograms(programs ?? [])),
map((programs: EpgProgram[]) =>
this.findCurrentProgram(programs)
),
catchError((err) => {
console.error('EPG get current program error:', err);
return of(null);
})
)
);
}
@@ -185,7 +202,8 @@ export class EpgService {
* @returns Observable of Map with channelId -> current program
*/
getCurrentProgramsForChannels(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Observable<Map<string, EpgProgram | null>> {
if (!this.epgBridge.supportsProgramLookup) {
return of(new Map());
@@ -195,6 +213,29 @@ export class EpgService {
return of(new Map());
}
const sourceUrls = this.normalizeSourceUrls(options);
if (
sourceUrls.length > 0 &&
this.epgBridge.supportsCurrentProgramBatch
) {
return this.getScopedCurrentProgramsForChannels(
channelIds,
sourceUrls
);
}
const globalSourceUrls = this.getGlobalEpgSourceUrls();
if (
globalSourceUrls.length > 0 &&
this.epgBridge.supportsCurrentProgramBatch
) {
return this.getScopedCurrentProgramsForChannels(
channelIds,
globalSourceUrls,
[]
);
}
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
const now = Date.now();
@@ -260,31 +301,236 @@ export class EpgService {
);
}
private getScopedCurrentProgramsForChannels(
channelIds: string[],
sourceUrls: string[],
fallbackSourceUrls = this.getGlobalEpgSourceUrls(sourceUrls)
): Observable<Map<string, EpgProgram | null>> {
const normalizedChannelIds = this.normalizeChannelIds(channelIds);
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
normalizedChannelIds.forEach((channelId) => {
const cached = this.getCachedProgram(
this.createProgramCacheKey(channelId, sourceUrls)
);
if (cached) {
resultMap.set(channelId, cached.program);
} else {
channelsToFetch.push(channelId);
}
});
if (channelsToFetch.length === 0) {
return of(resultMap);
}
const batchCacheKey = this.createProgramBatchCacheKey(
channelsToFetch,
sourceUrls,
fallbackSourceUrls
);
const existingRequest =
this.fetchingCurrentProgramBatches.get(batchCacheKey);
const request$ =
existingRequest ??
this.fetchScopedCurrentProgramsBatch(
channelsToFetch,
sourceUrls,
fallbackSourceUrls
).pipe(
tap((fetchedMap) => {
const cacheTimestamp = Date.now();
channelsToFetch.forEach((channelId) => {
this.programCache.set(
this.createProgramCacheKey(channelId, sourceUrls),
{
program: fetchedMap.get(channelId) ?? null,
timestamp: cacheTimestamp,
}
);
});
}),
finalize(() => {
this.fetchingCurrentProgramBatches.delete(batchCacheKey);
}),
shareReplay({ bufferSize: 1, refCount: false })
);
if (!existingRequest) {
this.fetchingCurrentProgramBatches.set(batchCacheKey, request$);
}
return request$.pipe(
map((fetchedMap) => {
const mergedResultMap = new Map(resultMap);
channelsToFetch.forEach((channelId) => {
mergedResultMap.set(
channelId,
fetchedMap.get(channelId) ?? null
);
});
return mergedResultMap;
})
);
}
private fetchScopedCurrentProgramsBatch(
channelIds: string[],
sourceUrls: string[],
fallbackSourceUrls: string[]
): Observable<Map<string, EpgProgram | null>> {
return from(
this.epgBridge.getCurrentProgramsBatch(channelIds, {
sourceUrls,
})
).pipe(
timeout(5000),
switchMap((scopedResult) => {
const resultMap = new Map<string, EpgProgram | null>();
const fallbackChannelIds: string[] = [];
channelIds.forEach((channelId) => {
const program = scopedResult?.[channelId] ?? null;
resultMap.set(channelId, program);
if (!program) {
fallbackChannelIds.push(channelId);
}
});
if (fallbackChannelIds.length === 0) {
return of(resultMap);
}
if (fallbackSourceUrls.length === 0) {
return of(resultMap);
}
return from(
this.epgBridge.getCurrentProgramsBatch(fallbackChannelIds, {
sourceUrls: fallbackSourceUrls,
})
).pipe(
timeout(5000),
map((globalResult) => {
fallbackChannelIds.forEach((channelId) => {
resultMap.set(
channelId,
globalResult?.[channelId] ?? null
);
});
return resultMap;
}),
catchError((err) => {
console.error(
'EPG global fallback current programs error:',
err
);
return of(resultMap);
})
);
}),
catchError((err) => {
console.error('EPG scoped batch current programs error:', err);
return of(this.createNullProgramMap(channelIds));
})
);
}
getChannelMetadataForChannels(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Observable<Map<string, EpgChannelMetadata | null>> {
if (!this.epgBridge.supportsChannelMetadata) {
return of(new Map());
}
const normalizedChannelIds = Array.from(
const normalizedChannelIds = this.normalizeChannelIds(channelIds);
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
const sourceUrls = this.normalizeSourceUrls(options);
const globalSourceUrls =
sourceUrls.length > 0
? this.getGlobalEpgSourceUrls(sourceUrls)
: this.getGlobalEpgSourceUrls();
const effectiveSourceUrls =
sourceUrls.length > 0 ? sourceUrls : globalSourceUrls;
return this.getChannelMetadataMapForSourceUrls(
normalizedChannelIds,
effectiveSourceUrls
).pipe(
switchMap((metadataMap) => {
const fallbackChannelIds =
sourceUrls.length > 0 && globalSourceUrls.length > 0
? normalizedChannelIds.filter(
(channelId) => !metadataMap.get(channelId)
)
: [];
if (fallbackChannelIds.length === 0) {
return of(metadataMap);
}
return this.getChannelMetadataMapForSourceUrls(
fallbackChannelIds,
globalSourceUrls
).pipe(
map((globalMetadataMap) => {
fallbackChannelIds.forEach((channelId) => {
metadataMap.set(
channelId,
globalMetadataMap.get(channelId) ?? null
);
});
return metadataMap;
}),
catchError((err) => {
console.error(
'EPG global fallback channel metadata error:',
err
);
return of(metadataMap);
})
);
})
);
}
private normalizeChannelIds(channelIds: string[]): string[] {
return Array.from(
new Set(
channelIds
.map((channelId) => channelId.trim())
.filter((channelId) => channelId.length > 0)
)
);
}
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
private normalizeSourceUrls(options?: EpgLookupOptions): string[] {
return normalizeEpgUrls(options?.sourceUrls ?? []);
}
private getChannelMetadataMapForSourceUrls(
channelIds: string[],
sourceUrls: string[]
): Observable<Map<string, EpgChannelMetadata | null>> {
return from(
this.epgBridge.getChannelMetadata(normalizedChannelIds)
this.epgBridge.getChannelMetadata(
channelIds,
sourceUrls.length > 0 ? { sourceUrls } : undefined
)
).pipe(
map((metadataByChannelId) => {
return new Map<string, EpgChannelMetadata | null>(
normalizedChannelIds.map((channelId) => [
channelIds.map((channelId) => [
channelId,
metadataByChannelId?.[channelId] ?? null,
])
@@ -297,10 +543,143 @@ export class EpgService {
);
}
private createProgramCacheKey(
channelId: string,
sourceUrls: string[] = []
): string {
const normalizedSourceUrls = normalizeEpgUrls(sourceUrls);
if (normalizedSourceUrls.length === 0) {
return channelId;
}
return `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`;
}
private createProgramBatchCacheKey(
channelIds: string[],
sourceUrls: string[],
fallbackSourceUrls: string[]
): string {
return JSON.stringify({
channelIds: [...channelIds].sort(),
sourceUrls: normalizeEpgUrls(sourceUrls),
fallbackSourceUrls: normalizeEpgUrls(fallbackSourceUrls),
});
}
private getCachedProgram(cacheKey: string): CachedProgram | undefined {
const cached = this.programCache.get(cacheKey);
if (!cached) {
return undefined;
}
if (Date.now() - cached.timestamp >= this.CACHE_TTL) {
this.programCache.delete(cacheKey);
return undefined;
}
return cached;
}
private getCachedOrFetchCurrentProgram(
cacheKey: string,
fetchProgram: () => Observable<EpgProgram | null>
): Observable<EpgProgram | null> {
const cached = this.getCachedProgram(cacheKey);
if (cached) {
return of(cached.program);
}
const existingRequest = this.fetchingCurrentPrograms.get(cacheKey);
if (existingRequest) {
return existingRequest;
}
const request$ = fetchProgram().pipe(
tap((program) => {
this.programCache.set(cacheKey, {
program,
timestamp: Date.now(),
});
}),
finalize(() => {
this.fetchingCurrentPrograms.delete(cacheKey);
}),
shareReplay({ bufferSize: 1, refCount: false })
);
this.fetchingCurrentPrograms.set(cacheKey, request$);
return request$;
}
private getScopedCurrentProgramForChannel(
channelId: string,
sourceUrls: string[],
fallbackSourceUrls: string[]
): Observable<EpgProgram | null> {
const cacheKey = this.createProgramCacheKey(channelId, sourceUrls);
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
from(
this.epgBridge.getChannelPrograms(channelId, { sourceUrls })
).pipe(
timeout(3000),
map((programs) => normalizeEpgPrograms(programs ?? [])),
switchMap((programs) => {
const currentProgram = this.findCurrentProgram(programs);
if (currentProgram) {
return of(currentProgram);
}
return this.getFallbackCurrentProgramForChannel(
channelId,
fallbackSourceUrls
);
}),
catchError((err) => {
console.error('EPG scoped current program error:', err);
return this.getFallbackCurrentProgramForChannel(
channelId,
fallbackSourceUrls
);
})
)
);
}
private getFallbackCurrentProgramForChannel(
channelId: string,
sourceUrls: string[]
): Observable<EpgProgram | null> {
if (sourceUrls.length === 0) {
return of(null);
}
return this.getScopedCurrentProgramForChannel(
channelId,
sourceUrls,
[]
);
}
private createNullProgramMap(
channelIds: string[]
): Map<string, EpgProgram | null> {
return new Map(channelIds.map((channelId) => [channelId, null]));
}
private getGlobalEpgSourceUrls(excluding: string[] = []): string[] {
const excludedUrls = new Set(excluding);
return normalizeEpgUrls(
this.settingsStore.getSettings().epgUrl ?? []
).filter((url) => !excludedUrls.has(url));
}
/**
* Clears the program cache (useful when EPG is refreshed)
*/
clearCache(): void {
this.programCache.clear();
this.fetchingCurrentPrograms.clear();
this.fetchingCurrentProgramBatches.clear();
}
}
+5 -1
View File
@@ -10,7 +10,11 @@ export const PlaylistActions = createActionGroup({
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(),
'Update Playlist': props<{
playlist: Playlist;
playlistId: string;
refreshEpg?: boolean;
}>(),
'Update Many Playlists': props<{ playlists: Playlist[] }>(),
'Parse Playlist': props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
+111 -13
View File
@@ -2,7 +2,10 @@ import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils';
import {
normalizeEpgUrls,
resolveM3uCatchupUrl,
} from '@iptvnator/shared/m3u-utils';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -18,12 +21,17 @@ import {
tap,
withLatestFrom,
} from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Channel,
Playlist,
PlaylistMeta,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
@@ -41,6 +49,7 @@ import {
} from './selectors';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import { buildExternalPlayerPayload } from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
@@ -53,6 +62,8 @@ export class PlaylistEffects {
private storage = inject(StorageMap);
private store = inject(Store);
private translate = inject(TranslateService);
private settingsStore = inject(SettingsStore);
private readonly playlistScopedEpgFetchKeys = new Map<string, string>();
updateFavorites$ = createEffect(
() => {
@@ -222,6 +233,9 @@ export class PlaylistEffects {
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
this.playlistsService.getAllPlaylists().pipe(
tap((playlists) => {
this.fetchPlaylistScopedEpgForPlaylists(playlists);
}),
map((playlists) =>
PlaylistActions.loadPlaylistsSuccess({
playlists,
@@ -263,6 +277,7 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.removePlaylist),
switchMap(async (action) => {
this.playlistScopedEpgFetchKeys.delete(action.playlistId);
await firstValueFrom(
this.playlistsService.deletePlaylist(action.playlistId)
);
@@ -280,7 +295,13 @@ export class PlaylistEffects {
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
})
}).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist, {
force: action.refreshEpg === true,
});
})
)
)
);
},
@@ -315,18 +336,16 @@ export class PlaylistEffects {
PlaylistActions.addPlaylist,
PlaylistActions.handleAddingPlaylistByUrl
),
tap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return;
}
this.navigateToPlaylist(action.playlist);
}),
switchMap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist);
return this.playlistsService.addPlaylist(action.playlist).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
})
);
},
@@ -338,7 +357,17 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist)
this.playlistsService.updatePlaylistMeta(action.playlist).pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(action.playlist);
}
})
)
)
);
},
@@ -376,7 +405,15 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updateManyPlaylists),
switchMap((action) =>
this.playlistsService.updateManyPlaylists(action.playlists)
this.playlistsService
.updateManyPlaylists(action.playlists)
.pipe(
tap(() => {
action.playlists.forEach((playlist) =>
this.fetchPlaylistScopedEpg(playlist)
);
})
)
)
);
},
@@ -424,4 +461,65 @@ export class PlaylistEffects {
void this.router.navigate(['/workspace', 'playlists', playlist._id]);
}
private fetchPlaylistScopedEpg(
playlist: Pick<
Playlist,
'_id' | 'epgUrls' | 'macAddress' | 'serverUrl'
>,
options: { force?: boolean } = {}
): void {
const plan = resolvePlaylistScopedEpgFetchPlan(
playlist,
this.getGlobalEpgUrls(),
this.playlistScopedEpgFetchKeys.get(playlist._id),
options
);
this.playlistScopedEpgFetchKeys.set(playlist._id, plan.key);
if (!plan.shouldFetch) {
return;
}
this.epgService.fetchEpg(plan.urls);
}
private hasPlaylistScopedEpgSourceChange(playlist: PlaylistMeta): boolean {
return (
Object.prototype.hasOwnProperty.call(playlist, 'epgUrls') ||
Object.prototype.hasOwnProperty.call(playlist, 'detectedEpgUrls') ||
Object.prototype.hasOwnProperty.call(playlist, 'manualEpgUrls') ||
Object.prototype.hasOwnProperty.call(playlist, 'disabledEpgUrls')
);
}
private fetchPlaylistScopedEpgForPlaylists(playlists: Playlist[]): void {
const epgUrls = new Set<string>();
const globalEpgUrls = this.getGlobalEpgUrls();
for (const playlist of playlists) {
const plan = resolvePlaylistScopedEpgFetchPlan(
playlist,
globalEpgUrls,
this.playlistScopedEpgFetchKeys.get(playlist._id)
);
this.playlistScopedEpgFetchKeys.set(playlist._id, plan.key);
if (!plan.shouldFetch) {
continue;
}
for (const url of plan.urls) {
epgUrls.add(url);
}
}
if (epgUrls.size > 0) {
this.epgService.fetchEpg(Array.from(epgUrls));
}
}
private getGlobalEpgUrls(): string[] {
return normalizeEpgUrls(this.settingsStore.getSettings().epgUrl ?? []);
}
}
@@ -0,0 +1,141 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Playlist',
count: 1,
importDate: '2026-06-21T10:00:00.000Z',
autoRefresh: false,
epgUrls: [],
...overrides,
};
}
describe('resolvePlaylistScopedEpgFetchPlan', () => {
it('does not refetch when playlist metadata changes without fetchable EPG URL changes', () => {
const playlist = createPlaylistMeta({
title: 'Renamed playlist',
epgUrls: ['https://playlist.example.com/guide.xml'],
});
expect(
resolvePlaylistScopedEpgFetchPlan(
playlist,
[],
'https://playlist.example.com/guide.xml'
)
).toEqual({
key: 'https://playlist.example.com/guide.xml',
shouldFetch: false,
urls: ['https://playlist.example.com/guide.xml'],
});
});
it('does not fetch playlist EPG URLs that are already configured globally', () => {
const playlist = createPlaylistMeta({
epgUrls: [
'https://global.example.com/guide.xml',
'https://playlist.example.com/guide.xml',
],
});
expect(
resolvePlaylistScopedEpgFetchPlan(playlist, [
' https://global.example.com/guide.xml ',
])
).toEqual({
key: 'https://playlist.example.com/guide.xml',
shouldFetch: true,
urls: ['https://playlist.example.com/guide.xml'],
});
});
it('keeps the previous fetch key when a partial playlist metadata update omits EPG URLs', () => {
expect(
resolvePlaylistScopedEpgFetchPlan(
{},
[],
'https://playlist.example.com/guide.xml'
)
).toEqual({
key: 'https://playlist.example.com/guide.xml',
shouldFetch: false,
urls: [],
});
});
it('does not refetch remaining playlist EPG URLs when one local source is disabled', () => {
const playlist = createPlaylistMeta({
epgUrls: ['https://playlist.example.com/keep.xml'],
});
expect(
resolvePlaylistScopedEpgFetchPlan(
playlist,
[],
[
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
].join('\n')
)
).toEqual({
key: 'https://playlist.example.com/keep.xml',
shouldFetch: false,
urls: ['https://playlist.example.com/keep.xml'],
});
});
it('fetches only newly added playlist EPG URLs when the local source set expands', () => {
const playlist = createPlaylistMeta({
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/new.xml',
],
});
expect(
resolvePlaylistScopedEpgFetchPlan(
playlist,
[],
'https://playlist.example.com/keep.xml'
)
).toEqual({
key: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/new.xml',
].join('\n'),
shouldFetch: true,
urls: ['https://playlist.example.com/new.xml'],
});
});
it('fetches all playlist EPG URLs when refresh is forced for an already fetched source set', () => {
const playlist = createPlaylistMeta({
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/other.xml',
],
});
const key = [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/other.xml',
].join('\n');
expect(
resolvePlaylistScopedEpgFetchPlan(playlist, [], key, {
force: true,
})
).toEqual({
key,
shouldFetch: true,
urls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/other.xml',
],
});
});
});
@@ -0,0 +1,47 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { filterPlaylistEpgUrlsForFetch } from '@iptvnator/shared/m3u-utils';
export interface PlaylistScopedEpgFetchPlan {
key: string;
shouldFetch: boolean;
urls: string[];
}
export interface PlaylistScopedEpgFetchOptions {
force?: boolean;
}
export function resolvePlaylistScopedEpgFetchPlan(
playlist: Pick<PlaylistMeta, 'epgUrls' | 'macAddress' | 'serverUrl'>,
globalEpgUrls: readonly string[],
previousKey = '',
options: PlaylistScopedEpgFetchOptions = {}
): PlaylistScopedEpgFetchPlan {
if (playlist.serverUrl || playlist.macAddress) {
return { key: '', shouldFetch: false, urls: [] };
}
if (
!Object.prototype.hasOwnProperty.call(playlist, 'epgUrls') &&
previousKey.length > 0
) {
return { key: previousKey, shouldFetch: false, urls: [] };
}
const urls = filterPlaylistEpgUrlsForFetch(playlist.epgUrls, globalEpgUrls);
const key = urls.join('\n');
const previousUrls = new Set(
previousKey
.split('\n')
.map((url) => url.trim())
.filter((url) => url.length > 0)
);
const newUrls = urls.filter((url) => !previousUrls.has(url));
const urlsToFetch = options.force ? urls : newUrls;
return {
key,
shouldFetch: urlsToFetch.length > 0,
urls: urlsToFetch.length > 0 ? urlsToFetch : urls,
};
}
@@ -1,5 +1,6 @@
import { on } from '@ngrx/store';
import { Channel } from '@iptvnator/shared/interfaces';
import { resolvePlaylistEpgSourceState } from '@iptvnator/shared/m3u-utils';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
@@ -34,6 +35,19 @@ export const playlistReducers = [
const isActivePlaylist =
state.playlists.selectedId === action.playlistId;
const currentPlaylist = state.playlists.entities[action.playlistId];
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
action.playlist.detectedEpgUrls ??
currentPlaylist?.detectedEpgUrls,
enabledEpgUrls:
action.playlist.epgUrls ?? currentPlaylist?.epgUrls,
manualEpgUrls:
action.playlist.manualEpgUrls ??
currentPlaylist?.manualEpgUrls,
disabledEpgUrls:
action.playlist.disabledEpgUrls ??
currentPlaylist?.disabledEpgUrls,
});
return {
...state,
channels: isActivePlaylist
@@ -49,8 +63,11 @@ export const playlistReducers = [
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
favorites:
currentPlaylist?.favorites ?? [],
favorites: currentPlaylist?.favorites ?? [],
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist?.autoRefresh ??
action.playlist.autoRefresh,
@@ -143,6 +160,16 @@ export const playlistReducers = [
...(p.hiddenGroupTitles != null
? { hiddenGroupTitles: p.hiddenGroupTitles }
: {}),
...(p.epgUrls != null ? { epgUrls: p.epgUrls } : {}),
...(p.detectedEpgUrls != null
? { detectedEpgUrls: p.detectedEpgUrls }
: {}),
...(p.manualEpgUrls != null
? { manualEpgUrls: p.manualEpgUrls }
: {}),
...(p.disabledEpgUrls != null
? { disabledEpgUrls: p.disabledEpgUrls }
: {}),
...(p.updateDate !== undefined
? { updateDate: p.updateDate }
: {}),
@@ -623,6 +623,7 @@ describe('PlaylistRefreshActionService', () => {
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: item._id,
refreshEpg: true,
})
);
});
@@ -227,6 +227,7 @@ export class PlaylistRefreshActionService {
_id: item._id,
},
playlistId: item._id,
refreshEpg: true,
})
);
@@ -152,6 +152,141 @@
}
</mat-form-field>
}
@if (canManagePlaylistEpgSources) {
<section
class="playlist-epg-sources"
data-test-id="playlist-epg-sources"
>
<div class="playlist-epg-sources__header">
<mat-icon>event_note</mat-icon>
<div>
<h3 class="playlist-epg-sources__title">
{{ 'SETTINGS.EPG_SOURCES' | translate }}
</h3>
<p>
@if (hiddenDetectedPlaylistEpgSourceCount > 0) {
{{
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
| translate
: {
enabled:
playlistEpgUrls.length,
detected:
playlistDetectedEpgUrls.length,
}
}}
} @else {
{{
'SETTINGS.EPG_SOURCES_DESCRIPTION'
| translate
}}
}
</p>
</div>
</div>
@for (epgUrl of playlistEpgUrls; track epgUrl) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input matInput [value]="epgUrl" readonly />
</mat-form-field>
<button
mat-icon-button
type="button"
[disabled]="!canRefreshPlaylistEpg"
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
(click)="refreshPlaylistEpgSource(epgUrl)"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="isGlobalEpgSource(epgUrl)"
[matTooltip]="'SETTINGS.ADD_EPG_SOURCE' | translate"
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
>
<mat-icon>playlist_add</mat-icon>
</button>
<button
mat-icon-button
type="button"
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="removePlaylistEpgSource(epgUrl)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div
formArrayName="playlistEpgSourceInputs"
class="playlist-epg-source-form"
>
@for (
control of playlistEpgSourceInputs.controls;
track $index
) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input matInput [formControlName]="$index" />
@if (control.invalid && control.touched) {
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
}
</mat-form-field>
<button
mat-icon-button
type="button"
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="removePlaylistEpgSourceInput($index)"
>
<mat-icon>remove</mat-icon>
</button>
</div>
}
<div class="playlist-epg-source-actions">
<button
mat-button
type="button"
(click)="addPlaylistEpgSourceInput()"
>
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-stroked-button
type="button"
[attr.aria-label]="
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
| translate) +
' ' +
('SETTINGS.EPG_SOURCES' | translate)
"
(click)="savePlaylistEpgSources()"
>
<mat-icon>playlist_add</mat-icon>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
</button>
</div>
</div>
</section>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
@@ -4,11 +4,13 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
@@ -22,11 +24,21 @@ describe('PlaylistInfoComponent', () => {
let databaseService: {
updateXtreamPlaylistDetails: jest.Mock;
};
let epgBridge: {
supportsDataManagement: boolean;
forceFetchEpg: jest.Mock;
clearEpgDataForSource: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsDesktopFileSave: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let settingsStore: {
getSettings: jest.Mock;
getTrustOptions: jest.Mock;
updateSettings: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
@@ -55,11 +67,28 @@ describe('PlaylistInfoComponent', () => {
databaseService = {
updateXtreamPlaylistDetails: jest.fn(),
};
epgBridge = {
supportsDataManagement: true,
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
};
runtime = {
isElectron: false,
supportsDesktopFileSave: false,
supportsXtreamSqliteDataSource: false,
};
settingsStore = {
getSettings: jest.fn(() => ({
epgUrl: [],
})),
getTrustOptions: jest.fn(() => ({
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
})),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
snackBar = {
open: jest.fn(),
};
@@ -85,10 +114,18 @@ describe('PlaylistInfoComponent', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: Store,
useValue: store,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
{
provide: MatSnackBar,
useValue: snackBar,
@@ -100,7 +137,18 @@ describe('PlaylistInfoComponent', () => {
{
provide: TranslateService,
useValue: {
currentLang: 'en',
get: jest.fn((key: string) => of(key)),
instant: jest.fn((key: string) => key),
onDefaultLangChange: of({
lang: 'en',
translations: {},
}),
onLangChange: of({ lang: 'en', translations: {} }),
onTranslationChange: of({
lang: 'en',
translations: {},
}),
},
},
{
@@ -265,6 +313,259 @@ describe('PlaylistInfoComponent', () => {
expect(component.isDesktop).toBe(false);
});
it('normalizes detected playlist EPG source URLs for the details UI', () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: [
' https://playlist.example.com/guide.xml ',
'',
'https://playlist.example.com/guide.xml',
'https://playlist.example.com/backup.xml',
],
},
});
createComponent();
expect(component.playlistEpgUrls).toEqual([
'https://playlist.example.com/guide.xml',
'https://playlist.example.com/backup.xml',
]);
});
it('keeps disabled detected playlist EPG candidates visible in the details UI summary', () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: ['https://playlist.example.com/ua.xml'],
detectedEpgUrls: [
'https://playlist.example.com/ua.xml',
'https://playlist.example.com/de.xml',
'https://playlist.example.com/us.xml',
'https://playlist.example.com/fr.xml',
],
},
});
createComponent();
expect(component.playlistDetectedEpgUrls).toEqual([
'https://playlist.example.com/ua.xml',
'https://playlist.example.com/de.xml',
'https://playlist.example.com/us.xml',
'https://playlist.example.com/fr.xml',
]);
expect(component.hiddenDetectedPlaylistEpgSourceCount).toBe(3);
});
it('refreshes a detected playlist EPG source through the runtime bridge', async () => {
createComponent();
await component.refreshPlaylistEpgSource(
' https://playlist.example.com/guide.xml '
);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
'https://playlist.example.com/guide.xml',
{
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
}
);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.FETCH_SUCCESS',
'CLOSE',
{ duration: 3000 }
);
});
it('adds a detected playlist EPG source to global settings on request', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: [
'https://global.example.com/guide.xml',
'https://playlist.example.com/guide.xml',
],
});
createComponent();
await component.addPlaylistEpgSourceToSettings(
'https://new-playlist.example.com/guide.xml'
);
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
epgUrl: [
'https://global.example.com/guide.xml',
'https://playlist.example.com/guide.xml',
'https://new-playlist.example.com/guide.xml',
],
});
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.ADD_EPG_SOURCE',
'CLOSE',
{ duration: 3000 }
);
});
it('does not duplicate a playlist EPG source that already exists globally', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://playlist.example.com/guide.xml'],
});
createComponent();
await component.addPlaylistEpgSourceToSettings(
' https://playlist.example.com/guide.xml '
);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
});
it('removes a detected playlist EPG source from the enabled list and records it as disabled', async () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
detectedEpgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
disabledEpgUrls: ['https://playlist.example.com/old.xml'],
},
});
createComponent();
await component.removePlaylistEpgSource(
'https://playlist.example.com/remove.xml'
);
expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/remove.xml'
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: expect.objectContaining({
_id: 'playlist-1',
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/manual.xml',
],
detectedEpgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
disabledEpgUrls: [
'https://playlist.example.com/old.xml',
'https://playlist.example.com/remove.xml',
],
}),
})
);
});
it('keeps a playlist EPG source enabled when source data cleanup fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
epgBridge.clearEpgDataForSource.mockRejectedValueOnce(
new Error('Database cleanup failed')
);
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
detectedEpgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
manualEpgUrls: [],
disabledEpgUrls: [],
},
});
createComponent();
await component.removePlaylistEpgSource(
'https://playlist.example.com/remove.xml'
);
expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/remove.xml'
);
expect(store.dispatch).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEAR_FAILED',
'CLOSE',
{ duration: 3000 }
);
} finally {
consoleError.mockRestore();
}
});
it('adds playlist-local EPG sources with URL normalization and deduplication', async () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: ['https://playlist.example.com/existing.xml'],
detectedEpgUrls: ['https://playlist.example.com/existing.xml'],
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
disabledEpgUrls: ['https://playlist.example.com/new.xml'],
},
});
createComponent();
component.playlistEpgSourceInputs
.at(0)
.setValue(' https://playlist.example.com/new.xml ');
component.addPlaylistEpgSourceInput();
component.playlistEpgSourceInputs
.at(1)
.setValue('https://playlist.example.com/manual.xml');
component.savePlaylistEpgSources();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: expect.objectContaining({
_id: 'playlist-1',
epgUrls: [
'https://playlist.example.com/existing.xml',
'https://playlist.example.com/new.xml',
'https://playlist.example.com/manual.xml',
],
manualEpgUrls: [
'https://playlist.example.com/manual.xml',
'https://playlist.example.com/new.xml',
],
disabledEpgUrls: [],
}),
})
);
expect(component.playlistEpgSourceInputs.length).toBe(1);
expect(component.playlistEpgSourceInputs.at(0).value).toBe('');
});
it('shows a validation error for invalid playlist-local EPG source URLs', () => {
createComponent();
fixture.detectChanges();
component.playlistEpgSourceInputs.at(0).setValue('not a url');
component.savePlaylistEpgSources();
fixture.detectChanges();
expect(store.dispatch).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'SETTINGS.EPG_URL_ERROR'
);
});
it('falls back to browser download when desktop file saving is unavailable', async () => {
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
@@ -4,6 +4,7 @@ import { Component, inject } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
Validators,
@@ -21,24 +22,32 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { firstValueFrom } from 'rxjs';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
normalizeXtreamServerUrl,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
normalizeEpgUrls,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
'saveFileDialog' | 'writeFile'
>;
const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
@@ -68,10 +77,61 @@ type DesktopFileSaveBridge = Pick<
mat-dialog-content p {
margin: 0;
color: var(--app-muted-color);
color: var(--mat-sys-on-surface-variant);
font-size: 12.5px;
line-height: 1.45;
}
.playlist-epg-sources {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid
var(
--app-widget-header-border,
var(--mat-sys-outline-variant)
);
border-radius: 8px;
background: var(--mat-sys-surface-container-low);
}
.playlist-epg-sources__header {
display: flex;
gap: 10px;
align-items: flex-start;
}
.playlist-epg-sources__header mat-icon {
color: var(--mat-sys-primary);
}
.playlist-epg-sources__title {
margin: 0 0 2px;
font-size: 14px;
font-weight: 600;
line-height: 1.25;
}
.playlist-epg-source-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 6px;
align-items: center;
}
.playlist-epg-source-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
@media (max-width: 520px) {
.playlist-epg-source-row {
grid-template-columns: minmax(0, 1fr);
}
}
`,
],
providers: [DatePipe],
@@ -98,6 +158,8 @@ export class PlaylistInfoComponent {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
private dialogRef = inject(MatDialogRef<PlaylistInfoComponent>, {
optional: true,
});
@@ -107,6 +169,38 @@ export class PlaylistInfoComponent {
return this.runtime.supportsDesktopFileSave;
}
get playlistEpgUrls(): string[] {
return this.normalizeEpgUrls(this.playlist.epgUrls);
}
get playlistDetectedEpgUrls(): string[] {
const detectedUrls = this.normalizeEpgUrls(
this.playlist.detectedEpgUrls
);
return detectedUrls.length > 0 ? detectedUrls : this.playlistEpgUrls;
}
get hiddenDetectedPlaylistEpgSourceCount(): number {
const enabledUrls = new Set(this.playlistEpgUrls);
return this.playlistDetectedEpgUrls.filter(
(url) => !enabledUrls.has(url)
).length;
}
get canRefreshPlaylistEpg(): boolean {
return this.epgBridge.supportsDataManagement;
}
get canManagePlaylistEpgSources(): boolean {
return !this.playlist.serverUrl && !this.playlist.macAddress;
}
get playlistEpgSourceInputs(): UntypedFormArray {
return this.playlistDetails.get(
'playlistEpgSourceInputs'
) as UntypedFormArray;
}
/** Playlist object */
playlist: Playlist & { id: string };
@@ -159,6 +253,9 @@ export class PlaylistInfoComponent {
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
playlistEpgSourceInputs: new UntypedFormArray([
this.createPlaylistEpgSourceControl(),
]),
});
}
@@ -238,6 +335,159 @@ export class PlaylistInfoComponent {
}); */
}
async refreshPlaylistEpgSource(url: string): Promise<void> {
const normalizedUrl = url.trim();
if (!normalizedUrl) {
return;
}
const result = await this.epgBridge.forceFetchEpg(
normalizedUrl,
this.settingsStore.getTrustOptions()
);
if (!result) {
return;
}
this.snackBar.open(
this.translate.instant(
result.success ? 'EPG.FETCH_SUCCESS' : 'EPG.ERROR'
),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
async addPlaylistEpgSourceToSettings(url: string): Promise<void> {
const epgUrl = url.trim();
if (!epgUrl || this.isGlobalEpgSource(epgUrl)) {
return;
}
const currentSettings = this.settingsStore.getSettings();
await this.settingsStore.updateSettings({
epgUrl: this.normalizeEpgUrls([
...(currentSettings.epgUrl ?? []),
epgUrl,
]),
});
this.snackBar.open(
this.translate.instant('SETTINGS.ADD_EPG_SOURCE'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
isGlobalEpgSource(url: string): boolean {
const normalizedUrl = url.trim();
if (!normalizedUrl) {
return false;
}
return this.normalizeEpgUrls(
this.settingsStore.getSettings().epgUrl
).includes(normalizedUrl);
}
async removePlaylistEpgSource(url: string): Promise<void> {
const epgUrl = url.trim();
if (!epgUrl) {
return;
}
if (this.epgBridge.supportsDataManagement) {
try {
const result =
await this.epgBridge.clearEpgDataForSource(epgUrl);
if (result && result.success === false) {
throw new Error('Clear EPG source returned false');
}
} catch (error) {
console.error(
'Failed to clear playlist EPG source data:',
error
);
this.snackBar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
return;
}
}
const detectedEpgUrls = this.getRawDetectedPlaylistEpgUrls();
const disabledEpgUrls = this.normalizeEpgUrls(
this.playlist.disabledEpgUrls
);
const nextDisabledEpgUrls = detectedEpgUrls.includes(epgUrl)
? this.normalizeEpgUrls([...disabledEpgUrls, epgUrl])
: disabledEpgUrls.filter((disabledUrl) => disabledUrl !== epgUrl);
const state = resolvePlaylistEpgSourceState({
detectedEpgUrls,
enabledEpgUrls: this.playlistEpgUrls.filter(
(enabledUrl) => enabledUrl !== epgUrl
),
manualEpgUrls: this.normalizeEpgUrls(
this.playlist.manualEpgUrls
).filter((manualUrl) => manualUrl !== epgUrl),
disabledEpgUrls: nextDisabledEpgUrls,
});
this.applyPlaylistEpgSourceState(state);
}
addPlaylistEpgSourceInput(): void {
this.playlistEpgSourceInputs.push(
this.createPlaylistEpgSourceControl()
);
}
removePlaylistEpgSourceInput(index: number): void {
if (this.playlistEpgSourceInputs.length <= 1) {
this.playlistEpgSourceInputs.at(0).reset('');
return;
}
this.playlistEpgSourceInputs.removeAt(index);
}
savePlaylistEpgSources(): void {
if (this.playlistEpgSourceInputs.invalid) {
this.playlistEpgSourceInputs.markAllAsTouched();
return;
}
const addedUrls = this.normalizeEpgUrls(
this.playlistEpgSourceInputs.value as string[]
);
if (addedUrls.length === 0) {
return;
}
const addedUrlSet = new Set(addedUrls);
const state = resolvePlaylistEpgSourceState({
detectedEpgUrls: this.getRawDetectedPlaylistEpgUrls(),
enabledEpgUrls: this.normalizeEpgUrls([
...this.playlistEpgUrls,
...addedUrls,
]),
manualEpgUrls: this.normalizeEpgUrls([
...(this.playlist.manualEpgUrls ?? []),
...addedUrls,
]),
disabledEpgUrls: this.normalizeEpgUrls(
this.playlist.disabledEpgUrls
).filter((url) => !addedUrlSet.has(url)),
});
this.applyPlaylistEpgSourceState(state);
this.resetPlaylistEpgSourceInputs();
}
async exportPlaylist() {
const playlistAsString = await firstValueFrom(
this.playlistsService.getRawPlaylistById(this.playlist._id)
@@ -322,4 +572,46 @@ export class PlaylistInfoComponent {
}
}
}
private normalizeEpgUrls(urls?: string[] | null): string[] {
return normalizeEpgUrls(urls ?? []);
}
private createPlaylistEpgSourceControl(value = ''): FormControl<string> {
return new FormControl(value, {
nonNullable: true,
validators: [Validators.pattern(EPG_URL_PATTERN)],
});
}
private getRawDetectedPlaylistEpgUrls(): string[] {
return this.normalizeEpgUrls(this.playlist.detectedEpgUrls);
}
private applyPlaylistEpgSourceState(
state: ReturnType<typeof resolvePlaylistEpgSourceState>
): void {
const playlistMeta = {
_id: this.playlist._id,
epgUrls: state.epgUrls,
detectedEpgUrls: state.detectedEpgUrls,
manualEpgUrls: state.manualEpgUrls,
disabledEpgUrls: state.disabledEpgUrls,
} as PlaylistMeta;
this.playlist = {
...this.playlist,
...playlistMeta,
};
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({ playlist: playlistMeta })
);
}
private resetPlaylistEpgSourceInputs(): void {
this.playlistEpgSourceInputs.clear();
this.playlistEpgSourceInputs.push(
this.createPlaylistEpgSourceControl()
);
}
}
@@ -462,6 +462,7 @@ export class RecentPlaylistsComponent {
_id: item._id,
},
playlistId: item._id,
refreshEpg: true,
})
);
+50 -2
View File
@@ -5,6 +5,7 @@ import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
createPlaylistObject,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import {
@@ -210,6 +211,10 @@ export class PlaylistsService {
playlist: playlist.playlist,
url: playlist.url,
filePath: playlist.filePath,
epgUrls: playlist.epgUrls ?? [],
detectedEpgUrls: playlist.detectedEpgUrls ?? playlist.epgUrls ?? [],
manualEpgUrls: playlist.manualEpgUrls ?? [],
disabledEpgUrls: playlist.disabledEpgUrls ?? [],
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
@@ -465,6 +470,19 @@ export class PlaylistsService {
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
return this.getPlaylistById(playlistId).pipe(
switchMap((currentPlaylist: Playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
currentPlaylist.detectedEpgUrls,
enabledEpgUrls:
updatedPlaylist.epgUrls ?? currentPlaylist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
currentPlaylist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
currentPlaylist.disabledEpgUrls,
});
const mergedPlaylist: Playlist = {
...currentPlaylist,
...updatedPlaylist,
@@ -475,6 +493,10 @@ export class PlaylistsService {
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
favorites: currentPlaylist.favorites,
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist.autoRefresh ??
updatedPlaylist.autoRefresh,
@@ -515,6 +537,18 @@ export class PlaylistsService {
updatePlaylistMeta(updatedPlaylist: PlaylistMeta) {
return this.getPlaylistById(updatedPlaylist._id).pipe(
switchMap((playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
playlist.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
playlist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
playlist.disabledEpgUrls,
});
const nextPlaylist: Playlist = {
...playlist,
...(updatedPlaylist.title != null
@@ -565,6 +599,21 @@ export class PlaylistsService {
updatedPlaylist.hiddenGroupTitles,
}
: {}),
...(updatedPlaylist.detectedEpgUrls !== undefined
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(updatedPlaylist.manualEpgUrls !== undefined
? { manualEpgUrls: epgSourceState.manualEpgUrls }
: {}),
...(updatedPlaylist.disabledEpgUrls !== undefined
? { disabledEpgUrls: epgSourceState.disabledEpgUrls }
: {}),
...(updatedPlaylist.epgUrls !== undefined ||
updatedPlaylist.detectedEpgUrls !== undefined ||
updatedPlaylist.manualEpgUrls !== undefined ||
updatedPlaylist.disabledEpgUrls !== undefined
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
@@ -659,8 +708,7 @@ export class PlaylistsService {
playlistId: string
): Observable<M3uFavoriteChannel[] | null> {
const electron = this.electronApi;
const getFavoriteChannels =
electron?.dbGetAppPlaylistFavoriteChannels;
const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels;
if (
!electron ||
!this.isElectronStorageAvailable ||
@@ -129,6 +129,7 @@ describe('RuntimeCapabilitiesService', () => {
onEpgProgress: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -305,6 +306,7 @@ describe('RuntimeCapabilitiesService', () => {
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -320,6 +322,7 @@ describe('RuntimeCapabilitiesService', () => {
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -102,7 +102,8 @@ export class RuntimeCapabilitiesService {
get supportsEpgDataManagement(): boolean {
return (
this.hasElectronMethod('forceFetchEpg') &&
this.hasElectronMethod('clearEpgData')
this.hasElectronMethod('clearEpgData') &&
this.hasElectronMethod('clearEpgDataForSource')
);
}
@@ -82,6 +82,9 @@ describe('database schema statements', () => {
expect.arrayContaining([
'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0',
'ALTER TABLE playlists ADD COLUMN payload TEXT',
'ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT',
'ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT',
'ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT',
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
])
@@ -102,10 +105,26 @@ describe('database schema statements', () => {
'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)',
'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)',
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)',
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)',
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)',
])
);
});
it('creates indexes for migrated EPG program columns only after column migrations run', () => {
const createSchemaSql = createTableStatements.map(compactSql);
expect(createSchemaSql).not.toContain(
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)'
);
expect(createSchemaSql).not.toContain(
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)'
);
expect(columnMigrationStatements.map(compactSql)).toContain(
'ALTER TABLE epg_programs ADD COLUMN source_url TEXT'
);
});
it('does not define duplicate fresh-install schema object names', () => {
const objectNames = [
...createdObjectNames(
@@ -307,4 +326,102 @@ describe('database schema statements', () => {
expect.stringContaining('UPDATE content')
);
});
it('backfills migrated EPG program source URLs after creating scoped EPG indexes', () => {
const callOrder: string[] = [];
const runMigrations = (
__databaseConnectionTestHooks as unknown as {
runMigrations: (sqlite: {
exec: (statement: string) => void;
prepare: (statement: string) => {
all?: () => unknown[];
get?: (...args: unknown[]) => unknown;
run?: (...args: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => void;
}) => void;
}
).runMigrations;
const sqlite = {
exec: jest.fn((statement: string) => {
if (statement.includes('idx_epg_programs_source')) {
callOrder.push('epg-source-index');
}
}),
prepare: jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
get: (): undefined => undefined,
};
}
if (statement.includes('UPDATE epg_programs')) {
return {
run: () => {
callOrder.push('epg-source-backfill');
},
};
}
if (statement.includes('INSERT INTO app_state')) {
return { run: jest.fn() };
}
return {
all: () => [],
get: (): undefined => undefined,
run: jest.fn(),
};
}),
transaction: jest.fn((callback: () => void) => callback),
};
runMigrations(sqlite);
expect(callOrder).toContain('epg-source-backfill');
expect(callOrder).toContain('epg-source-index');
expect(callOrder.indexOf('epg-source-index')).toBeLessThan(
callOrder.indexOf('epg-source-backfill')
);
});
it('backfills migrated EPG program source URLs in bounded batches', () => {
const { backfillEpgProgramSourceUrls } =
__databaseConnectionTestHooks;
let updateStatement = '';
const backfillRun = jest
.fn()
.mockReturnValueOnce({ changes: 50_000 })
.mockReturnValueOnce({ changes: 12 });
const stateRun = jest.fn();
const prepare = jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
get: (): undefined => undefined,
};
}
if (statement.includes('UPDATE epg_programs')) {
updateStatement = compactSql(statement);
return {
run: backfillRun,
};
}
if (statement.includes('INSERT INTO app_state')) {
return { run: stateRun };
}
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
});
const transaction = jest.fn((callback: () => void) => callback);
const sqlite = {
prepare,
transaction,
} as unknown as Parameters<typeof backfillEpgProgramSourceUrls>[0];
backfillEpgProgramSourceUrls(sqlite);
expect(updateStatement).toContain('LIMIT 50000');
expect(backfillRun).toHaveBeenCalledTimes(2);
expect(stateRun).toHaveBeenCalledWith(
'migration:epg-program-source-url-backfill:v1'
);
});
});
@@ -28,6 +28,9 @@ const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY =
'migration:xtream-content-added-epoch-seconds:v1';
const CONTENT_TITLE_FTS_MIGRATION_KEY =
'migration:content-title-fts-trigram:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
@@ -84,6 +87,10 @@ const CREATE_TABLE_STATEMENTS = [
origin TEXT,
referrer TEXT,
filePath TEXT,
epg_urls TEXT,
detected_epg_urls TEXT,
manual_epg_urls TEXT,
disabled_epg_urls TEXT,
autoRefresh INTEGER DEFAULT 0,
macAddress TEXT,
url TEXT,
@@ -210,6 +217,7 @@ const CREATE_TABLE_STATEMENTS = [
icon_url TEXT,
rating TEXT,
episode_num TEXT,
source_url TEXT,
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
)`,
// EPG indexes
@@ -304,6 +312,11 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE playlists ADD COLUMN favorites TEXT`,
`ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`,
`ALTER TABLE playlists ADD COLUMN payload TEXT`,
// v1.2.1: Keep M3U-detected EPG URLs available in lightweight playlist metadata
`ALTER TABLE playlists ADD COLUMN epg_urls TEXT`,
`ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT`,
`ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT`,
`ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT`,
// v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering
`ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`,
// v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up
@@ -313,6 +326,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN direct_source TEXT`,
// v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -321,6 +336,9 @@ const INDEX_MIGRATION_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)`,
];
export const __databaseConnectionTestHooks = {
@@ -329,6 +347,7 @@ export const __databaseConnectionTestHooks = {
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
backfillEpgProgramSourceUrls,
runMigrations,
} as const;
@@ -594,6 +613,78 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void {
}
}
function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
if (migrationState?.value === 'done') {
return;
}
const backfillStatement = sqliteDb.prepare(
`UPDATE epg_programs
SET source_url = (
SELECT epg_channels.source_url
FROM epg_channels
WHERE epg_channels.id = epg_programs.channel_id
LIMIT 1
)
WHERE id IN (
SELECT pending_programs.id
FROM epg_programs AS pending_programs
JOIN epg_channels
ON epg_channels.id = pending_programs.channel_id
WHERE pending_programs.source_url IS NULL
AND epg_channels.source_url IS NOT NULL
AND epg_channels.source_url <> ''
LIMIT ${EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE}
)`
);
const markMigrationDoneStatement = sqliteDb.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
);
const executeBackfillBatch = sqliteDb.transaction((): number => {
const result = backfillStatement.run();
return typeof result === 'object' &&
result !== null &&
'changes' in result &&
typeof result.changes === 'number'
? result.changes
: 0;
});
const markMigrationDone = sqliteDb.transaction(() => {
markMigrationDoneStatement.run(
EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY
);
});
let updatedRows = 0;
do {
updatedRows = executeBackfillBatch();
} while (updatedRows === EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE);
markMigrationDone();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`EPG program source URL backfill failed (continuing): ${message}`
);
}
}
function runMigrationStatements(
sqliteDb: Database.Database,
statements: string[]
@@ -631,6 +722,7 @@ function runMigrations(sqliteDb: Database.Database): void {
deduplicateXtreamCache(sqliteDb);
normalizeXtreamContentAddedEpochs(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
backfillEpgProgramSourceUrls(sqliteDb);
}
export interface DatabaseOptions {
+16 -3
View File
@@ -32,6 +32,10 @@ export const playlists = sqliteTable('playlists', {
origin: text('origin'),
referrer: text('referrer'),
filePath: text('filePath'),
epgUrls: text('epg_urls'),
detectedEpgUrls: text('detected_epg_urls'),
manualEpgUrls: text('manual_epg_urls'),
disabledEpgUrls: text('disabled_epg_urls'),
autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false),
macAddress: text('macAddress'),
url: text('url'),
@@ -204,9 +208,11 @@ export const epgPrograms = sqliteTable(
iconUrl: text('icon_url'),
rating: text('rating'),
episodeNum: text('episode_num'),
sourceUrl: text('source_url'),
},
(table) => ({
channelIdx: index('idx_epg_programs_channel').on(table.channelId),
sourceIdx: index('idx_epg_programs_source').on(table.sourceUrl),
startIdx: index('idx_epg_programs_start').on(table.start),
stopIdx: index('idx_epg_programs_stop').on(table.stop),
timeRangeIdx: index('idx_epg_programs_time_range').on(
@@ -214,6 +220,12 @@ export const epgPrograms = sqliteTable(
table.start,
table.stop
),
sourceTimeRangeIdx: index('idx_epg_programs_source_time_range').on(
table.sourceUrl,
table.channelId,
table.start,
table.stop
),
})
);
@@ -256,9 +268,10 @@ export const playbackPositions = sqliteTable(
table.seriesXtreamId
),
updatedIdx: index('playback_positions_updated_idx').on(table.updatedAt),
playlistUpdatedIdx: index(
'playback_positions_playlist_updated_idx'
).on(table.playlistId, sql`${table.updatedAt} DESC`),
playlistUpdatedIdx: index('playback_positions_playlist_updated_idx').on(
table.playlistId,
sql`${table.updatedAt} DESC`
),
})
);
@@ -206,6 +206,10 @@ export interface ElectronBridgeEpgFreshnessResult {
freshUrls: string[];
}
export interface ElectronBridgeEpgLookupOptions {
sourceUrls?: string[];
}
export interface ElectronBridgeEpgProgressStats {
totalChannels: number;
totalPrograms: number;
@@ -509,12 +513,17 @@ export interface ElectronBridgeApi {
urls: string[],
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getChannelPrograms: (
channelId: string,
options?: ElectronBridgeEpgLookupOptions
) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannels: () => Promise<ElectronBridgeEpgChannelListResult>;
getEpgChannelsByRange: (
@@ -526,6 +535,7 @@ export interface ElectronBridgeApi {
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
clearEpgData: () => Promise<ElectronBridgeResult>;
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
checkEpgFreshness: (
urls: string[],
maxAgeHours?: number
@@ -1,8 +1,6 @@
export interface ParsedPlaylist {
header: {
attrs: {
'x-tvg-url': string;
};
attrs: Record<string, string | undefined>;
raw: string;
};
items: ParsedPlaylistItem[];
@@ -12,6 +12,10 @@ export type PlaylistMeta = Pick<
| 'referrer'
| 'origin'
| 'filePath'
| 'epgUrls'
| 'detectedEpgUrls'
| 'manualEpgUrls'
| 'disabledEpgUrls'
| 'updateDate'
| 'updateState'
| 'position'
@@ -33,6 +33,14 @@ export interface Playlist {
referrer?: string;
origin?: string;
filePath?: string;
/** M3U-scoped EPG source URLs enabled for automatic import and lookup. */
epgUrls?: string[];
/** All M3U EPG source URLs detected from the playlist header. */
detectedEpgUrls?: string[];
/** Playlist-local EPG source URLs explicitly enabled by the user. */
manualEpgUrls?: string[];
/** Detected playlist EPG source URLs explicitly disabled by the user. */
disabledEpgUrls?: string[];
autoRefresh: boolean;
updateDate?: number;
updateState?: PlaylistUpdateState;
@@ -1,8 +1,12 @@
import { Channel, Playlist } from '@iptvnator/shared/interfaces';
import {
aggregateFavoriteChannels,
createPlaylistObject,
extractM3uEpgUrls,
filterPlaylistEpgUrlsForFetch,
getExtensionFromUrl,
getStreamExtensionFromUrl,
resolvePlaylistEpgSourceState,
} from './playlist.utils';
function createChannel(id: string, url: string, name = id): Channel {
@@ -105,4 +109,188 @@ describe('playlist utils', () => {
}
);
});
describe('extractM3uEpgUrls', () => {
it('extracts and deduplicates playlist-scoped EPG URLs from supported M3U header attributes', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {
'x-tvg-url':
' https://example.com/guide.xml, https://example.com/guide.xml https://example.com/extra.xml.gz ',
'url-tvg': 'https://example.com/url-tvg.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml" url-tvg="https://example.com/url-tvg.xml"',
},
})
).toEqual([
'https://example.com/guide.xml',
'https://example.com/extra.xml.gz',
'https://example.com/url-tvg.xml',
]);
});
it('falls back to the raw header for tvg-url variants the parser does not expose as attrs', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {},
raw: '#EXTM3U tvg-url="https://example.com/raw-guide.xml"',
},
})
).toEqual(['https://example.com/raw-guide.xml']);
});
});
it('stores detected M3U EPG URLs on the created playlist', () => {
const playlist = createPlaylistObject('Playlist with EPG', {
header: {
attrs: {
'x-tvg-url': 'https://example.com/guide.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
},
items: [],
});
expect(playlist.epgUrls).toEqual(['https://example.com/guide.xml']);
expect(playlist.detectedEpgUrls).toEqual([
'https://example.com/guide.xml',
]);
});
it('keeps large detected EPG lists separate from the auto-imported playlist EPG subset', () => {
const playlist = createPlaylistObject('Playlist with global EPG list', {
header: {
attrs: {
'x-tvg-url': [
'https://iptv-org.github.io/epg/guides/us/tvguide.com.epg.xml',
'https://iptv-org.github.io/epg/guides/de/hd-plus.de.epg.xml',
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
'https://iptv-org.github.io/epg/guides/fr/programme-tv.net.epg.xml',
'https://iptv-org.github.io/epg/guides/uk/sky.com.epg.xml',
].join(','),
},
raw: '#EXTM3U',
},
items: [
{
name: '1+1',
tvg: {
id: '1Plus1.ua',
name: '1+1',
url: '',
logo: '',
rec: '',
},
group: { title: 'Undefined' },
http: { referrer: '', 'user-agent': '' },
raw: '#EXTINF:-1 tvg-id="1Plus1.ua" tvg-country="RU;UA" tvg-language="Ukrainian",1+1',
url: 'https://example.com/stream.m3u8',
},
],
});
expect(playlist.detectedEpgUrls).toHaveLength(6);
expect(playlist.epgUrls).toEqual([
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
]);
});
it('falls back to the first detected EPG URLs when a large list has no region recommendation match', () => {
const epgUrls = [
'https://provider.example.com/epg/source-1.xml',
'https://provider.example.com/epg/source-2.xml',
'https://provider.example.com/epg/source-3.xml',
'https://provider.example.com/epg/source-4.xml',
'https://provider.example.com/epg/source-5.xml',
'https://provider.example.com/epg/source-6.xml',
];
const playlist = createPlaylistObject(
'Playlist with generic EPG list',
{
header: {
attrs: {
'x-tvg-url': epgUrls.join(','),
},
raw: '#EXTM3U',
},
items: [
{
name: 'Generic Channel',
tvg: {
id: 'generic-channel',
name: 'Generic Channel',
url: '',
logo: '',
rec: '',
},
group: { title: 'Undefined' },
http: { referrer: '', 'user-agent': '' },
raw: '#EXTINF:-1 tvg-id="generic-channel",Generic Channel',
url: 'https://example.com/stream.m3u8',
},
],
}
);
expect(playlist.detectedEpgUrls).toEqual(epgUrls);
expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5));
});
it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => {
expect(
resolvePlaylistEpgSourceState({
detectedEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
enabledEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
manualEpgUrls: [
' https://playlist.example.com/manual.xml ',
'https://playlist.example.com/auto.xml',
],
disabledEpgUrls: [
'https://playlist.example.com/disabled.xml',
'',
'https://playlist.example.com/disabled.xml',
],
})
).toEqual({
detectedEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
epgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/manual.xml',
],
manualEpgUrls: [
'https://playlist.example.com/manual.xml',
'https://playlist.example.com/auto.xml',
],
disabledEpgUrls: ['https://playlist.example.com/disabled.xml'],
});
});
it('filters playlist EPG fetch URLs that are already configured globally', () => {
expect(
filterPlaylistEpgUrlsForFetch(
[
'https://global.example.com/guide.xml',
'https://playlist.example.com/local.xml',
' https://playlist.example.com/local.xml ',
],
[
' https://global.example.com/guide.xml ',
'https://global.example.com/other.xml',
]
)
).toEqual(['https://playlist.example.com/local.xml']);
});
});
@@ -65,6 +65,306 @@ export const getFilenameFromUrl = (value: string): string => {
return 'Untitled playlist';
};
const M3U_EPG_HEADER_ATTRS = ['x-tvg-url', 'url-tvg', 'tvg-url'] as const;
const M3U_EPG_URL_PATTERN = /\b(?:https?|file):\/\/[^\s,"']+/gi;
export const M3U_AUTO_IMPORT_EPG_URL_LIMIT = 5;
export const M3U_RECOMMENDED_EPG_URL_LIMIT = 12;
export interface M3uEpgUrlSelection {
detectedEpgUrls: string[];
enabledEpgUrls: string[];
}
export interface PlaylistEpgSourceStateInput {
detectedEpgUrls?: string[];
enabledEpgUrls?: string[];
manualEpgUrls?: string[];
disabledEpgUrls?: string[];
}
export interface PlaylistEpgSourceState {
detectedEpgUrls: string[];
epgUrls: string[];
manualEpgUrls: string[];
disabledEpgUrls: string[];
}
export function normalizeEpgUrls(
urls: readonly string[] | undefined
): string[] {
return Array.from(
new Set(
(urls ?? [])
.map((url) => url.trim())
.filter((url) => url.length > 0)
)
);
}
export function resolvePlaylistEpgSourceState(
input: PlaylistEpgSourceStateInput
): PlaylistEpgSourceState {
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls);
const manualEpgUrls = normalizeEpgUrls(input.manualEpgUrls);
const disabledEpgUrls = normalizeEpgUrls(input.disabledEpgUrls);
const disabledSet = new Set(disabledEpgUrls);
const epgUrls = normalizeEpgUrls([
...normalizeEpgUrls(input.enabledEpgUrls),
...manualEpgUrls,
]).filter((url) => !disabledSet.has(url));
return {
detectedEpgUrls,
epgUrls,
manualEpgUrls,
disabledEpgUrls,
};
}
export function filterPlaylistEpgUrlsForFetch(
playlistEpgUrls: readonly string[] | undefined,
globalEpgUrls: readonly string[] | undefined
): string[] {
const globalUrlSet = new Set(normalizeEpgUrls(globalEpgUrls));
return normalizeEpgUrls(playlistEpgUrls).filter(
(url) => !globalUrlSet.has(url)
);
}
export function extractM3uEpgUrls(
playlist: Pick<ParsedPlaylist, 'header'> | null | undefined
): string[] {
const header = playlist?.header;
if (!header) {
return [];
}
const candidates: Array<string | undefined> = [];
for (const attr of M3U_EPG_HEADER_ATTRS) {
candidates.push(header.attrs?.[attr]);
candidates.push(extractHeaderAttributeFromRaw(header.raw, attr));
}
const urls: string[] = [];
for (const candidate of candidates) {
urls.push(...extractUrlsFromHeaderValue(candidate));
}
return Array.from(new Set(urls));
}
export function resolveM3uEpgUrlSelection(
playlist: Pick<ParsedPlaylist, 'header' | 'items'> | null | undefined
): M3uEpgUrlSelection {
const detectedEpgUrls = extractM3uEpgUrls(playlist);
if (detectedEpgUrls.length <= M3U_AUTO_IMPORT_EPG_URL_LIMIT) {
return {
detectedEpgUrls,
enabledEpgUrls: detectedEpgUrls,
};
}
const recommendedEpgUrls = selectRecommendedEpgUrls(
detectedEpgUrls,
playlist?.items ?? []
);
return {
detectedEpgUrls,
enabledEpgUrls:
recommendedEpgUrls.length > 0
? recommendedEpgUrls
: detectedEpgUrls.slice(0, M3U_AUTO_IMPORT_EPG_URL_LIMIT),
};
}
function extractHeaderAttributeFromRaw(
raw: string | undefined,
attr: string
): string | undefined {
if (!raw) {
return undefined;
}
const match = raw.match(
new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s]+))`, 'i')
);
return match?.[2] ?? match?.[3] ?? match?.[4];
}
function extractUrlsFromHeaderValue(value: string | undefined): string[] {
if (!value) {
return [];
}
const urls: string[] = [];
const pattern = new RegExp(M3U_EPG_URL_PATTERN);
let match: RegExpExecArray | null;
while ((match = pattern.exec(value)) !== null) {
const url = match[0].trim();
if (url.length > 0) {
urls.push(url);
}
}
return urls;
}
function selectRecommendedEpgUrls(
urls: string[],
items: ParsedPlaylistItem[]
): string[] {
const hints = collectPlaylistEpgRegionHints(items);
const recommendations: string[] = [];
for (const url of urls) {
const guideCodes = extractEpgGuideCodes(url);
const matchesCountry =
guideCodes.country !== undefined &&
hints.countries.has(guideCodes.country);
const matchesLanguageWithoutCountry =
hints.countries.size === 0 &&
guideCodes.language !== undefined &&
hints.languages.has(guideCodes.language);
if (matchesCountry || matchesLanguageWithoutCountry) {
recommendations.push(url);
}
if (recommendations.length >= M3U_RECOMMENDED_EPG_URL_LIMIT) {
break;
}
}
return recommendations;
}
function collectPlaylistEpgRegionHints(items: ParsedPlaylistItem[]): {
countries: Set<string>;
languages: Set<string>;
} {
const countries = new Set<string>();
const languages = new Set<string>();
for (const item of items) {
addDelimitedCodes(
countries,
extractExtinfAttribute(item.raw, 'tvg-country')
);
addDelimitedCodes(countries, extractCountrySuffix(item.tvg?.id));
const languageCode = normalizeLanguageCode(
extractExtinfAttribute(item.raw, 'tvg-language')
);
if (languageCode) {
languages.add(languageCode);
}
}
return { countries, languages };
}
function extractExtinfAttribute(
raw: string | undefined,
attr: string
): string | undefined {
if (!raw) {
return undefined;
}
const match = raw.match(
new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s,]+))`, 'i')
);
return match?.[2] ?? match?.[3] ?? match?.[4];
}
function addDelimitedCodes(target: Set<string>, value: string | undefined) {
if (!value) {
return;
}
for (const token of value.split(/[;,\s]+/)) {
const code = normalizeRegionCode(token);
if (code) {
target.add(code);
}
}
}
function normalizeRegionCode(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
return normalized && /^[a-z]{2}$/.test(normalized) ? normalized : undefined;
}
function normalizeLanguageCode(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const languageByName: Record<string, string> = {
arabic: 'ar',
english: 'en',
french: 'fr',
german: 'de',
italian: 'it',
polish: 'pl',
portuguese: 'pt',
russian: 'ru',
spanish: 'es',
turkish: 'tr',
ukrainian: 'uk',
};
return normalizeRegionCode(normalized) ?? languageByName[normalized];
}
function extractCountrySuffix(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const match = normalized.match(/\.([a-z]{2})$/);
return match?.[1];
}
function extractEpgGuideCodes(url: string): {
country?: string;
language?: string;
} {
let path = '';
try {
path = new URL(url).pathname;
} catch {
path = url;
}
const segments = path
.split('/')
.map((segment) => segment.trim().toLowerCase())
.filter((segment) => segment.length > 0);
const guidesIndex = segments.indexOf('guides');
const guideSegment =
guidesIndex >= 0 ? segments[guidesIndex + 1] : undefined;
if (!guideSegment) {
return {};
}
const [country, language] = guideSegment.split('-');
return {
country: normalizeRegionCode(country),
language: normalizeRegionCode(language),
};
}
/**
* Creates a playlist object
* @param name name of the playlist
@@ -78,6 +378,13 @@ export const createPlaylistObject = (
urlOrPath?: string,
uploadType?: 'URL' | 'FILE' | 'TEXT'
): Playlist => {
const { detectedEpgUrls, enabledEpgUrls } =
resolveM3uEpgUrlSelection(playlist);
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls,
enabledEpgUrls,
});
return {
_id: uuidv4(),
filename: name,
@@ -94,6 +401,12 @@ export const createPlaylistObject = (
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(epgSourceState.epgUrls.length > 0
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(epgSourceState.detectedEpgUrls.length > 0
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
@@ -65,6 +65,11 @@ describe('ChannelListContainerComponent', () => {
let fixture: ComponentFixture<ChannelListContainerComponent>;
let dispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let epgService: {
epgAvailable$: BehaviorSubject<boolean>;
getChannelMetadataForChannels: jest.Mock;
getCurrentProgramsForChannels: jest.Mock;
};
let favoriteChannelIds$: BehaviorSubject<string[]>;
let runtimeCapabilities: { supportsEpg: boolean };
let storageGet: jest.Mock;
@@ -75,6 +80,15 @@ describe('ChannelListContainerComponent', () => {
favoriteChannelIds$ = new BehaviorSubject<string[]>([]);
runtimeCapabilities = { supportsEpg: true };
storageGet = jest.fn().mockReturnValue(of({}));
epgService = {
epgAvailable$: new BehaviorSubject<boolean>(false),
getChannelMetadataForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
};
activePlaylistSignal = signal<PlaylistMeta | null>({
_id: 'playlist-1',
title: 'Playlist One',
@@ -110,14 +124,7 @@ describe('ChannelListContainerComponent', () => {
providers: [
{
provide: EpgService,
useValue: {
getChannelMetadataForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
},
useValue: epgService,
},
{
provide: PlaylistsService,
@@ -217,6 +224,109 @@ describe('ChannelListContainerComponent', () => {
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
});
it('enables EPG rows and scopes lookups when the active M3U playlist has detected EPG URLs', () => {
runtimeCapabilities.supportsEpg = true;
storageGet.mockReturnValue(of({ epgUrl: [] }));
activePlaylistSignal.set({
_id: 'playlist-1',
title: 'Playlist One',
count: 1,
importDate: '2026-04-11T00:00:00.000Z',
epgUrls: ['https://playlist.example.com/guide.xml'],
} as PlaylistMeta);
fixture.detectChanges();
fixture.componentInstance.channelList = [
createChannel('guide-news', 'https://example.com/news.m3u8'),
];
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
});
it('refreshes visible channel EPG when playlist EPG URLs arrive after channels', () => {
runtimeCapabilities.supportsEpg = true;
storageGet.mockReturnValue(of({ epgUrl: [] }));
fixture.detectChanges();
fixture.componentInstance.channelList = [
createChannel('guide-news', 'https://example.com/news.m3u8'),
];
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
['guide-news'],
undefined
);
epgService.getCurrentProgramsForChannels.mockClear();
epgService.getChannelMetadataForChannels.mockClear();
activePlaylistSignal.set({
_id: 'playlist-1',
title: 'Playlist One',
count: 1,
importDate: '2026-04-11T00:00:00.000Z',
epgUrls: ['https://playlist.example.com/guide.xml'],
} as PlaylistMeta);
fixture.detectChanges();
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
});
it('debounces visible channel EPG row refreshes after EPG imports complete', () => {
jest.useFakeTimers();
try {
runtimeCapabilities.supportsEpg = true;
activePlaylistSignal.set({
_id: 'playlist-1',
title: 'Playlist One',
count: 1,
importDate: '2026-04-11T00:00:00.000Z',
epgUrls: ['https://playlist.example.com/guide.xml'],
} as PlaylistMeta);
fixture.detectChanges();
fixture.componentInstance.channelList = [
createChannel('guide-news', 'https://example.com/news.m3u8'),
];
epgService.getCurrentProgramsForChannels.mockClear();
epgService.epgAvailable$.next(true);
epgService.epgAvailable$.next(true);
jest.advanceTimersByTime(1999);
expect(
epgService.getCurrentProgramsForChannels
).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(
epgService.getCurrentProgramsForChannels
).toHaveBeenCalledTimes(1);
expect(
epgService.getCurrentProgramsForChannels
).toHaveBeenCalledWith(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
} finally {
fixture.destroy();
jest.useRealTimers();
}
});
it('dispatches playlist meta updates when hidden group titles change', () => {
fixture.componentInstance.onHiddenGroupTitlesChanged([
'Movies',
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
Input,
input,
@@ -10,6 +11,7 @@ import {
OnInit,
output,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@@ -35,10 +37,12 @@ import {
import {
BehaviorSubject,
combineLatest,
debounceTime,
filter,
forkJoin,
firstValueFrom,
map,
Subscription,
} from 'rxjs';
import {
PlaylistsService,
@@ -55,6 +59,7 @@ import {
Settings,
STORE_KEY,
} from '@iptvnator/shared/interfaces';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
@@ -88,6 +93,8 @@ function mapChannelsByFirstUrl(channels: Channel[]): Map<string, Channel> {
return channelsByUrl;
}
const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000;
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
@@ -115,6 +122,10 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
@@ -128,9 +139,40 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Interval for global progress updates */
private progressInterval?: number;
private epgAvailabilitySubscription?: Subscription;
/** Whether to show EPG data in channel items */
readonly shouldShowEpg = signal(false);
private readonly globalEpgUrls = signal<string[]>([]);
readonly playlistEpgUrls = computed(() => {
const playlist = this.activePlaylist();
if (!playlist || playlist.serverUrl || playlist.macAddress) {
return [];
}
return normalizeEpgUrls(playlist.epgUrls ?? []);
});
readonly shouldShowEpg = computed(
() =>
this.runtime.supportsEpg &&
(this.globalEpgUrls().length > 0 ||
this.playlistEpgUrls().length > 0)
);
private readonly epgSourceRefreshKey = computed(() => {
if (!this.runtime.supportsEpg) {
return '';
}
const globalUrls = this.globalEpgUrls();
const playlistUrls = this.playlistEpgUrls();
if (globalUrls.length === 0 && playlistUrls.length === 0) {
return '';
}
return JSON.stringify({
globalUrls: Array.from(new Set(globalUrls)).sort(),
playlistUrls: Array.from(new Set(playlistUrls)).sort(),
});
});
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -182,6 +224,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
_channelList: Channel[] = [];
private readonly channelListSignal = signal<Channel[]>([]);
private channelList$ = new BehaviorSubject<Channel[]>([]);
private lastEpgSourceRefreshKey = '';
private readonly epgSourceRefreshEffect = effect(() => {
const refreshKey = this.epgSourceRefreshKey();
if (refreshKey === this.lastEpgSourceRefreshKey) {
return;
}
this.lastEpgSourceRefreshKey = refreshKey;
if (!refreshKey || this._channelList.length === 0) {
return;
}
untracked(() => {
this.fetchEpgForChannels(this._channelList);
});
});
get channelList(): Channel[] {
return this._channelList;
@@ -196,11 +254,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
this.fetchEpgForChannels(safeValue);
}
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
readonly hiddenGroupTitles = computed(() => {
const playlist = this.activePlaylist();
@@ -301,13 +354,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
Object.keys(settings as Settings).length > 0
) {
const epgUrl = (settings as Settings).epgUrl;
this.shouldShowEpg.set(!!(epgUrl && epgUrl.length > 0));
this.globalEpgUrls.set(normalizeEpgUrls(epgUrl));
}
});
} else {
this.shouldShowEpg.set(false);
this.globalEpgUrls.set([]);
}
this.epgAvailabilitySubscription = this.epgService.epgAvailable$
.pipe(
filter((available) => available),
debounceTime(EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS)
)
.subscribe(() => {
this.fetchEpgForChannels(this._channelList);
});
// Set up EPG refresh interval (every 60 seconds)
this.epgRefreshInterval = window.setInterval(() => {
this.fetchEpgForChannels(this._channelList);
@@ -330,6 +392,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
clearInterval(this.progressInterval);
}
this.epgAvailabilitySubscription?.unsubscribe();
this.channelList$.complete();
}
@@ -351,10 +414,17 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
)
);
const epgLookupOptions = this.getPlaylistEpgLookupOptions();
forkJoin({
epgMap: this.epgService.getCurrentProgramsForChannels(channelIds),
metadataMap:
this.epgService.getChannelMetadataForChannels(channelIds),
epgMap: this.epgService.getCurrentProgramsForChannels(
channelIds,
epgLookupOptions
),
metadataMap: this.epgService.getChannelMetadataForChannels(
channelIds,
epgLookupOptions
),
}).subscribe(({ epgMap, metadataMap }) => {
this.channelEpgMap.set(epgMap);
this.channelIconMap.set(
@@ -371,6 +441,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
});
}
private getPlaylistEpgLookupOptions():
| { sourceUrls: string[] }
| undefined {
const sourceUrls = this.playlistEpgUrls();
return sourceUrls.length > 0 ? { sourceUrls } : undefined;
}
/**
* Handles channel selection from any tab
*/