import type { XtreamAuthResponse, XtreamCategory, XtreamChannel, XtreamVOD, XtreamSeries, XtreamSeriesInfo, Channel, Movie, Series, } from '../types'; import { normalizeProviderUrl, getProtocolVariants } from '../utils/url'; import { extractContentMetadata } from '../utils/content-metadata'; /** * Default request timeout in milliseconds */ const DEFAULT_TIMEOUT = 30000; /** * User-Agent string for Xtream API requests * Many Xtream servers block requests without a valid User-Agent */ const USER_AGENT = 'Visioo/1.0 (IPTV Player)'; /** * Xtream Codes API Client * Handles authentication and fetching channels from Xtream Codes providers */ export class XtreamClient { private baseUrl: string; private originalBaseUrl: string; private username: string; private password: string; private authInfo?: XtreamAuthResponse; private proxyUrl?: string; private timeout: number; private preferredProtocol?: 'http' | 'https'; constructor(serverUrl: string, username: string, password: string, proxyUrl?: string, timeout = DEFAULT_TIMEOUT) { // Normalize the URL - ensure it has a protocol const normalized = normalizeProviderUrl(serverUrl, false) || serverUrl; // Remove trailing slash from server URL this.originalBaseUrl = normalized.replace(/\/$/, ''); this.baseUrl = this.originalBaseUrl; this.username = username; this.password = password; this.proxyUrl = proxyUrl; this.timeout = timeout; } /** * Make a fetch request with timeout and proper headers * Configured for React Native compatibility */ private async fetchWithTimeout(url: string, options: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { // React Native fetch configuration // Note: Don't set Content-Type for GET requests - some servers reject it const fetchOptions: RequestInit = { ...options, method: options.method || 'GET', signal: controller.signal, headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT, ...options.headers, // Only add Content-Type for non-GET requests ...(options.method && options.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}), }, // React Native specific: don't cache cache: 'no-store', }; // Log the fetch attempt console.log(`[XtreamClient] Fetching URL: ${url.replace(/password=[^&]*/, 'password=***')}`); // Try fetch with minimal options first let response: Response; try { response = await fetch(url, fetchOptions); console.log(`[XtreamClient] Fetch successful: ${response.status} ${response.statusText}`); } catch (fetchError) { console.error(`[XtreamClient] Fetch failed:`, fetchError); // If fetch fails, try with even more minimal options (React Native iOS compatibility) console.log(`[XtreamClient] Retrying with minimal options...`); const minimalOptions: RequestInit = { method: 'GET', signal: controller.signal, headers: { 'Accept': 'application/json', }, }; try { response = await fetch(url, minimalOptions); console.log(`[XtreamClient] Minimal fetch successful: ${response.status} ${response.statusText}`); } catch (minimalError) { console.error(`[XtreamClient] Minimal fetch also failed:`, minimalError); throw fetchError; // Throw original error } } return response; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${this.timeout}ms`); } // Provide more detailed error for network failures if (error instanceof TypeError) { if (error.message.includes('Network request failed') || error.message.includes('Failed to fetch')) { throw new Error( `Network request failed. This could be due to:\n` + `1. Server is unreachable from your network\n` + `2. Server is blocking mobile app requests\n` + `3. iOS App Transport Security restrictions (ensure app is rebuilt after ATS changes)\n` + `4. Firewall or VPN blocking the connection\n\n` + `URL attempted: ${url}` ); } } throw error; } finally { clearTimeout(timeoutId); } } /** * Make a request and parse JSON response * Uses fetch for both HTTP and HTTPS (better iOS Simulator compatibility) * Tries both HTTP and HTTPS if the initial request fails */ private async makeRequest(url: string, tryAlternateProtocol = true): Promise { let lastError: Error | null = null; try { // Use fetch for both HTTP and HTTPS (works better in iOS Simulator) // XHR can have issues with iOS Simulator networking const response = await this.fetchWithTimeout(url, { method: 'GET' }); if (!response.ok) { // Try to read the response body for more details let errorDetails = `HTTP ${response.status}: ${response.statusText}`; try { const text = await response.text(); if (text) { try { const json = JSON.parse(text); // Handle proxy error responses (from /api/proxy/xtream) if (json.error) { // Proxy returns { error: "...", details: {...} } if (json.details) { if (typeof json.details === 'object' && json.details.message) { errorDetails = `HTTP ${response.status}: ${json.details.message}`; } else if (typeof json.details === 'object' && json.details.statusText) { errorDetails = `HTTP ${response.status}: ${json.details.statusText}`; } else { errorDetails = `HTTP ${response.status}: ${json.error}`; } } else { errorDetails = `HTTP ${response.status}: ${json.error}`; } } else if (json.message) { // Direct error responses errorDetails = `HTTP ${response.status}: ${json.message}`; } else { // Try to extract any useful info from the JSON errorDetails = `HTTP ${response.status}: ${JSON.stringify(json).substring(0, 200)}`; } } catch { // Not JSON, use as text (limit length) errorDetails = `HTTP ${response.status}: ${text.substring(0, 200)}`; } } } catch (e) { // Failed to read response body, use status text console.warn(`[XtreamClient] Could not read error response body:`, e); } console.log(`[XtreamClient] Error response details:`, errorDetails); throw new Error(errorDetails); } // Read as text first for better error handling const contentType = response.headers.get('content-type'); const text = await response.text(); console.log(`[XtreamClient] Response Content-Type: ${contentType}`); console.log(`[XtreamClient] Response length: ${text.length}`); try { return JSON.parse(text) as T; } catch (parseError) { console.error(`[XtreamClient] JSON parse failed. Response preview: ${text.substring(0, 500)}`); // Include useful debugging info in the error const preview = text.substring(0, 200).replace(/\n/g, ' ').trim(); const isHtml = text.toLowerCase().includes(' { const url = this.buildRequestUrl(`/player_api.php?username=${this.username}&password=${this.password}`); // Log the base URL being used (without credentials) for debugging console.log(`[XtreamClient] Attempting authentication to: ${this.baseUrl}`); console.log(`[XtreamClient] Full auth URL (masked): ${url.replace(/password=[^&]*/, 'password=***')}`); console.log(`[XtreamClient] Using proxy: ${this.proxyUrl ? 'Yes' : 'No'}`); if (this.proxyUrl) { console.log(`[XtreamClient] Proxy URL: ${this.proxyUrl}`); console.log(`[XtreamClient] Final request URL (masked): ${url.replace(/password=[^&]*/, 'password=***')}`); } else { console.warn(`[XtreamClient] ⚠️ No proxy configured - direct connection will likely fail on iOS!`); } // Log platform info for debugging if (typeof navigator !== 'undefined') { console.log(`[XtreamClient] Platform: ${navigator.platform || 'unknown'}`); } try { this.authInfo = await this.makeRequest(url); if (!this.authInfo) { throw new Error('Invalid authentication response'); } if (this.authInfo.user_info?.status !== 'Active') { throw new Error( `Xtream account is not active. Status: ${this.authInfo.user_info?.status}` ); } return this.authInfo; } catch (error) { // Check if it's a network/connection error if (error instanceof TypeError && error.message.includes('Network request failed')) { throw new Error( `Xtream authentication failed: Unable to connect to server. ` + `Please check the server URL and ensure the server is accessible. ` + `Original error: ${error.message}` ); } // Check if it's a CORS error (web only) if (error instanceof TypeError && error.message.includes('Failed to fetch')) { throw new Error( `Xtream authentication failed: CORS error. The IPTV provider's server doesn't allow browser requests. ` + `This is a security restriction. Consider using a backend proxy or contact your provider. ` + `Original error: ${error.message}` ); } // Provide more specific error message const errorMessage = error instanceof Error ? error.message : String(error); // Check for JSON parse errors (often means invalid response) if (errorMessage.includes('JSON') || errorMessage.includes('Unexpected token')) { throw new Error( `Xtream authentication failed: Invalid server response. ` + `The server may not be a valid Xtream Codes server, or the URL may be incorrect. ` + `Please verify the server URL and try again.` ); } // Check for HTTP status errors if (errorMessage.includes('HTTP 401') || errorMessage.includes('HTTP 403')) { throw new Error( `Xtream authentication failed: Invalid username or password. ` + `Please check your credentials and try again.` ); } if (errorMessage.includes('HTTP 404')) { throw new Error( `Xtream authentication failed: Server endpoint not found. ` + `Please verify the server URL is correct and includes the correct port if needed.` ); } if (errorMessage.includes('HTTP 500') || errorMessage.includes('500')) { // Log the full error message for debugging console.error(`[XtreamClient] HTTP 500 error details:`, errorMessage); // Check if it's a proxy error (proxy couldn't reach the server) if (errorMessage.includes('fetch failed') || errorMessage.includes('Proxy request failed')) { throw new Error( `Xtream authentication failed: Unable to connect to the IPTV server. ` + `The proxy server could not reach the Xtream server. ` + `Please verify the server URL is correct and accessible. ` + `If using a proxy, ensure the proxy server can reach the IPTV server.` ); } // Extract the actual server response if available const serverResponse = errorMessage.includes(':') ? errorMessage.split(':').slice(1).join(':').trim() : 'Unknown error'; throw new Error( `Xtream authentication failed: The server returned an error (HTTP 500). ` + `This usually means the server is experiencing issues or the credentials are invalid. ` + `Please verify your username, password, and server URL are correct. ` + `Server response: ${serverResponse}` ); } throw new Error(`Xtream authentication failed: ${errorMessage}`); } } /** * Get live stream categories * @returns Array of categories */ async getCategories(): Promise { const url = this.buildRequestUrl(`/player_api.php?username=${this.username}&password=${this.password}&action=get_live_categories`); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream categories: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get live streams (channels) * @param categoryId - Optional category ID to filter by * @returns Array of Xtream channels */ async getLiveStreams(categoryId?: string): Promise { let apiPath = `/player_api.php?username=${this.username}&password=${this.password}&action=get_live_streams`; if (categoryId) { apiPath += `&category_id=${categoryId}`; } const url = this.buildRequestUrl(apiPath); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream streams: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get EPG URL for this Xtream provider * Xtream Codes API typically provides EPG at: /xmltv.php?username=...&password=... * @returns EPG URL if available */ getEPGUrl(): string { // Xtream Codes standard EPG endpoint return this.buildRequestUrl(`/xmltv.php?username=${this.username}&password=${this.password}`); } /** * Get all channels in our Channel format * @param providerId - ID of the provider * @returns Array of Channel objects */ async getChannels(providerId: string): Promise { try { // Fetch categories and streams in parallel const [categories, streams] = await Promise.all([ this.getCategories(), this.getLiveStreams(), ]); // Create a map of category IDs to names for quick lookup const categoryMap = new Map( categories.map((cat) => [cat.category_id, cat.category_name]) ); // Convert Xtream channels to our Channel format // Use the original baseUrl protocol (don't force HTTPS - server might not support it) const streamBaseUrl = this.baseUrl; return streams.map((stream) => ({ id: `${providerId}-live-${stream.stream_id}`, name: stream.name, streamUrl: `${streamBaseUrl}/live/${this.username}/${this.password}/${stream.stream_id}.m3u8`, // Keep original URL - many provider image servers don't support HTTPS logoUrl: stream.stream_icon || undefined, category: categoryMap.get(stream.category_id), tvgId: stream.epg_channel_id, providerId, type: 'live' as const, added: stream.num, // Order from provider (higher = more recently added) })); } catch (error) { throw new Error(`Failed to get Xtream channels: ${error}`); } } /** * Get VOD categories * @returns Array of VOD categories */ async getVODCategories(): Promise { const url = this.buildRequestUrl(`/player_api.php?username=${this.username}&password=${this.password}&action=get_vod_categories`); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream VOD categories: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get VOD (movies) * @param categoryId - Optional category ID to filter by * @returns Array of VOD items */ async getVOD(categoryId?: string): Promise { let apiPath = `/player_api.php?username=${this.username}&password=${this.password}&action=get_vod_streams`; if (categoryId) { apiPath += `&category_id=${categoryId}`; } const url = this.buildRequestUrl(apiPath); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream VOD: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get all movies in our Movie format * @param providerId - ID of the provider * @returns Array of Movie objects */ async getMovies(providerId: string): Promise { try { const [categories, vodItems] = await Promise.all([ this.getVODCategories(), this.getVOD(), ]); const categoryMap = new Map( categories.map((cat) => [cat.category_id, cat.category_name]) ); return vodItems.map((vod) => { const year = vod.releaseDate ? parseInt(vod.releaseDate.substring(0, 4)) : undefined; const duration = vod.duration ? parseInt(vod.duration.replace(/[^0-9]/g, '')) : undefined; // Use the original baseUrl protocol (don't force HTTPS - server might not support it) const streamBaseUrl = this.baseUrl; // Extract metadata tags (dubbed, subtitled, etc.) const tags = extractContentMetadata(vod.name); // Determine the stream extension // iOS doesn't support MKV/AVI natively - try to use m3u8 (HLS) instead // Most Xtream providers support HLS streaming for VOD content const originalExtension = vod.container_extension || 'mp4'; const unsupportedFormats = ['mkv', 'avi', 'wmv', 'flv', 'webm']; // For unsupported formats, try HLS (m3u8) which is widely supported // The original extension is stored so we can fall back if needed const streamExtension = unsupportedFormats.includes(originalExtension.toLowerCase()) ? 'm3u8' : originalExtension; return { id: `${providerId}-movie-${vod.stream_id}`, name: vod.name, streamUrl: `${streamBaseUrl}/movie/${this.username}/${this.password}/${vod.stream_id}.${streamExtension}`, // Keep original URL - many provider image servers don't support HTTPS logoUrl: vod.stream_icon || undefined, coverUrl: vod.stream_icon || undefined, category: categoryMap.get(vod.category_id), providerId, type: 'movie' as const, year, duration, description: vod.description, rating: vod.rating_5based ? vod.rating_5based / 2 : undefined, added: vod.num, // Order from provider (higher = more recently added) tags: tags.length > 0 ? tags : undefined, // Store original container extension for fallback if HLS doesn't work containerExtension: originalExtension, }; }); } catch (error) { throw new Error(`Failed to get Xtream movies: ${error}`); } } /** * Get Series categories * @returns Array of Series categories */ async getSeriesCategories(): Promise { const url = this.buildRequestUrl(`/player_api.php?username=${this.username}&password=${this.password}&action=get_series_categories`); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream Series categories: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get Series (TV Shows) * @param categoryId - Optional category ID to filter by * @returns Array of Series */ async getSeriesList(categoryId?: string): Promise { let apiPath = `/player_api.php?username=${this.username}&password=${this.password}&action=get_series`; if (categoryId) { apiPath += `&category_id=${categoryId}`; } const url = this.buildRequestUrl(apiPath); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream Series: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get all series in our Series format * @param providerId - ID of the provider * @returns Array of Series objects */ async getSeries(providerId: string): Promise { try { const [categories, seriesList] = await Promise.all([ this.getSeriesCategories(), this.getSeriesList(), ]); const categoryMap = new Map( categories.map((cat) => [cat.category_id, cat.category_name]) ); return seriesList.map((series) => { const tags = extractContentMetadata(series.name); // Extract year from releaseDate if available const year = series.releaseDate ? parseInt(series.releaseDate.substring(0, 4)) : undefined; return { id: `${providerId}-series-${series.series_id}`, name: series.name, streamUrl: '', // Series don't have direct stream URLs, episodes do // Keep original URL - many provider image servers don't support HTTPS logoUrl: series.cover || undefined, coverUrl: series.cover || undefined, category: categoryMap.get(series.category_id), providerId, type: 'series' as const, description: series.plot, rating: series.rating_5based ? series.rating_5based / 2 : undefined, added: series.num, // Order from provider (higher = more recently added) tags: tags.length > 0 ? tags : undefined, // Additional metadata from provider cast: series.cast, director: series.director, genre: series.genre, releaseDate: series.releaseDate, year, }; }); } catch (error) { throw new Error(`Failed to get Xtream series: ${error}`); } } /** * Get series info with episodes * @param seriesId - Series ID * @returns Series info with episodes */ async getSeriesInfo(seriesId: string): Promise { const url = this.buildRequestUrl(`/player_api.php?username=${this.username}&password=${this.password}&action=get_series_info&series_id=${seriesId}`); try { return await this.makeRequest(url); } catch (error) { throw new Error(`Failed to fetch Xtream Series info: ${error instanceof Error ? error.message : String(error)}`); } } /** * Test connection to Xtream server * @returns true if connection successful, false otherwise */ async testConnection(): Promise { try { await this.authenticate(); return true; } catch { return false; } } /** * Get server info (requires authentication) * @returns Server info object */ getServerInfo() { if (!this.authInfo) { throw new Error('Not authenticated. Call authenticate() first.'); } return this.authInfo.server_info; } /** * Get user info (requires authentication) * @returns User info object */ getUserInfo() { if (!this.authInfo) { throw new Error('Not authenticated. Call authenticate() first.'); } return this.authInfo.user_info; } /** * Get full auth response (requires authentication) * @returns Full authentication response with user and server info */ getAuthInfo() { if (!this.authInfo) { throw new Error('Not authenticated. Call authenticate() first.'); } return this.authInfo; } }