feat(epg): support playlist-scoped sources

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

No files matched your search

@@ -48,7 +48,9 @@ export type M3uTestChannel = {
logo?: string;
name: string;
radio?: boolean;
tvgCountry?: string;
tvgId?: string;
tvgLanguage?: string;
tvgName?: string;
url: string;
};
@@ -614,6 +616,10 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
for (const channel of channels) {
const attributes = [
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
channel.tvgLanguage
? `tvg-language="${channel.tvgLanguage}"`
: '',
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
@@ -1140,7 +1146,10 @@ export async function saveSourceDialog(
page: Page,
dialog: Locator
): Promise<void> {
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true })
.click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
await expectPlaylistUpdatedToast(page);
}
+135 -6
View File
@@ -28,6 +28,40 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
</tv>
`;
function createCurrentXmltvFixture(
channelId: string,
channelName: string,
programTitle: string
): string {
const start = new Date(Date.now() - 15 * 60 * 1000);
const stop = new Date(Date.now() + 45 * 60 * 1000);
return `<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="${channelId}">
<display-name>${channelName}</display-name>
</channel>
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
<title>${programTitle}</title>
<desc>M3U-declared EPG source smoke test.</desc>
</programme>
</tv>
`;
}
function formatXmltvDate(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return [
date.getUTCFullYear(),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds()),
].join('');
}
test.describe('Electron EPG', () => {
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
dataDir,
@@ -48,7 +82,10 @@ test.describe('Electron EPG', () => {
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow.locator('.epg-source-row button').first().click();
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
@@ -58,18 +95,27 @@ test.describe('Electron EPG', () => {
})
.toBeGreaterThan(0);
await expect(
app.mainWindow.locator('.epg-progress-panel .stat-badge').first()
app.mainWindow
.locator('.epg-progress-panel .stat-badge')
.first()
).toBeVisible();
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(0);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
0
);
await app.mainWindow
.getByRole('button', { name: 'Clear EPG data' })
.click();
const dialog = app.mainWindow.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await dialog
.getByRole('button', { name: 'Yes', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
@@ -85,6 +131,86 @@ test.describe('Electron EPG', () => {
}
});
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'playlist-guide-news',
'Playlist Guide News',
'Playlist Scoped Bulletin'
),
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guides/ua/playlist-guide.xml',
}
);
const detectedEpgUrls = [
`${epgServer.origin}/guides/us/ignored.xml`,
`${epgServer.origin}/guides/de/ignored.xml`,
epgServer.resourceUrl,
`${epgServer.origin}/guides/fr/ignored.xml`,
`${epgServer.origin}/guides/uk/ignored.xml`,
`${epgServer.origin}/guides/es/ignored.xml`,
];
const playlistContent = buildM3uContent([
{
name: 'Playlist Guide News',
tvgCountry: 'UA',
tvgId: 'playlist-guide-news',
url: 'https://example.com/live/playlist-guide-news.m3u8',
},
]).replace(
'#EXTM3U',
`#EXTM3U x-tvg-url="${detectedEpgUrls.join(',')}"`
);
const playlistServer = await createMutableTextServer(playlistContent, {
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/playlist-with-epg.m3u',
});
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
playlistServer.resourceUrl
);
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
await expect(
app.mainWindow.locator('.epg-progress-panel .import-item')
).toHaveCount(1);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1, { timeout: 30000 });
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle(
app.mainWindow,
'Playlist Guide News'
);
await expect(channelItem).toBeVisible();
await expect(channelItem.locator('.epg-title')).toContainText(
'Playlist Scoped Bulletin',
{ timeout: 30000 }
);
} finally {
await closeElectronApp(app);
await playlistServer.close();
await epgServer.close();
}
});
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
dataDir,
}) => {
@@ -135,7 +261,10 @@ test.describe('Electron EPG', () => {
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow.locator('.epg-source-row button').first().click();
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
@@ -405,6 +405,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
channel: 'EPG_CLEAR_ALL',
forwardedArgs: [],
},
{
method: 'clearEpgDataForSource',
args: ['https://example.com/guide.xml'],
channel: 'EPG_CLEAR_SOURCE',
forwardedArgs: [{ sourceUrl: 'https://example.com/guide.xml' }],
},
{
method: 'checkEpgFreshness',
args: [epgUrls, 24],
@@ -7,6 +7,7 @@ import type {
ElectronBridgeApi,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
ElectronBridgeEpgLookupOptions,
ElectronBridgeEpgProgress,
ElectronBridgePlaybackPositionInput,
ElectronBridgePlaylistInput,
@@ -460,18 +461,31 @@ const electronApi: ElectronBridgeApi = {
) => ipcRenderer.invoke('AUTO_UPDATE', playlists, options),
fetchEpg: (urls: string[], options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('FETCH_EPG', { url: urls, options }),
getChannelPrograms: (channelId: string) =>
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
getCurrentProgramsBatch: (channelIds: string[]) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds }),
getEpgChannelMetadata: (channelIds: string[]) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
getChannelPrograms: (
channelId: string,
options?: ElectronBridgeEpgLookupOptions
) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId, options }),
getCurrentProgramsBatch: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', {
channelIds,
options,
}),
getEpgChannelMetadata: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds, options }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
getEpgChannelsByRange: (skip: number, limit: number) =>
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
clearEpgDataForSource: (sourceUrl: string) =>
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
@@ -68,6 +68,17 @@ describe('playlist.operations', () => {
importDate: '2026-04-01T00:00:00.000Z',
favorites: JSON.stringify(['channel-1']),
recentlyViewed: JSON.stringify([{ id: 'recent-1' }]),
epgUrls: JSON.stringify(['https://example.com/enabled.xml']),
detectedEpgUrls: JSON.stringify([
'https://example.com/enabled.xml',
'https://example.com/detected-only.xml',
]),
manualEpgUrls: JSON.stringify([
'https://example.com/manual.xml',
]),
disabledEpgUrls: JSON.stringify([
'https://example.com/disabled.xml',
]),
autoRefresh: false,
url: 'https://example.com/list.m3u',
},
@@ -84,6 +95,13 @@ describe('playlist.operations', () => {
count: 2,
favorites: ['channel-1'],
recentlyViewed: [{ id: 'recent-1' }],
epgUrls: ['https://example.com/enabled.xml'],
detectedEpgUrls: [
'https://example.com/enabled.xml',
'https://example.com/detected-only.xml',
],
manualEpgUrls: ['https://example.com/manual.xml'],
disabledEpgUrls: ['https://example.com/disabled.xml'],
url: 'https://example.com/list.m3u',
}),
]);
@@ -59,6 +59,16 @@ function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
}
}
function getStringArrayValue(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
function getFavoriteChannelMatch(
channel: Channel,
favoritePositions: ReadonlyMap<string, number>
@@ -149,6 +159,22 @@ function buildPlaylistRow(
origin: getStringValue(playlist.origin),
referrer: getStringValue(playlist.referrer),
filePath: getStringValue(playlist.filePath),
epgUrls:
playlist.epgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.epgUrls))
: undefined,
detectedEpgUrls:
playlist.detectedEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.detectedEpgUrls))
: undefined,
manualEpgUrls:
playlist.manualEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.manualEpgUrls))
: undefined,
disabledEpgUrls:
playlist.disabledEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.disabledEpgUrls))
: undefined,
autoRefresh: Boolean(playlist.autoRefresh),
macAddress: getStringValue(playlist.macAddress),
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
@@ -170,7 +196,9 @@ function buildPlaylistRow(
};
}
export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
export function parseAppPlaylist(
row: schema.Playlist
): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
@@ -178,11 +206,28 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const epgUrls = getStringArrayValue(
row.epgUrls ? parseJsonValue<unknown[]>(row.epgUrls, []) : base.epgUrls
);
const detectedEpgUrls = getStringArrayValue(
row.detectedEpgUrls
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
: (base.detectedEpgUrls ?? epgUrls)
);
const manualEpgUrls = getStringArrayValue(
row.manualEpgUrls
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
: base.manualEpgUrls
);
const disabledEpgUrls = getStringArrayValue(
row.disabledEpgUrls
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
: base.disabledEpgUrls
);
const importDate =
row.importDate ?? row.dateCreated ?? new Date().toISOString();
const portalUrl =
row.portalUrl ??
(row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
@@ -191,9 +236,7 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
...base,
_id: row.id,
title:
getStringValue(base.title) ??
getStringValue(base.name) ??
row.name,
getStringValue(base.title) ?? getStringValue(base.name) ?? row.name,
count: row.count ?? getNumericValue(base.count) ?? 0,
importDate: getStringValue(base.importDate) ?? importDate,
lastUsage:
@@ -206,9 +249,13 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? row.url ?? getStringValue(base.url)
? (row.url ?? getStringValue(base.url))
: getStringValue(base.url),
filePath: row.filePath ?? getStringValue(base.filePath),
epgUrls,
detectedEpgUrls,
manualEpgUrls,
disabledEpgUrls,
userAgent: row.userAgent ?? getStringValue(base.userAgent),
referrer: row.referrer ?? getStringValue(base.referrer),
origin: row.origin ?? getStringValue(base.origin),
@@ -260,13 +307,10 @@ export async function upsertAppPlaylist(
throw new Error('Playlist ID is required for upsert');
}
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
return { success: true };
}
@@ -289,8 +333,7 @@ export async function upsertAppPlaylists(
await db.transaction((tx) => {
for (const row of rows) {
tx
.insert(schema.playlists)
tx.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
@@ -323,6 +366,10 @@ export async function getAppPlaylistMetas(db: AppDatabase) {
origin: schema.playlists.origin,
referrer: schema.playlists.referrer,
filePath: schema.playlists.filePath,
epgUrls: schema.playlists.epgUrls,
detectedEpgUrls: schema.playlists.detectedEpgUrls,
manualEpgUrls: schema.playlists.manualEpgUrls,
disabledEpgUrls: schema.playlists.disabledEpgUrls,
autoRefresh: schema.playlists.autoRefresh,
macAddress: schema.playlists.macAddress,
url: schema.playlists.url,
@@ -453,25 +500,29 @@ export async function deletePlaylist(
playlistId: string,
control?: OperationControl
): Promise<{ success: boolean }> {
const [favoriteRows, recentlyViewedRows, playbackPositionRows, downloadRows] =
await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const [
favoriteRows,
recentlyViewedRows,
playbackPositionRows,
downloadRows,
] = await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const categoryRows = await db
.select({ id: schema.categories.id })
@@ -543,7 +594,9 @@ export async function deletePlaylist(
}
await checkpointOperation(control);
await db.delete(schema.playlists).where(eq(schema.playlists.id, playlistId));
await db
.delete(schema.playlists)
.where(eq(schema.playlists.id, playlistId));
await reportOperationProgress(control, {
phase: 'deleting-playlist',
current: 1,
@@ -0,0 +1,83 @@
const getDatabase = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('../../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown {
const { ipcMain } = jest.requireMock('electron') as {
ipcMain: { handle: jest.Mock };
};
const calls = ipcMain.handle.mock.calls as Array<
[string, (...args: unknown[]) => unknown]
>;
const match = calls.find(
([registeredChannel]) => registeredChannel === channel
);
if (!match) {
throw new Error(`Missing ipcMain handler for ${channel}`);
}
return match[1];
}
describe('epg-db.events', () => {
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(async () => {
jest.resetModules();
getDatabase.mockReset();
const { ipcMain } = jest.requireMock('electron') as {
ipcMain: { handle: jest.Mock };
};
ipcMain.handle.mockClear();
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
await import('./epg-db.events');
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('clears a source in a single database transaction', async () => {
const runOrder: string[] = [];
const txDelete = jest
.fn()
.mockReturnValueOnce({
where: jest.fn(() => ({
run: jest.fn(() => runOrder.push('programs')),
})),
})
.mockReturnValueOnce({
where: jest.fn(() => ({
run: jest.fn(() => runOrder.push('channels')),
})),
});
const transaction = jest.fn((callback: (tx: unknown) => void) =>
callback({ delete: txDelete })
);
getDatabase.mockResolvedValue({ transaction });
await expect(
getIpcMainHandler('EPG_DB_CLEAR_SOURCE')(
{},
'https://playlist.example.com/guide.xml'
)
).resolves.toEqual({ success: true });
expect(transaction).toHaveBeenCalledTimes(1);
expect(txDelete).toHaveBeenCalledTimes(2);
expect(runOrder).toEqual(['programs', 'channels']);
});
});
@@ -298,10 +298,21 @@ ipcMain.handle('EPG_DB_CLEAR_SOURCE', async (_event, sourceUrl: string) => {
try {
const db = await getDatabase();
// Deleting channels will cascade delete programs due to foreign key
await db
.delete(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, sourceUrl));
await db.transaction((tx) => {
tx.delete(schema.epgPrograms)
.where(eq(schema.epgPrograms.sourceUrl, sourceUrl))
.run();
tx.delete(schema.epgChannels)
.where(sql`
${schema.epgChannels.sourceUrl} = ${sourceUrl}
AND NOT EXISTS (
SELECT 1
FROM ${schema.epgPrograms}
WHERE ${schema.epgPrograms.channelId} = ${schema.epgChannels.id}
)
`)
.run();
});
console.log(loggerLabel, `Cleared EPG data for source: ${sourceUrl}`);
return { success: true };
@@ -0,0 +1,257 @@
import { EpgQueryService } from './epg-query.service';
const getDatabase = jest.fn();
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
function flattenSql(value: unknown, seen = new Set<unknown>()): string {
if (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return String(value ?? '');
}
if (seen.has(value)) {
return '';
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => flattenSql(item, seen)).join(' ');
}
const sqlLike = value as {
name?: unknown;
queryChunks?: unknown[];
value?: unknown[];
};
if (Array.isArray(sqlLike.queryChunks)) {
return sqlLike.queryChunks
.map((chunk) => flattenSql(chunk, seen))
.join(' ');
}
if (Array.isArray(sqlLike.value)) {
return sqlLike.value.join(' ');
}
if (typeof sqlLike.name === 'string') {
return sqlLike.name;
}
return '';
}
function createSelectChain(
whereResult: unknown,
whereCalls: unknown[]
): { from: jest.Mock } {
const where = jest.fn((condition: unknown) => {
whereCalls.push(condition);
return whereResult;
});
return {
from: jest.fn(() => ({ where })),
};
}
function createLimitedSelectChain(
limitResult: unknown,
whereCalls: unknown[]
): { from: jest.Mock } {
const limit = jest.fn().mockResolvedValue(limitResult);
const where = jest.fn((condition: unknown) => {
whereCalls.push(condition);
return { limit };
});
return {
from: jest.fn(() => ({ where })),
};
}
describe('EpgQueryService', () => {
let service: EpgQueryService;
beforeEach(() => {
getDatabase.mockReset();
service = new EpgQueryService('[Test EPG]');
});
it('scopes display-name current program fallback by program source ownership for shared channels', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(createSelectChain([], whereCalls))
.mockReturnValueOnce(createSelectChain([], whereCalls));
getDatabase.mockResolvedValue({ select });
await service.getCurrentProgramsBatch(['Shared Channel'], {
sourceUrls: ['https://second.example.com/guide.xml'],
});
const channelScopeSql = whereCalls.map((condition) =>
flattenSql(condition)
);
expect(
channelScopeSql.some(
(condition) =>
condition.includes('EXISTS') &&
condition.includes('channel_id') &&
condition.includes('source_url')
)
).toBe(true);
});
it('falls back to legacy unscoped current programs after a scoped batch miss', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(
createLimitedSelectChain(
[
{
id: 1,
channelId: 'legacy-channel',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'Legacy Guide Program',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: null,
},
],
whereCalls
)
);
getDatabase.mockResolvedValue({ select });
const result = await service.getCurrentProgramsBatch(
['legacy-channel'],
{
sourceUrls: ['https://playlist.example.com/guide.xml'],
}
);
expect(result['legacy-channel']?.title).toBe('Legacy Guide Program');
expect(
whereCalls
.map((condition) => flattenSql(condition).toLowerCase())
.some(
(condition) =>
condition.includes('source_url') &&
condition.includes('is null')
)
).toBe(true);
});
it('resolves display-name current program fallback in batched queries', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce(
createSelectChain(
[
{
id: 'guide-news',
displayName: 'News Channel',
iconUrl: null,
},
{
id: 'guide-sports',
displayName: 'Sports Channel',
iconUrl: null,
},
],
whereCalls
)
)
.mockReturnValueOnce(
createLimitedSelectChain(
[
{
id: 1,
channelId: 'guide-news',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'News Now',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: 'https://playlist.example.com/guide.xml',
},
{
id: 2,
channelId: 'guide-sports',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'Sports Now',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: 'https://playlist.example.com/guide.xml',
},
],
whereCalls
)
);
getDatabase.mockResolvedValue({ select });
const result = await service.getCurrentProgramsBatch(
['News Channel', 'Sports Channel'],
{
sourceUrls: ['https://playlist.example.com/guide.xml'],
}
);
expect(result['News Channel']?.title).toBe('News Now');
expect(result['Sports Channel']?.title).toBe('Sports Now');
expect(select).toHaveBeenCalledTimes(4);
});
it('scopes channel metadata by program source ownership for shared channels', async () => {
const whereCalls: unknown[] = [];
const select = jest.fn(() => createSelectChain([], whereCalls));
getDatabase.mockResolvedValue({ select });
await service.getChannelMetadata(['Shared Channel'], {
sourceUrls: ['https://second.example.com/guide.xml'],
});
const metadataScopeSql = whereCalls.map((condition) =>
flattenSql(condition)
);
expect(
metadataScopeSql.some(
(condition) =>
condition.includes('EXISTS') &&
condition.includes('channel_id') &&
condition.includes('source_url')
)
).toBe(true);
});
});
@@ -1,4 +1,14 @@
import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm';
import {
and,
eq,
gte,
inArray,
isNull,
lte,
or,
sql,
type SQL,
} from 'drizzle-orm';
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
@@ -16,24 +26,37 @@ interface EpgProgramRow {
episodeNum: string | null;
}
type EpgDatabase = Awaited<ReturnType<typeof getDatabase>>;
type EpgChannelRow = typeof schema.epgChannels.$inferSelect;
export class EpgQueryService {
constructor(private readonly loggerLabel = '[EPG Events]') {}
async getChannelPrograms(channelId: string): Promise<EpgProgram[]> {
async getChannelPrograms(
channelId: string,
options: { sourceUrls?: string[] } = {}
): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const trimmedChannelId = channelId.trim();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
if (!trimmedChannelId) {
return [];
}
let results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, trimmedChannelId))
.orderBy(schema.epgPrograms.start)
.limit(500);
let results = await this.selectChannelPrograms(
db,
trimmedChannelId,
sourceUrls
);
if (results.length === 0) {
results = await this.selectLegacyChannelPrograms(
db,
trimmedChannelId,
sourceUrls
);
}
if (results.length > 0) {
return results
@@ -41,21 +64,32 @@ export class EpgQueryService {
.filter(this.isValidEpgProgram);
}
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${trimmedChannelId} COLLATE NOCASE`
)
.limit(1);
let channel = await this.selectChannelById(
db,
trimmedChannelId,
sourceUrls
);
if (channel.length === 0) {
channel = await this.selectLegacyChannelById(
db,
trimmedChannelId,
sourceUrls
);
}
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel[0].id))
.orderBy(schema.epgPrograms.start)
.limit(500);
results = await this.selectChannelPrograms(
db,
channel[0].id,
sourceUrls
);
if (results.length === 0) {
results = await this.selectLegacyChannelPrograms(
db,
channel[0].id,
sourceUrls
);
}
if (results.length > 0) {
return results
@@ -64,29 +98,51 @@ export class EpgQueryService {
}
}
channel = await db
.select()
.from(schema.epgChannels)
.where(eq(schema.epgChannels.displayName, trimmedChannelId))
.limit(1);
channel = await this.selectChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls
);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${trimmedChannelId} COLLATE NOCASE`
)
.limit(1);
channel = await this.selectChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls,
{ caseInsensitive: true }
);
}
if (channel.length === 0) {
channel = await this.selectLegacyChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls
);
}
if (channel.length === 0) {
channel = await this.selectLegacyChannelByDisplayName(
db,
trimmedChannelId,
sourceUrls,
{ caseInsensitive: true }
);
}
if (channel.length > 0) {
results = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel[0].id))
.orderBy(schema.epgPrograms.start)
.limit(500);
results = await this.selectChannelPrograms(
db,
channel[0].id,
sourceUrls
);
if (results.length === 0) {
results = await this.selectLegacyChannelPrograms(
db,
channel[0].id,
sourceUrls
);
}
return results
.map(this.transformDbRowToEpgProgram)
@@ -105,7 +161,8 @@ export class EpgQueryService {
}
async getCurrentProgramsBatch(
channelIds: string[]
channelIds: string[],
options: { sourceUrls?: string[] } = {}
): Promise<Record<string, EpgProgram | null>> {
const result: Record<string, EpgProgram | null> = {};
if (!Array.isArray(channelIds) || channelIds.length === 0) {
@@ -126,75 +183,102 @@ export class EpgQueryService {
try {
const db = await getDatabase();
const now = new Date().toISOString();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
const rows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
inArray(schema.epgPrograms.channelId, validIds),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
this.assignCurrentProgramRows(
result,
await this.selectCurrentProgramsForChannelIds(
db,
validIds,
now,
sourceUrls
)
);
let unmatchedIds = validIds.filter((id) => !(id in result));
if (unmatchedIds.length > 0) {
this.assignCurrentProgramRows(
result,
await this.selectCurrentProgramsForChannelIds(
db,
unmatchedIds,
now,
sourceUrls,
{ legacyOnly: true }
)
);
for (const row of rows) {
if (!result[row.channelId]) {
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
result[row.channelId] = program;
}
}
}
const unmatchedIds = validIds.filter((id) => !(id in result));
for (const channelId of unmatchedIds) {
result[channelId] = null;
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`
)
.limit(1);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${channelId} COLLATE NOCASE`
unmatchedIds = validIds.filter((id) => !(id in result));
if (unmatchedIds.length > 0) {
const scopedCandidates = await this.selectChannelLookupCandidates(
db,
unmatchedIds,
sourceUrls
);
const unresolvedIds = unmatchedIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(
channelId,
scopedCandidates
)
.limit(1);
}
if (channel.length === 0) {
continue;
}
const programRows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channel[0].id),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.limit(1);
if (programRows.length > 0) {
const program = this.transformDbRowToEpgProgram(
programRows[0]
);
const candidates =
unresolvedIds.length > 0
? [
...scopedCandidates,
...(await this.selectChannelLookupCandidates(
db,
unresolvedIds,
sourceUrls,
{ legacyOnly: true }
)),
]
: scopedCandidates;
const candidateIdsByRequestedId = new Map<string, string>();
for (const channelId of unmatchedIds) {
const candidate = this.resolveChannelMetadataCandidate(
channelId,
candidates
);
if (this.isValidEpgProgram(program)) {
result[channelId] = program;
if (candidate) {
candidateIdsByRequestedId.set(channelId, candidate.id);
}
}
const candidateIds = Array.from(
new Set(candidateIdsByRequestedId.values())
);
const matchedCandidateIds =
this.assignCandidateCurrentProgramRows(
result,
candidateIdsByRequestedId,
await this.selectCurrentProgramsForChannelIds(
db,
candidateIds,
now,
sourceUrls
)
);
const unresolvedCandidateIds = candidateIds.filter(
(candidateId) => !matchedCandidateIds.has(candidateId)
);
this.assignCandidateCurrentProgramRows(
result,
candidateIdsByRequestedId,
await this.selectCurrentProgramsForChannelIds(
db,
unresolvedCandidateIds,
now,
sourceUrls,
{ legacyOnly: true }
)
);
}
for (const channelId of validIds) {
result[channelId] ??= null;
}
return result;
} catch (error) {
console.error(
@@ -232,7 +316,8 @@ export class EpgQueryService {
}
async getChannelMetadata(
channelIds: string[]
channelIds: string[],
options: { sourceUrls?: string[] } = {}
): Promise<Record<string, EpgChannelMetadata | null>> {
try {
const normalizedChannelIds =
@@ -243,6 +328,7 @@ export class EpgQueryService {
}
const db = await getDatabase();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
const lowerKeys = Array.from(
new Set(
normalizedChannelIds.map((channelId) =>
@@ -251,17 +337,33 @@ export class EpgQueryService {
)
);
const lowerKeyValues = lowerKeys.map((key) => sql`${key}`);
const candidates = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels).where(sql`
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
`);
let candidates = await this.selectChannelMetadataCandidates(
db,
lowerKeyValues,
sourceUrls
);
if (sourceUrls.length > 0) {
const missingChannelIds = normalizedChannelIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(
channelId,
candidates
)
);
if (missingChannelIds.length > 0) {
candidates = [
...candidates,
...(await this.selectChannelMetadataCandidates(
db,
missingChannelIds
.map((channelId) => channelId.toLowerCase())
.map((key) => sql`${key}`),
sourceUrls,
{ legacyOnly: true }
)),
];
}
}
return Object.fromEntries(
normalizedChannelIds.map((channelId) => [
@@ -337,6 +439,332 @@ export class EpgQueryService {
);
}
private normalizeSourceUrls(sourceUrls?: string[]): string[] {
return Array.from(
new Set(
(sourceUrls ?? [])
.map((sourceUrl) => sourceUrl.trim())
.filter((sourceUrl) => sourceUrl.length > 0)
)
);
}
private assignCurrentProgramRows(
result: Record<string, EpgProgram | null>,
rows: EpgProgramRow[]
): Set<string> {
const matchedChannelIds = new Set<string>();
for (const row of rows) {
if (result[row.channelId]) {
continue;
}
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
result[row.channelId] = program;
matchedChannelIds.add(row.channelId);
}
}
return matchedChannelIds;
}
private assignCandidateCurrentProgramRows(
result: Record<string, EpgProgram | null>,
candidateIdsByRequestedId: Map<string, string>,
rows: EpgProgramRow[]
): Set<string> {
const matchedCandidateIds = new Set<string>();
for (const row of rows) {
const program = this.transformDbRowToEpgProgram(row);
if (!this.isValidEpgProgram(program)) {
continue;
}
for (const [
requestedId,
candidateId,
] of candidateIdsByRequestedId) {
if (candidateId !== row.channelId || result[requestedId]) {
continue;
}
result[requestedId] = program;
matchedCandidateIds.add(candidateId);
}
}
return matchedCandidateIds;
}
private async selectChannelPrograms(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgProgramRow[]> {
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
sourceUrls
)
)
.orderBy(schema.epgPrograms.start)
.limit(500);
}
private async selectLegacyChannelPrograms(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgProgramRow[]> {
if (sourceUrls.length === 0) {
return [];
}
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
sourceUrls,
{ legacyOnly: true }
)
)
.orderBy(schema.epgPrograms.start)
.limit(500);
}
private async selectCurrentProgramsForChannelIds(
db: EpgDatabase,
channelIds: string[],
now: string,
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): Promise<EpgProgramRow[]> {
if (channelIds.length === 0) {
return [];
}
if (options.legacyOnly && sourceUrls.length === 0) {
return [];
}
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
and(
inArray(schema.epgPrograms.channelId, channelIds),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
) as SQL,
sourceUrls,
options
)
)
.limit(channelIds.length);
}
private async selectChannelById(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgChannelRow[]> {
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`,
sourceUrls
)
)
.limit(1);
}
private async selectLegacyChannelById(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgChannelRow[]> {
if (sourceUrls.length === 0) {
return [];
}
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`,
sourceUrls,
{ legacyOnly: true }
)
)
.limit(1);
}
private async selectChannelByDisplayName(
db: EpgDatabase,
displayName: string,
sourceUrls: string[],
options: { caseInsensitive?: boolean } = {}
): Promise<EpgChannelRow[]> {
const condition = options.caseInsensitive
? (sql`${schema.epgChannels.displayName} = ${displayName} COLLATE NOCASE` as SQL)
: eq(schema.epgChannels.displayName, displayName);
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(condition, sourceUrls)
)
.limit(1);
}
private async selectLegacyChannelByDisplayName(
db: EpgDatabase,
displayName: string,
sourceUrls: string[],
options: { caseInsensitive?: boolean } = {}
): Promise<EpgChannelRow[]> {
if (sourceUrls.length === 0) {
return [];
}
const condition = options.caseInsensitive
? (sql`${schema.epgChannels.displayName} = ${displayName} COLLATE NOCASE` as SQL)
: eq(schema.epgChannels.displayName, displayName);
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
condition,
sourceUrls,
{ legacyOnly: true }
)
)
.limit(1);
}
private async selectChannelLookupCandidates(
db: EpgDatabase,
channelIds: string[],
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): Promise<EpgChannelMetadata[]> {
const lowerKeyValues = Array.from(
new Set(channelIds.map((channelId) => channelId.toLowerCase()))
).map((key) => sql`${key}`);
return this.selectChannelMetadataCandidates(
db,
lowerKeyValues,
sourceUrls,
options
);
}
private async selectChannelMetadataCandidates(
db: EpgDatabase,
lowerKeyValues: SQL[],
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): Promise<EpgChannelMetadata[]> {
if (options.legacyOnly && sourceUrls.length === 0) {
return [];
}
const lookupCondition = sql`
(
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
)
`;
return db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
lookupCondition as SQL,
sourceUrls,
options
)
);
}
private withProgramSourceScope(
condition: SQL,
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): SQL {
if (options.legacyOnly) {
return and(condition, this.legacyProgramSourceCondition()) as SQL;
}
return sourceUrls.length > 0
? (and(
condition,
inArray(schema.epgPrograms.sourceUrl, sourceUrls)
) as SQL)
: condition;
}
private withChannelSourceScope(
condition: SQL,
sourceUrls: string[],
options: { legacyOnly?: boolean } = {}
): SQL {
if (options.legacyOnly) {
return and(condition, this.legacyChannelSourceCondition()) as SQL;
}
return sourceUrls.length > 0
? (and(
condition,
or(
inArray(schema.epgChannels.sourceUrl, sourceUrls),
this.channelHasProgramsForSourceScope(sourceUrls)
) as SQL
) as SQL)
: condition;
}
private legacyProgramSourceCondition(): SQL {
return or(
isNull(schema.epgPrograms.sourceUrl),
eq(schema.epgPrograms.sourceUrl, '')
) as SQL;
}
private legacyChannelSourceCondition(): SQL {
return or(
isNull(schema.epgChannels.sourceUrl),
eq(schema.epgChannels.sourceUrl, '')
) as SQL;
}
private channelHasProgramsForSourceScope(sourceUrls: string[]): SQL {
const sourceUrlValues = sourceUrls.map((sourceUrl) => sql`${sourceUrl}`);
return sql`EXISTS (
SELECT 1
FROM ${schema.epgPrograms}
WHERE ${schema.epgPrograms.channelId} = ${schema.epgChannels.id}
AND ${schema.epgPrograms.sourceUrl} IN (${sql.join(sourceUrlValues, sql`, `)})
)`;
}
private resolveChannelMetadataCandidate(
channelId: string,
candidates: EpgChannelMetadata[]
@@ -24,6 +24,11 @@ interface EpgWorkerMessage {
stats?: EpgProgressStats;
}
interface ClearWorkerMessage {
type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
sourceUrl?: string;
}
export class EpgWorkerService {
private readonly fetchedUrls = new Set<string>();
private readonly workers = new Map<string, Worker>();
@@ -127,17 +132,54 @@ export class EpgWorkerService {
// Guards against double-settling and keeps the outer loop moving
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let lastProgressStats: EpgProgressStats = {
totalChannels: 0,
totalPrograms: 0,
};
const clearFetchTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
clearFetchTimeout();
fn();
};
const timeoutId = setTimeout(() => {
const scheduleFetchTimeout = () => {
clearFetchTimeout();
timeoutId = setTimeout(() => {
handleFetchTimeout();
}, this.fetchTimeoutMs);
};
const hasProgressMoved = (stats: EpgProgressStats): boolean =>
stats.totalChannels > lastProgressStats.totalChannels ||
stats.totalPrograms > lastProgressStats.totalPrograms;
const recordProgress = (stats: EpgProgressStats): void => {
lastProgressStats = {
totalChannels: Math.max(
lastProgressStats.totalChannels,
stats.totalChannels
),
totalPrograms: Math.max(
lastProgressStats.totalPrograms,
stats.totalPrograms
),
};
};
const handleFetchTimeout = () => {
const errorMessage = `EPG fetch timed out after ${
this.fetchTimeoutMs / 1000
}s`;
}s without progress`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
url,
@@ -154,12 +196,15 @@ export class EpgWorkerService {
() => reject(new Error(errorMessage))
);
});
}, this.fetchTimeoutMs);
};
scheduleFetchTimeout();
worker.on('message', async (message: EpgWorkerMessage) => {
try {
switch (message.type) {
case 'READY':
scheduleFetchTimeout();
this.sendProgressToRenderer(url, 'loading', {
totalChannels: 0,
totalPrograms: 0,
@@ -173,6 +218,10 @@ export class EpgWorkerService {
case 'EPG_PROGRESS':
if (message.stats) {
if (hasProgressMoved(message.stats)) {
recordProgress(message.stats);
scheduleFetchTimeout();
}
this.sendProgressToRenderer(
url,
'loading',
@@ -287,6 +336,77 @@ export class EpgWorkerService {
}
async clearEpgData(): Promise<void> {
return this.runClearWorker({
timeoutLabel: 'EPG clear',
exitLabel: 'Clear worker',
readyMessage: { type: 'CLEAR_EPG' },
completeWorkerLabel: 'completed clear',
failedWorkerLabel: 'failed clear',
erroredWorkerLabel: 'errored clear',
onComplete: async (worker) => {
console.log(this.loggerLabel, 'EPG data cleared via worker');
this.fetchedUrls.clear();
// Resolve only after every interrupted fetch worker has exited
// too — they may still hold the SQLite lock the caller expects
// to be free.
const terminations = [...this.workers.values()].map(
(runningWorker) =>
this.terminateWorker(
runningWorker,
'fetch during clear'
)
);
this.workers.clear();
terminations.push(
this.terminateWorker(worker, 'completed clear')
);
await Promise.all(terminations);
},
});
}
async clearEpgDataForSource(sourceUrl: string): Promise<void> {
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return;
}
const runningWorker = this.workers.get(normalizedSourceUrl);
if (runningWorker) {
this.workers.delete(normalizedSourceUrl);
await this.terminateWorker(runningWorker, 'source clear');
}
return this.runClearWorker({
timeoutLabel: 'EPG source clear',
exitLabel: 'Source clear worker',
readyMessage: {
type: 'CLEAR_EPG_SOURCE',
sourceUrl: normalizedSourceUrl,
},
completeWorkerLabel: 'completed source clear',
failedWorkerLabel: 'failed source clear',
erroredWorkerLabel: 'errored source clear',
onComplete: async (worker) => {
console.log(
this.loggerLabel,
`EPG data cleared for source via worker: ${normalizedSourceUrl}`
);
this.fetchedUrls.delete(normalizedSourceUrl);
await this.terminateWorker(worker, 'completed source clear');
},
});
}
private runClearWorker(options: {
timeoutLabel: string;
exitLabel: string;
readyMessage: ClearWorkerMessage;
completeWorkerLabel: string;
failedWorkerLabel: string;
erroredWorkerLabel: string;
onComplete: (worker: Worker) => Promise<void>;
}): Promise<void> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
@@ -302,6 +422,7 @@ export class EpgWorkerService {
}
let settled = false;
let timeoutId: ReturnType<typeof setTimeout>;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
@@ -309,15 +430,16 @@ export class EpgWorkerService {
fn();
};
const timeoutId = setTimeout(() => {
const errorMessage = `EPG clear timed out after ${
timeoutId = setTimeout(() => {
const errorMessage = `${options.timeoutLabel} timed out after ${
this.fetchTimeoutMs / 1000
}s`;
console.error(this.loggerLabel, errorMessage);
settle(() => {
void this.terminateWorker(worker, 'timed out clear').then(
() => reject(new Error(errorMessage))
);
void this.terminateWorker(
worker,
`timed out ${options.timeoutLabel}`
).then(() => reject(new Error(errorMessage)));
});
}, this.fetchTimeoutMs);
@@ -325,31 +447,12 @@ export class EpgWorkerService {
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEAR_EPG' });
worker.postMessage(options.readyMessage);
} else if (message.type === 'CLEAR_COMPLETE') {
settle(() => {
console.log(
this.loggerLabel,
'EPG data cleared via worker'
);
this.fetchedUrls.clear();
// Resolve only after every interrupted fetch
// worker has exited too — they may still hold the
// SQLite lock the caller expects to be free.
const terminations = [...this.workers.values()].map(
(runningWorker) =>
this.terminateWorker(
runningWorker,
'fetch during clear'
)
);
this.workers.clear();
terminations.push(
this.terminateWorker(worker, 'completed clear')
);
void Promise.all(terminations).then(() =>
resolve()
);
void options
.onComplete(worker)
.then(() => resolve(), reject);
});
} else if (message.type === 'EPG_ERROR') {
console.error(
@@ -360,7 +463,7 @@ export class EpgWorkerService {
settle(() => {
void this.terminateWorker(
worker,
'failed clear'
options.failedWorkerLabel
).then(() =>
reject(
new Error(message.error || 'Clear failed')
@@ -378,15 +481,16 @@ export class EpgWorkerService {
error
);
settle(() => {
void this.terminateWorker(worker, 'errored clear').then(
() => reject(error)
);
void this.terminateWorker(
worker,
options.erroredWorkerLabel
).then(() => reject(error));
});
});
worker.on('exit', (code) => {
if (settled) return;
const errorMessage = `Clear worker exited unexpectedly (code ${code})`;
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
console.error(this.loggerLabel, errorMessage);
settle(() => reject(new Error(errorMessage)));
});
@@ -282,6 +282,30 @@ describe('EpgEvents', () => {
expect(cleared).toBe(true);
});
it('clears one EPG source through a worker and allows it to be fetched again', async () => {
const workerService = new EpgWorkerService('[Test EPG]', 1000);
const sourceUrl = 'https://playlist.example.com/guide.xml';
workerService.markFetchedUrl(sourceUrl);
const clearPromise = workerService.clearEpgDataForSource(
` ${sourceUrl} `
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'READY' });
await flushPromises();
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'CLEAR_EPG_SOURCE',
sourceUrl,
});
worker.emit('message', { type: 'CLEAR_COMPLETE' });
await expect(clearPromise).resolves.toBeUndefined();
expect(workerService.hasFetchedUrl(sourceUrl)).toBe(false);
});
it('rejects a timed-out fetch with the timeout error after the worker has terminated', async () => {
jest.useFakeTimers();
@@ -307,6 +331,39 @@ describe('EpgEvents', () => {
expect(terminated).toBe(true);
});
it('keeps an active EPG fetch alive when worker progress keeps moving', async () => {
jest.useFakeTimers();
const workerService = new EpgWorkerService('[Test EPG]', 25);
const fetchPromise = workerService.fetchEpgFromUrl(
'https://example.com/large-guide.xml'
);
const fetchOutcome = fetchPromise.then(
() => 'resolved' as const,
(error) => error
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'READY' });
jest.advanceTimersByTime(20);
worker.emit('message', {
type: 'EPG_PROGRESS',
stats: { totalChannels: 100, totalPrograms: 500000 },
});
jest.advanceTimersByTime(20);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit('message', {
type: 'EPG_COMPLETE',
stats: { totalChannels: 100, totalPrograms: 510000 },
});
await expect(fetchOutcome).resolves.toBe('resolved');
});
it('falls back to case-insensitive channel id lookup for EPG programs', async () => {
const select = jest.fn();
const programLimitExact = jest.fn().mockResolvedValue([]);
@@ -33,15 +33,30 @@ export default class EpgEvents {
ipcMain.handle(
'GET_CHANNEL_PROGRAMS',
async (_event, args: { channelId: string }) => {
return this.handleGetChannelPrograms(args.channelId);
async (
_event,
args: { channelId: string; options?: { sourceUrls?: string[] } }
) => {
return this.handleGetChannelPrograms(
args.channelId,
args.options
);
}
);
ipcMain.handle(
'GET_CURRENT_PROGRAMS_BATCH',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetCurrentProgramsBatch(args.channelIds);
async (
_event,
args: {
channelIds: string[];
options?: { sourceUrls?: string[] };
}
) => {
return this.handleGetCurrentProgramsBatch(
args.channelIds,
args.options
);
}
);
@@ -51,8 +66,17 @@ export default class EpgEvents {
ipcMain.handle(
'EPG_GET_CHANNEL_METADATA',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetChannelMetadata(args.channelIds);
async (
_event,
args: {
channelIds: string[];
options?: { sourceUrls?: string[] };
}
) => {
return this.handleGetChannelMetadata(
args.channelIds,
args.options
);
}
);
@@ -84,6 +108,14 @@ export default class EpgEvents {
return { success: true };
});
ipcMain.handle(
'EPG_CLEAR_SOURCE',
async (_event, args: { sourceUrl: string }) => {
await this.clearEpgDataForSource(args.sourceUrl);
return { success: true };
}
);
ipcMain.handle(
'EPG_CHECK_FRESHNESS',
async (
@@ -252,15 +284,17 @@ export default class EpgEvents {
}
private static async handleGetChannelPrograms(
channelId: string
channelId: string,
options?: { sourceUrls?: string[] }
): Promise<EpgProgram[]> {
return epgQueryService.getChannelPrograms(channelId);
return epgQueryService.getChannelPrograms(channelId, options);
}
private static async handleGetCurrentProgramsBatch(
channelIds: string[]
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgProgram | null>> {
return epgQueryService.getCurrentProgramsBatch(channelIds);
return epgQueryService.getCurrentProgramsBatch(channelIds, options);
}
private static async handleGetAllChannels(): Promise<{
@@ -271,9 +305,10 @@ export default class EpgEvents {
}
private static async handleGetChannelMetadata(
channelIds: string[]
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgChannelMetadata | null>> {
return epgQueryService.getChannelMetadata(channelIds);
return epgQueryService.getChannelMetadata(channelIds, options);
}
private static async handleGetChannelsByRange(
@@ -293,4 +328,8 @@ export default class EpgEvents {
static async clearEpgData(): Promise<void> {
return epgWorkerService.clearEpgData();
}
static async clearEpgDataForSource(sourceUrl: string): Promise<void> {
return epgWorkerService.clearEpgDataForSource(sourceUrl);
}
}
@@ -1,5 +1,10 @@
import type BetterSqlite3 from 'better-sqlite3';
import { EpgDatabaseClearOperation } from './epg-database';
import {
EpgDatabase,
EpgDatabaseClearOperation,
EpgDatabaseSourceClearOperation,
} from './epg-database';
import type { ParsedChannel } from './epg-streaming-parser';
function createDatabaseMock(exec: jest.Mock) {
const database = {
@@ -12,6 +17,92 @@ function createDatabaseMock(exec: jest.Mock) {
return { Database, database };
}
function normalizeSql(sql: unknown): string {
return String(sql).replace(/\s+/g, ' ').trim();
}
function createEpgDatabaseMock() {
const statements = new Map<string, jest.Mock>();
const prepare = jest.fn((statement: string) => {
const run = jest.fn();
statements.set(normalizeSql(statement), run);
return { run };
});
const transaction = jest.fn((callback: (rows: unknown[]) => void) => {
return (rows: unknown[]) => callback(rows);
});
const database = {
close: jest.fn(),
pragma: jest.fn(),
prepare,
transaction,
};
const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3;
return { Database, database, statements };
}
describe('EpgDatabase', () => {
it('refreshes a source without cascading shared channel programs from other sources', () => {
const sourceUrl = 'https://example.com/playlist-guide.xml';
const { Database, database, statements } = createEpgDatabaseMock();
const epgDb = new EpgDatabase(Database);
const channels: ParsedChannel[] = [
{
id: 'shared.channel',
displayName: [{ lang: '', value: 'Shared Channel' }],
icon: [],
url: [],
},
];
epgDb.insertChannels(channels, sourceUrl, true);
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const deleteProgramsSql =
'DELETE FROM epg_programs WHERE source_url = ?';
const deleteOrphanChannelsSql = normalizeSql(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
expect(preparedSql).toContain(deleteOrphanChannelsSql);
expect(preparedSql).not.toContain(
'DELETE FROM epg_channels WHERE source_url = ?'
);
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
sourceUrl
);
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
sourceUrl
);
});
it('does not overwrite a shared channel source URL when another EPG source reuses the same channel ID', () => {
const { Database, database } = createEpgDatabaseMock();
new EpgDatabase(Database);
const insertChannelSql = database.prepare.mock.calls
.map(([sql]) => normalizeSql(sql))
.find((sql) => sql.startsWith('INSERT INTO epg_channels'));
expect(insertChannelSql).toBeDefined();
expect(insertChannelSql).not.toContain(
'source_url = excluded.source_url'
);
});
});
describe('EpgDatabaseClearOperation', () => {
it('clears programs and channels in one transaction', () => {
const exec = jest.fn();
@@ -48,3 +139,39 @@ describe('EpgDatabaseClearOperation', () => {
]);
});
});
describe('EpgDatabaseSourceClearOperation', () => {
it('clears programs for one source and prunes only orphan channels from that source', () => {
const sourceUrl = 'https://playlist.example.com/guide.xml';
const { Database, database, statements } = createEpgDatabaseMock();
new EpgDatabaseSourceClearOperation(Database).run(sourceUrl);
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const deleteProgramsSql =
'DELETE FROM epg_programs WHERE source_url = ?';
const deleteOrphanChannelsSql = normalizeSql(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
expect(preparedSql).toContain(deleteOrphanChannelsSql);
expect(preparedSql).not.toContain(
'DELETE FROM epg_channels WHERE source_url = ?'
);
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
sourceUrl
);
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
sourceUrl
);
});
});
@@ -11,7 +11,8 @@ export class EpgDatabase {
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteChannelsStmt: BetterSqlite3.Statement;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
@@ -26,17 +27,26 @@ export class EpgDatabase {
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
}
@@ -52,7 +62,8 @@ export class EpgDatabase {
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.deleteProgramsForSourceStmt.run(sourceUrl);
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
@@ -79,7 +90,7 @@ export class EpgDatabase {
/**
* Insert programs for channels already seen during the current parse.
*/
insertPrograms(programs: ParsedProgram[]): number {
insertPrograms(programs: ParsedProgram[], sourceUrl: string): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
@@ -103,7 +114,8 @@ export class EpgDatabase {
category,
iconUrl,
rating,
episodeNum
episodeNum,
sourceUrl
);
insertedCount++;
} catch {
@@ -145,3 +157,46 @@ export class EpgDatabaseClearOperation {
this.db.close();
}
}
export class EpgDatabaseSourceClearOperation {
private readonly db: BetterSqlite3.Database;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('busy_timeout = 5000');
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
`);
}
run(sourceUrl: string): void {
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return;
}
const clearSource = this.db.transaction((url: string) => {
this.deleteProgramsForSourceStmt.run(url);
this.deleteOrphanChannelsForSourceStmt.run(url);
});
clearSource(normalizedSourceUrl);
}
close(): void {
this.db.close();
}
}
@@ -6,7 +6,11 @@ import {
} from '@iptvnator/shared/interfaces';
import { Readable } from 'stream';
import { parentPort, workerData } from 'worker_threads';
import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database';
import {
EpgDatabase,
EpgDatabaseClearOperation,
EpgDatabaseSourceClearOperation,
} from './epg-database';
import { createDecodedEpgStream } from './epg-stream-decoder';
import { StreamingEpgParser } from './epg-streaming-parser';
import {
@@ -61,8 +65,9 @@ const Database = loadBetterSqlite3();
*/
interface WorkerMessage {
type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG';
type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
url?: string;
sourceUrl?: string;
options?: ElectronBridgeTrustOptions;
}
@@ -133,9 +138,7 @@ async function fetchAndParseEpgStreaming(
headers: response.headers,
url: responseUrl,
});
const contentEncoding = getEpgResponseContentEncoding(
response.headers
);
const contentEncoding = getEpgResponseContentEncoding(response.headers);
if (responseUrl && responseUrl !== url) {
console.log(
@@ -172,7 +175,7 @@ async function fetchAndParseEpgStreaming(
},
(programs) => {
// Insert programs directly into database
epgDb.insertPrograms(programs);
epgDb.insertPrograms(programs, url);
},
(totalChannels, totalPrograms) => {
// Send progress to main thread (lightweight)
@@ -332,6 +335,33 @@ function clearAllEpgData(): void {
}
}
function clearEpgDataForSource(sourceUrl: string): void {
const clearOperation = new EpgDatabaseSourceClearOperation(Database);
try {
console.log(
loggerLabel,
`Clearing EPG data for source ${sourceUrl}...`
);
clearOperation.run(sourceUrl);
console.log(loggerLabel, `EPG data cleared for source ${sourceUrl}`);
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG source data:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
};
parentPort?.postMessage(errorResponse);
} finally {
clearOperation.close();
}
}
/**
* Worker message handler
*/
@@ -345,6 +375,8 @@ if (parentPort) {
await fetchAndParseEpgStreaming(message.url!, message.options);
} else if (message.type === 'CLEAR_EPG') {
clearAllEpgData();
} else if (message.type === 'CLEAR_EPG_SOURCE') {
clearEpgDataForSource(message.sourceUrl ?? '');
}
} catch (error) {
console.error(loggerLabel, 'Worker error:', error);
+6 -4
View File
@@ -210,10 +210,12 @@ export async function updateSourceDialog(
}
export async function saveSourceDialog(dialog: Locator): Promise<void> {
await expect(
dialog.getByRole('button', { name: 'Save', exact: true })
).toBeEnabled();
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const saveButton = dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true });
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(dialog).toBeHidden();
}
+4 -1
View File
@@ -492,7 +492,10 @@ test('@xtream playlist details edit is retained in the PWA browser context', asy
dialog.locator('input[formcontrolname="password"]'),
'minimal'
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).toBeHidden();
await expect(
@@ -375,6 +375,7 @@ export class ElectronService extends DataService {
_id: data.id,
},
playlistId: data.id,
refreshEpg: true,
})
);
+1
View File
@@ -171,6 +171,7 @@ export class PwaService extends DataService {
PlaylistActions.updatePlaylist({
playlist,
playlistId,
refreshEpg: true,
})
);
@@ -194,6 +194,9 @@ describe('SettingsComponent', () => {
beforeEach(waitForAsync(() => {
epgBridge = {
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
supportsDataManagement: true,
supportsImport: true,
@@ -291,6 +294,9 @@ describe('SettingsComponent', () => {
staleUrls: [],
}),
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
@@ -1175,9 +1181,9 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(dashboardRails?.enabled).toBe(true);
expect(
component.settingsForm.get('dashboardRails.hero')?.enabled
).toBe(true);
expect(component.settingsForm.get('dashboardRails.hero')?.enabled).toBe(
true
);
});
it('should save settings on submit', async () => {
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "داكن",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "داكن",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Цёмная",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Zuletzt verwendete Quellen",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Dunkel",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Σκούρο",
+1
View File
@@ -218,6 +218,7 @@
"PLAYBACK": "Playback",
"EPG_SOURCES": "EPG sources",
"EPG_SOURCES_DESCRIPTION": "Add one or more URLs as EPG sources",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Prefer your EPG sources for Xtream playlists",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Oscuro",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Sombre",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Scuro",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "ダーク",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "어둡게",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Donker",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Ciemny",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Escuro",
+1
View File
@@ -218,6 +218,7 @@
"PLAYBACK": "Воспроизведение",
"EPG_SOURCES": "Источники EPG",
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько URL-адресов источников EPG",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} из {{detected}} найденных источников EPG плейлиста выбрано для импорта автоматически.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Предпочитать ваши источники EPG для плейлистов Xtream",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "Koyu",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "深色",
+2 -1
View File
@@ -394,7 +394,8 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
},
"THEMES": {
"DARK_THEME": "深色",