diff --git a/README.md b/README.md index 71840976d..d998ac24d 100644 --- a/README.md +++ b/README.md @@ -323,10 +323,11 @@ Useful narrower flags: Security-sensitive network compatibility flags are opt-in: - `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits strict EPG fetches from - playlist metadata (`url-tvg`) to resolve to localhost, LAN, or other private - addresses. Directly configured Xtream/Stalker portals and private playlist - servers remain supported without this flag. Prefer the in-app source-scoped - “Allow source” action for a trusted EPG URL. + playlist metadata (`x-tvg-url`, `url-tvg`, or `tvg-url`) to resolve to + localhost, LAN, or other private addresses. Directly configured + Xtream/Stalker portals and private playlist servers remain supported without + this flag. Prefer the in-app source-scoped “Allow source” action for a trusted + EPG URL. - `IPTVNATOR_ALLOW_INSECURE_TLS=1` disables certificate validation for remote playlist imports and refreshes for the whole Electron process. Prefer the in-app host-scoped trust action for a trusted provider with a self-signed or diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index a2edd6418..c1f8e57bf 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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 { - 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); } diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index de27fe91d..783daefdd 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -28,6 +28,40 @@ const epgFixtureXml = ` `; +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 ` + + + ${channelName} + + + ${programTitle} + M3U-declared EPG source smoke test. + + +`; +} + +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), { diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 2fa9c19ce..bd5857229 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -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], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index db35da06a..23dc47e75 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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) => diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index 350bb1817..c204da07c 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -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', }), ]); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index f2b2c400c..5b6333dc9 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -59,6 +59,16 @@ function parseJsonValue(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 @@ -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 { +export function parseAppPlaylist( + row: schema.Playlist +): Record { const payload = parseJsonValue | null>( row.payload, null @@ -178,11 +206,28 @@ export function parseAppPlaylist(row: schema.Playlist): Record const base = payload && typeof payload === 'object' ? payload : {}; const favorites = parseJsonValue(row.favorites, []); const recentlyViewed = parseJsonValue(row.recentlyViewed, []); + const epgUrls = getStringArrayValue( + row.epgUrls ? parseJsonValue(row.epgUrls, []) : base.epgUrls + ); + const detectedEpgUrls = getStringArrayValue( + row.detectedEpgUrls + ? parseJsonValue(row.detectedEpgUrls, []) + : (base.detectedEpgUrls ?? epgUrls) + ); + const manualEpgUrls = getStringArrayValue( + row.manualEpgUrls + ? parseJsonValue(row.manualEpgUrls, []) + : base.manualEpgUrls + ); + const disabledEpgUrls = getStringArrayValue( + row.disabledEpgUrls + ? parseJsonValue(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 ...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 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, diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts new file mode 100644 index 000000000..5eaa802b6 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts @@ -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']); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.ts b/apps/electron-backend/src/app/events/database/epg-db.events.ts index 1b7edb4fb..4cc2bd80c 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.ts @@ -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 }; diff --git a/apps/electron-backend/src/app/events/epg-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-query.service.spec.ts new file mode 100644 index 000000000..92a2886bf --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-query.service.spec.ts @@ -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()): 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); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index a28bf823a..8a87cfe5b 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -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>; +type EpgChannelRow = typeof schema.epgChannels.$inferSelect; + export class EpgQueryService { constructor(private readonly loggerLabel = '[EPG Events]') {} - async getChannelPrograms(channelId: string): Promise { + async getChannelPrograms( + channelId: string, + options: { sourceUrls?: string[] } = {} + ): Promise { 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> { const result: Record = {}; 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(); + 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> { 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, + rows: EpgProgramRow[] + ): Set { + const matchedChannelIds = new Set(); + 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, + candidateIdsByRequestedId: Map, + rows: EpgProgramRow[] + ): Set { + const matchedCandidateIds = new Set(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[] diff --git a/apps/electron-backend/src/app/events/epg-worker.service.ts b/apps/electron-backend/src/app/events/epg-worker.service.ts index 1542335ef..f671ab999 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -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(); private readonly workers = new Map(); @@ -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 | 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 { + 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 { + 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; + }): Promise { return new Promise((resolve, reject) => { let worker: Worker; try { @@ -302,6 +422,7 @@ export class EpgWorkerService { } let settled = false; + let timeoutId: ReturnType; 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))); }); diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 4cbe17c7e..b04fe05b5 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -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([]); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 41edea95c..7a371ca72 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -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 { - return epgQueryService.getChannelPrograms(channelId); + return epgQueryService.getChannelPrograms(channelId, options); } private static async handleGetCurrentProgramsBatch( - channelIds: string[] + channelIds: string[], + options?: { sourceUrls?: string[] } ): Promise> { - 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> { - 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 { return epgWorkerService.clearEpgData(); } + + static async clearEpgDataForSource(sourceUrl: string): Promise { + return epgWorkerService.clearEpgDataForSource(sourceUrl); + } } diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index b9684dbac..d448e47f7 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -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(); + 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 + ); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index 27391ac67..6123619c0 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -11,7 +11,8 @@ export class EpgDatabase { private readonly knownChannelIds = new Set(); 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(); + } +} diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index 1eb71fc1a..3ac510546 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -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); diff --git a/apps/web-e2e/src/sources-pwa.helpers.ts b/apps/web-e2e/src/sources-pwa.helpers.ts index ff3e38e97..5e8f2d722 100644 --- a/apps/web-e2e/src/sources-pwa.helpers.ts +++ b/apps/web-e2e/src/sources-pwa.helpers.ts @@ -210,10 +210,12 @@ export async function updateSourceDialog( } export async function saveSourceDialog(dialog: Locator): Promise { - 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(); } diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index a53c23c50..3bcf5e899 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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( diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index e65717678..00f3d96af 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -375,6 +375,7 @@ export class ElectronService extends DataService { _id: data.id, }, playlistId: data.id, + refreshEpg: true, }) ); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 89c053e31..e4f05f161 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -171,6 +171,7 @@ export class PwaService extends DataService { PlaylistActions.updatePlaylist({ playlist, playlistId, + refreshEpg: true, }) ); diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 044588932..5da488c9c 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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 () => { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 67796f181..5a000420d 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "داكن", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 029b5a668..4f9ba1acc 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "داكن", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 08a763626..cc570b499 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Цёмная", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 772764a9e..101a56113 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 9d05a48b1..ad7c709e3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Σκούρο", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 235bfe5c0..505e9b732 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 8db0ad043..399cc65b8 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 3d5cd3b73..b8442a44f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a11d35f6e..d52e18696 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 085508fcf..f6a216345 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "ダーク", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f13d09a5f..b06585608 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "어둡게", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c214f44c9..b4ad2049b 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 43042d40b..ee6f76613 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f95426f9f..e617dde18 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index de656f55a..35f25f1ea 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 10f05f25a..21040126d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ca2f3a839..0b8f7606e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "深色", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ccb24b0ef..ed249feea 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "深色", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index b22b25ab4..004eef3af 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -251,6 +251,98 @@ EPG lookup keys use the same precedence in both program and icon paths: 2. `tvg-name` 3. channel name +### Playlist-Declared EPG Sources + +Some M3U providers declare XMLTV sources in the playlist header instead of +requiring the user to add them in Settings. The importer extracts EPG URLs from +`#EXTM3U` header attributes `x-tvg-url`, `url-tvg`, and `tvg-url` in +`@iptvnator/shared/m3u-utils`, then stores the normalized, deduplicated +candidates on `Playlist.detectedEpgUrls`. + +`Playlist.epgUrls` is the enabled playlist-scoped subset used for automatic +import and lookup. Two additional lists preserve user edits: + +- `Playlist.manualEpgUrls` stores URLs the user explicitly added for this + playlist, including detected catalog URLs the user manually enabled. +- `Playlist.disabledEpgUrls` stores detected URLs the user removed from this + playlist so playlist refreshes do not silently re-enable them. + +- Up to five detected URLs are enabled automatically. +- Larger header lists are treated as provider catalogs. The importer keeps all + candidates in `detectedEpgUrls`, but auto-enables only recommended URLs whose + `guides/` path matches playlist hints such as `tvg-country` or the + country suffix in `tvg-id` (`channel.ua`). Language hints are used only when no + country hints are present. If no recommendation can be made, the importer + falls back to the first five detected URLs so generic provider catalogs still + produce usable local EPG sources instead of silently enabling none. +- Recommendations are capped so a malformed or global provider list cannot + start dozens of XMLTV downloads during playlist import. + +These URLs are playlist-scoped by default: + +- `libs/m3u-state` auto-fetches enabled `epgUrls` when M3U playlists are + loaded, added, or refreshed, using the same EPG progress/import pipeline as + Settings-managed XMLTV URLs. Before fetching, playlist URLs already present in + global Settings are filtered out so the same XMLTV URL is not downloaded + twice. Within a running session, the effect remembers the last fetchable URL + set per playlist and only re-fetches when that URL set changes; metadata-only + edits such as renaming a playlist or hiding groups do not re-download local + EPG sources. When the local URL set expands, only newly added fetchable URLs + are downloaded; disabling or removing one source does not re-download the + remaining sources. Explicit playlist refreshes bypass that session fetch key + and re-download the current fetchable local EPG URLs. Partial metadata updates + that omit `epgUrls` preserve the previous fetch key, while an explicit empty + `epgUrls` list clears it. Add/update metadata effects trigger playlist-local + EPG fetches only after the playlist persistence call succeeds, and metadata + updates that do not include any EPG source fields do not evaluate the fetch + plan. +- The Electron EPG database stores `source_url` on imported programs so current + program lookups can ask for the active playlist's EPG sources first. Existing + databases backfill this column from `epg_channels.source_url` once, in bounded + batches, after the scoped indexes are created. When multiple EPG files reuse + the same XMLTV channel id, the channel row keeps its original `source_url` + attribution instead of being overwritten by the last imported source; program + scoping remains source-specific through `epg_programs.source_url`. +- `ChannelListContainerComponent` enables EPG rows when either global settings + URLs or the active M3U playlist has `epgUrls`. EPG availability refreshes are + debounced so several playlist-local XMLTV imports completing close together + coalesce into one visible-channel EPG refresh. The visible channel list also + refreshes when the effective EPG source context changes, so a playlist whose + `epgUrls` arrive after the channels are rendered does not wait for the next + periodic refresh before showing current programs. A successful EPG import + clears current-program lookup caches before publishing availability, so an + early "no current program" lookup cannot mask freshly imported rows until the + TTL expires. +- Scoped lookups fall back only to Settings-managed EPG URLs for channels + missing from the playlist-declared source. Playlist-local sources from other + playlists are not treated as global fallback sources. Single-channel current + program lookups include the source URL set in their cache and in-flight keys, + so playlist-local and global lookups deduplicate without reusing the wrong + source scope. Batch current-program lookups use the same source-scoped + per-channel TTL cache and order-insensitive in-flight batch deduplication + before reaching IPC; missing exact channel-id matches are resolved with batched + case-insensitive id/display-name candidate queries rather than a per-channel + fallback loop. + When upgrading an existing database whose historical programs have no + `source_url`, scoped program and metadata queries try those legacy unscoped + rows only after the requested source scope returns no result, so old EPG data + remains visible without taking precedence over freshly imported scoped data. + Channel metadata lookups use the same playlist-first, Settings-managed + fallback strategy so icons and display names can still come from global EPG + sources when the playlist-local guide only supplies programs. If multiple EPG + sources reuse the same XMLTV channel id, channel metadata and display-name + fallback lookups treat a channel as source-scoped when either the channel row + itself or matching programs are tagged with the requested `source_url`. +- The playlist details dialog shows enabled EPG URLs with explicit actions to + refresh, remove, or add a source to global Settings. It also allows adding one + or more manual playlist-local sources and indicates when additional detected + candidates were not auto-enabled. Removing a playlist-local source also + clears programs tagged with that `source_url` and prunes only orphaned channel + rows for that same source before saving the playlist metadata change, so a + failed cleanup keeps the source enabled and visible. Shared XMLTV channel ids + from other sources are preserved. Detected playlist sources are not silently + promoted to global settings. + ### Performance Optimizations | Optimization | Implementation | @@ -291,7 +383,7 @@ EPG lookup keys use the same precedence in both program and icon paths: ## EPG Integration -### EpgService (libs/services/) +### EpgService (`@iptvnator/epg/data-access`) ```typescript class EpgService { @@ -303,12 +395,14 @@ class EpgService { // Batch fetch current programs getCurrentProgramsForChannels( - channelIds: string[] + channelIds: string[], + options?: { sourceUrls?: string[] } ): Observable>; // Batch fetch XMLTV channel metadata for logo fallback getChannelMetadataForChannels( - channelIds: string[] + channelIds: string[], + options?: { sourceUrls?: string[] } ): Observable>; // Observables diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 3cbe9e25c..09693bccd 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -346,6 +346,14 @@ Keep worker lifecycle state out of the IPC registration layer. Add new EPG DB lookup behavior to `epg-query.service.ts`; add new EPG worker/progress behavior to `epg-worker.service.ts`. +EPG fetch workers use an inactivity watchdog, not a fixed maximum import +duration. `EpgWorkerService` starts the watchdog when the worker is created, +refreshes it when the worker becomes ready, and refreshes it again whenever an +`EPG_PROGRESS` event increases the channel or program counters. This lets very +large XMLTV imports continue for longer than the nominal timeout as long as the +parser/database pipeline is still making progress, while still terminating a +worker that stops emitting progress. + ## UI Behavior Changes ### Search @@ -511,6 +519,7 @@ covers: 5. case-insensitive EPG program lookup fallbacks 6. metadata lookup precedence for exact/case-insensitive id and display name 7. malformed EPG row filtering +8. active EPG fetches keep running when worker progress keeps moving `apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts` covers: diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts index c9f9f6f05..ef82cd208 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -66,11 +66,15 @@ describe('EpgRuntimeBridgeService', () => { const fetchEpg = jest.fn().mockResolvedValue({ success: true }); const forceFetchEpg = jest.fn().mockResolvedValue({ success: true }); const clearEpgData = jest.fn().mockResolvedValue({ success: true }); + const clearEpgDataForSource = jest + .fn() + .mockResolvedValue({ success: true }); window.electron = { ...window.electron, fetchEpg, forceFetchEpg, clearEpgData, + clearEpgDataForSource, } as unknown as typeof window.electron; runtimeCapabilities.supportsEpgImport = true; runtimeCapabilities.supportsEpgDataManagement = true; @@ -84,6 +88,13 @@ describe('EpgRuntimeBridgeService', () => { await expect(service.clearEpgData()).resolves.toEqual({ success: true, }); + await expect( + service.clearEpgDataForSource( + ' https://playlist.example.com/guide.xml ' + ) + ).resolves.toEqual({ + success: true, + }); expect(fetchEpg).toHaveBeenCalledWith( ['https://example.com/epg.xml'], @@ -94,6 +105,9 @@ describe('EpgRuntimeBridgeService', () => { undefined ); expect(clearEpgData).toHaveBeenCalledTimes(1); + expect(clearEpgDataForSource).toHaveBeenCalledWith( + 'https://playlist.example.com/guide.xml' + ); }); it('delegates read-side EPG calls through the typed Electron bridge', async () => { @@ -127,15 +141,23 @@ describe('EpgRuntimeBridgeService', () => { runtimeCapabilities.supportsEpgProgramSearch = true; await service.getChannelPrograms('channel-1'); - await service.getCurrentProgramsBatch(['channel-1']); - await service.getChannelMetadata(['channel-1']); + await service.getCurrentProgramsBatch(['channel-1'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }); + await service.getChannelMetadata(['channel-1'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }); await service.checkFreshness(['https://example.com/epg.xml'], 12); await service.getChannelsByRange(0, 20); await service.searchPrograms('news', 20); expect(getChannelPrograms).toHaveBeenCalledWith('channel-1'); - expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1']); - expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1']); + expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }); + expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }); expect(checkEpgFreshness).toHaveBeenCalledWith( ['https://example.com/epg.xml'], 12 diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 4082b5d73..8e48fb452 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -7,6 +7,7 @@ import { ElectronBridgeEpgProgressStatus, ElectronBridgeEpgChannelWithPrograms, ElectronBridgeEpgFreshnessResult, + ElectronBridgeEpgLookupOptions, ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeResult, ElectronBridgeTrustOptions, @@ -22,11 +23,13 @@ export type EpgImportProgress = ElectronBridgeEpgProgress; export type EpgFetchResult = ElectronBridgeEpgFetchResult; export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult; export type EpgClearResult = ElectronBridgeResult; +export type EpgLookupOptions = ElectronBridgeEpgLookupOptions; type EpgElectronBridge = Pick< Partial, | 'checkEpgFreshness' | 'clearEpgData' + | 'clearEpgDataForSource' | 'fetchEpg' | 'forceFetchEpg' | 'getChannelPrograms' @@ -109,41 +112,71 @@ export class EpgRuntimeBridgeService { return this.bridge?.clearEpgData?.() ?? Promise.resolve(null); } - getChannelPrograms(channelId: string): Promise { - if (!this.supportsProgramLookup) { + clearEpgDataForSource(sourceUrl: string): Promise { + if (!this.supportsDataManagement) { + return Promise.resolve(null); + } + + const normalizedSourceUrl = sourceUrl.trim(); + if (!normalizedSourceUrl) { return Promise.resolve(null); } return ( - this.bridge?.getChannelPrograms?.(channelId) ?? + this.bridge?.clearEpgDataForSource?.(normalizedSourceUrl) ?? Promise.resolve(null) ); } + getChannelPrograms( + channelId: string, + options?: EpgLookupOptions + ): Promise { + if (!this.supportsProgramLookup) { + return Promise.resolve(null); + } + + if (!this.bridge?.getChannelPrograms) { + return Promise.resolve(null); + } + + return options + ? this.bridge.getChannelPrograms(channelId, options) + : this.bridge.getChannelPrograms(channelId); + } + getCurrentProgramsBatch( - channelIds: string[] + channelIds: string[], + options?: EpgLookupOptions ): Promise | null> { if (!this.supportsCurrentProgramBatch) { return Promise.resolve(null); } - return ( - this.bridge?.getCurrentProgramsBatch?.(channelIds) ?? - Promise.resolve(null) - ); + if (!this.bridge?.getCurrentProgramsBatch) { + return Promise.resolve(null); + } + + return options + ? this.bridge.getCurrentProgramsBatch(channelIds, options) + : this.bridge.getCurrentProgramsBatch(channelIds); } getChannelMetadata( - channelIds: string[] + channelIds: string[], + options?: EpgLookupOptions ): Promise | null> { if (!this.supportsChannelMetadata) { return Promise.resolve(null); } - return ( - this.bridge?.getEpgChannelMetadata?.(channelIds) ?? - Promise.resolve(null) - ); + if (!this.bridge?.getEpgChannelMetadata) { + return Promise.resolve(null); + } + + return options + ? this.bridge.getEpgChannelMetadata(channelIds, options) + : this.bridge.getEpgChannelMetadata(channelIds); } checkFreshness( diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 136779e92..daa521543 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, skip } from 'rxjs'; import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { EpgService } from './epg.service'; @@ -26,6 +26,7 @@ describe('EpgService', () => { }; settingsStore = { getSettings: jest.fn(() => ({ + epgUrl: [], trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], trustedInsecureTlsHosts: ['playlist.local'], })), @@ -75,6 +76,7 @@ describe('EpgService', () => { 'https://example.com/epg.xml', '', 'https://example.com/other.xml', + ' https://example.com/epg.xml ', ]); expect(epgBridge.fetchEpg).toHaveBeenCalledWith( @@ -132,4 +134,484 @@ describe('EpgService', () => { expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith('channel-1'); jest.useRealTimers(); }); + + it('caches current program lookups separately by EPG source URL scope', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.getChannelPrograms = jest + .fn() + .mockResolvedValueOnce([ + { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + ]) + .mockResolvedValueOnce([ + { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Global News Bulletin', + }, + ]) + .mockResolvedValue([]); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + + try { + const playlistResult = await firstValueFrom( + service.getCurrentProgramForChannel('guide-news', { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + const globalResult = await firstValueFrom( + service.getCurrentProgramForChannel('guide-news') + ); + const cachedPlaylistResult = await firstValueFrom( + service.getCurrentProgramForChannel('guide-news', { + sourceUrls: [' https://playlist.example.com/guide.xml '], + }) + ); + const cachedGlobalResult = await firstValueFrom( + service.getCurrentProgramForChannel('guide-news') + ); + + expect(playlistResult?.title).toBe('Playlist Guide Bulletin'); + expect(globalResult?.title).toBe('Global News Bulletin'); + expect(cachedPlaylistResult?.title).toBe('Playlist Guide Bulletin'); + expect(cachedGlobalResult?.title).toBe('Global News Bulletin'); + expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(2); + expect(epgBridge.getChannelPrograms).toHaveBeenNthCalledWith( + 1, + 'guide-news', + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + expect(epgBridge.getChannelPrograms).toHaveBeenNthCalledWith( + 2, + 'guide-news', + { sourceUrls: ['https://global.example.com/guide.xml'] } + ); + } finally { + jest.useRealTimers(); + } + }); + + it('deduplicates concurrent scoped current program lookups for the same source scope', async () => { + epgBridge.supportsProgramLookup = true; + let resolvePrograms: + | (( + programs: { + channel: string; + start: string; + stop: string; + title: string; + }[] + ) => void) + | undefined; + epgBridge.getChannelPrograms = jest.fn( + () => + new Promise((resolve) => { + resolvePrograms = resolve; + }) + ); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + + try { + const firstLookup = firstValueFrom( + service.getCurrentProgramForChannel('guide-news', { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + const secondLookup = firstValueFrom( + service.getCurrentProgramForChannel('guide-news', { + sourceUrls: [' https://playlist.example.com/guide.xml '], + }) + ); + + expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(1); + resolvePrograms?.([ + { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + ]); + + await expect( + Promise.all([firstLookup, secondLookup]) + ).resolves.toEqual([ + expect.objectContaining({ + title: 'Playlist Guide Bulletin', + }), + expect.objectContaining({ + title: 'Playlist Guide Bulletin', + }), + ]); + } finally { + jest.useRealTimers(); + } + }); + + it('queries playlist-scoped current programs first and falls back to global EPG for missing channels', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: [ + 'https://global.example.com/guide.xml', + ' https://global.example.com/guide.xml ', + ], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValueOnce({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + 'guide-sports': null, + }) + .mockResolvedValueOnce({ + 'guide-sports': { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Global Sports Bulletin', + }, + }); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels( + ['guide-news', 'guide-sports'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ) + ); + + expect(result.get('guide-news')?.title).toBe('Playlist Guide Bulletin'); + expect(result.get('guide-sports')?.title).toBe( + 'Global Sports Bulletin' + ); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith( + 1, + ['guide-news', 'guide-sports'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith( + 2, + ['guide-sports'], + { sourceUrls: ['https://global.example.com/guide.xml'] } + ); + }); + + it('does not fall back to the unscoped EPG pool when no global EPG URLs are configured', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest.fn().mockResolvedValue({ + 'guide-sports': null, + }); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + + expect(result.get('guide-sports')).toBeNull(); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith( + ['guide-sports'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + }); + + it('caches scoped batch current programs by EPG source URL scope', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValue({}) + .mockResolvedValueOnce({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + }); + + const firstResult = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + const secondResult = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: [' https://playlist.example.com/guide.xml '], + }) + ); + + expect(firstResult.get('guide-news')?.title).toBe( + 'Playlist Guide Bulletin' + ); + expect(secondResult.get('guide-news')?.title).toBe( + 'Playlist Guide Bulletin' + ); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + }); + + it('clears cached null current programs after a successful EPG import', async () => { + epgBridge.supportsImport = true; + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValueOnce({ + 'guide-news': null, + }) + .mockResolvedValueOnce({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + }); + + const beforeImport = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + expect(beforeImport.get('guide-news')).toBeNull(); + + const availability = firstValueFrom(service.epgAvailable$.pipe(skip(1))); + service.fetchEpg(['https://playlist.example.com/guide.xml']); + await expect(availability).resolves.toBe(true); + + const afterImport = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + + expect(afterImport.get('guide-news')?.title).toBe( + 'Playlist Guide Bulletin' + ); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2); + }); + + it('deduplicates concurrent scoped batch current program lookups for the same source scope', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + const batchResolvers: Array< + (programs: Record) => void + > = []; + epgBridge.getCurrentProgramsBatch = jest.fn( + () => + new Promise((resolve) => { + batchResolvers.push(resolve); + }) + ); + + const firstLookup = firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + const secondLookup = firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: [' https://playlist.example.com/guide.xml '], + }) + ); + + try { + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + batchResolvers.forEach((resolve) => + resolve({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + }) + ); + + const [firstResult, secondResult] = await Promise.all([ + firstLookup, + secondLookup, + ]); + + expect(firstResult.get('guide-news')?.title).toBe( + 'Playlist Guide Bulletin' + ); + expect(secondResult.get('guide-news')?.title).toBe( + 'Playlist Guide Bulletin' + ); + } finally { + batchResolvers.forEach((resolve) => resolve({})); + } + }); + + it('deduplicates concurrent scoped batch current program lookups regardless of channel order', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + let resolveBatch: + | ((programs: Record) => void) + | undefined; + epgBridge.getCurrentProgramsBatch = jest.fn( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }) + ); + + const firstLookup = firstValueFrom( + service.getCurrentProgramsForChannels( + ['guide-news', 'guide-sports'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ) + ); + const secondLookup = firstValueFrom( + service.getCurrentProgramsForChannels( + ['guide-sports', 'guide-news'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ) + ); + + try { + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + resolveBatch?.({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Guide Bulletin', + }, + 'guide-sports': { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Playlist Sports Bulletin', + }, + }); + + const [firstResult, secondResult] = await Promise.all([ + firstLookup, + secondLookup, + ]); + + expect(firstResult.get('guide-news')?.title).toBe( + 'Playlist Guide Bulletin' + ); + expect(secondResult.get('guide-sports')?.title).toBe( + 'Playlist Sports Bulletin' + ); + } finally { + resolveBatch?.({}); + } + }); + + it('returns null scoped current programs instead of re-entering global lookup when scoped batch lookup fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockRejectedValueOnce(new Error('ipc down')) + .mockResolvedValueOnce({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Global News Bulletin', + }, + }); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + + expect(result.get('guide-news')).toBeNull(); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith( + ['guide-news'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('falls back to global EPG metadata for channels missing from playlist-scoped sources', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsChannelMetadata = true; + epgBridge.getChannelMetadata = jest + .fn() + .mockResolvedValueOnce({ + 'guide-news': { + id: 'guide-news', + displayName: 'Playlist News', + iconUrl: 'https://playlist.example.com/news.png', + }, + 'guide-sports': null, + }) + .mockResolvedValueOnce({ + 'guide-sports': { + id: 'guide-sports', + displayName: 'Global Sports', + iconUrl: 'https://global.example.com/sports.png', + }, + }); + + const result = await firstValueFrom( + service.getChannelMetadataForChannels( + ['guide-news', 'guide-sports'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ) + ); + + expect(result.get('guide-news')).toMatchObject({ + displayName: 'Playlist News', + }); + expect(result.get('guide-sports')).toMatchObject({ + displayName: 'Global Sports', + iconUrl: 'https://global.example.com/sports.png', + }); + expect(epgBridge.getChannelMetadata).toHaveBeenNthCalledWith( + 1, + ['guide-news', 'guide-sports'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + expect(epgBridge.getChannelMetadata).toHaveBeenNthCalledWith( + 2, + ['guide-sports'], + { sourceUrls: ['https://global.example.com/guide.xml'] } + ); + }); }); diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 9bc9b84f7..78e458698 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -2,15 +2,27 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; -import { catchError, map, tap, timeout } from 'rxjs/operators'; +import { + catchError, + finalize, + map, + shareReplay, + switchMap, + tap, + timeout, +} from 'rxjs/operators'; import { createDevLogger, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; -import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; +import { + EpgLookupOptions, + EpgRuntimeBridgeService, +} from './epg-runtime-bridge.service'; import { normalizeEpgPrograms } from './epg-program-normalization.util'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; interface CachedProgram { program: EpgProgram | null; @@ -33,6 +45,14 @@ export class EpgService { // Cache for channel programs with 60-second TTL private programCache = new Map(); + private fetchingCurrentPrograms = new Map< + string, + Observable + >(); + private fetchingCurrentProgramBatches = new Map< + string, + Observable> + >(); private readonly CACHE_TTL = 60000; // 60 seconds readonly epgAvailable$ = this.epgAvailable.asObservable(); @@ -44,8 +64,8 @@ export class EpgService { fetchEpg(urls: string[]): void { if (!this.epgBridge.supportsImport) return; - // Filter out empty URLs and send all URLs at once - const validUrls = urls.filter((url) => url?.trim()); + // Filter out empty and duplicate URLs and send all URLs at once. + const validUrls = normalizeEpgUrls(urls); if (validUrls.length === 0) return; from( @@ -59,6 +79,7 @@ export class EpgService { if (result === null) return; if (result.success) { + this.clearCache(); this.epgAvailable.next(true); } else { this.epgAvailable.next(false); @@ -117,50 +138,46 @@ export class EpgService { * @returns Observable of current program or null */ getCurrentProgramForChannel( - channelId: string + channelId: string, + options?: EpgLookupOptions ): Observable { if (!this.epgBridge.supportsProgramLookup || !channelId) { return of(null); } - // Check cache first - const cached = this.programCache.get(channelId); - const now = Date.now(); - - if (cached && now - cached.timestamp < this.CACHE_TTL) { - return of(cached.program); + const sourceUrls = this.normalizeSourceUrls(options); + if (sourceUrls.length > 0) { + return this.getScopedCurrentProgramForChannel( + channelId, + sourceUrls, + this.getGlobalEpgSourceUrls(sourceUrls) + ); } + const globalSourceUrls = this.getGlobalEpgSourceUrls(); + if (globalSourceUrls.length > 0) { + return this.getScopedCurrentProgramForChannel( + channelId, + globalSourceUrls, + [] + ); + } + + // Check cache first + const cacheKey = this.createProgramCacheKey(channelId); + // Fetch from backend - return from(this.epgBridge.getChannelPrograms(channelId)).pipe( - map((programs) => normalizeEpgPrograms(programs ?? [])), - map((programs: EpgProgram[]) => { - if (!programs.length) { - this.programCache.set(channelId, { - program: null, - timestamp: now, - }); - return null; - } - - const currentProgram = this.findCurrentProgram(programs); - - // Cache the result - this.programCache.set(channelId, { - program: currentProgram, - timestamp: now, - }); - - return currentProgram; - }), - catchError((err) => { - console.error('EPG get current program error:', err); - this.programCache.set(channelId, { - program: null, - timestamp: now, - }); - return of(null); - }) + return this.getCachedOrFetchCurrentProgram(cacheKey, () => + from(this.epgBridge.getChannelPrograms(channelId)).pipe( + map((programs) => normalizeEpgPrograms(programs ?? [])), + map((programs: EpgProgram[]) => + this.findCurrentProgram(programs) + ), + catchError((err) => { + console.error('EPG get current program error:', err); + return of(null); + }) + ) ); } @@ -185,7 +202,8 @@ export class EpgService { * @returns Observable of Map with channelId -> current program */ getCurrentProgramsForChannels( - channelIds: string[] + channelIds: string[], + options?: EpgLookupOptions ): Observable> { if (!this.epgBridge.supportsProgramLookup) { return of(new Map()); @@ -195,6 +213,29 @@ export class EpgService { return of(new Map()); } + const sourceUrls = this.normalizeSourceUrls(options); + if ( + sourceUrls.length > 0 && + this.epgBridge.supportsCurrentProgramBatch + ) { + return this.getScopedCurrentProgramsForChannels( + channelIds, + sourceUrls + ); + } + + const globalSourceUrls = this.getGlobalEpgSourceUrls(); + if ( + globalSourceUrls.length > 0 && + this.epgBridge.supportsCurrentProgramBatch + ) { + return this.getScopedCurrentProgramsForChannels( + channelIds, + globalSourceUrls, + [] + ); + } + const resultMap = new Map(); const channelsToFetch: string[] = []; const now = Date.now(); @@ -260,31 +301,236 @@ export class EpgService { ); } + private getScopedCurrentProgramsForChannels( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls = this.getGlobalEpgSourceUrls(sourceUrls) + ): Observable> { + const normalizedChannelIds = this.normalizeChannelIds(channelIds); + if (normalizedChannelIds.length === 0) { + return of(new Map()); + } + + const resultMap = new Map(); + const channelsToFetch: string[] = []; + + normalizedChannelIds.forEach((channelId) => { + const cached = this.getCachedProgram( + this.createProgramCacheKey(channelId, sourceUrls) + ); + if (cached) { + resultMap.set(channelId, cached.program); + } else { + channelsToFetch.push(channelId); + } + }); + + if (channelsToFetch.length === 0) { + return of(resultMap); + } + + const batchCacheKey = this.createProgramBatchCacheKey( + channelsToFetch, + sourceUrls, + fallbackSourceUrls + ); + const existingRequest = + this.fetchingCurrentProgramBatches.get(batchCacheKey); + const request$ = + existingRequest ?? + this.fetchScopedCurrentProgramsBatch( + channelsToFetch, + sourceUrls, + fallbackSourceUrls + ).pipe( + tap((fetchedMap) => { + const cacheTimestamp = Date.now(); + channelsToFetch.forEach((channelId) => { + this.programCache.set( + this.createProgramCacheKey(channelId, sourceUrls), + { + program: fetchedMap.get(channelId) ?? null, + timestamp: cacheTimestamp, + } + ); + }); + }), + finalize(() => { + this.fetchingCurrentProgramBatches.delete(batchCacheKey); + }), + shareReplay({ bufferSize: 1, refCount: false }) + ); + + if (!existingRequest) { + this.fetchingCurrentProgramBatches.set(batchCacheKey, request$); + } + + return request$.pipe( + map((fetchedMap) => { + const mergedResultMap = new Map(resultMap); + channelsToFetch.forEach((channelId) => { + mergedResultMap.set( + channelId, + fetchedMap.get(channelId) ?? null + ); + }); + return mergedResultMap; + }) + ); + } + + private fetchScopedCurrentProgramsBatch( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls: string[] + ): Observable> { + return from( + this.epgBridge.getCurrentProgramsBatch(channelIds, { + sourceUrls, + }) + ).pipe( + timeout(5000), + switchMap((scopedResult) => { + const resultMap = new Map(); + const fallbackChannelIds: string[] = []; + + channelIds.forEach((channelId) => { + const program = scopedResult?.[channelId] ?? null; + resultMap.set(channelId, program); + if (!program) { + fallbackChannelIds.push(channelId); + } + }); + + if (fallbackChannelIds.length === 0) { + return of(resultMap); + } + + if (fallbackSourceUrls.length === 0) { + return of(resultMap); + } + + return from( + this.epgBridge.getCurrentProgramsBatch(fallbackChannelIds, { + sourceUrls: fallbackSourceUrls, + }) + ).pipe( + timeout(5000), + map((globalResult) => { + fallbackChannelIds.forEach((channelId) => { + resultMap.set( + channelId, + globalResult?.[channelId] ?? null + ); + }); + return resultMap; + }), + catchError((err) => { + console.error( + 'EPG global fallback current programs error:', + err + ); + return of(resultMap); + }) + ); + }), + catchError((err) => { + console.error('EPG scoped batch current programs error:', err); + return of(this.createNullProgramMap(channelIds)); + }) + ); + } + getChannelMetadataForChannels( - channelIds: string[] + channelIds: string[], + options?: EpgLookupOptions ): Observable> { if (!this.epgBridge.supportsChannelMetadata) { return of(new Map()); } - const normalizedChannelIds = Array.from( + const normalizedChannelIds = this.normalizeChannelIds(channelIds); + + if (normalizedChannelIds.length === 0) { + return of(new Map()); + } + + const sourceUrls = this.normalizeSourceUrls(options); + const globalSourceUrls = + sourceUrls.length > 0 + ? this.getGlobalEpgSourceUrls(sourceUrls) + : this.getGlobalEpgSourceUrls(); + const effectiveSourceUrls = + sourceUrls.length > 0 ? sourceUrls : globalSourceUrls; + + return this.getChannelMetadataMapForSourceUrls( + normalizedChannelIds, + effectiveSourceUrls + ).pipe( + switchMap((metadataMap) => { + const fallbackChannelIds = + sourceUrls.length > 0 && globalSourceUrls.length > 0 + ? normalizedChannelIds.filter( + (channelId) => !metadataMap.get(channelId) + ) + : []; + + if (fallbackChannelIds.length === 0) { + return of(metadataMap); + } + + return this.getChannelMetadataMapForSourceUrls( + fallbackChannelIds, + globalSourceUrls + ).pipe( + map((globalMetadataMap) => { + fallbackChannelIds.forEach((channelId) => { + metadataMap.set( + channelId, + globalMetadataMap.get(channelId) ?? null + ); + }); + return metadataMap; + }), + catchError((err) => { + console.error( + 'EPG global fallback channel metadata error:', + err + ); + return of(metadataMap); + }) + ); + }) + ); + } + + private normalizeChannelIds(channelIds: string[]): string[] { + return Array.from( new Set( channelIds .map((channelId) => channelId.trim()) .filter((channelId) => channelId.length > 0) ) ); + } - if (normalizedChannelIds.length === 0) { - return of(new Map()); - } + private normalizeSourceUrls(options?: EpgLookupOptions): string[] { + return normalizeEpgUrls(options?.sourceUrls ?? []); + } + private getChannelMetadataMapForSourceUrls( + channelIds: string[], + sourceUrls: string[] + ): Observable> { return from( - this.epgBridge.getChannelMetadata(normalizedChannelIds) + this.epgBridge.getChannelMetadata( + channelIds, + sourceUrls.length > 0 ? { sourceUrls } : undefined + ) ).pipe( map((metadataByChannelId) => { return new Map( - normalizedChannelIds.map((channelId) => [ + channelIds.map((channelId) => [ channelId, metadataByChannelId?.[channelId] ?? null, ]) @@ -297,10 +543,143 @@ export class EpgService { ); } + private createProgramCacheKey( + channelId: string, + sourceUrls: string[] = [] + ): string { + const normalizedSourceUrls = normalizeEpgUrls(sourceUrls); + if (normalizedSourceUrls.length === 0) { + return channelId; + } + + return `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`; + } + + private createProgramBatchCacheKey( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls: string[] + ): string { + return JSON.stringify({ + channelIds: [...channelIds].sort(), + sourceUrls: normalizeEpgUrls(sourceUrls), + fallbackSourceUrls: normalizeEpgUrls(fallbackSourceUrls), + }); + } + + private getCachedProgram(cacheKey: string): CachedProgram | undefined { + const cached = this.programCache.get(cacheKey); + if (!cached) { + return undefined; + } + + if (Date.now() - cached.timestamp >= this.CACHE_TTL) { + this.programCache.delete(cacheKey); + return undefined; + } + + return cached; + } + + private getCachedOrFetchCurrentProgram( + cacheKey: string, + fetchProgram: () => Observable + ): Observable { + const cached = this.getCachedProgram(cacheKey); + if (cached) { + return of(cached.program); + } + + const existingRequest = this.fetchingCurrentPrograms.get(cacheKey); + if (existingRequest) { + return existingRequest; + } + + const request$ = fetchProgram().pipe( + tap((program) => { + this.programCache.set(cacheKey, { + program, + timestamp: Date.now(), + }); + }), + finalize(() => { + this.fetchingCurrentPrograms.delete(cacheKey); + }), + shareReplay({ bufferSize: 1, refCount: false }) + ); + this.fetchingCurrentPrograms.set(cacheKey, request$); + return request$; + } + + private getScopedCurrentProgramForChannel( + channelId: string, + sourceUrls: string[], + fallbackSourceUrls: string[] + ): Observable { + const cacheKey = this.createProgramCacheKey(channelId, sourceUrls); + + return this.getCachedOrFetchCurrentProgram(cacheKey, () => + from( + this.epgBridge.getChannelPrograms(channelId, { sourceUrls }) + ).pipe( + timeout(3000), + map((programs) => normalizeEpgPrograms(programs ?? [])), + switchMap((programs) => { + const currentProgram = this.findCurrentProgram(programs); + if (currentProgram) { + return of(currentProgram); + } + + return this.getFallbackCurrentProgramForChannel( + channelId, + fallbackSourceUrls + ); + }), + catchError((err) => { + console.error('EPG scoped current program error:', err); + return this.getFallbackCurrentProgramForChannel( + channelId, + fallbackSourceUrls + ); + }) + ) + ); + } + + private getFallbackCurrentProgramForChannel( + channelId: string, + sourceUrls: string[] + ): Observable { + if (sourceUrls.length === 0) { + return of(null); + } + + return this.getScopedCurrentProgramForChannel( + channelId, + sourceUrls, + [] + ); + } + + private createNullProgramMap( + channelIds: string[] + ): Map { + return new Map(channelIds.map((channelId) => [channelId, null])); + } + + private getGlobalEpgSourceUrls(excluding: string[] = []): string[] { + const excludedUrls = new Set(excluding); + return normalizeEpgUrls( + this.settingsStore.getSettings().epgUrl ?? [] + ).filter((url) => !excludedUrls.has(url)); + } + /** * Clears the program cache (useful when EPG is refreshed) */ clearCache(): void { this.programCache.clear(); + this.fetchingCurrentPrograms.clear(); + this.fetchingCurrentProgramBatches.clear(); } } diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 6a8ff0cfe..2d70f0708 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -10,7 +10,11 @@ export const PlaylistActions = createActionGroup({ 'Add Many Playlists': props<{ playlists: Playlist[] }>(), 'Remove Playlist': props<{ playlistId: string }>(), 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(), - 'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(), + 'Update Playlist': props<{ + playlist: Playlist; + playlistId: string; + refreshEpg?: boolean; + }>(), 'Update Many Playlists': props<{ playlists: Playlist[] }>(), 'Parse Playlist': props<{ uploadType: 'FILE' | 'URL' | 'TEXT'; diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index ba8d1632c..4f673e056 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -2,7 +2,10 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { EpgService } from '@iptvnator/epg/data-access'; -import { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils'; +import { + normalizeEpgUrls, + resolveM3uCatchupUrl, +} from '@iptvnator/shared/m3u-utils'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -18,12 +21,17 @@ import { tap, withLatestFrom, } from 'rxjs'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + PlaylistsService, + SettingsStore, +} from '@iptvnator/services'; import { OPEN_MPV_PLAYER, OPEN_VLC_PLAYER, Channel, Playlist, + PlaylistMeta, STORE_KEY, VideoPlayer, } from '@iptvnator/shared/interfaces'; @@ -41,6 +49,7 @@ import { } from './selectors'; import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util'; import { buildExternalPlayerPayload } from './external-player-payload.util'; +import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; @Injectable({ providedIn: 'any' }) export class PlaylistEffects { @@ -53,6 +62,8 @@ export class PlaylistEffects { private storage = inject(StorageMap); private store = inject(Store); private translate = inject(TranslateService); + private settingsStore = inject(SettingsStore); + private readonly playlistScopedEpgFetchKeys = new Map(); updateFavorites$ = createEffect( () => { @@ -222,6 +233,9 @@ export class PlaylistEffects { ofType(PlaylistActions.loadPlaylists), switchMap(() => this.playlistsService.getAllPlaylists().pipe( + tap((playlists) => { + this.fetchPlaylistScopedEpgForPlaylists(playlists); + }), map((playlists) => PlaylistActions.loadPlaylistsSuccess({ playlists, @@ -263,6 +277,7 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.removePlaylist), switchMap(async (action) => { + this.playlistScopedEpgFetchKeys.delete(action.playlistId); await firstValueFrom( this.playlistsService.deletePlaylist(action.playlistId) ); @@ -280,7 +295,13 @@ export class PlaylistEffects { this.playlistsService.updatePlaylist(action.playlistId, { ...action.playlist, _id: action.playlistId, - }) + }).pipe( + tap(() => { + this.fetchPlaylistScopedEpg(action.playlist, { + force: action.refreshEpg === true, + }); + }) + ) ) ); }, @@ -315,18 +336,16 @@ export class PlaylistEffects { PlaylistActions.addPlaylist, PlaylistActions.handleAddingPlaylistByUrl ), - tap((action) => { - if ('isTemporary' in action && action.isTemporary) { - return; - } - - this.navigateToPlaylist(action.playlist); - }), switchMap((action) => { if ('isTemporary' in action && action.isTemporary) { return EMPTY; } - return this.playlistsService.addPlaylist(action.playlist); + return this.playlistsService.addPlaylist(action.playlist).pipe( + tap(() => { + this.fetchPlaylistScopedEpg(action.playlist); + this.navigateToPlaylist(action.playlist); + }) + ); }) ); }, @@ -338,7 +357,17 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylistMeta), switchMap((action) => - this.playlistsService.updatePlaylistMeta(action.playlist) + this.playlistsService.updatePlaylistMeta(action.playlist).pipe( + tap(() => { + if ( + this.hasPlaylistScopedEpgSourceChange( + action.playlist + ) + ) { + this.fetchPlaylistScopedEpg(action.playlist); + } + }) + ) ) ); }, @@ -376,7 +405,15 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.updateManyPlaylists), switchMap((action) => - this.playlistsService.updateManyPlaylists(action.playlists) + this.playlistsService + .updateManyPlaylists(action.playlists) + .pipe( + tap(() => { + action.playlists.forEach((playlist) => + this.fetchPlaylistScopedEpg(playlist) + ); + }) + ) ) ); }, @@ -424,4 +461,65 @@ export class PlaylistEffects { void this.router.navigate(['/workspace', 'playlists', playlist._id]); } + + private fetchPlaylistScopedEpg( + playlist: Pick< + Playlist, + '_id' | 'epgUrls' | 'macAddress' | 'serverUrl' + >, + options: { force?: boolean } = {} + ): void { + const plan = resolvePlaylistScopedEpgFetchPlan( + playlist, + this.getGlobalEpgUrls(), + this.playlistScopedEpgFetchKeys.get(playlist._id), + options + ); + this.playlistScopedEpgFetchKeys.set(playlist._id, plan.key); + + if (!plan.shouldFetch) { + return; + } + + this.epgService.fetchEpg(plan.urls); + } + + private hasPlaylistScopedEpgSourceChange(playlist: PlaylistMeta): boolean { + return ( + Object.prototype.hasOwnProperty.call(playlist, 'epgUrls') || + Object.prototype.hasOwnProperty.call(playlist, 'detectedEpgUrls') || + Object.prototype.hasOwnProperty.call(playlist, 'manualEpgUrls') || + Object.prototype.hasOwnProperty.call(playlist, 'disabledEpgUrls') + ); + } + + private fetchPlaylistScopedEpgForPlaylists(playlists: Playlist[]): void { + const epgUrls = new Set(); + const globalEpgUrls = this.getGlobalEpgUrls(); + + for (const playlist of playlists) { + const plan = resolvePlaylistScopedEpgFetchPlan( + playlist, + globalEpgUrls, + this.playlistScopedEpgFetchKeys.get(playlist._id) + ); + this.playlistScopedEpgFetchKeys.set(playlist._id, plan.key); + + if (!plan.shouldFetch) { + continue; + } + + for (const url of plan.urls) { + epgUrls.add(url); + } + } + + if (epgUrls.size > 0) { + this.epgService.fetchEpg(Array.from(epgUrls)); + } + } + + private getGlobalEpgUrls(): string[] { + return normalizeEpgUrls(this.settingsStore.getSettings().epgUrl ?? []); + } } diff --git a/libs/m3u-state/src/lib/playlist-scoped-epg-fetch.util.spec.ts b/libs/m3u-state/src/lib/playlist-scoped-epg-fetch.util.spec.ts new file mode 100644 index 000000000..d2858b559 --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-scoped-epg-fetch.util.spec.ts @@ -0,0 +1,141 @@ +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; + +function createPlaylistMeta( + overrides: Partial = {} +): PlaylistMeta { + return { + _id: 'playlist-1', + title: 'Playlist', + count: 1, + importDate: '2026-06-21T10:00:00.000Z', + autoRefresh: false, + epgUrls: [], + ...overrides, + }; +} + +describe('resolvePlaylistScopedEpgFetchPlan', () => { + it('does not refetch when playlist metadata changes without fetchable EPG URL changes', () => { + const playlist = createPlaylistMeta({ + title: 'Renamed playlist', + epgUrls: ['https://playlist.example.com/guide.xml'], + }); + + expect( + resolvePlaylistScopedEpgFetchPlan( + playlist, + [], + 'https://playlist.example.com/guide.xml' + ) + ).toEqual({ + key: 'https://playlist.example.com/guide.xml', + shouldFetch: false, + urls: ['https://playlist.example.com/guide.xml'], + }); + }); + + it('does not fetch playlist EPG URLs that are already configured globally', () => { + const playlist = createPlaylistMeta({ + epgUrls: [ + 'https://global.example.com/guide.xml', + 'https://playlist.example.com/guide.xml', + ], + }); + + expect( + resolvePlaylistScopedEpgFetchPlan(playlist, [ + ' https://global.example.com/guide.xml ', + ]) + ).toEqual({ + key: 'https://playlist.example.com/guide.xml', + shouldFetch: true, + urls: ['https://playlist.example.com/guide.xml'], + }); + }); + + it('keeps the previous fetch key when a partial playlist metadata update omits EPG URLs', () => { + expect( + resolvePlaylistScopedEpgFetchPlan( + {}, + [], + 'https://playlist.example.com/guide.xml' + ) + ).toEqual({ + key: 'https://playlist.example.com/guide.xml', + shouldFetch: false, + urls: [], + }); + }); + + it('does not refetch remaining playlist EPG URLs when one local source is disabled', () => { + const playlist = createPlaylistMeta({ + epgUrls: ['https://playlist.example.com/keep.xml'], + }); + + expect( + resolvePlaylistScopedEpgFetchPlan( + playlist, + [], + [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/remove.xml', + ].join('\n') + ) + ).toEqual({ + key: 'https://playlist.example.com/keep.xml', + shouldFetch: false, + urls: ['https://playlist.example.com/keep.xml'], + }); + }); + + it('fetches only newly added playlist EPG URLs when the local source set expands', () => { + const playlist = createPlaylistMeta({ + epgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/new.xml', + ], + }); + + expect( + resolvePlaylistScopedEpgFetchPlan( + playlist, + [], + 'https://playlist.example.com/keep.xml' + ) + ).toEqual({ + key: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/new.xml', + ].join('\n'), + shouldFetch: true, + urls: ['https://playlist.example.com/new.xml'], + }); + }); + + it('fetches all playlist EPG URLs when refresh is forced for an already fetched source set', () => { + const playlist = createPlaylistMeta({ + epgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/other.xml', + ], + }); + const key = [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/other.xml', + ].join('\n'); + + expect( + resolvePlaylistScopedEpgFetchPlan(playlist, [], key, { + force: true, + }) + ).toEqual({ + key, + shouldFetch: true, + urls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/other.xml', + ], + }); + }); +}); diff --git a/libs/m3u-state/src/lib/playlist-scoped-epg-fetch.util.ts b/libs/m3u-state/src/lib/playlist-scoped-epg-fetch.util.ts new file mode 100644 index 000000000..7295a4ae7 --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-scoped-epg-fetch.util.ts @@ -0,0 +1,47 @@ +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { filterPlaylistEpgUrlsForFetch } from '@iptvnator/shared/m3u-utils'; + +export interface PlaylistScopedEpgFetchPlan { + key: string; + shouldFetch: boolean; + urls: string[]; +} + +export interface PlaylistScopedEpgFetchOptions { + force?: boolean; +} + +export function resolvePlaylistScopedEpgFetchPlan( + playlist: Pick, + globalEpgUrls: readonly string[], + previousKey = '', + options: PlaylistScopedEpgFetchOptions = {} +): PlaylistScopedEpgFetchPlan { + if (playlist.serverUrl || playlist.macAddress) { + return { key: '', shouldFetch: false, urls: [] }; + } + + if ( + !Object.prototype.hasOwnProperty.call(playlist, 'epgUrls') && + previousKey.length > 0 + ) { + return { key: previousKey, shouldFetch: false, urls: [] }; + } + + const urls = filterPlaylistEpgUrlsForFetch(playlist.epgUrls, globalEpgUrls); + const key = urls.join('\n'); + const previousUrls = new Set( + previousKey + .split('\n') + .map((url) => url.trim()) + .filter((url) => url.length > 0) + ); + const newUrls = urls.filter((url) => !previousUrls.has(url)); + const urlsToFetch = options.force ? urls : newUrls; + + return { + key, + shouldFetch: urlsToFetch.length > 0, + urls: urlsToFetch.length > 0 ? urlsToFetch : urls, + }; +} diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index 1ed089d32..fb22c6be4 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -1,5 +1,6 @@ import { on } from '@ngrx/store'; import { Channel } from '@iptvnator/shared/interfaces'; +import { resolvePlaylistEpgSourceState } from '@iptvnator/shared/m3u-utils'; import { PlaylistActions } from '../actions'; import { playlistsAdapter } from '../playlists.state'; import { PlaylistState } from '../state'; @@ -34,6 +35,19 @@ export const playlistReducers = [ const isActivePlaylist = state.playlists.selectedId === action.playlistId; const currentPlaylist = state.playlists.entities[action.playlistId]; + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls: + action.playlist.detectedEpgUrls ?? + currentPlaylist?.detectedEpgUrls, + enabledEpgUrls: + action.playlist.epgUrls ?? currentPlaylist?.epgUrls, + manualEpgUrls: + action.playlist.manualEpgUrls ?? + currentPlaylist?.manualEpgUrls, + disabledEpgUrls: + action.playlist.disabledEpgUrls ?? + currentPlaylist?.disabledEpgUrls, + }); return { ...state, channels: isActivePlaylist @@ -49,8 +63,11 @@ export const playlistReducers = [ updateDate: Date.now(), count: action.playlist.playlist.items.length, userAgent: action.playlist.userAgent, - favorites: - currentPlaylist?.favorites ?? [], + favorites: currentPlaylist?.favorites ?? [], + epgUrls: epgSourceState.epgUrls, + detectedEpgUrls: epgSourceState.detectedEpgUrls, + manualEpgUrls: epgSourceState.manualEpgUrls, + disabledEpgUrls: epgSourceState.disabledEpgUrls, autoRefresh: currentPlaylist?.autoRefresh ?? action.playlist.autoRefresh, @@ -143,6 +160,16 @@ export const playlistReducers = [ ...(p.hiddenGroupTitles != null ? { hiddenGroupTitles: p.hiddenGroupTitles } : {}), + ...(p.epgUrls != null ? { epgUrls: p.epgUrls } : {}), + ...(p.detectedEpgUrls != null + ? { detectedEpgUrls: p.detectedEpgUrls } + : {}), + ...(p.manualEpgUrls != null + ? { manualEpgUrls: p.manualEpgUrls } + : {}), + ...(p.disabledEpgUrls != null + ? { disabledEpgUrls: p.disabledEpgUrls } + : {}), ...(p.updateDate !== undefined ? { updateDate: p.updateDate } : {}), diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 17912fcd0..b8e84e9e4 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -623,6 +623,7 @@ describe('PlaylistRefreshActionService', () => { PlaylistActions.updatePlaylist({ playlist: refreshedPlaylist, playlistId: item._id, + refreshEpg: true, }) ); }); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index e74d7b26f..8ca73a67a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -227,6 +227,7 @@ export class PlaylistRefreshActionService { _id: item._id, }, playlistId: item._id, + refreshEpg: true, }) ); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html index 3de3d1bfb..6df821c86 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html @@ -152,6 +152,141 @@ } } + @if (canManagePlaylistEpgSources) { +
+
+ event_note +
+

