mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -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
@@ -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": "深色",
|
||||
|
||||
Reference in new issue
Block a user