mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
* 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>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { SaxesParser, SaxesTagPlain } from 'saxes';
|
|
|
|
export interface ParsedTextValue {
|
|
lang: string;
|
|
value: string;
|
|
}
|
|
|
|
export interface ParsedIcon {
|
|
src: string;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export interface ParsedRating {
|
|
system: string;
|
|
value: string;
|
|
}
|
|
|
|
export interface ParsedEpisodeNum {
|
|
system: string;
|
|
value: string;
|
|
}
|
|
|
|
export interface ParsedChannel {
|
|
id: string;
|
|
displayName: ParsedTextValue[];
|
|
icon: ParsedIcon[];
|
|
url: string[];
|
|
}
|
|
|
|
export interface ParsedProgram {
|
|
start: string;
|
|
stop: string;
|
|
channel: string;
|
|
title: ParsedTextValue[];
|
|
desc: ParsedTextValue[];
|
|
category: ParsedTextValue[];
|
|
date: string;
|
|
episodeNum: ParsedEpisodeNum[];
|
|
icon: ParsedIcon[];
|
|
rating: ParsedRating[];
|
|
}
|
|
|
|
/**
|
|
* Parse XMLTV datetime format to ISO string
|
|
* Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss
|
|
*/
|
|
export function parseXmltvDate(dateStr: string): string {
|
|
if (!dateStr) return '';
|
|
|
|
const match = dateStr.match(
|
|
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/
|
|
);
|
|
|
|
if (!match) return dateStr;
|
|
|
|
const [, year, month, day, hour, minute, second, tz] = match;
|
|
|
|
if (tz) {
|
|
// 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 `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
|
|
}
|
|
|
|
/**
|
|
* Streaming EPG parser using SAX
|
|
*/
|
|
export class StreamingEpgParser {
|
|
private parser: SaxesParser;
|
|
private channels: ParsedChannel[] = [];
|
|
private programs: ParsedProgram[] = [];
|
|
private totalChannels = 0;
|
|
private totalPrograms = 0;
|
|
|
|
// Current element being parsed
|
|
private currentChannel: Partial<ParsedChannel> | null = null;
|
|
private currentProgram: Partial<ParsedProgram> | null = null;
|
|
private currentTextContent = '';
|
|
private currentLang = '';
|
|
|
|
// For nested elements
|
|
private elementStack: string[] = [];
|
|
|
|
constructor(
|
|
private readonly onChannelsBatch: (channels: ParsedChannel[]) => void,
|
|
private readonly onProgramsBatch: (programs: ParsedProgram[]) => void,
|
|
private readonly onProgress: (channels: number, programs: number) => void,
|
|
private readonly channelBatchSize = 100,
|
|
private readonly programBatchSize = 1000
|
|
) {
|
|
this.parser = new SaxesParser();
|
|
this.setupParser();
|
|
}
|
|
|
|
private setupParser(): void {
|
|
this.parser.on('opentag', (tag: SaxesTagPlain) => {
|
|
this.elementStack.push(tag.name);
|
|
this.currentTextContent = '';
|
|
|
|
switch (tag.name) {
|
|
case 'channel':
|
|
this.currentChannel = {
|
|
id: (tag.attributes['id'] as string) || '',
|
|
displayName: [],
|
|
icon: [],
|
|
url: [],
|
|
};
|
|
break;
|
|
|
|
case 'programme':
|
|
// XMLTV feeds typically emit all <channel> entries before
|
|
// the first <programme>. Flush any pending channel batch
|
|
// so downstream program consumers can resolve late-channel
|
|
// IDs immediately instead of dropping the first rows.
|
|
this.flushChannels();
|
|
this.currentProgram = {
|
|
start: parseXmltvDate(
|
|
(tag.attributes['start'] as string) || ''
|
|
),
|
|
stop: parseXmltvDate(
|
|
(tag.attributes['stop'] as string) || ''
|
|
),
|
|
channel: (tag.attributes['channel'] as string) || '',
|
|
title: [],
|
|
desc: [],
|
|
category: [],
|
|
date: '',
|
|
episodeNum: [],
|
|
icon: [],
|
|
rating: [],
|
|
};
|
|
break;
|
|
|
|
case 'icon':
|
|
if (this.currentChannel) {
|
|
this.currentChannel.icon!.push({
|
|
src: (tag.attributes['src'] as string) || '',
|
|
width: tag.attributes['width']
|
|
? parseInt(tag.attributes['width'] as string)
|
|
: undefined,
|
|
height: tag.attributes['height']
|
|
? parseInt(tag.attributes['height'] as string)
|
|
: undefined,
|
|
});
|
|
} else if (this.currentProgram) {
|
|
this.currentProgram.icon!.push({
|
|
src: (tag.attributes['src'] as string) || '',
|
|
width: tag.attributes['width']
|
|
? parseInt(tag.attributes['width'] as string)
|
|
: undefined,
|
|
height: tag.attributes['height']
|
|
? parseInt(tag.attributes['height'] as string)
|
|
: undefined,
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'display-name':
|
|
case 'title':
|
|
case 'desc':
|
|
case 'category':
|
|
this.currentLang = (tag.attributes['lang'] as string) || '';
|
|
break;
|
|
|
|
case 'rating':
|
|
if (this.currentProgram) {
|
|
const system =
|
|
(tag.attributes['system'] as string) || '';
|
|
this.currentProgram.rating!.push({ system, value: '' });
|
|
}
|
|
break;
|
|
|
|
case 'episode-num':
|
|
if (this.currentProgram) {
|
|
const system =
|
|
(tag.attributes['system'] as string) || '';
|
|
this.currentProgram.episodeNum!.push({
|
|
system,
|
|
value: '',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
this.parser.on('text', (text: string) => {
|
|
this.currentTextContent += text;
|
|
});
|
|
|
|
this.parser.on('closetag', (tag: SaxesTagPlain) => {
|
|
const text = this.currentTextContent.trim();
|
|
|
|
if (this.currentChannel) {
|
|
switch (tag.name) {
|
|
case 'display-name':
|
|
this.currentChannel.displayName!.push({
|
|
lang: this.currentLang,
|
|
value: text,
|
|
});
|
|
break;
|
|
case 'url':
|
|
if (text) this.currentChannel.url!.push(text);
|
|
break;
|
|
case 'channel':
|
|
this.channels.push(this.currentChannel as ParsedChannel);
|
|
this.totalChannels++;
|
|
this.currentChannel = null;
|
|
|
|
if (this.channels.length >= this.channelBatchSize) {
|
|
this.flushChannels();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this.currentProgram) {
|
|
switch (tag.name) {
|
|
case 'title':
|
|
this.currentProgram.title!.push({
|
|
lang: this.currentLang,
|
|
value: text,
|
|
});
|
|
break;
|
|
case 'desc':
|
|
this.currentProgram.desc!.push({
|
|
lang: this.currentLang,
|
|
value: text,
|
|
});
|
|
break;
|
|
case 'category':
|
|
this.currentProgram.category!.push({
|
|
lang: this.currentLang,
|
|
value: text,
|
|
});
|
|
break;
|
|
case 'date':
|
|
this.currentProgram.date = text;
|
|
break;
|
|
case 'value':
|
|
if (
|
|
this.elementStack.includes('rating') &&
|
|
this.currentProgram.rating!.length > 0
|
|
) {
|
|
this.currentProgram.rating![
|
|
this.currentProgram.rating!.length - 1
|
|
].value = text;
|
|
}
|
|
break;
|
|
case 'episode-num':
|
|
if (this.currentProgram.episodeNum!.length > 0) {
|
|
this.currentProgram.episodeNum![
|
|
this.currentProgram.episodeNum!.length - 1
|
|
].value = text;
|
|
}
|
|
break;
|
|
case 'programme':
|
|
this.programs.push(this.currentProgram as ParsedProgram);
|
|
this.totalPrograms++;
|
|
|
|
if (this.programs.length >= this.programBatchSize) {
|
|
this.flushChannels();
|
|
this.flushPrograms();
|
|
}
|
|
this.currentProgram = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.elementStack.pop();
|
|
this.currentTextContent = '';
|
|
});
|
|
}
|
|
|
|
private flushChannels(): void {
|
|
if (this.channels.length > 0) {
|
|
this.onChannelsBatch([...this.channels]);
|
|
this.channels = [];
|
|
this.onProgress(this.totalChannels, this.totalPrograms);
|
|
}
|
|
}
|
|
|
|
private flushPrograms(): void {
|
|
if (this.programs.length > 0) {
|
|
this.onProgramsBatch([...this.programs]);
|
|
this.programs = [];
|
|
this.onProgress(this.totalChannels, this.totalPrograms);
|
|
}
|
|
}
|
|
|
|
write(chunk: string): void {
|
|
this.parser.write(chunk);
|
|
}
|
|
|
|
finish(): { totalChannels: number; totalPrograms: number } {
|
|
this.parser.close();
|
|
this.flushChannels();
|
|
this.flushPrograms();
|
|
|
|
return {
|
|
totalChannels: this.totalChannels,
|
|
totalPrograms: this.totalPrograms,
|
|
};
|
|
}
|
|
}
|