+ {{ 'SETTINGS.EPG_SOURCES' | translate }} +

+

+ @if (hiddenDetectedPlaylistEpgSourceCount > 0) { + {{ + 'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED' + | translate + : { + enabled: + playlistEpgUrls.length, + detected: + playlistDetectedEpgUrls.length, + } + }} + } @else { + {{ + 'SETTINGS.EPG_SOURCES_DESCRIPTION' + | translate + }} + } +

+
+
+ @for (epgUrl of playlistEpgUrls; track epgUrl) { +
+ + {{ + 'SETTINGS.EPG_URL_LABEL' | translate + }} + + + + + +
+ } +
+ @for ( + control of playlistEpgSourceInputs.controls; + track $index + ) { +
+ + {{ + 'SETTINGS.EPG_URL_LABEL' | translate + }} + + @if (control.invalid && control.touched) { + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + } + + +
+ } +
+ + +
+
+
+ } @if (isDesktop && (playlist.url || playlist.filePath)) { {{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts index cbfe7108c..dcd1afd69 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -4,11 +4,13 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { DatabaseService, PlaylistsService, RuntimeCapabilitiesService, + SettingsStore, } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; import { PlaylistInfoComponent } from './playlist-info.component'; @@ -22,11 +24,21 @@ describe('PlaylistInfoComponent', () => { let databaseService: { updateXtreamPlaylistDetails: jest.Mock; }; + let epgBridge: { + supportsDataManagement: boolean; + forceFetchEpg: jest.Mock; + clearEpgDataForSource: jest.Mock; + }; let runtime: { isElectron: boolean; supportsDesktopFileSave: boolean; supportsXtreamSqliteDataSource: boolean; }; + let settingsStore: { + getSettings: jest.Mock; + getTrustOptions: jest.Mock; + updateSettings: jest.Mock; + }; let snackBar: { open: jest.Mock; }; @@ -55,11 +67,28 @@ describe('PlaylistInfoComponent', () => { databaseService = { updateXtreamPlaylistDetails: jest.fn(), }; + epgBridge = { + supportsDataManagement: true, + forceFetchEpg: jest.fn().mockResolvedValue({ success: true }), + clearEpgDataForSource: jest + .fn() + .mockResolvedValue({ success: true }), + }; runtime = { isElectron: false, supportsDesktopFileSave: false, supportsXtreamSqliteDataSource: false, }; + settingsStore = { + getSettings: jest.fn(() => ({ + epgUrl: [], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; snackBar = { open: jest.fn(), }; @@ -85,10 +114,18 @@ describe('PlaylistInfoComponent', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: EpgRuntimeBridgeService, + useValue: epgBridge, + }, { provide: Store, useValue: store, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, { provide: MatSnackBar, useValue: snackBar, @@ -100,7 +137,18 @@ describe('PlaylistInfoComponent', () => { { provide: TranslateService, useValue: { + currentLang: 'en', + get: jest.fn((key: string) => of(key)), instant: jest.fn((key: string) => key), + onDefaultLangChange: of({ + lang: 'en', + translations: {}, + }), + onLangChange: of({ lang: 'en', translations: {} }), + onTranslationChange: of({ + lang: 'en', + translations: {}, + }), }, }, { @@ -265,6 +313,259 @@ describe('PlaylistInfoComponent', () => { expect(component.isDesktop).toBe(false); }); + it('normalizes detected playlist EPG source URLs for the details UI', () => { + TestBed.overrideProvider(MAT_DIALOG_DATA, { + useValue: { + ...playlist, + epgUrls: [ + ' https://playlist.example.com/guide.xml ', + '', + 'https://playlist.example.com/guide.xml', + 'https://playlist.example.com/backup.xml', + ], + }, + }); + createComponent(); + + expect(component.playlistEpgUrls).toEqual([ + 'https://playlist.example.com/guide.xml', + 'https://playlist.example.com/backup.xml', + ]); + }); + + it('keeps disabled detected playlist EPG candidates visible in the details UI summary', () => { + TestBed.overrideProvider(MAT_DIALOG_DATA, { + useValue: { + ...playlist, + epgUrls: ['https://playlist.example.com/ua.xml'], + detectedEpgUrls: [ + 'https://playlist.example.com/ua.xml', + 'https://playlist.example.com/de.xml', + 'https://playlist.example.com/us.xml', + 'https://playlist.example.com/fr.xml', + ], + }, + }); + createComponent(); + + expect(component.playlistDetectedEpgUrls).toEqual([ + 'https://playlist.example.com/ua.xml', + 'https://playlist.example.com/de.xml', + 'https://playlist.example.com/us.xml', + 'https://playlist.example.com/fr.xml', + ]); + expect(component.hiddenDetectedPlaylistEpgSourceCount).toBe(3); + }); + + it('refreshes a detected playlist EPG source through the runtime bridge', async () => { + createComponent(); + + await component.refreshPlaylistEpgSource( + ' https://playlist.example.com/guide.xml ' + ); + + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( + 'https://playlist.example.com/guide.xml', + { + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + } + ); + expect(settingsStore.updateSettings).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + 'EPG.FETCH_SUCCESS', + 'CLOSE', + { duration: 3000 } + ); + }); + + it('adds a detected playlist EPG source to global settings on request', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: [ + 'https://global.example.com/guide.xml', + 'https://playlist.example.com/guide.xml', + ], + }); + createComponent(); + + await component.addPlaylistEpgSourceToSettings( + 'https://new-playlist.example.com/guide.xml' + ); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + epgUrl: [ + 'https://global.example.com/guide.xml', + 'https://playlist.example.com/guide.xml', + 'https://new-playlist.example.com/guide.xml', + ], + }); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.ADD_EPG_SOURCE', + 'CLOSE', + { duration: 3000 } + ); + }); + + it('does not duplicate a playlist EPG source that already exists globally', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://playlist.example.com/guide.xml'], + }); + createComponent(); + + await component.addPlaylistEpgSourceToSettings( + ' https://playlist.example.com/guide.xml ' + ); + + expect(settingsStore.updateSettings).not.toHaveBeenCalled(); + }); + + it('removes a detected playlist EPG source from the enabled list and records it as disabled', async () => { + TestBed.overrideProvider(MAT_DIALOG_DATA, { + useValue: { + ...playlist, + epgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/remove.xml', + ], + detectedEpgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/remove.xml', + ], + manualEpgUrls: ['https://playlist.example.com/manual.xml'], + disabledEpgUrls: ['https://playlist.example.com/old.xml'], + }, + }); + createComponent(); + + await component.removePlaylistEpgSource( + 'https://playlist.example.com/remove.xml' + ); + + expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith( + 'https://playlist.example.com/remove.xml' + ); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: expect.objectContaining({ + _id: 'playlist-1', + epgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/manual.xml', + ], + detectedEpgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/remove.xml', + ], + manualEpgUrls: ['https://playlist.example.com/manual.xml'], + disabledEpgUrls: [ + 'https://playlist.example.com/old.xml', + 'https://playlist.example.com/remove.xml', + ], + }), + }) + ); + }); + + it('keeps a playlist EPG source enabled when source data cleanup fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + epgBridge.clearEpgDataForSource.mockRejectedValueOnce( + new Error('Database cleanup failed') + ); + TestBed.overrideProvider(MAT_DIALOG_DATA, { + useValue: { + ...playlist, + epgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/remove.xml', + ], + detectedEpgUrls: [ + 'https://playlist.example.com/keep.xml', + 'https://playlist.example.com/remove.xml', + ], + manualEpgUrls: [], + disabledEpgUrls: [], + }, + }); + createComponent(); + + await component.removePlaylistEpgSource( + 'https://playlist.example.com/remove.xml' + ); + + expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith( + 'https://playlist.example.com/remove.xml' + ); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.EPG_DATA_CLEAR_FAILED', + 'CLOSE', + { duration: 3000 } + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('adds playlist-local EPG sources with URL normalization and deduplication', async () => { + TestBed.overrideProvider(MAT_DIALOG_DATA, { + useValue: { + ...playlist, + epgUrls: ['https://playlist.example.com/existing.xml'], + detectedEpgUrls: ['https://playlist.example.com/existing.xml'], + manualEpgUrls: ['https://playlist.example.com/manual.xml'], + disabledEpgUrls: ['https://playlist.example.com/new.xml'], + }, + }); + createComponent(); + + component.playlistEpgSourceInputs + .at(0) + .setValue(' https://playlist.example.com/new.xml '); + component.addPlaylistEpgSourceInput(); + component.playlistEpgSourceInputs + .at(1) + .setValue('https://playlist.example.com/manual.xml'); + + component.savePlaylistEpgSources(); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: expect.objectContaining({ + _id: 'playlist-1', + epgUrls: [ + 'https://playlist.example.com/existing.xml', + 'https://playlist.example.com/new.xml', + 'https://playlist.example.com/manual.xml', + ], + manualEpgUrls: [ + 'https://playlist.example.com/manual.xml', + 'https://playlist.example.com/new.xml', + ], + disabledEpgUrls: [], + }), + }) + ); + expect(component.playlistEpgSourceInputs.length).toBe(1); + expect(component.playlistEpgSourceInputs.at(0).value).toBe(''); + }); + + it('shows a validation error for invalid playlist-local EPG source URLs', () => { + createComponent(); + fixture.detectChanges(); + + component.playlistEpgSourceInputs.at(0).setValue('not a url'); + component.savePlaylistEpgSources(); + fixture.detectChanges(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain( + 'SETTINGS.EPG_URL_ERROR' + ); + }); + it('falls back to browser download when desktop file saving is unavailable', async () => { const clickSpy = jest .spyOn(HTMLAnchorElement.prototype, 'click') diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index b809d1e31..3d0882346 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -4,6 +4,7 @@ import { Component, inject } from '@angular/core'; import { FormControl, ReactiveFormsModule, + UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators, @@ -21,24 +22,32 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { firstValueFrom } from 'rxjs'; import { DatabaseService, PlaylistsService, RuntimeCapabilitiesService, + SettingsStore, } from '@iptvnator/services'; import { normalizeXtreamServerUrl, Playlist, PlaylistMeta, } from '@iptvnator/shared/interfaces'; +import { + normalizeEpgUrls, + resolvePlaylistEpgSourceState, +} from '@iptvnator/shared/m3u-utils'; type DesktopFileSaveBridge = Pick< typeof window.electron, 'saveFileDialog' | 'writeFile' >; +const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/; + @Component({ selector: 'app-playlist-info', templateUrl: './playlist-info.component.html', @@ -68,10 +77,61 @@ type DesktopFileSaveBridge = Pick< mat-dialog-content p { margin: 0; - color: var(--app-muted-color); + color: var(--mat-sys-on-surface-variant); font-size: 12.5px; line-height: 1.45; } + + .playlist-epg-sources { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid + var( + --app-widget-header-border, + var(--mat-sys-outline-variant) + ); + border-radius: 8px; + background: var(--mat-sys-surface-container-low); + } + + .playlist-epg-sources__header { + display: flex; + gap: 10px; + align-items: flex-start; + } + + .playlist-epg-sources__header mat-icon { + color: var(--mat-sys-primary); + } + + .playlist-epg-sources__title { + margin: 0 0 2px; + font-size: 14px; + font-weight: 600; + line-height: 1.25; + } + + .playlist-epg-source-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 6px; + align-items: center; + } + + .playlist-epg-source-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + } + + @media (max-width: 520px) { + .playlist-epg-source-row { + grid-template-columns: minmax(0, 1fr); + } + } `, ], providers: [DatePipe], @@ -98,6 +158,8 @@ export class PlaylistInfoComponent { private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); private runtime = inject(RuntimeCapabilitiesService); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private dialogRef = inject(MatDialogRef, { optional: true, }); @@ -107,6 +169,38 @@ export class PlaylistInfoComponent { return this.runtime.supportsDesktopFileSave; } + get playlistEpgUrls(): string[] { + return this.normalizeEpgUrls(this.playlist.epgUrls); + } + + get playlistDetectedEpgUrls(): string[] { + const detectedUrls = this.normalizeEpgUrls( + this.playlist.detectedEpgUrls + ); + return detectedUrls.length > 0 ? detectedUrls : this.playlistEpgUrls; + } + + get hiddenDetectedPlaylistEpgSourceCount(): number { + const enabledUrls = new Set(this.playlistEpgUrls); + return this.playlistDetectedEpgUrls.filter( + (url) => !enabledUrls.has(url) + ).length; + } + + get canRefreshPlaylistEpg(): boolean { + return this.epgBridge.supportsDataManagement; + } + + get canManagePlaylistEpgSources(): boolean { + return !this.playlist.serverUrl && !this.playlist.macAddress; + } + + get playlistEpgSourceInputs(): UntypedFormArray { + return this.playlistDetails.get( + 'playlistEpgSourceInputs' + ) as UntypedFormArray; + } + /** Playlist object */ playlist: Playlist & { id: string }; @@ -159,6 +253,9 @@ export class PlaylistInfoComponent { stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2), stalkerSignature1: new FormControl(this.playlist.stalkerSignature1), stalkerSignature2: new FormControl(this.playlist.stalkerSignature2), + playlistEpgSourceInputs: new UntypedFormArray([ + this.createPlaylistEpgSourceControl(), + ]), }); } @@ -238,6 +335,159 @@ export class PlaylistInfoComponent { }); */ } + async refreshPlaylistEpgSource(url: string): Promise { + const normalizedUrl = url.trim(); + if (!normalizedUrl) { + return; + } + + const result = await this.epgBridge.forceFetchEpg( + normalizedUrl, + this.settingsStore.getTrustOptions() + ); + + if (!result) { + return; + } + + this.snackBar.open( + this.translate.instant( + result.success ? 'EPG.FETCH_SUCCESS' : 'EPG.ERROR' + ), + this.translate.instant('CLOSE'), + { duration: 3000 } + ); + } + + async addPlaylistEpgSourceToSettings(url: string): Promise { + const epgUrl = url.trim(); + if (!epgUrl || this.isGlobalEpgSource(epgUrl)) { + return; + } + + const currentSettings = this.settingsStore.getSettings(); + await this.settingsStore.updateSettings({ + epgUrl: this.normalizeEpgUrls([ + ...(currentSettings.epgUrl ?? []), + epgUrl, + ]), + }); + + this.snackBar.open( + this.translate.instant('SETTINGS.ADD_EPG_SOURCE'), + this.translate.instant('CLOSE'), + { duration: 3000 } + ); + } + + isGlobalEpgSource(url: string): boolean { + const normalizedUrl = url.trim(); + if (!normalizedUrl) { + return false; + } + + return this.normalizeEpgUrls( + this.settingsStore.getSettings().epgUrl + ).includes(normalizedUrl); + } + + async removePlaylistEpgSource(url: string): Promise { + const epgUrl = url.trim(); + if (!epgUrl) { + return; + } + + if (this.epgBridge.supportsDataManagement) { + try { + const result = + await this.epgBridge.clearEpgDataForSource(epgUrl); + if (result && result.success === false) { + throw new Error('Clear EPG source returned false'); + } + } catch (error) { + console.error( + 'Failed to clear playlist EPG source data:', + error + ); + this.snackBar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED'), + this.translate.instant('CLOSE'), + { duration: 3000 } + ); + return; + } + } + + const detectedEpgUrls = this.getRawDetectedPlaylistEpgUrls(); + const disabledEpgUrls = this.normalizeEpgUrls( + this.playlist.disabledEpgUrls + ); + const nextDisabledEpgUrls = detectedEpgUrls.includes(epgUrl) + ? this.normalizeEpgUrls([...disabledEpgUrls, epgUrl]) + : disabledEpgUrls.filter((disabledUrl) => disabledUrl !== epgUrl); + + const state = resolvePlaylistEpgSourceState({ + detectedEpgUrls, + enabledEpgUrls: this.playlistEpgUrls.filter( + (enabledUrl) => enabledUrl !== epgUrl + ), + manualEpgUrls: this.normalizeEpgUrls( + this.playlist.manualEpgUrls + ).filter((manualUrl) => manualUrl !== epgUrl), + disabledEpgUrls: nextDisabledEpgUrls, + }); + + this.applyPlaylistEpgSourceState(state); + } + + addPlaylistEpgSourceInput(): void { + this.playlistEpgSourceInputs.push( + this.createPlaylistEpgSourceControl() + ); + } + + removePlaylistEpgSourceInput(index: number): void { + if (this.playlistEpgSourceInputs.length <= 1) { + this.playlistEpgSourceInputs.at(0).reset(''); + return; + } + + this.playlistEpgSourceInputs.removeAt(index); + } + + savePlaylistEpgSources(): void { + if (this.playlistEpgSourceInputs.invalid) { + this.playlistEpgSourceInputs.markAllAsTouched(); + return; + } + + const addedUrls = this.normalizeEpgUrls( + this.playlistEpgSourceInputs.value as string[] + ); + if (addedUrls.length === 0) { + return; + } + + const addedUrlSet = new Set(addedUrls); + const state = resolvePlaylistEpgSourceState({ + detectedEpgUrls: this.getRawDetectedPlaylistEpgUrls(), + enabledEpgUrls: this.normalizeEpgUrls([ + ...this.playlistEpgUrls, + ...addedUrls, + ]), + manualEpgUrls: this.normalizeEpgUrls([ + ...(this.playlist.manualEpgUrls ?? []), + ...addedUrls, + ]), + disabledEpgUrls: this.normalizeEpgUrls( + this.playlist.disabledEpgUrls + ).filter((url) => !addedUrlSet.has(url)), + }); + + this.applyPlaylistEpgSourceState(state); + this.resetPlaylistEpgSourceInputs(); + } + async exportPlaylist() { const playlistAsString = await firstValueFrom( this.playlistsService.getRawPlaylistById(this.playlist._id) @@ -322,4 +572,46 @@ export class PlaylistInfoComponent { } } } + + private normalizeEpgUrls(urls?: string[] | null): string[] { + return normalizeEpgUrls(urls ?? []); + } + + private createPlaylistEpgSourceControl(value = ''): FormControl { + return new FormControl(value, { + nonNullable: true, + validators: [Validators.pattern(EPG_URL_PATTERN)], + }); + } + + private getRawDetectedPlaylistEpgUrls(): string[] { + return this.normalizeEpgUrls(this.playlist.detectedEpgUrls); + } + + private applyPlaylistEpgSourceState( + state: ReturnType + ): void { + const playlistMeta = { + _id: this.playlist._id, + epgUrls: state.epgUrls, + detectedEpgUrls: state.detectedEpgUrls, + manualEpgUrls: state.manualEpgUrls, + disabledEpgUrls: state.disabledEpgUrls, + } as PlaylistMeta; + + this.playlist = { + ...this.playlist, + ...playlistMeta, + }; + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ playlist: playlistMeta }) + ); + } + + private resetPlaylistEpgSourceInputs(): void { + this.playlistEpgSourceInputs.clear(); + this.playlistEpgSourceInputs.push( + this.createPlaylistEpgSourceControl() + ); + } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 9a0fc1b34..a615e96ec 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -462,6 +462,7 @@ export class RecentPlaylistsComponent { _id: item._id, }, playlistId: item._id, + refreshEpg: true, }) ); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 4d1c55ca1..f9fefc171 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -5,6 +5,7 @@ import { aggregateFavoriteChannels, createFavoritesPlaylist, createPlaylistObject, + resolvePlaylistEpgSourceState, } from '@iptvnator/shared/m3u-utils'; import { NgxIndexedDBService } from 'ngx-indexed-db'; import { @@ -210,6 +211,10 @@ export class PlaylistsService { playlist: playlist.playlist, url: playlist.url, filePath: playlist.filePath, + epgUrls: playlist.epgUrls ?? [], + detectedEpgUrls: playlist.detectedEpgUrls ?? playlist.epgUrls ?? [], + manualEpgUrls: playlist.manualEpgUrls ?? [], + disabledEpgUrls: playlist.disabledEpgUrls ?? [], userAgent: playlist.userAgent, referrer: playlist.referrer, origin: playlist.origin, @@ -465,6 +470,19 @@ export class PlaylistsService { updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { return this.getPlaylistById(playlistId).pipe( switchMap((currentPlaylist: Playlist) => { + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls: + updatedPlaylist.detectedEpgUrls ?? + currentPlaylist.detectedEpgUrls, + enabledEpgUrls: + updatedPlaylist.epgUrls ?? currentPlaylist.epgUrls, + manualEpgUrls: + updatedPlaylist.manualEpgUrls ?? + currentPlaylist.manualEpgUrls, + disabledEpgUrls: + updatedPlaylist.disabledEpgUrls ?? + currentPlaylist.disabledEpgUrls, + }); const mergedPlaylist: Playlist = { ...currentPlaylist, ...updatedPlaylist, @@ -475,6 +493,10 @@ export class PlaylistsService { updateDate: Date.now(), updateState: PlaylistUpdateState.UPDATED, favorites: currentPlaylist.favorites, + epgUrls: epgSourceState.epgUrls, + detectedEpgUrls: epgSourceState.detectedEpgUrls, + manualEpgUrls: epgSourceState.manualEpgUrls, + disabledEpgUrls: epgSourceState.disabledEpgUrls, autoRefresh: currentPlaylist.autoRefresh ?? updatedPlaylist.autoRefresh, @@ -515,6 +537,18 @@ export class PlaylistsService { updatePlaylistMeta(updatedPlaylist: PlaylistMeta) { return this.getPlaylistById(updatedPlaylist._id).pipe( switchMap((playlist) => { + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls: + updatedPlaylist.detectedEpgUrls ?? + playlist.detectedEpgUrls, + enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls, + manualEpgUrls: + updatedPlaylist.manualEpgUrls ?? + playlist.manualEpgUrls, + disabledEpgUrls: + updatedPlaylist.disabledEpgUrls ?? + playlist.disabledEpgUrls, + }); const nextPlaylist: Playlist = { ...playlist, ...(updatedPlaylist.title != null @@ -565,6 +599,21 @@ export class PlaylistsService { updatedPlaylist.hiddenGroupTitles, } : {}), + ...(updatedPlaylist.detectedEpgUrls !== undefined + ? { detectedEpgUrls: epgSourceState.detectedEpgUrls } + : {}), + ...(updatedPlaylist.manualEpgUrls !== undefined + ? { manualEpgUrls: epgSourceState.manualEpgUrls } + : {}), + ...(updatedPlaylist.disabledEpgUrls !== undefined + ? { disabledEpgUrls: epgSourceState.disabledEpgUrls } + : {}), + ...(updatedPlaylist.epgUrls !== undefined || + updatedPlaylist.detectedEpgUrls !== undefined || + updatedPlaylist.manualEpgUrls !== undefined || + updatedPlaylist.disabledEpgUrls !== undefined + ? { epgUrls: epgSourceState.epgUrls } + : {}), ...(updatedPlaylist.updateDate !== undefined ? { updateDate: updatedPlaylist.updateDate } : {}), @@ -659,8 +708,7 @@ export class PlaylistsService { playlistId: string ): Observable { const electron = this.electronApi; - const getFavoriteChannels = - electron?.dbGetAppPlaylistFavoriteChannels; + const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels; if ( !electron || !this.isElectronStorageAvailable || diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index f0e5516e0..7cb3e3afd 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -129,6 +129,7 @@ describe('RuntimeCapabilitiesService', () => { onEpgProgress: jest.fn(), forceFetchEpg: jest.fn(), clearEpgData: jest.fn(), + clearEpgDataForSource: jest.fn(), getEpgChannelsByRange: jest.fn(), searchEpgPrograms: jest.fn(), }; @@ -305,6 +306,7 @@ describe('RuntimeCapabilitiesService', () => { checkEpgFreshness: jest.fn(), forceFetchEpg: jest.fn(), clearEpgData: jest.fn(), + clearEpgDataForSource: jest.fn(), getEpgChannelsByRange: jest.fn(), searchEpgPrograms: jest.fn(), }; @@ -320,6 +322,7 @@ describe('RuntimeCapabilitiesService', () => { checkEpgFreshness: jest.fn(), forceFetchEpg: jest.fn(), clearEpgData: jest.fn(), + clearEpgDataForSource: jest.fn(), getEpgChannelsByRange: jest.fn(), searchEpgPrograms: jest.fn(), }; diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index f56123f2d..e2d3ed248 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -102,7 +102,8 @@ export class RuntimeCapabilitiesService { get supportsEpgDataManagement(): boolean { return ( this.hasElectronMethod('forceFetchEpg') && - this.hasElectronMethod('clearEpgData') + this.hasElectronMethod('clearEpgData') && + this.hasElectronMethod('clearEpgDataForSource') ); } diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 175617f82..a7cca91b1 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -82,6 +82,9 @@ describe('database schema statements', () => { expect.arrayContaining([ 'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0', 'ALTER TABLE playlists ADD COLUMN payload TEXT', + 'ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT', + 'ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT', + 'ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT', 'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0', 'ALTER TABLE content ADD COLUMN backdrop_url TEXT', ]) @@ -102,10 +105,26 @@ describe('database schema statements', () => { 'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)', 'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)', 'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)', + 'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)', + 'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)', ]) ); }); + it('creates indexes for migrated EPG program columns only after column migrations run', () => { + const createSchemaSql = createTableStatements.map(compactSql); + + expect(createSchemaSql).not.toContain( + 'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)' + ); + expect(createSchemaSql).not.toContain( + 'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)' + ); + expect(columnMigrationStatements.map(compactSql)).toContain( + 'ALTER TABLE epg_programs ADD COLUMN source_url TEXT' + ); + }); + it('does not define duplicate fresh-install schema object names', () => { const objectNames = [ ...createdObjectNames( @@ -307,4 +326,102 @@ describe('database schema statements', () => { expect.stringContaining('UPDATE content') ); }); + + it('backfills migrated EPG program source URLs after creating scoped EPG indexes', () => { + const callOrder: string[] = []; + const runMigrations = ( + __databaseConnectionTestHooks as unknown as { + runMigrations: (sqlite: { + exec: (statement: string) => void; + prepare: (statement: string) => { + all?: () => unknown[]; + get?: (...args: unknown[]) => unknown; + run?: (...args: unknown[]) => unknown; + }; + transaction: (callback: () => void) => () => void; + }) => void; + } + ).runMigrations; + const sqlite = { + exec: jest.fn((statement: string) => { + if (statement.includes('idx_epg_programs_source')) { + callOrder.push('epg-source-index'); + } + }), + prepare: jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { + get: (): undefined => undefined, + }; + } + if (statement.includes('UPDATE epg_programs')) { + return { + run: () => { + callOrder.push('epg-source-backfill'); + }, + }; + } + if (statement.includes('INSERT INTO app_state')) { + return { run: jest.fn() }; + } + + return { + all: () => [], + get: (): undefined => undefined, + run: jest.fn(), + }; + }), + transaction: jest.fn((callback: () => void) => callback), + }; + + runMigrations(sqlite); + + expect(callOrder).toContain('epg-source-backfill'); + expect(callOrder).toContain('epg-source-index'); + expect(callOrder.indexOf('epg-source-index')).toBeLessThan( + callOrder.indexOf('epg-source-backfill') + ); + }); + + it('backfills migrated EPG program source URLs in bounded batches', () => { + const { backfillEpgProgramSourceUrls } = + __databaseConnectionTestHooks; + let updateStatement = ''; + const backfillRun = jest + .fn() + .mockReturnValueOnce({ changes: 50_000 }) + .mockReturnValueOnce({ changes: 12 }); + const stateRun = jest.fn(); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { + get: (): undefined => undefined, + }; + } + if (statement.includes('UPDATE epg_programs')) { + updateStatement = compactSql(statement); + return { + run: backfillRun, + }; + } + if (statement.includes('INSERT INTO app_state')) { + return { run: stateRun }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + backfillEpgProgramSourceUrls(sqlite); + + expect(updateStatement).toContain('LIMIT 50000'); + expect(backfillRun).toHaveBeenCalledTimes(2); + expect(stateRun).toHaveBeenCalledWith( + 'migration:epg-program-source-url-backfill:v1' + ); + }); }); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 17278b2aa..e4227a73f 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -28,6 +28,9 @@ const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY = 'migration:xtream-content-added-epoch-seconds:v1'; const CONTENT_TITLE_FTS_MIGRATION_KEY = 'migration:content-title-fts-trigram:v1'; +const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY = + 'migration:epg-program-source-url-backfill:v1'; +const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000; function readTraceFlag(name: string): boolean { const value = process.env[name]?.trim().toLowerCase(); @@ -84,6 +87,10 @@ const CREATE_TABLE_STATEMENTS = [ origin TEXT, referrer TEXT, filePath TEXT, + epg_urls TEXT, + detected_epg_urls TEXT, + manual_epg_urls TEXT, + disabled_epg_urls TEXT, autoRefresh INTEGER DEFAULT 0, macAddress TEXT, url TEXT, @@ -210,6 +217,7 @@ const CREATE_TABLE_STATEMENTS = [ icon_url TEXT, rating TEXT, episode_num TEXT, + source_url TEXT, FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE )`, // EPG indexes @@ -304,6 +312,11 @@ const COLUMN_MIGRATION_STATEMENTS = [ `ALTER TABLE playlists ADD COLUMN favorites TEXT`, `ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`, `ALTER TABLE playlists ADD COLUMN payload TEXT`, + // v1.2.1: Keep M3U-detected EPG URLs available in lightweight playlist metadata + `ALTER TABLE playlists ADD COLUMN epg_urls TEXT`, + `ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT`, + `ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT`, + `ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT`, // v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering `ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`, // v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up @@ -313,6 +326,8 @@ const COLUMN_MIGRATION_STATEMENTS = [ `ALTER TABLE content ADD COLUMN direct_source TEXT`, // v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch `ALTER TABLE content ADD COLUMN backdrop_url TEXT`, + // v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup + `ALTER TABLE epg_programs ADD COLUMN source_url TEXT`, ]; const INDEX_MIGRATION_STATEMENTS = [ @@ -321,6 +336,9 @@ const INDEX_MIGRATION_STATEMENTS = [ `CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`, // v1.6.0 -> v1.7.0: Query global favorites in stable display order `CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`, + // v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time + `CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)`, ]; export const __databaseConnectionTestHooks = { @@ -329,6 +347,7 @@ export const __databaseConnectionTestHooks = { indexMigrationStatements: INDEX_MIGRATION_STATEMENTS, normalizeXtreamContentAddedEpochs, ensureContentTitleFts, + backfillEpgProgramSourceUrls, runMigrations, } as const; @@ -594,6 +613,78 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void { } } +function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + if (migrationState?.value === 'done') { + return; + } + + const backfillStatement = sqliteDb.prepare( + `UPDATE epg_programs + SET source_url = ( + SELECT epg_channels.source_url + FROM epg_channels + WHERE epg_channels.id = epg_programs.channel_id + LIMIT 1 + ) + WHERE id IN ( + SELECT pending_programs.id + FROM epg_programs AS pending_programs + JOIN epg_channels + ON epg_channels.id = pending_programs.channel_id + WHERE pending_programs.source_url IS NULL + AND epg_channels.source_url IS NOT NULL + AND epg_channels.source_url <> '' + LIMIT ${EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE} + )` + ); + const markMigrationDoneStatement = sqliteDb.prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ); + + const executeBackfillBatch = sqliteDb.transaction((): number => { + const result = backfillStatement.run(); + return typeof result === 'object' && + result !== null && + 'changes' in result && + typeof result.changes === 'number' + ? result.changes + : 0; + }); + const markMigrationDone = sqliteDb.transaction(() => { + markMigrationDoneStatement.run( + EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY + ); + }); + + let updatedRows = 0; + do { + updatedRows = executeBackfillBatch(); + } while (updatedRows === EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE); + + markMigrationDone(); + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `EPG program source URL backfill failed (continuing): ${message}` + ); + } +} + function runMigrationStatements( sqliteDb: Database.Database, statements: string[] @@ -631,6 +722,7 @@ function runMigrations(sqliteDb: Database.Database): void { deduplicateXtreamCache(sqliteDb); normalizeXtreamContentAddedEpochs(sqliteDb); runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS); + backfillEpgProgramSourceUrls(sqliteDb); } export interface DatabaseOptions { diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index c6c3c2b7e..87d075c41 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -32,6 +32,10 @@ export const playlists = sqliteTable('playlists', { origin: text('origin'), referrer: text('referrer'), filePath: text('filePath'), + epgUrls: text('epg_urls'), + detectedEpgUrls: text('detected_epg_urls'), + manualEpgUrls: text('manual_epg_urls'), + disabledEpgUrls: text('disabled_epg_urls'), autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false), macAddress: text('macAddress'), url: text('url'), @@ -204,9 +208,11 @@ export const epgPrograms = sqliteTable( iconUrl: text('icon_url'), rating: text('rating'), episodeNum: text('episode_num'), + sourceUrl: text('source_url'), }, (table) => ({ channelIdx: index('idx_epg_programs_channel').on(table.channelId), + sourceIdx: index('idx_epg_programs_source').on(table.sourceUrl), startIdx: index('idx_epg_programs_start').on(table.start), stopIdx: index('idx_epg_programs_stop').on(table.stop), timeRangeIdx: index('idx_epg_programs_time_range').on( @@ -214,6 +220,12 @@ export const epgPrograms = sqliteTable( table.start, table.stop ), + sourceTimeRangeIdx: index('idx_epg_programs_source_time_range').on( + table.sourceUrl, + table.channelId, + table.start, + table.stop + ), }) ); @@ -256,9 +268,10 @@ export const playbackPositions = sqliteTable( table.seriesXtreamId ), updatedIdx: index('playback_positions_updated_idx').on(table.updatedAt), - playlistUpdatedIdx: index( - 'playback_positions_playlist_updated_idx' - ).on(table.playlistId, sql`${table.updatedAt} DESC`), + playlistUpdatedIdx: index('playback_positions_playlist_updated_idx').on( + table.playlistId, + sql`${table.updatedAt} DESC` + ), }) ); diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index fb0e6e98b..8a03b68ea 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -206,6 +206,10 @@ export interface ElectronBridgeEpgFreshnessResult { freshUrls: string[]; } +export interface ElectronBridgeEpgLookupOptions { + sourceUrls?: string[]; +} + export interface ElectronBridgeEpgProgressStats { totalChannels: number; totalPrograms: number; @@ -509,12 +513,17 @@ export interface ElectronBridgeApi { urls: string[], options?: ElectronBridgeTrustOptions ) => Promise; - getChannelPrograms: (channelId: string) => Promise; + getChannelPrograms: ( + channelId: string, + options?: ElectronBridgeEpgLookupOptions + ) => Promise; getCurrentProgramsBatch: ( - channelIds: string[] + channelIds: string[], + options?: ElectronBridgeEpgLookupOptions ) => Promise>; getEpgChannelMetadata: ( - channelIds: string[] + channelIds: string[], + options?: ElectronBridgeEpgLookupOptions ) => Promise>; getEpgChannels: () => Promise; getEpgChannelsByRange: ( @@ -526,6 +535,7 @@ export interface ElectronBridgeApi { options?: ElectronBridgeTrustOptions ) => Promise; clearEpgData: () => Promise; + clearEpgDataForSource: (sourceUrl: string) => Promise; checkEpgFreshness: ( urls: string[], maxAgeHours?: number diff --git a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts index 79c623e4a..32f7a3473 100644 --- a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts @@ -1,8 +1,6 @@ export interface ParsedPlaylist { header: { - attrs: { - 'x-tvg-url': string; - }; + attrs: Record; raw: string; }; items: ParsedPlaylistItem[]; diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index 5a0ea96d9..e06cd9185 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -12,6 +12,10 @@ export type PlaylistMeta = Pick< | 'referrer' | 'origin' | 'filePath' + | 'epgUrls' + | 'detectedEpgUrls' + | 'manualEpgUrls' + | 'disabledEpgUrls' | 'updateDate' | 'updateState' | 'position' diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 9c95962bb..5453567e3 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -33,6 +33,14 @@ export interface Playlist { referrer?: string; origin?: string; filePath?: string; + /** M3U-scoped EPG source URLs enabled for automatic import and lookup. */ + epgUrls?: string[]; + /** All M3U EPG source URLs detected from the playlist header. */ + detectedEpgUrls?: string[]; + /** Playlist-local EPG source URLs explicitly enabled by the user. */ + manualEpgUrls?: string[]; + /** Detected playlist EPG source URLs explicitly disabled by the user. */ + disabledEpgUrls?: string[]; autoRefresh: boolean; updateDate?: number; updateState?: PlaylistUpdateState; diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts index 235b279dd..37e296596 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts @@ -1,8 +1,12 @@ import { Channel, Playlist } from '@iptvnator/shared/interfaces'; import { aggregateFavoriteChannels, + createPlaylistObject, + extractM3uEpgUrls, + filterPlaylistEpgUrlsForFetch, getExtensionFromUrl, getStreamExtensionFromUrl, + resolvePlaylistEpgSourceState, } from './playlist.utils'; function createChannel(id: string, url: string, name = id): Channel { @@ -105,4 +109,188 @@ describe('playlist utils', () => { } ); }); + + describe('extractM3uEpgUrls', () => { + it('extracts and deduplicates playlist-scoped EPG URLs from supported M3U header attributes', () => { + expect( + extractM3uEpgUrls({ + header: { + attrs: { + 'x-tvg-url': + ' https://example.com/guide.xml, https://example.com/guide.xml https://example.com/extra.xml.gz ', + 'url-tvg': 'https://example.com/url-tvg.xml', + }, + raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml" url-tvg="https://example.com/url-tvg.xml"', + }, + }) + ).toEqual([ + 'https://example.com/guide.xml', + 'https://example.com/extra.xml.gz', + 'https://example.com/url-tvg.xml', + ]); + }); + + it('falls back to the raw header for tvg-url variants the parser does not expose as attrs', () => { + expect( + extractM3uEpgUrls({ + header: { + attrs: {}, + raw: '#EXTM3U tvg-url="https://example.com/raw-guide.xml"', + }, + }) + ).toEqual(['https://example.com/raw-guide.xml']); + }); + }); + + it('stores detected M3U EPG URLs on the created playlist', () => { + const playlist = createPlaylistObject('Playlist with EPG', { + header: { + attrs: { + 'x-tvg-url': 'https://example.com/guide.xml', + }, + raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"', + }, + items: [], + }); + + expect(playlist.epgUrls).toEqual(['https://example.com/guide.xml']); + expect(playlist.detectedEpgUrls).toEqual([ + 'https://example.com/guide.xml', + ]); + }); + + it('keeps large detected EPG lists separate from the auto-imported playlist EPG subset', () => { + const playlist = createPlaylistObject('Playlist with global EPG list', { + header: { + attrs: { + 'x-tvg-url': [ + 'https://iptv-org.github.io/epg/guides/us/tvguide.com.epg.xml', + 'https://iptv-org.github.io/epg/guides/de/hd-plus.de.epg.xml', + 'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml', + 'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml', + 'https://iptv-org.github.io/epg/guides/fr/programme-tv.net.epg.xml', + 'https://iptv-org.github.io/epg/guides/uk/sky.com.epg.xml', + ].join(','), + }, + raw: '#EXTM3U', + }, + items: [ + { + name: '1+1', + tvg: { + id: '1Plus1.ua', + name: '1+1', + url: '', + logo: '', + rec: '', + }, + group: { title: 'Undefined' }, + http: { referrer: '', 'user-agent': '' }, + raw: '#EXTINF:-1 tvg-id="1Plus1.ua" tvg-country="RU;UA" tvg-language="Ukrainian",1+1', + url: 'https://example.com/stream.m3u8', + }, + ], + }); + + expect(playlist.detectedEpgUrls).toHaveLength(6); + expect(playlist.epgUrls).toEqual([ + 'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml', + 'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml', + ]); + }); + + it('falls back to the first detected EPG URLs when a large list has no region recommendation match', () => { + const epgUrls = [ + 'https://provider.example.com/epg/source-1.xml', + 'https://provider.example.com/epg/source-2.xml', + 'https://provider.example.com/epg/source-3.xml', + 'https://provider.example.com/epg/source-4.xml', + 'https://provider.example.com/epg/source-5.xml', + 'https://provider.example.com/epg/source-6.xml', + ]; + const playlist = createPlaylistObject( + 'Playlist with generic EPG list', + { + header: { + attrs: { + 'x-tvg-url': epgUrls.join(','), + }, + raw: '#EXTM3U', + }, + items: [ + { + name: 'Generic Channel', + tvg: { + id: 'generic-channel', + name: 'Generic Channel', + url: '', + logo: '', + rec: '', + }, + group: { title: 'Undefined' }, + http: { referrer: '', 'user-agent': '' }, + raw: '#EXTINF:-1 tvg-id="generic-channel",Generic Channel', + url: 'https://example.com/stream.m3u8', + }, + ], + } + ); + + expect(playlist.detectedEpgUrls).toEqual(epgUrls); + expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5)); + }); + + it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => { + expect( + resolvePlaylistEpgSourceState({ + detectedEpgUrls: [ + 'https://playlist.example.com/auto.xml', + 'https://playlist.example.com/disabled.xml', + ], + enabledEpgUrls: [ + 'https://playlist.example.com/auto.xml', + 'https://playlist.example.com/disabled.xml', + ], + manualEpgUrls: [ + ' https://playlist.example.com/manual.xml ', + 'https://playlist.example.com/auto.xml', + ], + disabledEpgUrls: [ + 'https://playlist.example.com/disabled.xml', + '', + 'https://playlist.example.com/disabled.xml', + ], + }) + ).toEqual({ + detectedEpgUrls: [ + 'https://playlist.example.com/auto.xml', + 'https://playlist.example.com/disabled.xml', + ], + epgUrls: [ + 'https://playlist.example.com/auto.xml', + 'https://playlist.example.com/manual.xml', + ], + manualEpgUrls: [ + 'https://playlist.example.com/manual.xml', + 'https://playlist.example.com/auto.xml', + ], + disabledEpgUrls: ['https://playlist.example.com/disabled.xml'], + }); + }); + + it('filters playlist EPG fetch URLs that are already configured globally', () => { + expect( + filterPlaylistEpgUrlsForFetch( + [ + 'https://global.example.com/guide.xml', + 'https://playlist.example.com/local.xml', + ' https://playlist.example.com/local.xml ', + ], + [ + ' https://global.example.com/guide.xml ', + 'https://global.example.com/other.xml', + ] + ) + ).toEqual(['https://playlist.example.com/local.xml']); + }); }); diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index ab4a338ca..2aa9e23d8 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -65,6 +65,306 @@ export const getFilenameFromUrl = (value: string): string => { return 'Untitled playlist'; }; +const M3U_EPG_HEADER_ATTRS = ['x-tvg-url', 'url-tvg', 'tvg-url'] as const; +const M3U_EPG_URL_PATTERN = /\b(?:https?|file):\/\/[^\s,"']+/gi; +export const M3U_AUTO_IMPORT_EPG_URL_LIMIT = 5; +export const M3U_RECOMMENDED_EPG_URL_LIMIT = 12; + +export interface M3uEpgUrlSelection { + detectedEpgUrls: string[]; + enabledEpgUrls: string[]; +} + +export interface PlaylistEpgSourceStateInput { + detectedEpgUrls?: string[]; + enabledEpgUrls?: string[]; + manualEpgUrls?: string[]; + disabledEpgUrls?: string[]; +} + +export interface PlaylistEpgSourceState { + detectedEpgUrls: string[]; + epgUrls: string[]; + manualEpgUrls: string[]; + disabledEpgUrls: string[]; +} + +export function normalizeEpgUrls( + urls: readonly string[] | undefined +): string[] { + return Array.from( + new Set( + (urls ?? []) + .map((url) => url.trim()) + .filter((url) => url.length > 0) + ) + ); +} + +export function resolvePlaylistEpgSourceState( + input: PlaylistEpgSourceStateInput +): PlaylistEpgSourceState { + const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls); + const manualEpgUrls = normalizeEpgUrls(input.manualEpgUrls); + const disabledEpgUrls = normalizeEpgUrls(input.disabledEpgUrls); + const disabledSet = new Set(disabledEpgUrls); + const epgUrls = normalizeEpgUrls([ + ...normalizeEpgUrls(input.enabledEpgUrls), + ...manualEpgUrls, + ]).filter((url) => !disabledSet.has(url)); + + return { + detectedEpgUrls, + epgUrls, + manualEpgUrls, + disabledEpgUrls, + }; +} + +export function filterPlaylistEpgUrlsForFetch( + playlistEpgUrls: readonly string[] | undefined, + globalEpgUrls: readonly string[] | undefined +): string[] { + const globalUrlSet = new Set(normalizeEpgUrls(globalEpgUrls)); + return normalizeEpgUrls(playlistEpgUrls).filter( + (url) => !globalUrlSet.has(url) + ); +} + +export function extractM3uEpgUrls( + playlist: Pick | null | undefined +): string[] { + const header = playlist?.header; + if (!header) { + return []; + } + + const candidates: Array = []; + for (const attr of M3U_EPG_HEADER_ATTRS) { + candidates.push(header.attrs?.[attr]); + candidates.push(extractHeaderAttributeFromRaw(header.raw, attr)); + } + + const urls: string[] = []; + for (const candidate of candidates) { + urls.push(...extractUrlsFromHeaderValue(candidate)); + } + + return Array.from(new Set(urls)); +} + +export function resolveM3uEpgUrlSelection( + playlist: Pick | null | undefined +): M3uEpgUrlSelection { + const detectedEpgUrls = extractM3uEpgUrls(playlist); + + if (detectedEpgUrls.length <= M3U_AUTO_IMPORT_EPG_URL_LIMIT) { + return { + detectedEpgUrls, + enabledEpgUrls: detectedEpgUrls, + }; + } + + const recommendedEpgUrls = selectRecommendedEpgUrls( + detectedEpgUrls, + playlist?.items ?? [] + ); + + return { + detectedEpgUrls, + enabledEpgUrls: + recommendedEpgUrls.length > 0 + ? recommendedEpgUrls + : detectedEpgUrls.slice(0, M3U_AUTO_IMPORT_EPG_URL_LIMIT), + }; +} + +function extractHeaderAttributeFromRaw( + raw: string | undefined, + attr: string +): string | undefined { + if (!raw) { + return undefined; + } + + const match = raw.match( + new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s]+))`, 'i') + ); + + return match?.[2] ?? match?.[3] ?? match?.[4]; +} + +function extractUrlsFromHeaderValue(value: string | undefined): string[] { + if (!value) { + return []; + } + + const urls: string[] = []; + const pattern = new RegExp(M3U_EPG_URL_PATTERN); + let match: RegExpExecArray | null; + + while ((match = pattern.exec(value)) !== null) { + const url = match[0].trim(); + if (url.length > 0) { + urls.push(url); + } + } + + return urls; +} + +function selectRecommendedEpgUrls( + urls: string[], + items: ParsedPlaylistItem[] +): string[] { + const hints = collectPlaylistEpgRegionHints(items); + const recommendations: string[] = []; + + for (const url of urls) { + const guideCodes = extractEpgGuideCodes(url); + const matchesCountry = + guideCodes.country !== undefined && + hints.countries.has(guideCodes.country); + const matchesLanguageWithoutCountry = + hints.countries.size === 0 && + guideCodes.language !== undefined && + hints.languages.has(guideCodes.language); + + if (matchesCountry || matchesLanguageWithoutCountry) { + recommendations.push(url); + } + + if (recommendations.length >= M3U_RECOMMENDED_EPG_URL_LIMIT) { + break; + } + } + + return recommendations; +} + +function collectPlaylistEpgRegionHints(items: ParsedPlaylistItem[]): { + countries: Set; + languages: Set; +} { + const countries = new Set(); + const languages = new Set(); + + for (const item of items) { + addDelimitedCodes( + countries, + extractExtinfAttribute(item.raw, 'tvg-country') + ); + addDelimitedCodes(countries, extractCountrySuffix(item.tvg?.id)); + + const languageCode = normalizeLanguageCode( + extractExtinfAttribute(item.raw, 'tvg-language') + ); + if (languageCode) { + languages.add(languageCode); + } + } + + return { countries, languages }; +} + +function extractExtinfAttribute( + raw: string | undefined, + attr: string +): string | undefined { + if (!raw) { + return undefined; + } + + const match = raw.match( + new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s,]+))`, 'i') + ); + + return match?.[2] ?? match?.[3] ?? match?.[4]; +} + +function addDelimitedCodes(target: Set, value: string | undefined) { + if (!value) { + return; + } + + for (const token of value.split(/[;,\s]+/)) { + const code = normalizeRegionCode(token); + if (code) { + target.add(code); + } + } +} + +function normalizeRegionCode(value: string | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + return normalized && /^[a-z]{2}$/.test(normalized) ? normalized : undefined; +} + +function normalizeLanguageCode(value: string | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + const languageByName: Record = { + arabic: 'ar', + english: 'en', + french: 'fr', + german: 'de', + italian: 'it', + polish: 'pl', + portuguese: 'pt', + russian: 'ru', + spanish: 'es', + turkish: 'tr', + ukrainian: 'uk', + }; + + return normalizeRegionCode(normalized) ?? languageByName[normalized]; +} + +function extractCountrySuffix(value: string | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + const match = normalized.match(/\.([a-z]{2})$/); + return match?.[1]; +} + +function extractEpgGuideCodes(url: string): { + country?: string; + language?: string; +} { + let path = ''; + + try { + path = new URL(url).pathname; + } catch { + path = url; + } + + const segments = path + .split('/') + .map((segment) => segment.trim().toLowerCase()) + .filter((segment) => segment.length > 0); + const guidesIndex = segments.indexOf('guides'); + const guideSegment = + guidesIndex >= 0 ? segments[guidesIndex + 1] : undefined; + + if (!guideSegment) { + return {}; + } + + const [country, language] = guideSegment.split('-'); + + return { + country: normalizeRegionCode(country), + language: normalizeRegionCode(language), + }; +} + /** * Creates a playlist object * @param name name of the playlist @@ -78,6 +378,13 @@ export const createPlaylistObject = ( urlOrPath?: string, uploadType?: 'URL' | 'FILE' | 'TEXT' ): Playlist => { + const { detectedEpgUrls, enabledEpgUrls } = + resolveM3uEpgUrlSelection(playlist); + const epgSourceState = resolvePlaylistEpgSourceState({ + detectedEpgUrls, + enabledEpgUrls, + }); + return { _id: uuidv4(), filename: name, @@ -94,6 +401,12 @@ export const createPlaylistObject = ( lastUsage: new Date().toISOString(), favorites: [], autoRefresh: false, + ...(epgSourceState.epgUrls.length > 0 + ? { epgUrls: epgSourceState.epgUrls } + : {}), + ...(epgSourceState.detectedEpgUrls.length > 0 + ? { detectedEpgUrls: epgSourceState.detectedEpgUrls } + : {}), ...(uploadType === 'URL' ? { url: urlOrPath } : {}), ...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}), }; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 93c7e4ec3..8c423a128 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -65,6 +65,11 @@ describe('ChannelListContainerComponent', () => { let fixture: ComponentFixture; let dispatch: jest.Mock; let activePlaylistSignal: ReturnType>; + let epgService: { + epgAvailable$: BehaviorSubject; + getChannelMetadataForChannels: jest.Mock; + getCurrentProgramsForChannels: jest.Mock; + }; let favoriteChannelIds$: BehaviorSubject; let runtimeCapabilities: { supportsEpg: boolean }; let storageGet: jest.Mock; @@ -75,6 +80,15 @@ describe('ChannelListContainerComponent', () => { favoriteChannelIds$ = new BehaviorSubject([]); runtimeCapabilities = { supportsEpg: true }; storageGet = jest.fn().mockReturnValue(of({})); + epgService = { + epgAvailable$: new BehaviorSubject(false), + getChannelMetadataForChannels: jest + .fn() + .mockReturnValue(of(new Map())), + getCurrentProgramsForChannels: jest + .fn() + .mockReturnValue(of(new Map())), + }; activePlaylistSignal = signal({ _id: 'playlist-1', title: 'Playlist One', @@ -110,14 +124,7 @@ describe('ChannelListContainerComponent', () => { providers: [ { provide: EpgService, - useValue: { - getChannelMetadataForChannels: jest - .fn() - .mockReturnValue(of(new Map())), - getCurrentProgramsForChannels: jest - .fn() - .mockReturnValue(of(new Map())), - }, + useValue: epgService, }, { provide: PlaylistsService, @@ -217,6 +224,109 @@ describe('ChannelListContainerComponent', () => { expect(fixture.componentInstance.shouldShowEpg()).toBe(true); }); + it('enables EPG rows and scopes lookups when the active M3U playlist has detected EPG URLs', () => { + runtimeCapabilities.supportsEpg = true; + storageGet.mockReturnValue(of({ epgUrl: [] })); + activePlaylistSignal.set({ + _id: 'playlist-1', + title: 'Playlist One', + count: 1, + importDate: '2026-04-11T00:00:00.000Z', + epgUrls: ['https://playlist.example.com/guide.xml'], + } as PlaylistMeta); + + fixture.detectChanges(); + fixture.componentInstance.channelList = [ + createChannel('guide-news', 'https://example.com/news.m3u8'), + ]; + + expect(fixture.componentInstance.shouldShowEpg()).toBe(true); + expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith( + ['guide-news'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith( + ['guide-news'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + }); + + it('refreshes visible channel EPG when playlist EPG URLs arrive after channels', () => { + runtimeCapabilities.supportsEpg = true; + storageGet.mockReturnValue(of({ epgUrl: [] })); + + fixture.detectChanges(); + fixture.componentInstance.channelList = [ + createChannel('guide-news', 'https://example.com/news.m3u8'), + ]; + expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith( + ['guide-news'], + undefined + ); + epgService.getCurrentProgramsForChannels.mockClear(); + epgService.getChannelMetadataForChannels.mockClear(); + + activePlaylistSignal.set({ + _id: 'playlist-1', + title: 'Playlist One', + count: 1, + importDate: '2026-04-11T00:00:00.000Z', + epgUrls: ['https://playlist.example.com/guide.xml'], + } as PlaylistMeta); + fixture.detectChanges(); + + expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith( + ['guide-news'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith( + ['guide-news'], + { sourceUrls: ['https://playlist.example.com/guide.xml'] } + ); + }); + + it('debounces visible channel EPG row refreshes after EPG imports complete', () => { + jest.useFakeTimers(); + try { + runtimeCapabilities.supportsEpg = true; + activePlaylistSignal.set({ + _id: 'playlist-1', + title: 'Playlist One', + count: 1, + importDate: '2026-04-11T00:00:00.000Z', + epgUrls: ['https://playlist.example.com/guide.xml'], + } as PlaylistMeta); + + fixture.detectChanges(); + fixture.componentInstance.channelList = [ + createChannel('guide-news', 'https://example.com/news.m3u8'), + ]; + epgService.getCurrentProgramsForChannels.mockClear(); + + epgService.epgAvailable$.next(true); + epgService.epgAvailable$.next(true); + jest.advanceTimersByTime(1999); + + expect( + epgService.getCurrentProgramsForChannels + ).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + + expect( + epgService.getCurrentProgramsForChannels + ).toHaveBeenCalledTimes(1); + expect( + epgService.getCurrentProgramsForChannels + ).toHaveBeenCalledWith(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }); + } finally { + fixture.destroy(); + jest.useRealTimers(); + } + }); + it('dispatches playlist meta updates when hidden group titles change', () => { fixture.componentInstance.onHiddenGroupTitlesChanged([ 'Movies', diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 8eb489b43..d36f2aa81 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, Input, input, @@ -10,6 +11,7 @@ import { OnInit, output, signal, + untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -35,10 +37,12 @@ import { import { BehaviorSubject, combineLatest, + debounceTime, filter, forkJoin, firstValueFrom, map, + Subscription, } from 'rxjs'; import { PlaylistsService, @@ -55,6 +59,7 @@ import { Settings, STORE_KEY, } from '@iptvnator/shared/interfaces'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component'; import { FavoritesViewComponent } from './favorites-view/favorites-view.component'; import { GroupsViewComponent } from './groups-view/groups-view.component'; @@ -88,6 +93,8 @@ function mapChannelsByFirstUrl(channels: Channel[]): Map { return channelsByUrl; } +const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000; + @Component({ selector: 'app-channel-list-container', templateUrl: './channel-list-container.component.html', @@ -115,6 +122,10 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly playlistContext = inject(PlaylistContextFacade); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); + /** Route-aware playlist ID for recent-item mutations */ + private readonly resolvedPlaylistId = + this.playlistContext.resolvedPlaylistId; + private readonly activePlaylist = this.playlistContext.activePlaylist; /** Map of channel ID to current EPG program */ readonly channelEpgMap = signal(new Map()); @@ -128,9 +139,40 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Interval for global progress updates */ private progressInterval?: number; + private epgAvailabilitySubscription?: Subscription; /** Whether to show EPG data in channel items */ - readonly shouldShowEpg = signal(false); + private readonly globalEpgUrls = signal([]); + readonly playlistEpgUrls = computed(() => { + const playlist = this.activePlaylist(); + if (!playlist || playlist.serverUrl || playlist.macAddress) { + return []; + } + + return normalizeEpgUrls(playlist.epgUrls ?? []); + }); + readonly shouldShowEpg = computed( + () => + this.runtime.supportsEpg && + (this.globalEpgUrls().length > 0 || + this.playlistEpgUrls().length > 0) + ); + private readonly epgSourceRefreshKey = computed(() => { + if (!this.runtime.supportsEpg) { + return ''; + } + + const globalUrls = this.globalEpgUrls(); + const playlistUrls = this.playlistEpgUrls(); + if (globalUrls.length === 0 && playlistUrls.length === 0) { + return ''; + } + + return JSON.stringify({ + globalUrls: Array.from(new Set(globalUrls)).sort(), + playlistUrls: Array.from(new Set(playlistUrls)).sort(), + }); + }); readonly openStreamOnDoubleClick = computed(() => this.settingsStore.openStreamOnDoubleClick() ); @@ -182,6 +224,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { _channelList: Channel[] = []; private readonly channelListSignal = signal([]); private channelList$ = new BehaviorSubject([]); + private lastEpgSourceRefreshKey = ''; + private readonly epgSourceRefreshEffect = effect(() => { + const refreshKey = this.epgSourceRefreshKey(); + if (refreshKey === this.lastEpgSourceRefreshKey) { + return; + } + + this.lastEpgSourceRefreshKey = refreshKey; + if (!refreshKey || this._channelList.length === 0) { + return; + } + + untracked(() => { + this.fetchEpgForChannels(this._channelList); + }); + }); get channelList(): Channel[] { return this._channelList; @@ -196,11 +254,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { this.fetchEpgForChannels(safeValue); } - /** Route-aware playlist ID for recent-item mutations */ - private readonly resolvedPlaylistId = - this.playlistContext.resolvedPlaylistId; - private readonly activePlaylist = this.playlistContext.activePlaylist; - readonly hiddenGroupTitles = computed(() => { const playlist = this.activePlaylist(); @@ -301,13 +354,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { Object.keys(settings as Settings).length > 0 ) { const epgUrl = (settings as Settings).epgUrl; - this.shouldShowEpg.set(!!(epgUrl && epgUrl.length > 0)); + this.globalEpgUrls.set(normalizeEpgUrls(epgUrl)); } }); } else { - this.shouldShowEpg.set(false); + this.globalEpgUrls.set([]); } + this.epgAvailabilitySubscription = this.epgService.epgAvailable$ + .pipe( + filter((available) => available), + debounceTime(EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS) + ) + .subscribe(() => { + this.fetchEpgForChannels(this._channelList); + }); + // Set up EPG refresh interval (every 60 seconds) this.epgRefreshInterval = window.setInterval(() => { this.fetchEpgForChannels(this._channelList); @@ -330,6 +392,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { clearInterval(this.progressInterval); } + this.epgAvailabilitySubscription?.unsubscribe(); this.channelList$.complete(); } @@ -351,10 +414,17 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ) ); + const epgLookupOptions = this.getPlaylistEpgLookupOptions(); + forkJoin({ - epgMap: this.epgService.getCurrentProgramsForChannels(channelIds), - metadataMap: - this.epgService.getChannelMetadataForChannels(channelIds), + epgMap: this.epgService.getCurrentProgramsForChannels( + channelIds, + epgLookupOptions + ), + metadataMap: this.epgService.getChannelMetadataForChannels( + channelIds, + epgLookupOptions + ), }).subscribe(({ epgMap, metadataMap }) => { this.channelEpgMap.set(epgMap); this.channelIconMap.set( @@ -371,6 +441,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { }); } + private getPlaylistEpgLookupOptions(): + | { sourceUrls: string[] } + | undefined { + const sourceUrls = this.playlistEpgUrls(); + return sourceUrls.length > 0 ? { sourceUrls } : undefined; + } + /** * Handles channel selection from any tab */