diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 43c7a2de7..f232d88d4 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -526,6 +526,16 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }), searchEpgPrograms: (searchTerm: string, limit?: number) => ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit), + getEpgMapping: (channelKey: string) => + ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }), + getEpgMappingsBatch: (channelKeys: string[]) => + ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }), + setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) => + ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }), + deleteEpgMapping: (channelKey: string) => + ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }), + searchEpgChannels: (searchTerm: string, limit?: number) => + ipcRenderer.invoke('EPG_CHANNEL_SEARCH', { searchTerm, limit }), setMpvPlayerPath: (mpvPlayerPath: string) => ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath), setVlcPlayerPath: (vlcPlayerPath: string) => diff --git a/apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts new file mode 100644 index 000000000..337aa8b85 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts @@ -0,0 +1,223 @@ +const eqMock = jest.fn((left: unknown, right: unknown) => ({ + kind: 'eq', + left, + right, +})); +const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({ + kind: 'inArray', + left, + values, +})); +const orMock = jest.fn((...conditions: unknown[]) => ({ + kind: 'or', + conditions, +})); +const sqlMock = jest.fn( + (strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings), + values, + }) +); + +jest.mock('drizzle-orm', () => ({ + and: jest.fn(), + eq: (left: unknown, right: unknown) => eqMock(left, right), + inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values), + or: (...conditions: unknown[]) => orMock(...conditions), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => + sqlMock(strings, ...values), +})); + +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { + deleteEpgMapping, + getEpgMapping, + getEpgMappingsBatch, + searchEpgChannels, + setEpgMapping, +} from './epg-mapping.operations'; + +function createSelectChain(result: unknown[]) { + const chain: Record = {}; + chain['from'] = jest.fn().mockReturnValue(chain); + chain['where'] = jest.fn().mockReturnValue(chain); + chain['orderBy'] = jest.fn().mockReturnValue(chain); + chain['limit'] = jest.fn().mockResolvedValue(result); + chain['then'] = ( + resolve: (value: unknown) => unknown, + reject: (reason: unknown) => unknown + ) => Promise.resolve(result).then(resolve, reject); + return chain; +} + +function createDbMock(selectResult: unknown[] = []) { + const chain = createSelectChain(selectResult); + const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); + const values = jest.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = jest.fn().mockReturnValue({ values }); + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere }); + const select = jest.fn().mockReturnValue(chain); + + return { + db: { + select, + insert, + delete: deleteFn, + } as unknown as AppDatabase, + chain, + insert, + values, + onConflictDoUpdate, + deleteFn, + deleteWhere, + select, + }; +} + +describe('epg-mapping.operations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getEpgMapping', () => { + it('returns the mapping row when present', async () => { + const row = { + id: 1, + channelKey: 'xtream:pl-1:42', + epgChannelId: 'bbc.one.uk', + playlistId: 'pl-1', + }; + const { db } = createDbMock([row]); + + await expect(getEpgMapping(db, 'xtream:pl-1:42')).resolves.toEqual( + row + ); + expect(eqMock).toHaveBeenCalledWith( + schema.epgChannelMappings.channelKey, + 'xtream:pl-1:42' + ); + }); + + it('returns null when no mapping exists', async () => { + const { db } = createDbMock([]); + + await expect(getEpgMapping(db, 'missing')).resolves.toBeNull(); + }); + }); + + describe('getEpgMappingsBatch', () => { + it('returns an empty map without querying for an empty input', async () => { + const { db, select } = createDbMock(); + + const result = await getEpgMappingsBatch(db, []); + + expect(result.size).toBe(0); + expect(select).not.toHaveBeenCalled(); + }); + + it('maps channel keys to their EPG channel ids', async () => { + const { db } = createDbMock([ + { channelKey: 'a', epgChannelId: 'epg-a' }, + { channelKey: 'b', epgChannelId: 'epg-b' }, + ]); + + const result = await getEpgMappingsBatch(db, ['a', 'b', 'c']); + + expect(result.get('a')).toBe('epg-a'); + expect(result.get('b')).toBe('epg-b'); + expect(result.has('c')).toBe(false); + expect(inArrayMock).toHaveBeenCalledWith( + schema.epgChannelMappings.channelKey, + ['a', 'b', 'c'] + ); + }); + }); + + describe('setEpgMapping', () => { + it('upserts on the channel key', async () => { + const { db, insert, values, onConflictDoUpdate } = createDbMock(); + + await expect( + setEpgMapping(db, 'xtream:pl-1:42', 'bbc.one.uk', 'pl-1') + ).resolves.toEqual({ success: true }); + + expect(insert).toHaveBeenCalledWith(schema.epgChannelMappings); + expect(values).toHaveBeenCalledWith({ + channelKey: 'xtream:pl-1:42', + epgChannelId: 'bbc.one.uk', + playlistId: 'pl-1', + }); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: schema.epgChannelMappings.channelKey, + }) + ); + }); + + it('stores null when no playlist id is provided', async () => { + const { db, values } = createDbMock(); + + await setEpgMapping(db, 'bbc.one', 'bbc.one.uk'); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ playlistId: null }) + ); + }); + }); + + describe('deleteEpgMapping', () => { + it('deletes by channel key', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect(deleteEpgMapping(db, 'bbc.one')).resolves.toEqual({ + success: true, + }); + + expect(deleteFn).toHaveBeenCalledWith(schema.epgChannelMappings); + expect(deleteWhere).toHaveBeenCalled(); + expect(eqMock).toHaveBeenCalledWith( + schema.epgChannelMappings.channelKey, + 'bbc.one' + ); + }); + }); + + describe('searchEpgChannels', () => { + it('escapes LIKE wildcards and searches name and id with an ESCAPE clause', async () => { + const { db } = createDbMock([]); + + await searchEpgChannels(db, 'HBO%_'); + + // sql`${column} LIKE ${pattern} ESCAPE '\\'` — the pattern is the + // second interpolated value. + const patterns = sqlMock.mock.calls.map((call) => call[2]); + expect(patterns).toContain('%HBO\\%\\_%'); + const templates = sqlMock.mock.calls.map((call) => + Array.from(call[0] as TemplateStringsArray).join('?') + ); + expect( + templates.every((template) => template.includes("ESCAPE '\\'")) + ).toBe(true); + }); + + it('escapes backslashes so a trailing "\\" cannot corrupt the pattern', async () => { + const { db } = createDbMock([]); + + await searchEpgChannels(db, 'C\\'); + + const patterns = sqlMock.mock.calls.map((call) => call[2]); + expect(patterns).toContain('%C\\\\%'); + }); + + it('applies the result limit', async () => { + const { db, chain } = createDbMock([]); + + await searchEpgChannels(db, 'news', 5); + + expect(chain['limit']).toHaveBeenCalledWith(5); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts new file mode 100644 index 000000000..1f19e7934 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts @@ -0,0 +1,125 @@ +import { and, eq, inArray, or, sql } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; + +/** + * Get a single EPG channel mapping by lookup key. + */ +export async function getEpgMapping( + db: AppDatabase, + channelKey: string +): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> { + const rows = await db + .select({ + id: schema.epgChannelMappings.id, + channelKey: schema.epgChannelMappings.channelKey, + epgChannelId: schema.epgChannelMappings.epgChannelId, + playlistId: schema.epgChannelMappings.playlistId, + }) + .from(schema.epgChannelMappings) + .where(eq(schema.epgChannelMappings.channelKey, channelKey)) + .limit(1); + + return rows[0] ?? null; +} + +/** + * Batch lookup of EPG channel mappings for multiple keys. + * Returns a Map of channelKey → epgChannelId (only for keys that have mappings). + */ +export async function getEpgMappingsBatch( + db: AppDatabase, + channelKeys: string[] +): Promise> { + if (channelKeys.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + channelKey: schema.epgChannelMappings.channelKey, + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.epgChannelMappings) + .where(inArray(schema.epgChannelMappings.channelKey, channelKeys)); + + const mapping = new Map(); + for (const row of rows) { + mapping.set(row.channelKey, row.epgChannelId); + } + return mapping; +} + +/** + * Create or update an EPG channel mapping (upsert). + */ +export async function setEpgMapping( + db: AppDatabase, + channelKey: string, + epgChannelId: string, + playlistId?: string +): Promise<{ success: boolean }> { + await db + .insert(schema.epgChannelMappings) + .values({ + channelKey, + epgChannelId, + playlistId: playlistId ?? null, + }) + .onConflictDoUpdate({ + target: schema.epgChannelMappings.channelKey, + set: { + epgChannelId, + playlistId: playlistId ?? null, + updatedAt: sql`(datetime('now'))`, + }, + }); + + return { success: true }; +} + +/** + * Remove an EPG channel mapping. + */ +export async function deleteEpgMapping( + db: AppDatabase, + channelKey: string +): Promise<{ success: boolean }> { + await db + .delete(schema.epgChannelMappings) + .where(eq(schema.epgChannelMappings.channelKey, channelKey)); + + return { success: true }; +} + +/** + * Search EPG channels by display name for the mapping dialog. + */ +export async function searchEpgChannels( + db: AppDatabase, + searchTerm: string, + limit = 50 +): Promise> { + // Escape the escape character itself and the LIKE wildcards so user + // input like "HBO%", "_BC" or a trailing "\" is matched literally — + // an unescaped backslash would pair with the following character (or + // the closing "%") under the ESCAPE clause and corrupt the pattern. + const escaped = searchTerm.trim().replace(/[\\%_]/g, '\\$&'); + const pattern = `%${escaped}%`; + + return db + .select({ + id: schema.epgChannels.id, + displayName: schema.epgChannels.displayName, + iconUrl: schema.epgChannels.iconUrl, + }) + .from(schema.epgChannels) + .where( + or( + sql`${schema.epgChannels.displayName} LIKE ${pattern} ESCAPE '\\'`, + sql`${schema.epgChannels.id} LIKE ${pattern} ESCAPE '\\'` + ) + ) + .orderBy(schema.epgChannels.displayName) + .limit(limit); +} diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index 84dfb64ae..5cbc118ec 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -11,6 +11,7 @@ export { recentlyViewed, favorites, epgChannels, + epgChannelMappings, epgPrograms, playbackPositions, downloads, 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 63db5b36e..75c8a23c4 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -1,5 +1,9 @@ import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + buildXtreamEpgMappingKey, + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; @@ -36,13 +40,21 @@ export class EpgQueryService { ): Promise { try { const db = await getDatabase(); - const trimmedChannelId = channelId.trim(); + let trimmedChannelId = channelId.trim(); const sourceUrls = this.normalizeSourceUrls(options.sourceUrls); if (!trimmedChannelId) { return []; } + // Check for manual EPG mapping — if a user has mapped this + // channel key to a different EPG channel ID, use the mapped + // ID for all subsequent lookups. + const mapped = await this.getMapping(db, trimmedChannelId); + if (mapped) { + trimmedChannelId = mapped; + } + let results = await this.selectChannelPrograms( db, trimmedChannelId, @@ -905,6 +917,69 @@ export class EpgQueryService { !Number.isNaN(new Date(program.stop).getTime()) ); } + + private async getMapping( + db: EpgDatabase, + channelKey: string + ): Promise { + try { + // Direct lookup by channel key. + const rows = await db + .select({ + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.epgChannelMappings) + .where( + eq(schema.epgChannelMappings.channelKey, channelKey) + ) + .limit(1); + if (rows.length > 0) { + return rows[0].epgChannelId; + } + + // Fallback: the key may be an Xtream provider epg_channel_id + // while the mapping was saved under the playlist-scoped Xtream + // key. Resolve the owning streams (joined through categories for + // the playlist ID) and check their mapping keys. The same + // epg_channel_id can appear in several playlists, so check a few. + const contentRows = await db + .select({ + xtreamId: schema.content.xtreamId, + playlistId: schema.categories.playlistId, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where(eq(schema.content.epgChannelId, channelKey)) + .limit(5); + if (contentRows.length > 0) { + const candidateKeys = contentRows.map((row) => + buildXtreamEpgMappingKey(row.playlistId, row.xtreamId) + ); + const mapped = await db + .select({ + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.epgChannelMappings) + .where( + inArray( + schema.epgChannelMappings.channelKey, + candidateKeys + ) + ) + .limit(1); + if (mapped.length > 0) { + return mapped[0].epgChannelId; + } + } + + return null; + } catch { + return null; + } + } } export const epgQueryService = new EpgQueryService(); 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 efbc14696..6f6799a9a 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -371,46 +371,46 @@ describe('EpgEvents', () => { it('falls back to case-insensitive channel id lookup for EPG programs', async () => { const select = jest.fn(); - const programLimitExact = jest.fn().mockResolvedValue([]); - const channelLimit = jest - .fn() - .mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]); - const programLimitResolved = jest.fn().mockResolvedValue([ - { - id: 1, - channelId: 'BBC.ONE.UK', - start: '2026-04-14T10:00:00Z', - stop: '2026-04-14T11:00:00Z', - title: 'News', - description: null, - category: null, - iconUrl: null, - rating: null, - episodeNum: null, - }, - ]); + /** + * Build a flexible query-chain mock that every db.select().from().() call + * resolves to the same chain object. Each method (where, orderBy, limit) returns + * the chain itself, and limit() additionally resolves to the given data. This + * handles any query shape our service uses (with or without orderBy) without + * requiring exact call-count ordering — important because our mapping feature + * inserts extra queries that the original test didn't anticipate. + */ + function queryChain(data: T) { + const chain: Record = {} as Record; + chain.where = jest.fn().mockReturnValue(chain); + chain.innerJoin = jest.fn().mockReturnValue(chain); + chain.orderBy = jest.fn().mockReturnValue(chain); + chain.limit = jest.fn().mockResolvedValue(data); + return chain; + } + + // getMapping queries (must come first — they return empty) const from = jest .fn() - .mockReturnValueOnce({ - where: jest.fn().mockReturnValue({ - orderBy: jest.fn().mockReturnValue({ - limit: programLimitExact, - }), - }), - }) - .mockReturnValueOnce({ - where: jest.fn().mockReturnValue({ - limit: channelLimit, - }), - }) - .mockReturnValueOnce({ - where: jest.fn().mockReturnValue({ - orderBy: jest.fn().mockReturnValue({ - limit: programLimitResolved, - }), - }), - }); + .mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings + .mockReturnValueOnce(queryChain([])) // 2. getMapping: content table + // Test expectations below + .mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty) + .mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found + .mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs) + { + id: 1, + channelId: 'BBC.ONE.UK', + start: '2026-04-14T10:00:00Z', + stop: '2026-04-14T11:00:00Z', + title: 'News', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }, + ])); select.mockImplementation(() => ({ from })); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 7a371ca72..795f93a75 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -9,6 +9,13 @@ import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; import { epgWorkerService } from './epg-worker.service'; +import { + getEpgMapping, + getEpgMappingsBatch, + setEpgMapping, + deleteEpgMapping, + searchEpgChannels, +} from '../database/operations/epg-mapping.operations'; /** * EPG Events Handler @@ -129,6 +136,59 @@ export default class EpgEvents { } ); + // EPG channel mapping CRUD + ipcMain.handle( + 'EPG_MAPPING_GET', + async (_event, args: { channelKey: string }) => { + return this.handleGetEpgMapping(args.channelKey); + } + ); + + ipcMain.handle( + 'EPG_MAPPING_SET', + async ( + _event, + args: { + channelKey: string; + epgChannelId: string; + playlistId?: string; + } + ) => { + return this.handleSetEpgMapping( + args.channelKey, + args.epgChannelId, + args.playlistId + ); + } + ); + + ipcMain.handle( + 'EPG_MAPPING_GET_BATCH', + async (_event, args: { channelKeys: string[] }) => { + return this.handleGetEpgMappingsBatch(args.channelKeys); + } + ); + + ipcMain.handle( + 'EPG_MAPPING_DELETE', + async (_event, args: { channelKey: string }) => { + return this.handleDeleteEpgMapping(args.channelKey); + } + ); + + ipcMain.handle( + 'EPG_CHANNEL_SEARCH', + async ( + _event, + args: { searchTerm: string; limit?: number } + ) => { + return this.handleSearchEpgChannels( + args.searchTerm, + args.limit + ); + } + ); + return ipcMain; } @@ -294,7 +354,21 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - return epgQueryService.getCurrentProgramsBatch(channelIds, options); + const resolvedMap = await this.resolveChannelIds(channelIds); + const resolvedIds = channelIds.map( + (id) => resolvedMap.get(id) ?? id + ); + const results = await epgQueryService.getCurrentProgramsBatch( + resolvedIds, + options + ); + // Remap results back to the original request keys. + const remapped: Record = {}; + for (const originalId of channelIds) { + const resolvedId = resolvedMap.get(originalId) ?? originalId; + remapped[originalId] = results[resolvedId] ?? null; + } + return remapped; } private static async handleGetAllChannels(): Promise<{ @@ -308,7 +382,21 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - return epgQueryService.getChannelMetadata(channelIds, options); + const resolvedMap = await this.resolveChannelIds(channelIds); + const resolvedIds = channelIds.map( + (id) => resolvedMap.get(id) ?? id + ); + const results = await epgQueryService.getChannelMetadata( + resolvedIds, + options + ); + // Remap results back to the original request keys. + const remapped: Record = {}; + for (const originalId of channelIds) { + const resolvedId = resolvedMap.get(originalId) ?? originalId; + remapped[originalId] = results[resolvedId] ?? null; + } + return remapped; } private static async handleGetChannelsByRange( @@ -325,6 +413,97 @@ export default class EpgEvents { return epgQueryService.getChannelsByRange(skip, limit); } + // --------------------------------------------------------------------------- + // EPG mapping resolution — called at the IPC boundary before queries. + // --------------------------------------------------------------------------- + + /** + * Batch-resolve multiple channel IDs through manual mappings. + * Returns a Map of original ID → mapped ID (identity when no mapping). + */ + private static async resolveChannelIds( + channelIds: string[] + ): Promise> { + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelIds); + return mappings; + } catch { + return new Map(); + } + } + + // --------------------------------------------------------------------------- + // EPG mapping CRUD handlers + // --------------------------------------------------------------------------- + + private static async handleGetEpgMapping( + channelKey: string + ): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> { + try { + const db = await getDatabase(); + return getEpgMapping(db, channelKey); + } catch { + return null; + } + } + + private static async handleGetEpgMappingsBatch( + channelKeys: string[] + ): Promise> { + if (!Array.isArray(channelKeys) || channelKeys.length === 0) { + return {}; + } + + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelKeys); + return Object.fromEntries(mappings); + } catch { + return {}; + } + } + + private static async handleSetEpgMapping( + channelKey: string, + epgChannelId: string, + playlistId?: string + ): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return setEpgMapping(db, channelKey, epgChannelId, playlistId); + } catch { + return { success: false }; + } + } + + private static async handleDeleteEpgMapping( + channelKey: string + ): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return deleteEpgMapping(db, channelKey); + } catch { + return { success: false }; + } + } + + private static async handleSearchEpgChannels( + searchTerm: string, + limit?: number + ): Promise> { + if (!searchTerm?.trim()) { + return []; + } + + try { + const db = await getDatabase(); + return searchEpgChannels(db, searchTerm, limit); + } catch { + return []; + } + } + static async clearEpgData(): Promise { return epgWorkerService.clearEpgData(); } 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 2ec863a8b..ed7c5e1a2 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -21,12 +21,30 @@ function normalizeSql(sql: unknown): string { return String(sql).replace(/\s+/g, ' ').trim(); } -function createEpgDatabaseMock() { +function createEpgDatabaseMock( + options: { + /** Result of the sqlite_master index-existence probe. */ + dedupIndexExists?: boolean; + /** Make the CREATE UNIQUE INDEX statement throw on run(). */ + failIndexCreation?: boolean; + } = {} +) { const statements = new Map(); const prepare = jest.fn((statement: string) => { - const run = jest.fn(); - statements.set(normalizeSql(statement), run); - return { run }; + const normalized = normalizeSql(statement); + const run = jest.fn(() => { + if ( + options.failIndexCreation && + normalized.startsWith('CREATE UNIQUE INDEX') + ) { + throw new Error('UNIQUE constraint failed'); + } + }); + const get = jest.fn(() => + options.dedupIndexExists ? { 1: 1 } : undefined + ); + statements.set(normalized, run); + return { run, get }; }); const transaction = jest.fn((callback: (rows: unknown[]) => void) => { return (rows: unknown[]) => callback(rows); @@ -110,6 +128,78 @@ describe('EpgDatabase', () => { 'source_url = excluded.source_url' ); }); + + it('removes pre-existing duplicate programs before creating the dedup index', () => { + const { Database, database, statements } = createEpgDatabaseMock(); + + new EpgDatabase(Database); + + const preparedSql = database.prepare.mock.calls.map(([sql]) => + normalizeSql(sql) + ); + const dedupDeleteSql = preparedSql.find((sql) => + sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN') + ); + const createIndexSql = preparedSql.find((sql) => + sql.startsWith('CREATE UNIQUE INDEX idx_epg_programs_dedup') + ); + + expect(dedupDeleteSql).toBeDefined(); + expect(createIndexSql).toBeDefined(); + expect(statements.get(dedupDeleteSql!)).toHaveBeenCalled(); + expect(statements.get(createIndexSql!)).toHaveBeenCalled(); + + const insertProgramSql = preparedSql.find((sql) => + sql.startsWith('INSERT INTO epg_programs') + ); + expect(insertProgramSql).toContain( + 'ON CONFLICT(channel_id, start, title) DO UPDATE SET' + ); + }); + + it('skips the dedup pass when the index already exists', () => { + const { Database, database } = createEpgDatabaseMock({ + dedupIndexExists: true, + }); + + new EpgDatabase(Database); + + const preparedSql = database.prepare.mock.calls.map(([sql]) => + normalizeSql(sql) + ); + expect( + preparedSql.some((sql) => + sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN') + ) + ).toBe(false); + expect( + preparedSql.some((sql) => sql.startsWith('CREATE UNIQUE INDEX')) + ).toBe(false); + + const insertProgramSql = preparedSql.find((sql) => + sql.startsWith('INSERT INTO epg_programs') + ); + expect(insertProgramSql).toContain( + 'ON CONFLICT(channel_id, start, title) DO UPDATE SET' + ); + }); + + it('falls back to plain inserts when index creation fails', () => { + const { Database, database } = createEpgDatabaseMock({ + failIndexCreation: true, + }); + + expect(() => new EpgDatabase(Database)).not.toThrow(); + + const preparedSql = database.prepare.mock.calls.map(([sql]) => + normalizeSql(sql) + ); + const insertProgramSql = preparedSql.find((sql) => + sql.startsWith('INSERT INTO epg_programs') + ); + expect(insertProgramSql).toBeDefined(); + expect(insertProgramSql).not.toContain('ON CONFLICT'); + }); }); describe('EpgDatabaseClearOperation', () => { diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index e58d3db98..669faaddb 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -30,10 +30,30 @@ export class EpgDatabase { 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, source_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + // Guard against duplicate entries when the clearFirst logic misses old + // rows (e.g. because the source URL changed between imports). The same + // channel + start + title is treated as the same programme — a later + // import with a corrected stop time simply updates the earlier row. + // The upsert (instead of INSERT OR REPLACE) keeps the epg_programs_fts + // triggers consistent: REPLACE deletes rows without firing the delete + // trigger unless recursive_triggers is enabled, leaving ghost FTS rows. + const dedupIndexReady = this.ensureProgramDedupIndex(); + + this.insertProgramStmt = this.db.prepare( + dedupIndexReady + ? `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(channel_id, start, title) DO UPDATE SET + stop = excluded.stop, + description = excluded.description, + category = excluded.category, + icon_url = excluded.icon_url, + rating = excluded.rating, + episode_num = excluded.episode_num, + source_url = excluded.source_url` + : `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); this.deleteOrphanChannelsForSourceStmt = this.db.prepare(` DELETE FROM epg_channels @@ -134,6 +154,46 @@ export class EpgDatabase { close(): void { this.db.close(); } + + /** + * Create the unique (channel_id, start, title) dedup index. Databases + * that predate the index may already contain duplicate rows — exactly + * the situation the index is meant to prevent — so those are removed + * first; a plain `CREATE UNIQUE INDEX` would otherwise throw in this + * constructor and permanently break EPG imports for upgrading users. + * Returns false when the index could not be created, in which case the + * insert statement falls back to the previous plain-INSERT behaviour. + */ + private ensureProgramDedupIndex(): boolean { + try { + const exists = this.db + .prepare( + `SELECT 1 FROM sqlite_master + WHERE type = 'index' AND name = 'idx_epg_programs_dedup'` + ) + .get(); + if (!exists) { + this.db + .prepare( + `DELETE FROM epg_programs + WHERE id NOT IN ( + SELECT MIN(id) FROM epg_programs + GROUP BY channel_id, start, title + )` + ) + .run(); + this.db + .prepare( + `CREATE UNIQUE INDEX idx_epg_programs_dedup + ON epg_programs(channel_id, start, title)` + ) + .run(); + } + return true; + } catch { + return false; + } + } } export class EpgDatabaseClearOperation { diff --git a/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts b/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts index a36a3320f..bbd929208 100644 --- a/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts @@ -8,9 +8,40 @@ import { describe('parseXmltvDate', () => { it('normalizes XMLTV timestamps with timezone offsets', () => { expect(parseXmltvDate('20260415053700 +0000')).toBe( - '2026-04-15T05:37:00+00:00' + '2026-04-15T05:37:00.000Z' ); }); + + it('converts positive offsets to UTC', () => { + expect(parseXmltvDate('20260415053700 +0200')).toBe( + '2026-04-15T03:37:00.000Z' + ); + }); + + it('converts negative offsets to UTC', () => { + expect(parseXmltvDate('20260415053700 -0530')).toBe( + '2026-04-15T11:07:00.000Z' + ); + }); + + it('keeps the minutes component of zero-hour offsets', () => { + expect(parseXmltvDate('20260415053700 +0030')).toBe( + '2026-04-15T05:07:00.000Z' + ); + expect(parseXmltvDate('20260415053700 -0030')).toBe( + '2026-04-15T06:07:00.000Z' + ); + }); + + it('handles half-hour and 45-minute offsets', () => { + expect(parseXmltvDate('20260415053700 +0545')).toBe( + '2026-04-14T23:52:00.000Z' + ); + }); + + it('treats offset-less timestamps as UTC', () => { + expect(parseXmltvDate('20260415053700')).toBe('2026-04-15T05:37:00Z'); + }); }); describe('StreamingEpgParser', () => { diff --git a/apps/electron-backend/src/app/workers/epg-streaming-parser.ts b/apps/electron-backend/src/app/workers/epg-streaming-parser.ts index 7c82e7d00..c660870a0 100644 --- a/apps/electron-backend/src/app/workers/epg-streaming-parser.ts +++ b/apps/electron-backend/src/app/workers/epg-streaming-parser.ts @@ -56,15 +56,31 @@ export function parseXmltvDate(dateStr: string): string { const [, year, month, day, hour, minute, second, tz] = match; - let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - if (tz) { - isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`; - } else { - isoString += 'Z'; + // Normalise to UTC so all stored timestamps have a consistent + // representation. Without this, lexicographic comparisons in the + // SQL queries (e.g. lte(start, now)) produce incorrect results + // when offset-containing strings like "+01:00" are compared against + // the always-UTC `now` string. + // The sign is parsed from the string rather than derived from the + // hours value: Math.sign(0) would silently drop the minutes of + // offsets like "+0030". + const offsetSign = tz.startsWith('-') ? -1 : 1; + const offsetTotalMinutes = + offsetSign * + (Number(tz.slice(1, 3)) * 60 + Number(tz.slice(3))); + const utcMs = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute) - offsetTotalMinutes, + Number(second) + ); + return new Date(utcMs).toISOString(); } - return isoString; + return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`; } /** diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 694708e82..fb6f1f064 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "إزالة من المفضلة", "ADD_FAVORITE": "إضافة إلى المفضلة", "SHOW_DETAILS": "عرض تفاصيل القناة", + "MAP_EPG": "ربط قناة دليل البرامج", "FAVORITES_UPDATED": "تم تحديث المفضلة!", "SELECT_CHANNEL_PLAYBACK": "يرجى اختيار قناة لبدء التشغيل", "SORT_BY": "ترتيب حسب", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "عرض ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "ربط قناة دليل البرامج (EPG)", + "SUBTITLE": "اختر يدويًا قناة دليل البرامج لـ \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "البحث في قنوات دليل البرامج...", + "CURRENT_MAPPING": "الربط الحالي", + "NO_MAPPING": "لم يتم تعيين ربط لدليل البرامج", + "SAVE": "حفظ الربط", + "REMOVE": "إزالة الربط", + "CLOSE": "إغلاق", + "NO_RESULTS": "لم يتم العثور على قنوات دليل البرامج", + "SEARCH_HINT": "اكتب {{min}} أحرف على الأقل للبحث", + "SAVED": "تم حفظ ربط دليل البرامج", + "REMOVED": "تمت إزالة ربط دليل البرامج", + "SAVE_FAILED": "تعذر تحديث ربط دليل البرامج" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index aa115e6ed..3384a01bb 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "حيد من المفضلة", "ADD_FAVORITE": "زيد للمفضلة", "SHOW_DETAILS": "وري تفاصيل القناة", + "MAP_EPG": "ربط قناة ديال EPG", "FAVORITES_UPDATED": "المفضلة تحدثات!", "SELECT_CHANNEL_PLAYBACK": "عفاك اختار قناة باش يبدا التشغيل", "SORT_BY": "رتب بـ", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "وري ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "ربط قناة ديال EPG", + "SUBTITLE": "اختار يدويا قناة EPG لـ \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "قلب على قنوات EPG...", + "CURRENT_MAPPING": "الربط الحالي", + "NO_MAPPING": "ما كاين حتى ربط ديال EPG", + "SAVE": "سجل الربط", + "REMOVE": "حيد الربط", + "CLOSE": "سد", + "NO_RESULTS": "ما لقينا حتى قناة EPG", + "SEARCH_HINT": "كتب على الأقل {{min}} حروف باش تقلب", + "SAVED": "تسجل الربط ديال EPG", + "REMOVED": "تحيد الربط ديال EPG", + "SAVE_FAILED": "ما قدرناش نبدلو الربط ديال EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4c086d4f8..df3746201 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Выдаліць са спісу фаварытаў", "ADD_FAVORITE": "Дадаць у выбранае", "SHOW_DETAILS": "Паказаць інфармацыю аб канале", + "MAP_EPG": "Прывязаць канал EPG", "FAVORITES_UPDATED": "Спіс фаварытаў быў абноўлены!", "SELECT_CHANNEL_PLAYBACK": "Калі ласка, выберыце канал, каб пачаць прайграванне", "SORT_BY": "Сартаваць па", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "паказаць ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Прывязка канала EPG", + "SUBTITLE": "Выберыце канал EPG для \"{{channelName}}\" уручную", + "SEARCH_PLACEHOLDER": "Пошук каналаў EPG...", + "CURRENT_MAPPING": "Бягучая прывязка", + "NO_MAPPING": "Прывязка EPG не зададзена", + "SAVE": "Захаваць прывязку", + "REMOVE": "Выдаліць прывязку", + "CLOSE": "Закрыць", + "NO_RESULTS": "Каналы EPG не знойдзены", + "SEARCH_HINT": "Увядзіце не менш за {{min}} сімвалы для пошуку", + "SAVED": "Прывязка EPG захавана", + "REMOVED": "Прывязка EPG выдалена", + "SAVE_FAILED": "Не ўдалося абнавіць прывязку EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index cfc44846a..5d324b6ac 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Aus Favoritenliste entfernen", "ADD_FAVORITE": "Zu Favoriten hinzufügen", "SHOW_DETAILS": "Senderdetails anzeigen", + "MAP_EPG": "EPG-Kanal zuordnen", "FAVORITES_UPDATED": "Favoriten wurden aktualisiert!", "SELECT_CHANNEL_PLAYBACK": "Bitte wählen Sie einen Sender aus, um die Wiedergabe zu starten", "SORT_BY": "Sortieren nach", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "anzeigen ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG-Kanal zuordnen", + "SUBTITLE": "EPG-Kanal für \"{{channelName}}\" manuell auswählen", + "SEARCH_PLACEHOLDER": "EPG-Kanäle durchsuchen...", + "CURRENT_MAPPING": "Aktuelle Zuordnung", + "NO_MAPPING": "Keine EPG-Zuordnung festgelegt", + "SAVE": "Zuordnung speichern", + "REMOVE": "Zuordnung entfernen", + "CLOSE": "Schließen", + "NO_RESULTS": "Keine EPG-Kanäle gefunden", + "SEARCH_HINT": "Mindestens {{min}} Zeichen zum Suchen eingeben", + "SAVED": "EPG-Zuordnung gespeichert", + "REMOVED": "EPG-Zuordnung entfernt", + "SAVE_FAILED": "EPG-Zuordnung konnte nicht aktualisiert werden" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 0f430fa8c..292507e06 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Αφαίρεση από τα αγαπημένα", "ADD_FAVORITE": "Προσθήκη στα αγαπημένα", "SHOW_DETAILS": "Εμφάνιση λεπτομερειών καναλιού", + "MAP_EPG": "Αντιστοίχιση καναλιού EPG", "FAVORITES_UPDATED": "Τα αγαπημένα ενημερώθηκαν!", "SELECT_CHANNEL_PLAYBACK": "Επιλέξτε ένα κανάλι για να ξεκινήσει η αναπαραγωγή", "SORT_BY": "Ταξινόμηση κατά", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "εμφάνιση ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Αντιστοίχιση καναλιού EPG", + "SUBTITLE": "Επιλέξτε χειροκίνητα κανάλι EPG για το \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Αναζήτηση καναλιών EPG...", + "CURRENT_MAPPING": "Τρέχουσα αντιστοίχιση", + "NO_MAPPING": "Δεν έχει οριστεί αντιστοίχιση EPG", + "SAVE": "Αποθήκευση αντιστοίχισης", + "REMOVE": "Κατάργηση αντιστοίχισης", + "CLOSE": "Κλείσιμο", + "NO_RESULTS": "Δεν βρέθηκαν κανάλια EPG", + "SEARCH_HINT": "Πληκτρολογήστε τουλάχιστον {{min}} χαρακτήρες για αναζήτηση", + "SAVED": "Η αντιστοίχιση EPG αποθηκεύτηκε", + "REMOVED": "Η αντιστοίχιση EPG καταργήθηκε", + "SAVE_FAILED": "Δεν ήταν δυνατή η ενημέρωση της αντιστοίχισης EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 38321a321..2ec687719 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Remove from favorites", "ADD_FAVORITE": "Add to favorites", "SHOW_DETAILS": "Show channel details", + "MAP_EPG": "Map EPG channel", "FAVORITES_UPDATED": "Favorites were updated!", "SELECT_CHANNEL_PLAYBACK": "Please select a channel to start playback", "SORT_BY": "Sort by", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "show ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Map EPG Channel", + "SUBTITLE": "Manually select an EPG channel for \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Search EPG channels...", + "CURRENT_MAPPING": "Current mapping", + "NO_MAPPING": "No EPG mapping set", + "SAVE": "Save mapping", + "REMOVE": "Remove mapping", + "CLOSE": "Close", + "NO_RESULTS": "No EPG channels found", + "SEARCH_HINT": "Type at least {{min}} characters to search", + "SAVED": "EPG mapping saved", + "REMOVED": "EPG mapping removed", + "SAVE_FAILED": "Could not update the EPG mapping" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e4972fa64..bee82556b 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Quitar de favoritos", "ADD_FAVORITE": "Agregar a favoritos", "SHOW_DETAILS": "Mostrar detalles del canal", + "MAP_EPG": "Asignar canal EPG", "FAVORITES_UPDATED": "¡Se actualizaron los favoritos!", "SELECT_CHANNEL_PLAYBACK": "Selecciona un canal para iniciar la reproducción", "SORT_BY": "Ordenar por", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "ver ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Asignar canal EPG", + "SUBTITLE": "Selecciona manualmente un canal EPG para \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Buscar canales EPG...", + "CURRENT_MAPPING": "Asignación actual", + "NO_MAPPING": "No hay asignación EPG", + "SAVE": "Guardar asignación", + "REMOVE": "Eliminar asignación", + "CLOSE": "Cerrar", + "NO_RESULTS": "No se encontraron canales EPG", + "SEARCH_HINT": "Escribe al menos {{min}} caracteres para buscar", + "SAVED": "Asignación EPG guardada", + "REMOVED": "Asignación EPG eliminada", + "SAVE_FAILED": "No se pudo actualizar la asignación EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 8a06f85be..b020bc756 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Retirer des favoris", "ADD_FAVORITE": "Ajouter aux favoris", "SHOW_DETAILS": "Afficher les détails de la chaîne", + "MAP_EPG": "Associer une chaîne EPG", "FAVORITES_UPDATED": "Les favoris ont été mis à jour !", "SELECT_CHANNEL_PLAYBACK": "Veuillez sélectionner une chaîne pour démarrer la lecture", "SORT_BY": "Trier par", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "afficher ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Associer une chaîne EPG", + "SUBTITLE": "Sélectionner manuellement une chaîne EPG pour « {{channelName}} »", + "SEARCH_PLACEHOLDER": "Rechercher des chaînes EPG...", + "CURRENT_MAPPING": "Association actuelle", + "NO_MAPPING": "Aucune association EPG définie", + "SAVE": "Enregistrer l'association", + "REMOVE": "Supprimer l'association", + "CLOSE": "Fermer", + "NO_RESULTS": "Aucune chaîne EPG trouvée", + "SEARCH_HINT": "Saisissez au moins {{min}} caractères pour rechercher", + "SAVED": "Association EPG enregistrée", + "REMOVED": "Association EPG supprimée", + "SAVE_FAILED": "Impossible de mettre à jour l'association EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 51b91c1f5..2207419fc 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Rimuovi dai preferiti", "ADD_FAVORITE": "Aggiungi ai preferiti", "SHOW_DETAILS": "Mostra dettagli canale", + "MAP_EPG": "Associa canale EPG", "FAVORITES_UPDATED": "Aggiornamento preferiti completato!", "SELECT_CHANNEL_PLAYBACK": "Seleziona un canale per avviare la riproduzione", "SORT_BY": "Ordina per", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "mostra ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Associa canale EPG", + "SUBTITLE": "Seleziona manualmente un canale EPG per \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Cerca canali EPG...", + "CURRENT_MAPPING": "Associazione attuale", + "NO_MAPPING": "Nessuna associazione EPG impostata", + "SAVE": "Salva associazione", + "REMOVE": "Rimuovi associazione", + "CLOSE": "Chiudi", + "NO_RESULTS": "Nessun canale EPG trovato", + "SEARCH_HINT": "Digita almeno {{min}} caratteri per cercare", + "SAVED": "Associazione EPG salvata", + "REMOVED": "Associazione EPG rimossa", + "SAVE_FAILED": "Impossibile aggiornare l'associazione EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 3f5fe87f9..24bed12b9 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "お気に入りから削除", "ADD_FAVORITE": "お気に入りに追加", "SHOW_DETAILS": "チャンネルの詳細を表示", + "MAP_EPG": "EPGチャンネルを割り当て", "FAVORITES_UPDATED": "お気に入りが更新されました!", "SELECT_CHANNEL_PLAYBACK": "再生を開始するチャンネルを選択してください", "SORT_BY": "並び替え", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "表示 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPGチャンネルの割り当て", + "SUBTITLE": "「{{channelName}}」のEPGチャンネルを手動で選択します", + "SEARCH_PLACEHOLDER": "EPGチャンネルを検索...", + "CURRENT_MAPPING": "現在の割り当て", + "NO_MAPPING": "EPGの割り当てはありません", + "SAVE": "割り当てを保存", + "REMOVE": "割り当てを削除", + "CLOSE": "閉じる", + "NO_RESULTS": "EPGチャンネルが見つかりません", + "SEARCH_HINT": "検索するには{{min}}文字以上入力してください", + "SAVED": "EPGの割り当てを保存しました", + "REMOVED": "EPGの割り当てを削除しました", + "SAVE_FAILED": "EPGの割り当てを更新できませんでした" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 04700d99d..563e17b7c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "즐겨찾기에서 삭제하기", "ADD_FAVORITE": "즐겨찾기에 추가", "SHOW_DETAILS": "채널 세부정보 표시", + "MAP_EPG": "EPG 채널 매핑", "FAVORITES_UPDATED": "즐겨찾기가 업데이트되었습니다!", "SELECT_CHANNEL_PLAYBACK": "재생을 시작할 채널을 선택하세요", "SORT_BY": "정렬 기준", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "보기 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG 채널 매핑", + "SUBTITLE": "\"{{channelName}}\"의 EPG 채널을 직접 선택하세요", + "SEARCH_PLACEHOLDER": "EPG 채널 검색...", + "CURRENT_MAPPING": "현재 매핑", + "NO_MAPPING": "설정된 EPG 매핑 없음", + "SAVE": "매핑 저장", + "REMOVE": "매핑 제거", + "CLOSE": "닫기", + "NO_RESULTS": "EPG 채널을 찾을 수 없음", + "SEARCH_HINT": "검색하려면 {{min}}자 이상 입력하세요", + "SAVED": "EPG 매핑이 저장되었습니다", + "REMOVED": "EPG 매핑이 제거되었습니다", + "SAVE_FAILED": "EPG 매핑을 업데이트할 수 없습니다" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index eb2a99e17..4a7fa9131 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Verwijder uit favorieten", "ADD_FAVORITE": "Toevoegen aan favorieten", "SHOW_DETAILS": "Toon kanaaldetails", + "MAP_EPG": "EPG-kanaal koppelen", "FAVORITES_UPDATED": "Favorieten zijn bijgewerkt!", "SELECT_CHANNEL_PLAYBACK": "Selecteer een kanaal om af te spelen", "SORT_BY": "Sorteren op", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "toon ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG-kanaal koppelen", + "SUBTITLE": "Selecteer handmatig een EPG-kanaal voor \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "EPG-kanalen zoeken...", + "CURRENT_MAPPING": "Huidige koppeling", + "NO_MAPPING": "Geen EPG-koppeling ingesteld", + "SAVE": "Koppeling opslaan", + "REMOVE": "Koppeling verwijderen", + "CLOSE": "Sluiten", + "NO_RESULTS": "Geen EPG-kanalen gevonden", + "SEARCH_HINT": "Typ minimaal {{min}} tekens om te zoeken", + "SAVED": "EPG-koppeling opgeslagen", + "REMOVED": "EPG-koppeling verwijderd", + "SAVE_FAILED": "Kan de EPG-koppeling niet bijwerken" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ff88234c3..d35d051ca 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Usuń z ulubionych", "ADD_FAVORITE": "Dodaj do ulubionych", "SHOW_DETAILS": "Pokaż szczegóły kanału", + "MAP_EPG": "Przypisz kanał EPG", "FAVORITES_UPDATED": "Ulubione zostały zaktualizowane!", "SELECT_CHANNEL_PLAYBACK": "Wybierz kanał, aby rozpocząć odtwarzanie", "SORT_BY": "Sortuj według", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "pokaż ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Przypisz kanał EPG", + "SUBTITLE": "Ręcznie wybierz kanał EPG dla \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Szukaj kanałów EPG...", + "CURRENT_MAPPING": "Bieżące przypisanie", + "NO_MAPPING": "Brak przypisania EPG", + "SAVE": "Zapisz przypisanie", + "REMOVE": "Usuń przypisanie", + "CLOSE": "Zamknij", + "NO_RESULTS": "Nie znaleziono kanałów EPG", + "SEARCH_HINT": "Wpisz co najmniej {{min}} znaki, aby wyszukać", + "SAVED": "Przypisanie EPG zapisane", + "REMOVED": "Przypisanie EPG usunięte", + "SAVE_FAILED": "Nie udało się zaktualizować przypisania EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 585377af9..e1cfb1a61 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Remover dos favoritos", "ADD_FAVORITE": "Adicionar aos favoritos", "SHOW_DETAILS": "Mostrar detalhes do canal", + "MAP_EPG": "Associar canal EPG", "FAVORITES_UPDATED": "Favoritos foram atualizados!", "SELECT_CHANNEL_PLAYBACK": "Selecione um canal para iniciar a reprodução", "SORT_BY": "Ordenar por", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "mostrar ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Associar canal EPG", + "SUBTITLE": "Selecione manualmente um canal EPG para \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Pesquisar canais EPG...", + "CURRENT_MAPPING": "Associação atual", + "NO_MAPPING": "Nenhuma associação EPG definida", + "SAVE": "Guardar associação", + "REMOVE": "Remover associação", + "CLOSE": "Fechar", + "NO_RESULTS": "Nenhum canal EPG encontrado", + "SEARCH_HINT": "Digite pelo menos {{min}} caracteres para pesquisar", + "SAVED": "Associação EPG guardada", + "REMOVED": "Associação EPG removida", + "SAVE_FAILED": "Não foi possível atualizar a associação EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 027fe915a..5ca85485c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Удалить из списка фаворитов", "ADD_FAVORITE": "Добавить в избранное", "SHOW_DETAILS": "Показать сведения о канале", + "MAP_EPG": "Привязать канал EPG", "FAVORITES_UPDATED": "Список фаворитов был обновлен!", "SELECT_CHANNEL_PLAYBACK": "Пожалуйста, выберите канал для начала воспроизведения", "SORT_BY": "Сортировать по", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "показать ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Привязка канала EPG", + "SUBTITLE": "Выберите канал EPG для \"{{channelName}}\" вручную", + "SEARCH_PLACEHOLDER": "Поиск каналов EPG...", + "CURRENT_MAPPING": "Текущая привязка", + "NO_MAPPING": "Привязка EPG не задана", + "SAVE": "Сохранить привязку", + "REMOVE": "Удалить привязку", + "CLOSE": "Закрыть", + "NO_RESULTS": "Каналы EPG не найдены", + "SEARCH_HINT": "Введите не менее {{min}} символов для поиска", + "SAVED": "Привязка EPG сохранена", + "REMOVED": "Привязка EPG удалена", + "SAVE_FAILED": "Не удалось обновить привязку EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 2a3d4a970..b0c0fd1c7 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Favorilerden kaldır", "ADD_FAVORITE": "Favorilere ekle", "SHOW_DETAILS": "Kanal ayrıntılarını göster", + "MAP_EPG": "EPG kanalı eşle", "FAVORITES_UPDATED": "Favoriler güncellendi!", "SELECT_CHANNEL_PLAYBACK": "Lütfen oynatmaya başlamak için bir kanal seçin", "SORT_BY": "Sırala", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "göster ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG Kanalı Eşle", + "SUBTITLE": "\"{{channelName}}\" için EPG kanalını elle seçin", + "SEARCH_PLACEHOLDER": "EPG kanallarında ara...", + "CURRENT_MAPPING": "Geçerli eşleme", + "NO_MAPPING": "EPG eşlemesi ayarlanmadı", + "SAVE": "Eşlemeyi kaydet", + "REMOVE": "Eşlemeyi kaldır", + "CLOSE": "Kapat", + "NO_RESULTS": "EPG kanalı bulunamadı", + "SEARCH_HINT": "Aramak için en az {{min}} karakter yazın", + "SAVED": "EPG eşlemesi kaydedildi", + "REMOVED": "EPG eşlemesi kaldırıldı", + "SAVE_FAILED": "EPG eşlemesi güncellenemedi" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 2e831cabb..957581e4f 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "从收藏夹中删除", "ADD_FAVORITE": "添加到收藏", "SHOW_DETAILS": "显示频道详情", + "MAP_EPG": "映射 EPG 频道", "FAVORITES_UPDATED": "收藏夹已更新!", "SELECT_CHANNEL_PLAYBACK": "请选择一个频道开始播放", "SORT_BY": "排序方式", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "展开 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "映射 EPG 频道", + "SUBTITLE": "为“{{channelName}}”手动选择 EPG 频道", + "SEARCH_PLACEHOLDER": "搜索 EPG 频道...", + "CURRENT_MAPPING": "当前映射", + "NO_MAPPING": "未设置 EPG 映射", + "SAVE": "保存映射", + "REMOVE": "移除映射", + "CLOSE": "关闭", + "NO_RESULTS": "未找到 EPG 频道", + "SEARCH_HINT": "输入至少 {{min}} 个字符进行搜索", + "SAVED": "EPG 映射已保存", + "REMOVED": "EPG 映射已移除", + "SAVE_FAILED": "无法更新 EPG 映射" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f7589cdca..292ff3b8f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "從我的最愛中移除", "ADD_FAVORITE": "加入我的最愛", "SHOW_DETAILS": "顯示頻道詳細資訊", + "MAP_EPG": "對應 EPG 頻道", "FAVORITES_UPDATED": "我的最愛已更新!", "SELECT_CHANNEL_PLAYBACK": "請選擇一個頻道以開始播放", "SORT_BY": "排序依據", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "顯示 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "對應 EPG 頻道", + "SUBTITLE": "為「{{channelName}}」手動選擇 EPG 頻道", + "SEARCH_PLACEHOLDER": "搜尋 EPG 頻道...", + "CURRENT_MAPPING": "目前對應", + "NO_MAPPING": "尚未設定 EPG 對應", + "SAVE": "儲存對應", + "REMOVE": "移除對應", + "CLOSE": "關閉", + "NO_RESULTS": "找不到 EPG 頻道", + "SEARCH_HINT": "輸入至少 {{min}} 個字元進行搜尋", + "SAVED": "EPG 對應已儲存", + "REMOVED": "EPG 對應已移除", + "SAVE_FAILED": "無法更新 EPG 對應" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", 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 8e48fb452..b8c3d5815 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 @@ -1,14 +1,16 @@ import { inject, Injectable } from '@angular/core'; import { + ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeApi, ElectronBridgeEpgFetchResult, + ElectronBridgeEpgMapping, ElectronBridgeEpgProgress, ElectronBridgeEpgProgressStats, ElectronBridgeEpgProgressStatus, + ElectronBridgeEpgSearchResult, ElectronBridgeEpgChannelWithPrograms, ElectronBridgeEpgFreshnessResult, ElectronBridgeEpgLookupOptions, - ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeResult, ElectronBridgeTrustOptions, EpgChannelMetadata, @@ -38,6 +40,11 @@ type EpgElectronBridge = Pick< | 'getEpgChannelsByRange' | 'onEpgProgress' | 'searchEpgPrograms' + | 'getEpgMapping' + | 'getEpgMappingsBatch' + | 'setEpgMapping' + | 'deleteEpgMapping' + | 'searchEpgChannels' >; @Injectable({ providedIn: 'root' }) @@ -80,6 +87,10 @@ export class EpgRuntimeBridgeService { return this.runtime.supportsEpgProgramSearch; } + get supportsEpgMapping(): boolean { + return this.runtime.supportsEpgMapping; + } + fetchEpg( urls: string[], options?: ElectronBridgeTrustOptions @@ -221,6 +232,66 @@ export class EpgRuntimeBridgeService { ); } + getEpgMapping( + channelKey: string + ): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null); + } + + getEpgMappingsBatch( + channelKeys: string[] + ): Promise | null> { + if (!this.supportsEpgMapping || channelKeys.length === 0) { + return Promise.resolve(null); + } + + return ( + this.bridge?.getEpgMappingsBatch?.(channelKeys) ?? + Promise.resolve(null) + ); + } + + setEpgMapping( + channelKey: string, + epgChannelId: string, + playlistId?: string + ): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return ( + this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ?? + Promise.resolve(null) + ); + } + + deleteEpgMapping(channelKey: string): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null); + } + + searchEpgChannels( + searchTerm: string, + limit?: number + ): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return ( + this.bridge?.searchEpgChannels?.(searchTerm, limit) ?? + Promise.resolve(null) + ); + } + onProgress(callback: (data: EpgImportProgress) => void): void { if (!this.supportsProgress) { return; diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 9578dc38b..b323ed2cc 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -44,6 +44,8 @@ describe('StreamResolverService', () => { }; epgBridge = { getChannelPrograms: jest.fn(), + getEpgMapping: jest.fn().mockResolvedValue(null), + getEpgMappingsBatch: jest.fn().mockResolvedValue(null), supportsProgramLookup: true, }; @@ -267,7 +269,7 @@ describe('StreamResolverService', () => { password: 'pass', }, 1, - 10, + 50, { suppressErrorLog: true, } @@ -342,7 +344,7 @@ describe('StreamResolverService', () => { password: 'pass', }, 1, - 2, + 5, { suppressErrorLog: true, } @@ -416,7 +418,7 @@ describe('StreamResolverService', () => { stop_timestamp: '1774526400', }, ]), - 10_000 + 30_000 ); }) ); @@ -431,7 +433,7 @@ describe('StreamResolverService', () => { xtreamId: 1, } satisfies UnifiedCollectionItem); - await jest.advanceTimersByTimeAsync(3000); + await jest.advanceTimersByTimeAsync(10_000); await expect(detailPromise).resolves.toMatchObject({ epgMode: 'portal', @@ -445,6 +447,53 @@ describe('StreamResolverService', () => { } }); + it('resolves manual EPG mappings for Xtream previews with one batched lookup', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + } satisfies Partial) + ); + + const nowMs = Date.now(); + epgBridge.getEpgMappingsBatch = jest.fn().mockResolvedValue({ + 'xtream:xtream-1:7': 'mapped.channel.id', + }); + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([ + { + channel: 'mapped.channel.id', + title: 'Mapped Program', + desc: 'From uploaded XMLTV', + start: new Date(nowMs - 60_000).toISOString(), + stop: new Date(nowMs + 60_000).toISOString(), + }, + ]); + + const epgMap = await service.loadEpgForItems([ + { + uid: 'xtream::xtream-1::7', + name: 'Mapped Channel', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 7, + } satisfies UnifiedCollectionItem, + ]); + + expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1); + expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledWith( + expect.arrayContaining(['xtream:xtream-1:7', 'Mapped Channel']) + ); + // The mapping resolved via XMLTV, so no provider EPG call is needed. + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(epgMap.get('Mapped Channel')).toMatchObject({ + title: 'Mapped Program', + }); + }); + it('loads current Stalker EPG previews for live collection rows', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 6893ee6b6..0b487fafd 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { + buildXtreamEpgMappingKey, Channel, EpgItem, EpgProgram, @@ -63,11 +64,11 @@ export class StreamResolverService { private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly stalkerSession = inject(StalkerSessionService); private readonly m3uEpgTimeoutMs = 3000; - private readonly portalEpgTimeoutMs = 3000; + private readonly portalEpgTimeoutMs = 10000; private readonly xtreamEpgCache = new Map(); private readonly xtreamEpgFailureTimestamps = new Map(); private readonly xtreamEpgCacheTtlMs = 60 * 1000; - private readonly xtreamEpgFailureCooldownMs = 60 * 1000; + private readonly xtreamEpgFailureCooldownMs = 30 * 1000; private get supportsProgramLookup(): boolean { return this.epgBridge.supportsProgramLookup; @@ -410,11 +411,59 @@ export class StreamResolverService { return []; } + // 1) Check uploaded XMLTV EPG via the provider's epg_channel_id. + // The field is populated at runtime from the content table's + // epg_channel_id column but is not declared on the TS interface. + const epgKey = (item as unknown as Record).epgChannelId?.trim(); + if (this.supportsProgramLookup && epgKey) { + const uploaded = await this.epgBridge + .getChannelPrograms(epgKey) + .catch(() => null); + if (uploaded && uploaded.length > 0) { + return this.mapProgramsToEpgItems(uploaded); + } + } + + // 2) Fall back to the manual mapping table (playlist-scoped + // Xtream key → epgChannelId). + if (this.supportsProgramLookup && item.xtreamId) { + const mapping = await this.epgBridge + .getEpgMapping( + buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) + ) + .catch(() => null); + if (mapping?.epgChannelId) { + const mapped = await this.epgBridge + .getChannelPrograms(mapping.epgChannelId) + .catch(() => null); + if (mapped && mapped.length > 0) { + return this.mapProgramsToEpgItems(mapped); + } + } + } + + // 3) Try the full EPG endpoint (same as the main live-view + // loadEpg() in with-epg.feature.ts) — many providers only + // support get_simple_data_table, not get_short_epg. + try { + const fullEpg = await this.xtreamApi.getFullEpg( + creds, + item.xtreamId, + { suppressErrorLog: true } + ); + if (fullEpg.length > 0) { + return fullEpg; + } + } catch { + // getFullEpg failed — continue to short-EPG fallback below. + } + + // Fall back to the short-EPG endpoint with a generous limit. return await this.fetchXtreamEpgItems( item.playlistId, creds, item.xtreamId, - 10 + 50 ); } catch { return []; @@ -595,31 +644,78 @@ export class StreamResolverService { return; } - await Promise.all( - channels.map(async (channel) => { - if (!channel.xtreamId) { - return; - } + // Prefetch manual EPG mappings for the whole batch in one IPC + // round-trip — a per-channel lookup would issue O(N × candidates) + // IPC calls on every list load. + const mappingByKey = await this.prefetchEpgMappings( + playlistId, + channels + ); + + // Limit concurrency to avoid overwhelming the provider with + // simultaneous EPG requests when loading a large channel list. + const concurrency = 3; + const pending: Promise[] = []; + const iterator = channels.entries(); + + const enqueueNext = async (): Promise => { + for (;;) { + const entry = iterator.next(); + if (entry.done) return; + const [, channel] = entry.value; + if (!channel.xtreamId) continue; try { - const items = await this.fetchXtreamEpgItems( - playlistId, - creds, - channel.xtreamId, - 2 - ); const nowSeconds = Math.floor(now / 1000); - const currentItem = - items.find( - (item) => - Number(item.start_timestamp) <= nowSeconds && - nowSeconds < Number(item.stop_timestamp) - ) ?? null; + let currentItem: EpgItem | null = null; + + // 1) Try uploaded XMLTV EPG via the provider's epg_channel_id. + const epgChannelKey = (channel as unknown as Record).epgChannelId?.trim(); + if (this.supportsProgramLookup && epgChannelKey) { + currentItem = await this.findCurrentInXmltv( + epgChannelKey, nowSeconds + ); + } + + // 2) Fall back to manual mapping (epg_channel_mappings + // table), using the batch-prefetched map. Candidate key + // order matches how mappings can be saved: the + // playlist-scoped Xtream key (portal/favorites dialogs), + // then tvgId and name (M3U dialogs). + if (!currentItem && this.supportsProgramLookup) { + for (const key of this.mappingCandidateKeys( + playlistId, + channel + )) { + const mappedId = mappingByKey.get(key); + if (!mappedId) continue; + currentItem = await this.findCurrentInXmltv( + mappedId, + nowSeconds + ); + if (currentItem) break; + } + } + + if (!currentItem) { + const items = await this.fetchXtreamEpgItems( + playlistId, + creds, + channel.xtreamId, + 5 + ); + currentItem = + items.find( + (item) => + Number(item.start_timestamp) <= nowSeconds && + nowSeconds < Number(item.stop_timestamp) + ) ?? null; + } const epgKey = channel.tvgId?.trim() || channel.name?.trim(); if (!epgKey) { - return; + continue; } epgMap.set( @@ -646,8 +742,14 @@ export class StreamResolverService { epgMap.set(epgKey, null); } } - }) - ); + } + }; + + // Start limited concurrent workers. + for (let i = 0; i < concurrency; i++) { + pending.push(enqueueNext()); + } + await Promise.all(pending); } private getXtreamEpgCacheKey( @@ -958,4 +1060,79 @@ export class StreamResolverService { private isHttpUrl(value: string): boolean { return value.startsWith('http://') || value.startsWith('https://'); } + + /** + * All keys a manual mapping for this channel may have been saved under: + * the playlist-scoped Xtream key first, then the M3U lookup keys. + */ + private mappingCandidateKeys( + playlistId: string, + channel: UnifiedCollectionItem + ): string[] { + return [ + channel.xtreamId != null + ? buildXtreamEpgMappingKey(playlistId, channel.xtreamId) + : null, + channel.tvgId?.trim() || null, + channel.name?.trim() || null, + ].filter((key): key is string => Boolean(key)); + } + + /** + * Resolve the manual EPG mappings for every candidate key in the batch + * with a single IPC call. Returns an empty map when program lookup or + * the mapping bridge is unavailable (PWA). + */ + private async prefetchEpgMappings( + playlistId: string, + channels: UnifiedCollectionItem[] + ): Promise> { + const result = new Map(); + if (!this.supportsProgramLookup) { + return result; + } + + const keys = new Set(); + for (const channel of channels) { + if (!channel.xtreamId) continue; + for (const key of this.mappingCandidateKeys(playlistId, channel)) { + keys.add(key); + } + } + if (keys.size === 0) { + return result; + } + + const mappings = await this.epgBridge + .getEpgMappingsBatch([...keys]) + .catch(() => null); + if (!mappings) { + return result; + } + for (const [key, mappedId] of Object.entries(mappings)) { + const trimmed = mappedId?.trim(); + if (trimmed) { + result.set(key, trimmed); + } + } + return result; + } + + /** Look up the current program for an EPG channel ID from uploaded XMLTV. */ + private async findCurrentInXmltv( + epgChannelId: string, + nowSeconds: number + ): Promise { + if (!this.supportsProgramLookup || !epgChannelId) return null; + const programs = await this.epgBridge + .getChannelPrograms(epgChannelId) + .catch(() => null); + if (!programs || programs.length === 0) return null; + const items = this.mapProgramsToEpgItems(programs); + return items.find( + (item) => + Number(item.start_timestamp) <= nowSeconds && + nowSeconds < Number(item.stop_timestamp) + ) ?? null; + } } diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index ce4bbe8bf..b686dc97d 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -61,6 +61,12 @@ > + @if (supportsEpgMapping) { + + } @if (contextMenuChannel()?.m3uChannel) { + } @else {
search_off diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss index 0df50214b..341cb6b6a 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss @@ -171,3 +171,11 @@ transform: translateY(0); } } + +.channel-context-menu-anchor { + position: fixed; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 991a2100a..56c71104b 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -13,15 +13,20 @@ import { input, OnDestroy, output, + signal, untracked, viewChild, } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { ActivatedRoute } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { + buildXtreamEpgMappingKey, EpgItem, EpgProgram, XtreamCategory, @@ -30,6 +35,7 @@ import { import { ChannelListItemComponent, ChannelListSkeletonComponent, + EpgMappingDialogComponent, } from '@iptvnator/ui/components'; import { PortalChannelSortMode, @@ -66,7 +72,9 @@ interface XtreamCategoryLike { imports: [ ChannelListItemComponent, ChannelListSkeletonComponent, + MatButtonModule, MatIcon, + MatMenuModule, ScrollingModule, TranslatePipe, ], @@ -83,7 +91,14 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private readonly epgQueueService = inject(EpgQueueService); private readonly route = inject(ActivatedRoute); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly dialog = inject(MatDialog); + + readonly contextMenuTrigger = + viewChild.required('contextMenuTrigger'); + readonly contextMenuChannel = signal(null); + readonly contextMenuPosition = signal({ x: '0px', y: '0px' }); readonly supportsEpg = this.runtime.supportsEpg; + readonly supportsEpgMapping = this.runtime.supportsEpgMapping; readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; readonly channels = computed(() => { @@ -246,6 +261,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { const uncachedEntries: { streamId: number; epgChannelId?: string | null; + playlistId?: string | null; }[] = []; // Apply cached results immediately @@ -266,6 +282,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { uncachedEntries.push({ streamId: channel.xtream_id, epgChannelId: channel.epg_channel_id ?? null, + playlistId: playlist.id ?? null, }); } } @@ -459,4 +476,43 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { ? Math.floor(parsedDate / 1000) : null; } + + // ── Context menu ──────────────────────────────────────────── + + onChannelContextMenu(channel: XtreamChannelListItem, event: MouseEvent): void { + this.contextMenuChannel.set(channel); + this.contextMenuPosition.set({ + x: `${event.clientX}px`, + y: `${event.clientY}px`, + }); + + const trigger = this.contextMenuTrigger(); + if (trigger.menuOpen) { + trigger.closeMenu(); + } + + queueMicrotask(() => { + this.contextMenuTrigger().openMenu(); + }); + } + + openEpgMapping(): void { + const channel = this.contextMenuChannel(); + if (!channel) { + return; + } + + this.contextMenuTrigger().closeMenu(); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const xtreamId = channel.xtream_id ?? channel.id; + if (!playlistId || xtreamId == null) { + return; + } + + EpgMappingDialogComponent.open(this.dialog, { + channelKey: buildXtreamEpgMappingKey(playlistId, xtreamId), + channelName: channel.title ?? channel.name ?? String(xtreamId), + playlistId, + }); + } } diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index e2d3ed248..c3703b129 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -115,6 +115,15 @@ export class RuntimeCapabilitiesService { return this.hasElectronMethod('searchEpgPrograms'); } + get supportsEpgMapping(): boolean { + return ( + this.hasElectronMethod('getEpgMapping') && + this.hasElectronMethod('setEpgMapping') && + this.hasElectronMethod('deleteEpgMapping') && + this.hasElectronMethod('searchEpgChannels') + ); + } + get supportsSqlite(): boolean { return [ 'dbDeleteAllPlaylists', diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index aed2531c2..7bc8ca437 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -169,6 +169,7 @@ const CREATE_TABLE_STATEMENTS = [ `CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`, `CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`, `CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`, + `CREATE INDEX IF NOT EXISTS idx_content_epg_channel ON content(epg_channel_id)`, `CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added)`, `CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`, // Partial covering index for visible categories — supports the dashboard's @@ -261,6 +262,15 @@ const CREATE_TABLE_STATEMENTS = [ INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END`, + // EPG channel mappings (manual user overrides) + `CREATE TABLE IF NOT EXISTS epg_channel_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_key TEXT NOT NULL UNIQUE, + epg_channel_id TEXT NOT NULL, + playlist_id TEXT, + updated_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id)`, // Playback Positions table `CREATE TABLE IF NOT EXISTS playback_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 0093dab99..1c00709ba 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -110,6 +110,9 @@ export const content = sqliteTable( categoryIdx: index('idx_content_category').on(table.categoryId), titleIdx: index('idx_content_title').on(table.title), xtreamIdx: index('idx_content_xtream').on(table.xtreamId), + epgChannelIdx: index('idx_content_epg_channel').on( + table.epgChannelId + ), categoryTypeXtreamUnique: uniqueIndex( 'content_category_type_xtream_unique' ).on(table.categoryId, table.type, table.xtreamId), @@ -347,6 +350,26 @@ export const downloads = sqliteTable( }) ); +// EPG channel mappings (manual user overrides) +export const epgChannelMappings = sqliteTable( + 'epg_channel_mappings', + { + id: integer('id').primaryKey({ autoIncrement: true }), + channelKey: text('channel_key').notNull().unique(), + epgChannelId: text('epg_channel_id').notNull(), + playlistId: text('playlist_id'), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + playlistIdx: index('idx_epg_channel_mappings_playlist').on( + table.playlistId + ), + }) +); + +export type EpgChannelMapping = typeof epgChannelMappings.$inferSelect; +export type NewEpgChannelMapping = typeof epgChannelMappings.$inferInsert; + export type Download = typeof downloads.$inferSelect; export type NewDownload = typeof downloads.$inferInsert; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b65a7381a..2f25d78e0 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; export * from './lib/epg-channel.model'; export * from './lib/epg-item.interface'; +export * from './lib/epg-mapping-key.util'; export * from './lib/epg-program.model'; export * from './lib/external-player-arguments.utils'; export * from './lib/external-player-session.interface'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 489ed1b0e..6c92e676a 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -266,6 +266,19 @@ export interface ElectronBridgeEpgFreshnessResult { freshUrls: string[]; } +export interface ElectronBridgeEpgMapping { + id: number; + channelKey: string; + epgChannelId: string; + playlistId: string | null; +} + +export interface ElectronBridgeEpgSearchResult { + id: string; + displayName: string; + iconUrl: string | null; +} + export interface ElectronBridgeEpgLookupOptions { sourceUrls?: string[]; } @@ -616,6 +629,25 @@ export interface ElectronBridgeApi { searchTerm: string, limit?: number ) => Promise; + + // EPG channel mapping (manual user overrides) + getEpgMapping: ( + channelKey: string + ) => Promise; + getEpgMappingsBatch: ( + channelKeys: string[] + ) => Promise>; + setEpgMapping: ( + channelKey: string, + epgChannelId: string, + playlistId?: string + ) => Promise; + deleteEpgMapping: (channelKey: string) => Promise; + searchEpgChannels: ( + searchTerm: string, + limit?: number + ) => Promise; + updateSettings: (settings: Partial) => Promise; getAiSettings: () => Promise; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts new file mode 100644 index 000000000..fd7453c38 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts @@ -0,0 +1,21 @@ +import { buildXtreamEpgMappingKey } from './epg-mapping-key.util'; + +describe('buildXtreamEpgMappingKey', () => { + it('scopes the key to the playlist', () => { + expect(buildXtreamEpgMappingKey('playlist-a', 12345)).toBe( + 'xtream:playlist-a:12345' + ); + }); + + it('produces distinct keys for the same stream id in different playlists', () => { + expect(buildXtreamEpgMappingKey('playlist-a', 12345)).not.toBe( + buildXtreamEpgMappingKey('playlist-b', 12345) + ); + }); + + it('accepts string stream ids', () => { + expect(buildXtreamEpgMappingKey('playlist-a', '42')).toBe( + 'xtream:playlist-a:42' + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts new file mode 100644 index 000000000..5173ea8d3 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts @@ -0,0 +1,21 @@ +/** + * Key helpers for the manual EPG channel mapping table + * (`epg_channel_mappings`). + * + * M3U channels use their EPG lookup key (tvg-id, falling back to the + * channel name) directly — those identifiers are meaningful across + * playlists, so a mapping saved once applies everywhere. + * + * Xtream stream IDs, on the other hand, are provider-local integers: + * stream 12345 in portal A has nothing to do with stream 12345 in + * portal B. Mapping keys for Xtream channels therefore embed the + * playlist ID so mappings never leak across portals. Shared between the + * renderer and the Electron main process/worker, like + * `title-normalization.util.ts`. + */ +export function buildXtreamEpgMappingKey( + playlistId: string, + xtreamId: number | string +): string { + return `xtream:${playlistId}:${xtreamId}`; +} diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index a725b7ea6..09fca1a52 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/channel-list-container/channel-list-container.component'; export * from './lib/channel-list-container/channel-details-dialog/channel-details-dialog.component'; +export * from './lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component'; export * from './lib/channel-list-container/channel-list-item/channel-list-item.component'; export * from './lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component'; export * from './lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component'; diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 92006c4d0..accebb6d3 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -110,6 +110,12 @@ >
+ @if (supportsEpgMapping) { + + } + } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts index 73f85d009..d8aaca3ca 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts @@ -1,11 +1,18 @@ import { ClipboardModule } from '@angular/cdk/clipboard'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, +} from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils'; import { Channel } from '@iptvnator/shared/interfaces'; +import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; +import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; interface ChannelDetailField { readonly empty?: boolean; @@ -35,6 +42,9 @@ interface HeroStat { }) export class ChannelDetailsDialogComponent { readonly channel = inject(MAT_DIALOG_DATA); + private readonly dialog = inject(MatDialog); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; readonly archiveDays = getM3uArchiveDays(this.channel); readonly catchupAvailable = this.archiveDays > 0; @@ -182,4 +192,16 @@ export class ChannelDetailsDialogComponent { return { labelKey, monospace, value: normalized }; } + + openEpgMapping(): void { + const channelKey = resolveChannelEpgLookupKey(this.channel); + if (!channelKey) { + return; + } + + EpgMappingDialogComponent.open(this.dialog, { + channelKey, + channelName: this.channel.name ?? channelKey, + }); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html new file mode 100644 index 000000000..e4be4b844 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html @@ -0,0 +1,77 @@ +

{{ 'EPG_MAPPING_DIALOG.TITLE' | translate }}

+ + +

+ {{ 'EPG_MAPPING_DIALOG.SUBTITLE' | translate : { channelName: data.channelName } }} +

+ +
+ + {{ 'EPG_MAPPING_DIALOG.CURRENT_MAPPING' | translate }}: + + + {{ currentMapping() || ('EPG_MAPPING_DIALOG.NO_MAPPING' | translate) }} + +
+ + + {{ 'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate }} + + search + @if (loading()) { + + } + @if (!hasSearchTerm()) { + + {{ + 'EPG_MAPPING_DIALOG.SEARCH_HINT' + | translate: { min: searchMinChars } + }} + + } + + + @if (hasSearchTerm()) { +
+ @for (channel of results(); track channel.id) { + + } @empty { +
+ search_off + {{ 'EPG_MAPPING_DIALOG.NO_RESULTS' | translate }} +
+ } +
+ } +
+ + + @if (currentMapping()) { + + } + + + diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss new file mode 100644 index 000000000..dde913830 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss @@ -0,0 +1,95 @@ +.epg-mapping-subtitle { + margin: 0 0 16px; + color: var(--mat-sys-on-surface-variant); + font-size: 14px; +} + +.epg-mapping-current { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding: 8px 12px; + border-radius: 8px; + background: var(--mat-sys-surface-container-high); + + &__label { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); + } + + &__value { + font-size: 14px; + font-weight: 500; + font-family: monospace; + } +} + +.epg-mapping-search { + width: 100%; + margin-bottom: 8px; +} + +.epg-mapping-results { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 300px; + overflow-y: auto; +} + +.epg-mapping-result { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + cursor: pointer; + text-align: left; + width: 100%; + color: inherit; + transition: background 0.15s, border-color 0.15s; + + &:hover { + background: var(--mat-sys-surface-container-high); + } + + &--selected { + border-color: var(--mat-sys-primary); + background: color-mix( + in srgb, + var(--mat-sys-primary) 10%, + transparent + ); + } + + &__name { + font-size: 14px; + font-weight: 500; + color: var(--mat-sys-on-surface); + } + + &__id { + font-size: 11px; + color: var(--mat-sys-on-surface-variant); + font-family: monospace; + } +} + +.epg-mapping-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px; + color: var(--mat-sys-on-surface-variant); + + mat-icon { + font-size: 40px; + width: 40px; + height: 40px; + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts new file mode 100644 index 000000000..bc0e423c8 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts @@ -0,0 +1,205 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { debounceTime, from, Subject, switchMap } from 'rxjs'; + +export interface EpgMappingDialogData { + channelKey: string; + channelName: string; + /** Owning playlist for portal channels; omitted for M3U lookup keys. */ + playlistId?: string | null; +} + +export interface EpgMappingDialogResult { + channelKey: string; + epgChannelId: string; +} + +const SEARCH_MIN_CHARS = 2; + +@Component({ + selector: 'app-epg-mapping-dialog', + templateUrl: './epg-mapping-dialog.component.html', + styleUrl: './epg-mapping-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + TranslatePipe, + ], +}) +export class EpgMappingDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject( + MatDialogRef + ); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly epgService = inject(EpgService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + + readonly searchMinChars = SEARCH_MIN_CHARS; + readonly searchTerm = signal(''); + readonly loading = signal(false); + readonly results = signal< + Array<{ id: string; displayName: string; iconUrl: string | null }> + >([]); + readonly selectedId = signal(null); + readonly currentMapping = signal(null); + + private readonly search$ = new Subject(); + + /** Open the dialog with the shared sizing used by every caller. */ + static open( + dialog: MatDialog, + data: EpgMappingDialogData + ): MatDialogRef { + return dialog.open(EpgMappingDialogComponent, { + data, + width: '500px', + maxHeight: '90vh', + }); + } + + constructor() { + // Fetch the current mapping (if any) on open — callers don't know it. + this.loadCurrentMapping(); + + this.search$ + .pipe( + debounceTime(300), + switchMap((term) => { + if (term.trim().length < SEARCH_MIN_CHARS) { + return from(Promise.resolve([])); + } + this.loading.set(true); + const promise = this.epgBridge.searchEpgChannels(term); + return from(promise ?? Promise.resolve([])); + }), + takeUntilDestroyed() + ) + .subscribe({ + next: (items) => { + this.results.set(items ?? []); + this.loading.set(false); + }, + error: () => { + this.results.set([]); + this.loading.set(false); + }, + }); + } + + readonly hasSearchTerm = computed( + () => this.searchTerm().trim().length >= SEARCH_MIN_CHARS + ); + + onSearchInput(value: string): void { + this.searchTerm.set(value); + this.search$.next(value); + } + + selectChannel(id: string): void { + this.selectedId.set(id); + } + + save(): void { + const epgChannelId = this.selectedId(); + if (!epgChannelId) return; + + this.epgBridge + .setEpgMapping( + this.data.channelKey, + epgChannelId, + this.data.playlistId ?? undefined + ) + .then((result) => { + if (!result?.success) { + this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED'); + return; + } + this.epgService.clearCache(); + this.notify('EPG_MAPPING_DIALOG.SAVED'); + this.dialogRef.close({ + channelKey: this.data.channelKey, + epgChannelId, + }); + }); + } + + removeMapping(): void { + this.epgBridge.deleteEpgMapping(this.data.channelKey).then((result) => { + if (!result?.success) { + this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED'); + return; + } + this.epgService.clearCache(); + this.notify('EPG_MAPPING_DIALOG.REMOVED'); + this.dialogRef.close(); + }); + } + + close(): void { + this.dialogRef.close(); + } + + /** + * Resolve the stored mapping and enrich it with the EPG channel's + * display name so the user sees "BBC One (bbc.one.uk)" instead of a + * bare ID. + */ + private loadCurrentMapping(): void { + this.epgBridge.getEpgMapping(this.data.channelKey).then(async (m) => { + const mappedId = m?.epgChannelId ?? null; + if (!mappedId) { + this.currentMapping.set(null); + return; + } + this.currentMapping.set(mappedId); + try { + const matches = + (await this.epgBridge.searchEpgChannels(mappedId, 10)) ?? + []; + const exact = matches.find((r) => r.id === mappedId); + if (exact?.displayName && exact.displayName !== mappedId) { + this.currentMapping.set( + `${exact.displayName} (${mappedId})` + ); + } + } catch { + // Display name enrichment is cosmetic — keep the bare ID. + } + }); + } + + private notify(key: string): void { + this.snackBar.open(this.translate.instant(key), undefined, { + duration: 2000, + }); + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html index 9960ee3db..769c96a45 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html @@ -69,6 +69,12 @@ > + @if (supportsEpgMapping) { + + } + } + }