mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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": "العربية (اللهجة المغربية)",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+37
-2
@@ -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),
|
||||
|
||||
+18
@@ -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>
|
||||
|
||||
+8
@@ -171,3 +171,11 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-context-menu-anchor {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
+56
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+22
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+23
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+77
@@ -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>
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+205
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+24
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user