/** * XMLTV Parser * Parses XMLTV format XML into our EPGProgram[] format * * XMLTV Format Reference: * http://xmltv.cvs.sourceforge.net/viewvc/xmltv/xmltv/xmltv.dtd */ import { ungzip } from 'pako'; import type { EPGProgram, EPGChannel } from '../types'; /** * Default request timeout in milliseconds * Increased to 2 minutes to handle large EPG files (e.g., iptv-org ~50MB compressed) */ const DEFAULT_TIMEOUT = 120000; /** * User-Agent string for XMLTV fetch requests */ const USER_AGENT = 'Visioo/1.0 (IPTV Player)'; /** * Make a fetch request with timeout and proper headers */ async function fetchWithTimeout(url: string, headers?: HeadersInit, timeout = DEFAULT_TIMEOUT): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { method: 'GET', signal: controller.signal, headers: { 'Accept': 'application/xml, text/xml', 'User-Agent': USER_AGENT, ...headers, }, }); return response; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${timeout}ms`); } throw error; } finally { clearTimeout(timeoutId); } } /** * Parse XMLTV date string to ISO date * Format: "YYYYMMDDHHmmss +TZ" or "YYYYMMDDHHmmss" * Example: "20240101120000 +0000" -> "2024-01-01T12:00:00.000Z" */ export function parseXMLTVDate(dateStr: string, defaultTimezone?: string): string { // XMLTV format: YYYYMMDDHHmmss +TZ or YYYYMMDDHHmmss const match = dateStr.match(/^(\d{14})\s*([+-]\d{4})?$/); if (!match) { throw new Error(`Invalid XMLTV date format: ${dateStr}`); } const [, dateTime, timezone] = match; // Extract components const year = dateTime.substring(0, 4); const month = dateTime.substring(4, 6); const day = dateTime.substring(6, 8); const hour = dateTime.substring(8, 10); const minute = dateTime.substring(10, 12); const second = dateTime.substring(12, 14); // Use provided timezone or default, or UTC if none const tz = timezone || defaultTimezone || '+0000'; // Format timezone properly (e.g., +0500 -> +05:00) const tzFormatted = tz.length === 5 ? `${tz.substring(0, 3)}:${tz.substring(3)}` : tz; // Build ISO string const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}.000${tzFormatted}`; // Convert to ISO string (always returns UTC) return new Date(isoString).toISOString(); } // Note: extractText function removed as it's not currently used // If needed in the future, it can extract text from XMLTV text nodes // that handle both string and array formats /** * Parse XMLTV XML content to EPGProgram[] and EPGChannel[] */ export function parseXMLTV( xmlContent: string, timezone?: string ): { channels: EPGChannel[]; programs: EPGProgram[] } { // Parse XML using DOMParser (browser) or xml2js (Node) // For now, we'll use DOMParser which works in both browser and Node (with jsdom) let doc: Document; if (typeof DOMParser !== 'undefined') { // Browser environment const parser = new DOMParser(); doc = parser.parseFromString(xmlContent, 'text/xml'); } else { // Node environment - would need xml2js or similar // For now, throw error - we'll handle this in Node environment separately throw new Error('DOMParser not available. Use xml2js in Node environment.'); } // Check for parsing errors const parserError = doc.querySelector('parsererror'); if (parserError) { throw new Error(`XML parsing error: ${parserError.textContent || 'Unknown error'}`); } const channels: EPGChannel[] = []; const programs: EPGProgram[] = []; // Parse channels const channelElements = doc.querySelectorAll('channel'); channelElements.forEach((channelEl: Element) => { const id = channelEl.getAttribute('id'); if (!id) return; const displayNameEl = channelEl.querySelector('display-name'); const iconEl = channelEl.querySelector('icon'); const displayName = displayNameEl?.textContent?.trim() || id; const icon = iconEl?.getAttribute('src') || undefined; channels.push({ id, displayName, icon, channelIds: [], // Will be populated during matching }); }); // Parse programmes const programmeElements = doc.querySelectorAll('programme'); programmeElements.forEach((programmeEl: Element, index: number) => { const channelId = programmeEl.getAttribute('channel'); if (!channelId) return; const startStr = programmeEl.getAttribute('start'); const stopStr = programmeEl.getAttribute('stop'); if (!startStr || !stopStr) return; const titleEl = programmeEl.querySelector('title'); const descEl = programmeEl.querySelector('desc'); const categoryEl = programmeEl.querySelector('category'); const iconEl = programmeEl.querySelector('icon'); const episodeEl = programmeEl.querySelector('episode-num'); const title = titleEl?.textContent?.trim() || 'Untitled'; const description = descEl?.textContent?.trim() || undefined; const category = categoryEl?.textContent?.trim() || undefined; const image = iconEl?.getAttribute('src') || undefined; // Parse episode information let episode: EPGProgram['episode'] | undefined; if (episodeEl) { const episodeText = episodeEl.textContent?.trim() || ''; // Handle formats like "S01E01" or "1.1" or "1/1" const seasonMatch = episodeText.match(/[Ss](\d+)[Ee](\d+)/) || episodeText.match(/(\d+)\.(\d+)/) || episodeText.match(/(\d+)\/(\d+)/); if (seasonMatch) { episode = { season: parseInt(seasonMatch[1], 10), episode: parseInt(seasonMatch[2], 10), }; } } try { const start = parseXMLTVDate(startStr, timezone); const end = parseXMLTVDate(stopStr, timezone); // Generate unique ID for program const programId = `${channelId}-${start}-${index}`; programs.push({ id: programId, channelId, title, description, start, end, category, image, episode, }); } catch (error) { // Skip programs with invalid dates console.warn(`Skipping program with invalid date: ${error}`); } }); return { channels, programs }; } /** * Fetch and parse XMLTV from URL * Supports both plain XML and gzipped XML (.xml.gz) files */ export async function fetchAndParseXMLTV( url: string, timezone?: string, proxyUrl?: string ): Promise<{ channels: EPGChannel[]; programs: EPGProgram[] }> { const fetchUrl = proxyUrl ? `${proxyUrl}?url=${encodeURIComponent(url)}` : url; try { const response = await fetchWithTimeout(fetchUrl); if (!response.ok) { throw new Error(`Failed to fetch XMLTV: ${response.statusText}`); } let xmlContent: string; // Check if gzipped (by URL extension or content headers) const isGzipped = url.endsWith('.gz') || response.headers.get('content-encoding') === 'gzip' || response.headers.get('content-type')?.includes('gzip'); if (isGzipped) { // Decompress gzipped content const buffer = await response.arrayBuffer(); const decompressed = ungzip(new Uint8Array(buffer)); xmlContent = new TextDecoder('utf-8').decode(decompressed); } else { xmlContent = await response.text(); } return parseXMLTV(xmlContent, timezone); } catch (error) { // Provide more helpful error messages for common issues if (error instanceof TypeError && error.message.includes('Network request failed')) { throw new Error( `Failed to fetch XMLTV: Unable to connect to server. ` + `Please check the URL and ensure the server is accessible.` ); } if (error instanceof Error) { throw new Error(`Failed to fetch XMLTV: ${error.message}`); } throw new Error(`Failed to fetch XMLTV: Unknown error occurred`); } }