import type { Channel } from '../types'; import { sanitizeUrl, sanitizeString, sanitizeCategory, validateM3UStructure, countChannels, validateChannelCount, } from './m3u-sanitize'; /** * Default request timeout in milliseconds */ const DEFAULT_TIMEOUT = 60000; /** * User-Agent string for M3U fetch requests */ const USER_AGENT = 'Visioo/1.0 (IPTV Player)'; /** * Make a fetch request with timeout and proper headers */ async function fetchWithTimeout(url: string, 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': 'text/plain, application/x-mpegURL, */*', 'User-Agent': USER_AGENT, }, }); return response; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${timeout}ms`); } throw error; } finally { clearTimeout(timeoutId); } } /** * Extract EPG URL from M3U playlist header * M3U format: #EXTM3U url-tvg="http://example.com/epg.xml" * @param content - M3U playlist content as string * @returns EPG URL if found, undefined otherwise */ export function extractEPGUrlFromM3U(content: string): string | undefined { const lines = content.split('\n'); for (const line of lines) { if (line.startsWith('#EXTM3U')) { // Extract url-tvg attribute const urlTvgMatch = line.match(/url-tvg="([^"]*)"/i); if (urlTvgMatch && urlTvgMatch[1]) { const epgUrl = urlTvgMatch[1].trim(); if (epgUrl && (epgUrl.startsWith('http://') || epgUrl.startsWith('https://'))) { return epgUrl; } } } } return undefined; } /** * Parse M3U playlist content into Channel array * @param content - M3U playlist content as string * @param providerId - ID of the provider this playlist belongs to * @returns Array of Channel objects * @throws Error if content is invalid or exceeds limits */ export function parseM3U(content: string, providerId: string): Channel[] { // Validate M3U structure and size const validation = validateM3UStructure(content); if (!validation.isValid) { throw new Error(`Invalid M3U content: ${validation.error}`); } // Check channel count before parsing const channelCount = countChannels(content); if (!validateChannelCount(channelCount)) { throw new Error( `Too many channels: ${channelCount} exceeds maximum of 5000 channels` ); } const lines = content.split('\n').map((line) => line.trim()); const channels: Channel[] = []; let currentChannel: Partial | null = null; let channelIndex = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Skip empty lines and the header if (!line || line.startsWith('#EXTM3U')) continue; // Parse channel info line (#EXTINF) if (line.startsWith('#EXTINF:')) { currentChannel = { id: `${providerId}-${Date.now()}-${channelIndex++}`, providerId, }; // Extract channel name (last part after commas) and sanitize const nameParts = line.split(','); const rawName = nameParts[nameParts.length - 1].trim(); const sanitizedName = sanitizeString(rawName); if (!sanitizedName) { // Skip channels with invalid names currentChannel = null; continue; } currentChannel.name = sanitizedName; // Extract tvg-logo attribute and sanitize const logoMatch = line.match(/tvg-logo="([^"]*)"/); if (logoMatch) { const logoUrl = sanitizeUrl(logoMatch[1]); if (logoUrl) { // Keep original URL - many provider image servers don't support HTTPS currentChannel.logoUrl = logoUrl; } } // Extract tvg-id attribute and sanitize const idMatch = line.match(/tvg-id="([^"]*)"/); if (idMatch) { const sanitizedId = sanitizeString(idMatch[1], 100); if (sanitizedId) { currentChannel.tvgId = sanitizedId; } } // Extract tvg-name attribute and sanitize const nameMatch = line.match(/tvg-name="([^"]*)"/); if (nameMatch) { const sanitizedTvgName = sanitizeString(nameMatch[1]); if (sanitizedTvgName) { currentChannel.tvgName = sanitizedTvgName; } } // Extract group-title attribute (category) and sanitize const groupMatch = line.match(/group-title="([^"]*)"/); if (groupMatch) { const sanitizedCategory = sanitizeCategory(groupMatch[1]); if (sanitizedCategory) { currentChannel.category = sanitizedCategory; currentChannel.groupTitle = sanitizedCategory; } } } else if (line.startsWith('http') && currentChannel) { // Stream URL line - sanitize and validate const sanitizedStreamUrl = sanitizeUrl(line); if (!sanitizedStreamUrl) { // Skip channels with invalid stream URLs currentChannel = null; continue; } // Keep original URL - many provider servers don't support HTTPS currentChannel.streamUrl = sanitizedStreamUrl; // Add to channels array if valid (name and streamUrl are required) if (currentChannel.name && currentChannel.streamUrl) { channels.push({ ...currentChannel, type: 'live' as const, } as Channel); } currentChannel = null; } } return channels; } /** * Options for fetching M3U playlists */ export interface FetchM3UOptions { /** * Proxy URL for HTTP requests (useful for iOS which blocks HTTP fetch) * The proxy should accept a `url` query parameter * Example: https://web.visioo.online/api/proxy/m3u */ proxyUrl?: string; } /** * Fetch M3U playlist from URL and parse it * @param url - URL to fetch M3U playlist from * @param providerId - ID of the provider * @param options - Optional configuration (e.g., proxy URL) * @returns Object with channels and EPG URL (if found) * @throws Error if fetch fails or content is invalid */ export async function fetchAndParseM3U( url: string, providerId: string, options?: FetchM3UOptions ): Promise<{ channels: Channel[]; epgUrl?: string }> { // Validate URL before fetching const sanitizedUrl = sanitizeUrl(url); if (!sanitizedUrl) { throw new Error('Invalid M3U URL: URL must be a valid HTTP(S) URL'); } // Use proxy for HTTP URLs if proxyUrl is provided const isHttpUrl = sanitizedUrl.startsWith('http://'); const useProxy = isHttpUrl && options?.proxyUrl; const fetchUrl = useProxy ? `${options.proxyUrl}?url=${encodeURIComponent(sanitizedUrl)}` : sanitizedUrl; if (useProxy) { console.log(`[M3U Parser] Using proxy for HTTP URL`); } try { const response = await fetchWithTimeout(fetchUrl); if (!response.ok) { // Try to get error details from proxy response if (useProxy) { try { const errorData = await response.json(); throw new Error(`Failed to fetch M3U: ${errorData.error || errorData.message || response.statusText}`); } catch (e) { if (e instanceof SyntaxError) { // Not JSON, continue with default error handling } else { throw e; } } } throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`); } // Check content type if available (skip for proxy which returns text/plain) if (!useProxy) { const contentType = response.headers.get('content-type'); if (contentType && !contentType.includes('text/') && !contentType.includes('application/')) { throw new Error(`Invalid content type: expected text/plain or application/x-mpegURL, got ${contentType}`); } } // Check content length if available const contentLength = response.headers.get('content-length'); if (contentLength) { const size = parseInt(contentLength, 10); if (size > 10 * 1024 * 1024) { throw new Error(`Content too large: ${size} bytes exceeds maximum of 10MB`); } } const content = await response.text(); const channels = parseM3U(content, providerId); const epgUrl = extractEPGUrlFromM3U(content); return { channels, epgUrl }; } 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 M3U: 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 and parse M3U: ${error.message}`); } throw new Error(`Failed to fetch and parse M3U: Unknown error occurred`); } } /** * Validate if a string is a valid M3U playlist * @param content - Content to validate * @returns true if valid M3U, false otherwise */ export function isValidM3U(content: string): boolean { return content.trim().startsWith('#EXTM3U'); }