feat: manual EPG-to-channel mapping with mapping fallback (#1165)

* feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths

* fix: epg mapping in live tv list

* fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys

Follow-up fixes on top of the manual EPG-to-channel mapping feature:

- epg-database: dedupe existing epg_programs rows before creating the
  unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX
  crashed the EPG worker on upgrade when historical duplicates exist;
  replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the
  epg_programs_fts delete trigger is not bypassed (REPLACE skips delete
  triggers unless recursive_triggers is on), with a plain-INSERT
  fallback when the index cannot be created
- db: add idx_content_epg_channel — the mapping fallback scanned the
  whole content table on every single-channel EPG lookup
- keys: scope Xtream mapping keys per playlist via shared
  buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids
  collide across portals; the backend fallback now joins categories to
  resolve the playlist id
- pwa: hide "Map EPG channel" entries behind the supportsEpgMapping
  capability — the menu item was a dead end in the PWA
- parser: parse the XMLTV offset sign from the string — Math.sign(0)
  dropped the minutes of ±00:xx offsets
- cleanup: typed window.electron access instead of ad-hoc casts, drop
  unused resolveChannelId and dialog data field, shared
  EpgMappingDialogComponent.open() for all seven call sites
- dialog UX: minimum-characters search hint, save/remove snackbars,
  current mapping shows the EPG channel display name,
  takeUntilDestroyed on the search stream
- i18n: fill the new keys in all 17 locales
- tests: cover mapping CRUD/search escaping, the dedup-index guard,
  offset parsing and playlist-scoped keys; update stale stream-resolver
  specs for the new 50-item limit and 10s timeout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(epg): escape backslashes in EPG channel search LIKE pattern

CodeQL js/incomplete-sanitization: a lone trailing backslash in the
search term paired with the closing wildcard under the ESCAPE clause
and corrupted the pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(epg): batch mapping lookups in the viewport preview queue

Expose the existing getEpgMappingsBatch operation over a new
EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings —
the per-entry lookup issued one IPC round-trip per visible channel on
every scroll event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(epg): batch mapping prefetch in the collection preview loader

Resolve all candidate mapping keys for an Xtream preview batch with a
single getEpgMappingsBatch IPC call instead of per-channel lookups.
Also fix a worker early-exit: a channel without tvgId/name returned out
of the shared-iterator loop and silently killed one of the three
concurrent preview workers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-19 09:00:14 +02:00
1 parent 45b6d8a041
commit ec6fc403a3
60 files changed
+2343 -91

No files matched your search

@@ -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) =>
@@ -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<string, unknown> = {};
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);
});
});
});
@@ -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<Map<string, string>> {
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<string, string>();
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<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
// 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);
}
@@ -11,6 +11,7 @@ export {
recentlyViewed,
favorites,
epgChannels,
epgChannelMappings,
epgPrograms,
playbackPositions,
downloads,
@@ -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<EpgProgram[]> {
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<string | null> {
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();
@@ -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().<method>() 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<T>(data: T) {
const chain: Record<string, jest.Mock> = {} as Record<string, jest.Mock>;
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 }));
@@ -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<Record<string, EpgProgram | null>> {
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<string, EpgProgram | null> = {};
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<Record<string, EpgChannelMetadata | null>> {
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<string, EpgChannelMetadata | null> = {};
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<Map<string, string>> {
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<Record<string, string>> {
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<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
if (!searchTerm?.trim()) {
return [];
}
try {
const db = await getDatabase();
return searchEpgChannels(db, searchTerm, limit);
} catch {
return [];
}
}
static async clearEpgData(): Promise<void> {
return epgWorkerService.clearEpgData();
}
@@ -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<string, jest.Mock>();
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', () => {
@@ -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 {
@@ -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', () => {
@@ -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`;
}
/**
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
+16
View File
@@ -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": "العربية (اللهجة المغربية)",
@@ -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<ElectronBridgeEpgMapping | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null);
}
getEpgMappingsBatch(
channelKeys: string[]
): Promise<Record<string, string> | 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<ElectronBridgeResult | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return (
this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ??
Promise.resolve(null)
);
}
deleteEpgMapping(channelKey: string): Promise<ElectronBridgeResult | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null);
}
searchEpgChannels(
searchTerm: string,
limit?: number
): Promise<ElectronBridgeEpgSearchResult[] | null> {
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;
@@ -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<Playlist>)
);
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({
@@ -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<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
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<string, string | undefined | null>).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<void>[] = [];
const iterator = channels.entries();
const enqueueNext = async (): Promise<void> => {
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<string, string | undefined | null>).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<Map<string, string>> {
const result = new Map<string, string>();
if (!this.supportsProgramLookup) {
return result;
}
const keys = new Set<string>();
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<EpgItem | null> {
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;
}
}
@@ -61,6 +61,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
@if (contextMenuChannel()?.m3uChannel) {
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
@@ -21,7 +21,13 @@ import {
ChannelListItemComponent,
} from '@iptvnator/ui/components';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
buildXtreamEpgMappingKey,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { EpgMappingDialogComponent } from '@iptvnator/ui/components';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
@@ -52,6 +58,8 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent';
})
export class GlobalFavoritesListComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
private readonly settingsStore = inject(SettingsStore);
readonly contextMenuTrigger =
@@ -163,7 +171,34 @@ export class GlobalFavoritesListComponent {
}
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
return Boolean(channel.m3uChannel) || this.mode() === 'recent';
return (
Boolean(channel.m3uChannel) ||
this.mode() === 'recent' ||
(this.supportsEpgMapping && Boolean(channel.xtreamId))
);
}
openEpgMapping(): void {
const item = this.contextMenuChannel();
if (!item) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = item.m3uChannel
? resolveChannelEpgLookupKey(item.m3uChannel)
: item.xtreamId != null
? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId)
: null;
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: item.name,
playlistId: item.m3uChannel ? undefined : item.playlistId,
});
}
openChannelDetails(): void {
@@ -1,6 +1,9 @@
import { inject, Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { EpgItem } from '@iptvnator/shared/interfaces';
import {
buildXtreamEpgMappingKey,
EpgItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
@@ -19,6 +22,8 @@ interface CacheEntry {
export interface EpgQueueEntry {
streamId: number;
epgChannelId?: string | null;
/** Owning playlist — required to resolve manual EPG mappings. */
playlistId?: string | null;
}
/**
@@ -114,6 +119,17 @@ export class EpgQueueService implements OnDestroy {
typeof entry === 'number' ? { streamId: entry } : { ...entry }
);
// Resolve manual EPG mappings before building the per-EPG-id index.
// When the user has right-clicked a channel and created a mapping,
// the stored key is the playlist-scoped Xtream key, not the
// provider's epg_channel_id. By resolving upfront we get the
// correct EPG channel ID for the XMLTV batch call that follows.
// Guarded so environments without the bridge (PWA) skip the await
// entirely and the enqueue keeps its original microtask timing.
if (typeof window.electron?.getEpgMappingsBatch === 'function') {
await this.resolveManualMappings(normalized);
}
const streamsByEpgId = new Map<string, number[]>();
for (const entry of normalized) {
const id = entry.epgChannelId?.trim();
@@ -192,6 +208,62 @@ export class EpgQueueService implements OnDestroy {
return this.fallbackService.getCurrentProgramsBatch(epgChannelIds);
}
/**
* Resolve manual EPG mappings for the queued entries.
*
* The user may have opened the mapping dialog (right-click → "Map EPG")
* from any channel list; the stored key is the playlist-scoped Xtream
* key, which does not match the provider's epg_channel_id, so the batch
* IPC handler's resolveChannelIds() would miss it. We resolve here,
* upfront, so the XMLTV batch call later uses the *mapped* epgChannelId
* — the actual EPG channel that carries the XMLTV data. A mapping also
* supplies an epgChannelId to entries whose provider did not send one.
*/
private async resolveManualMappings(
entries: EpgQueueEntry[]
): Promise<void> {
const getEpgMappingsBatch =
typeof window.electron?.getEpgMappingsBatch === 'function'
? window.electron.getEpgMappingsBatch
: null;
if (!getEpgMappingsBatch) {
return;
}
const keyByStreamId = new Map<number, string>();
for (const entry of entries) {
if (!entry.playlistId) continue;
keyByStreamId.set(
entry.streamId,
buildXtreamEpgMappingKey(entry.playlistId, entry.streamId)
);
}
if (keyByStreamId.size === 0) {
return;
}
try {
// One IPC round-trip for the whole viewport — a per-entry
// lookup would put O(N) IPC calls on every scroll event.
const mappings = await getEpgMappingsBatch([
...keyByStreamId.values(),
]);
for (const entry of entries) {
const key = keyByStreamId.get(entry.streamId);
const mapped = key ? mappings[key]?.trim() : undefined;
if (mapped) {
this.logger.info(
`Mapped stream ${entry.streamId}: ${entry.epgChannelId ?? '(none)'} → ${mapped}`
);
entry.epgChannelId = mapped;
}
}
} catch {
// Mapping lookup failure is non-fatal; keep the original
// epgChannelId values and proceed.
}
}
private pruneEphemeralMaps(visibleIds: Set<number>): void {
for (const id of [...this.epgChannelByStreamId.keys()]) {
// getCached() honors TTL and lazily evicts expired entries;
@@ -6,7 +6,7 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { buildXtreamEpgMappingKey, EpgItem } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
XtreamApiService,
@@ -40,7 +40,7 @@ const initialEpgState: EpgState = {
export function withEpg() {
const logger = createLogger('withEpg');
type ParentSelectionStoreLike = {
currentPlaylist?: () => XtreamCredentials | null;
currentPlaylist?: () => (XtreamCredentials & { id?: string }) | null;
selectedItem?: () => {
xtream_id?: number | null;
epg_channel_id?: string | null;
@@ -146,10 +146,33 @@ export function withEpg() {
patchState(store, { epgItems: [], isLoadingEpg: true });
// Resolve manual EPG mapping before the provider waterfall.
// The mapping dialog saves under the playlist-scoped
// Xtream key, so look it up and use the mapped EPG
// channel ID for the XMLTV / provider lookup.
let epgChannelId = selectedItem?.epg_channel_id ?? null;
const playlistId = storeAny.currentPlaylist?.()?.id;
if (runtime.supportsEpgMapping && playlistId) {
try {
const mapping =
await window.electron?.getEpgMapping?.(
buildXtreamEpgMappingKey(
playlistId,
xtreamId
)
);
if (mapping?.epgChannelId?.trim()) {
epgChannelId = mapping.epgChannelId.trim();
}
} catch {
// Non-fatal; keep original epgChannelId.
}
}
try {
const epgItems =
await fallbackService.resolveCurrentEpg({
epgChannelId: selectedItem.epg_channel_id,
epgChannelId,
preferUploaded: preferUploaded(),
fetchProvider: () =>
fetchFullProvider(credentials, xtreamId),
@@ -43,12 +43,30 @@
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
(activated)="playbackRequested.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
/>
</cdk-virtual-scroll-viewport>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
</mat-menu>
} @else {
<div class="empty-search-state">
<mat-icon class="empty-state-icon">search_off</mat-icon>
@@ -171,3 +171,11 @@
transform: translateY(0);
}
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -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<MatMenuTrigger>('contextMenuTrigger');
readonly contextMenuChannel = signal<XtreamChannelListItem | null>(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,
});
}
}
@@ -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',
@@ -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,
+23
View File
@@ -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;
+1
View File
@@ -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';
@@ -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<EpgProgram[]>;
// EPG channel mapping (manual user overrides)
getEpgMapping: (
channelKey: string
) => Promise<ElectronBridgeEpgMapping | null>;
getEpgMappingsBatch: (
channelKeys: string[]
) => Promise<Record<string, string>>;
setEpgMapping: (
channelKey: string,
epgChannelId: string,
playlistId?: string
) => Promise<ElectronBridgeResult>;
deleteEpgMapping: (channelKey: string) => Promise<ElectronBridgeResult>;
searchEpgChannels: (
searchTerm: string,
limit?: number
) => Promise<ElectronBridgeEpgSearchResult[]>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<ElectronBridgeAiSettings>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
@@ -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'
);
});
});
@@ -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}`;
}
+1
View File
@@ -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';
@@ -110,6 +110,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -16,6 +16,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
@@ -28,6 +29,7 @@ import {
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
@@ -51,6 +53,8 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
})
export class AllChannelsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -202,4 +206,22 @@ export class AllChannelsViewComponent {
width: 'calc(100vw - 32px)',
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
}
@@ -227,6 +227,12 @@
</mat-dialog-content>
<mat-dialog-actions class="channel-dialog__actions">
@if (supportsEpgMapping) {
<button mat-stroked-button (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
{{ 'CHANNELS.MAP_EPG' | translate }}
</button>
}
<button mat-stroked-button mat-dialog-close class="channel-dialog__close">
{{ 'CLOSE' | translate }}
</button>
@@ -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<Channel>(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,
});
}
}
@@ -0,0 +1,77 @@
<h2 mat-dialog-title>{{ 'EPG_MAPPING_DIALOG.TITLE' | translate }}</h2>
<mat-dialog-content>
<p class="epg-mapping-subtitle">
{{ 'EPG_MAPPING_DIALOG.SUBTITLE' | translate : { channelName: data.channelName } }}
</p>
<div class="epg-mapping-current">
<span class="epg-mapping-current__label">
{{ 'EPG_MAPPING_DIALOG.CURRENT_MAPPING' | translate }}:
</span>
<span class="epg-mapping-current__value">
{{ currentMapping() || ('EPG_MAPPING_DIALOG.NO_MAPPING' | translate) }}
</span>
</div>
<mat-form-field class="epg-mapping-search" appearance="outline">
<mat-label>{{ 'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate }}</mat-label>
<input
matInput
[ngModel]="searchTerm()"
(ngModelChange)="onSearchInput($event)"
[placeholder]="'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate"
/>
<mat-icon matPrefix>search</mat-icon>
@if (loading()) {
<mat-spinner matSuffix diameter="20" />
}
@if (!hasSearchTerm()) {
<mat-hint>
{{
'EPG_MAPPING_DIALOG.SEARCH_HINT'
| translate: { min: searchMinChars }
}}
</mat-hint>
}
</mat-form-field>
@if (hasSearchTerm()) {
<div class="epg-mapping-results">
@for (channel of results(); track channel.id) {
<button
class="epg-mapping-result"
[class.epg-mapping-result--selected]="selectedId() === channel.id"
(click)="selectChannel(channel.id)"
>
<span class="epg-mapping-result__name">{{ channel.displayName }}</span>
<span class="epg-mapping-result__id">{{ channel.id }}</span>
</button>
} @empty {
<div class="epg-mapping-empty">
<mat-icon>search_off</mat-icon>
<span>{{ 'EPG_MAPPING_DIALOG.NO_RESULTS' | translate }}</span>
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (currentMapping()) {
<button mat-button color="warn" (click)="removeMapping()">
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
</button>
}
<button mat-button (click)="close()">
{{ 'EPG_MAPPING_DIALOG.CLOSE' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!selectedId()"
(click)="save()"
>
{{ 'EPG_MAPPING_DIALOG.SAVE' | translate }}
</button>
</mat-dialog-actions>
@@ -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;
}
}
@@ -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<EpgMappingDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult>
);
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<string | null>(null);
readonly currentMapping = signal<string | null>(null);
private readonly search$ = new Subject<string>();
/** Open the dialog with the shared sizing used by every caller. */
static open(
dialog: MatDialog,
data: EpgMappingDialogData
): MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult> {
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,
});
}
}
@@ -69,6 +69,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -17,9 +17,11 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -39,6 +41,8 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
})
export class FavoritesViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -152,6 +156,25 @@ export class FavoritesViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -331,6 +331,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -19,6 +19,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
@@ -30,6 +31,7 @@ import {
sortPlaylistChannelItems,
} from '../channel-list-sort.util';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
@@ -72,6 +74,8 @@ interface FilteredGroupView {
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly contextMenuTrigger =
@@ -490,6 +494,25 @@ export class GroupsViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -62,6 +62,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -12,7 +12,10 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import {
@@ -40,6 +43,8 @@ export interface RecentViewItem {
})
export class RecentViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -133,6 +138,25 @@ export class RecentViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {