mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
1 parent
36a7ce9f3d
commit
e801028005
71 files changed
+4876
-373
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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": "داكن",
|
||||
|
||||
@@ -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": "داكن",
|
||||
|
||||
@@ -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": "Цёмная",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Σκούρο",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ダーク",
|
||||
|
||||
@@ -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": "어둡게",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
+135
@@ -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 }}
|
||||
|
||||
+301
@@ -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')
|
||||
|
||||
+293
-1
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
+118
-8
@@ -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',
|
||||
|
||||
+88
-11
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user