const XTREAM_USER_AGENT = 'VLC/3.0.20 (Linux; x86_64)'; let currentXtreamServerInfo = null; let xtreamData = { live: [], vod: [], series: [] }; let xtreamGroupSelectionResolver = null; function isXtreamUrl(url) { try { const parsedUrl = new URL(url); return parsedUrl.pathname.endsWith('/get.php') && parsedUrl.searchParams.has('username') && parsedUrl.searchParams.has('password'); } catch (e) { return false; } } function handleXtreamUrl(url) { try { const parsedUrl = new URL(url); const host = `${parsedUrl.protocol}//${parsedUrl.hostname}${parsedUrl.port ? ':' + parsedUrl.port : ''}`; const username = parsedUrl.searchParams.get('username'); const password = parsedUrl.searchParams.get('password'); let outputType = 'm3u_plus'; if (parsedUrl.searchParams.has('type')) { const typeParam = parsedUrl.searchParams.get('type'); if (typeParam === 'm3u_plus') outputType = 'm3u_plus'; } if (parsedUrl.searchParams.has('output')) { const outputParam = parsedUrl.searchParams.get('output'); if (outputParam === 'ts') outputType = 'ts'; else if (outputParam === 'hls' || outputParam === 'm3u8') outputType = 'hls'; } $('#xtreamHostInput').val(host); $('#xtreamUsernameInput').val(username); $('#xtreamPasswordInput').val(password); $('#xtreamOutputTypeSelect').val(outputType); $('#xtreamServerNameInput').val(''); $('#xtreamFetchEpgCheck').prop('checked', true); showXtreamConnectionModal(); if (typeof showNotification === 'function') showNotification("Datos de URL Xtream precargados en el modal.", "info"); } catch (e) { if (typeof showNotification === 'function') showNotification("URL Xtream inválida.", "error"); console.error("Error parsing Xtream URL:", e); } } async function showXtreamConnectionModal() { if (typeof dbPromise === 'undefined' || !dbPromise) { if (typeof showLoading === 'function') showLoading(true, 'Iniciando base de datos local...'); try { if (typeof openDB === 'function') await openDB(); } catch (error) { if (typeof showNotification === 'function') showNotification(`Error DB: ${error.message}`, 'error'); if (typeof showLoading === 'function') showLoading(false); return; } finally { if (typeof showLoading === 'function') showLoading(false); } } $('#xtreamConnectionModal').modal('show'); loadSavedXtreamServers(); } async function loadSavedXtreamServers() { if (typeof showLoading === 'function') showLoading(true, 'Cargando servidores Xtream guardados...'); try { const servers = typeof getAllXtreamServersFromDB === 'function' ? await getAllXtreamServersFromDB() : []; const $list = $('#savedXtreamServersList').empty(); if (!servers || servers.length === 0) { $list.append('
  • No hay servidores guardados.
  • '); } else { servers.sort((a,b) => (b.timestamp || 0) - (a.timestamp || 0)); servers.forEach(server => { const serverDisplayName = server.name || server.host; $list.append(`
  • ${escapeHtml(serverDisplayName)} ${escapeHtml(server.host)}
  • `); }); } } catch (error) { if (typeof showNotification === 'function') showNotification(`Error cargando servidores Xtream: ${error.message}`, 'error'); $('#savedXtreamServersList').empty().append('
  • Error al cargar servidores.
  • '); } finally { if (typeof showLoading === 'function') showLoading(false); } } async function fetchXtreamData(action = null, params = {}, currentServer = null) { const serverToUse = currentServer || currentXtreamServerInfo; if (!serverToUse || !serverToUse.host || !serverToUse.username || !serverToUse.password) { throw new Error("Datos del servidor Xtream no configurados."); } let url = `${serverToUse.host.replace(/\/$/, '')}/player_api.php?username=${encodeURIComponent(serverToUse.username)}&password=${encodeURIComponent(serverToUse.password)}`; if (action) { url += `&action=${action}`; } if (params) { for (const key in params) { url += `&${key}=${encodeURIComponent(params[key])}`; } } const response = await fetch(url, { headers: { 'User-Agent': XTREAM_USER_AGENT }}); if (!response.ok) { throw new Error(`Error API Xtream (${action || 'base'}): ${response.status}`); } const data = await response.json(); if (data && data.user_info && data.user_info.auth === 0) { throw new Error(`Autenticación fallida con el servidor Xtream: ${data.user_info.status || 'Error desconocido'}`); } return data; } function buildM3UFromString(items) { let m3uString = "#EXTM3U\n"; items.forEach(ch => { let attributesString = `tvg-id="${ch['tvg-id'] || ''}" tvg-logo="${ch['tvg-logo'] || ''}" group-title="${ch['group-title'] || ''}"`; if (ch.attributes) { for (const key in ch.attributes) { attributesString += ` ${key}="${ch.attributes[key]}"`; } } m3uString += `#EXTINF:-1 ${attributesString},${ch.name || ''}\n${ch.url || ''}\n`; }); return m3uString; } function showXtreamGroupSelectionModal(categories) { return new Promise((resolve) => { xtreamGroupSelectionResolver = resolve; const { live, vod, series } = categories; const liveCol = $('#xtreamLiveGroupsCol').hide(); const vodCol = $('#xtreamVodGroupsCol').hide(); const seriesCol = $('#xtreamSeriesGroupsCol').hide(); const setupGroup = (col, listEl, btnSelect, btnDeselect, cats, type) => { listEl.empty(); if (cats && cats.length > 0) { cats.forEach(cat => listEl.append(`
  • `)); btnSelect.off('click').on('click', () => listEl.find('input[type="checkbox"]').prop('checked', true)); btnDeselect.off('click').on('click', () => listEl.find('input[type="checkbox"]').prop('checked', false)); col.show(); } else { listEl.append('
  • No disponible
  • '); if(cats) col.show(); } }; setupGroup(liveCol, $('#xtreamLiveGroupList'), $('#xtreamSelectAllLive'), $('#xtreamDeselectAllLive'), live, 'live'); setupGroup(vodCol, $('#xtreamVodGroupList'), $('#xtreamSelectAllVod'), $('#xtreamDeselectAllVod'), vod, 'vod'); setupGroup(seriesCol, $('#xtreamSeriesGroupList'), $('#xtreamSelectAllSeries'), $('#xtreamDeselectAllSeries'), series, 'series'); const groupSelectionModal = new bootstrap.Modal(document.getElementById('xtreamGroupSelectionModal')); groupSelectionModal.show(); }); } function handleXtreamGroupSelection() { const selectedGroups = { live: [], vod: [], series: [] }; $('#xtreamLiveGroupList input:checked').each(function() { selectedGroups.live.push($(this).val()); }); $('#xtreamVodGroupList input:checked').each(function() { selectedGroups.vod.push($(this).val()); }); $('#xtreamSeriesGroupList input:checked').each(function() { selectedGroups.series.push($(this).val()); }); if (xtreamGroupSelectionResolver) { xtreamGroupSelectionResolver(selectedGroups); xtreamGroupSelectionResolver = null; } const groupSelectionModal = bootstrap.Modal.getInstance(document.getElementById('xtreamGroupSelectionModal')); if (groupSelectionModal) { groupSelectionModal.hide(); } } async function handleConnectXtreamServer() { const host = $('#xtreamHostInput').val().trim(); const username = $('#xtreamUsernameInput').val().trim(); const password = $('#xtreamPasswordInput').val(); const outputType = $('#xtreamOutputTypeSelect').val(); const fetchEpgFlag = $('#xtreamFetchEpgCheck').is(':checked'); const forceGroupSelection = $('#xtreamForceGroupSelectionCheck').is(':checked'); const loadLive = $('#xtreamLoadLive').is(':checked'); const loadVod = $('#xtreamLoadVod').is(':checked'); const loadSeries = $('#xtreamLoadSeries').is(':checked'); const serverName = $('#xtreamServerNameInput').val().trim() || host; if (!host || !username || !password) { showNotification('Host, usuario y contraseña son obligatorios.', 'warning'); return; } if (!loadLive && !loadVod && !loadSeries) { showNotification('Debes seleccionar al menos un tipo de contenido para cargar.', 'warning'); return; } currentXtreamServerInfo = { host, username, password, outputType, name: serverName, fetchEpg: fetchEpgFlag }; showLoading(true, `Conectando a Xtream: ${escapeHtml(serverName)}...`); try { const playerApiData = await fetchXtreamData(); displayXtreamInfoBar(playerApiData); const existingServer = (await getAllXtreamServersFromDB()).find(s => s.host === host && s.username === username); let selectedGroupIds; if (existingServer && existingServer.id) { currentXtreamServerInfo.id = existingServer.id; } if (existingServer && existingServer.selectedGroups && !forceGroupSelection) { selectedGroupIds = existingServer.selectedGroups; showNotification('Usando selección de grupos guardada para este servidor.', 'info'); } else { showLoading(true, 'Obteniendo categorías...'); let categoryPromises = []; if (loadLive) categoryPromises.push(fetchXtreamData('get_live_categories').catch(e => { console.error("Error fetching live categories:", e); return null; })); else categoryPromises.push(Promise.resolve(null)); if (loadVod) categoryPromises.push(fetchXtreamData('get_vod_categories').catch(e => { console.error("Error fetching vod categories:", e); return null; })); else categoryPromises.push(Promise.resolve(null)); if (loadSeries) categoryPromises.push(fetchXtreamData('get_series_categories').catch(e => { console.error("Error fetching series categories:", e); return null; })); else categoryPromises.push(Promise.resolve(null)); const [liveCategories, vodCategories, seriesCategories] = await Promise.all(categoryPromises); $('#xtreamConnectionModal').modal('hide'); showLoading(false); selectedGroupIds = await showXtreamGroupSelectionModal({ live: liveCategories, vod: vodCategories, series: seriesCategories }); currentXtreamServerInfo.selectedGroups = selectedGroupIds; await saveXtreamServerToDB(currentXtreamServerInfo); } showLoading(true, `Cargando streams seleccionados de Xtream...`); let streamPromises = []; if (loadLive && selectedGroupIds.live.length > 0) streamPromises.push(fetchXtreamData('get_live_streams').catch(e => [])); else streamPromises.push(Promise.resolve([])); if (loadVod && selectedGroupIds.vod.length > 0) streamPromises.push(fetchXtreamData('get_vod_streams').catch(e => [])); else streamPromises.push(Promise.resolve([])); if (loadSeries && selectedGroupIds.series.length > 0) streamPromises.push(fetchXtreamData('get_series').catch(e => [])); else streamPromises.push(Promise.resolve([])); let [liveStreams, vodStreams, seriesStreams] = await Promise.all(streamPromises); const allCategories = await Promise.all([ loadLive ? fetchXtreamData('get_live_categories') : Promise.resolve([]), loadVod ? fetchXtreamData('get_vod_categories') : Promise.resolve([]), loadSeries ? fetchXtreamData('get_series_categories') : Promise.resolve([]) ]).then(([live, vod, series]) => [...(live||[]), ...(vod||[]), ...(series||[])]); const categoryMap = {}; allCategories.forEach(cat => categoryMap[cat.category_id] = cat.category_name); xtreamData.live = transformXtreamItems(liveStreams, 'live', currentXtreamServerInfo, categoryMap).filter(item => selectedGroupIds.live.includes(item.attributes['category_id'])); xtreamData.vod = transformXtreamItems(vodStreams, 'vod', currentXtreamServerInfo, categoryMap).filter(item => selectedGroupIds.vod.includes(item.attributes['category_id'])); xtreamData.series = transformXtreamItems(seriesStreams, 'series', currentXtreamServerInfo, categoryMap).filter(item => selectedGroupIds.series.includes(item.attributes['category_id'])); channels = [...xtreamData.live, ...xtreamData.vod, ...xtreamData.series]; currentM3UContent = buildM3UFromString(channels); currentM3UName = `Xtream: ${serverName}`; currentGroupOrder = [...new Set(channels.map(c => c['group-title']))].sort(); if(userSettings.autoSaveM3U) { localStorage.setItem('currentXtreamServerInfo', JSON.stringify(currentXtreamServerInfo)); localStorage.removeItem('lastM3UUrl'); localStorage.removeItem('lastM3UFileContent'); localStorage.removeItem('lastM3UFileName'); } $('#xtreamConnectionModal').modal('hide'); displayXtreamRootView(); if (fetchEpgFlag) { const epgUrl = `${currentXtreamServerInfo.host.replace(/\/$/, '')}/xmltv.php?username=${encodeURIComponent(currentXtreamServerInfo.username)}&password=${encodeURIComponent(currentXtreamServerInfo.password)}`; if (typeof loadEpgFromUrl === 'function') { loadEpgFromUrl(epgUrl).catch(err => { console.error("Error cargando EPG de Xtream en segundo plano:", err); if (typeof showNotification === 'function') { showNotification('Fallo al cargar EPG de Xtream: ' + err.message, 'error'); } }); } } } catch (error) { showNotification(`Error conectando a Xtream: ${error.message}`, 'error'); hideXtreamInfoBar(); } finally { showLoading(false); } } function displayXtreamRootView() { navigationHistory = []; currentView = { type: 'main' }; renderCurrentView(); showNotification(`Xtream: Canales cargados. Live: ${xtreamData.live.length}, VOD: ${xtreamData.vod.length}, Series: ${xtreamData.series.length}`, "success"); } function transformXtreamItems(items, type, serverInfo, categoryMap) { if (!Array.isArray(items)) return []; return items.map(item => { let baseObject = { 'group-title': categoryMap[item.category_id] || `Xtream ${type}`, attributes: {'category_id': item.category_id}, kodiProps: {}, vlcOptions: {}, extHttp: {}, sourceOrigin: `xtream-${serverInfo.name || serverInfo.host}` }; if (type === 'live') { let streamUrl; const serverHost = serverInfo.host.replace(/\/$/, ''); const ds = item.direct_source ? item.direct_source.trim() : ''; if (ds) { try { new URL(ds); streamUrl = ds; } catch (e) { streamUrl = `${serverHost}${ds.startsWith('/') ? '' : '/'}${ds}`; } } else { let extension; switch (serverInfo.outputType) { case 'ts': extension = 'ts'; break; case 'hls': case 'm3u_plus': default: extension = 'm3u8'; break; } streamUrl = `${serverHost}/live/${serverInfo.username}/${serverInfo.password}/${item.stream_id}.${extension}`; } return { ...baseObject, name: item.name, url: streamUrl, 'tvg-id': item.epg_channel_id || `xtream.${item.stream_id}`, 'tvg-logo': item.stream_icon || '', attributes: { ...baseObject.attributes, 'xtream-type': 'live', 'stream-id': item.stream_id } }; } if (type === 'vod') { const vodInfo = item.info || {}; return { ...baseObject, name: item.name, url: `${serverInfo.host.replace(/\/$/, '')}/movie/${serverInfo.username}/${serverInfo.password}/${item.stream_id}.${item.container_extension || 'mp4'}`, 'tvg-id': `vod.${item.stream_id}`, 'tvg-logo': item.stream_icon || vodInfo.movie_image || '', attributes: { ...baseObject.attributes, 'xtream-type': 'vod', 'stream-id': item.stream_id, 'xtream-info': JSON.stringify(vodInfo) } }; } if (type === 'series') { return { ...baseObject, name: item.name, url: `#xtream-series-${item.series_id}`, 'tvg-id': `series.${item.series_id}`, 'tvg-logo': item.cover || (item.backdrop_path && item.backdrop_path[0]) || '', attributes: { ...baseObject.attributes, 'xtream-type': 'series', 'xtream-series-id': item.series_id, 'xtream-info': JSON.stringify(item) } }; } return null; }).filter(Boolean); } async function loadXtreamSeasons(seriesId, seriesName) { if (!currentXtreamServerInfo) { showNotification("No hay servidor Xtream activo para cargar las temporadas.", "warning"); return null; } showLoading(true, `Cargando temporadas para: ${escapeHtml(seriesName)}`); try { const seriesData = await fetchXtreamData('get_series_info', { series_id: seriesId }); const seasons = []; if (seriesData && seriesData.episodes) { const seriesInfo = seriesData.info || {}; const sortedSeasonKeys = Object.keys(seriesData.episodes).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); for (const seasonNumber of sortedSeasonKeys) { seasons.push({ name: `Temporada ${seasonNumber}`, 'tvg-logo': seriesInfo.cover || '', 'group-title': seriesName, season_number: seasonNumber, series_id: seriesId }); } } return seasons; } catch (error) { showNotification(`Error cargando temporadas: ${error.message}`, 'error'); return null; } finally { showLoading(false); } } async function loadXtreamSeasonEpisodes(seriesId, seasonNumber) { if (!currentXtreamServerInfo) { showNotification("No hay servidor Xtream activo para cargar los episodios.", "warning"); return null; } showLoading(true, `Cargando episodios para la temporada ${seasonNumber}...`); try { const seriesData = await fetchXtreamData('get_series_info', { series_id: seriesId }); const episodes = []; const seriesInfo = seriesData.info || {}; if (seriesData && seriesData.episodes && seriesData.episodes[seasonNumber]) { const episodesInSeason = seriesData.episodes[seasonNumber]; episodesInSeason.sort((a,b) => (a.episode_num || 0) - (b.episode_num || 0)); episodesInSeason.forEach(ep => { const episodeNum = ep.episode_num || 0; const episodeInfo = ep.info || {}; const containerExtension = ep.container_extension || 'mp4'; episodes.push({ name: `${ep.title || 'Episodio ' + episodeNum} (T${seasonNumber}E${episodeNum})`, url: `${currentXtreamServerInfo.host.replace(/\/$/, '')}/series/${currentXtreamServerInfo.username}/${currentXtreamServerInfo.password}/${ep.id}.${containerExtension}`, 'tvg-id': `series.ep.${ep.id}`, 'tvg-logo': episodeInfo.movie_image || seriesInfo.cover || '', 'group-title': `${seriesInfo.name} - Temporada ${seasonNumber}`, attributes: { 'xtream-type': 'episode', 'stream-id': ep.id }, kodiProps: {}, vlcOptions: {}, extHttp: {}, sourceOrigin: `xtream-${currentXtreamServerInfo.name || currentXtreamServerInfo.host}` }); }); } return episodes; } catch (error) { showNotification(`Error cargando episodios: ${error.message}`, 'error'); return null; } finally { showLoading(false); } } async function loadXtreamSeriesEpisodes(seriesId, seriesName) { if (!currentXtreamServerInfo) { showNotification("No hay servidor Xtream activo para cargar episodios.", "warning"); return; } showLoading(true, `Cargando episodios para: ${escapeHtml(seriesName)}`); try { const seriesData = await fetchXtreamData('get_series_info', { series_id: seriesId }); let episodesForGrid = []; const seriesInfo = seriesData.info || {}; if (seriesData && seriesData.episodes && typeof seriesData.episodes === 'object') { const seasons = seriesData.episodes; const sortedSeasonKeys = Object.keys(seasons).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); for (const seasonNumber of sortedSeasonKeys) { const episodesInSeason = seasons[seasonNumber]; if (Array.isArray(episodesInSeason)) { episodesInSeason.sort((a,b) => (a.episode_num || a.episode_number || 0) - (b.episode_num || b.episode_number || 0)); episodesInSeason.forEach(ep => { const episodeNum = ep.episode_num || ep.episode_number; const episodeInfo = ep.info || {}; const containerExtension = ep.container_extension || 'mp4'; episodesForGrid.push({ name: `${ep.title || 'Episodio ' + episodeNum} (T${ep.season || seasonNumber}E${episodeNum})`, url: `${currentXtreamServerInfo.host.replace(/\/$/, '')}/series/${currentXtreamServerInfo.username}/${currentXtreamServerInfo.password}/${ep.id}.${containerExtension}`, 'tvg-id': `series.ep.${ep.id}`, 'tvg-logo': episodeInfo.movie_image || seriesInfo.cover || '', 'group-title': `${seriesName} - Temporada ${ep.season || seasonNumber}`, attributes: { 'xtream-type': 'episode', 'stream-id': ep.id }, kodiProps: {}, vlcOptions: {}, extHttp: {}, sourceOrigin: `xtream-${currentXtreamServerInfo.name || currentXtreamServerInfo.host}` }); }); } } } if (episodesForGrid.length > 0) { pushNavigationState(); currentView = { type: 'episode_list', data: episodesForGrid, title: seriesName }; renderCurrentView(); showNotification(`${episodesForGrid.length} episodios cargados.`, 'success'); } else { showNotification(`No se encontraron episodios para ${escapeHtml(seriesName)}.`, 'info'); } } catch (error) { showNotification(`Error cargando episodios: ${error.message}`, 'error'); } finally { showLoading(false); } } async function handleSaveXtreamServer() { const serverName = $('#xtreamServerNameInput').val().trim(); const host = $('#xtreamHostInput').val().trim(); const username = $('#xtreamUsernameInput').val().trim(); const password = $('#xtreamPasswordInput').val(); const outputType = $('#xtreamOutputTypeSelect').val(); const fetchEpg = $('#xtreamFetchEpgCheck').is(':checked'); if (!host || !username || !password) { if (typeof showNotification === 'function') showNotification('Host, usuario y contraseña son obligatorios para guardar.', 'warning'); return; } const serverData = { name: serverName || host, host, username, password, outputType, fetchEpg }; if (typeof showLoading === 'function') showLoading(true, `Guardando servidor Xtream: ${escapeHtml(serverData.name)}...`); try { await saveXtreamServerToDB(serverData); if (typeof showNotification === 'function') showNotification(`Servidor Xtream "${escapeHtml(serverData.name)}" guardado.`, 'success'); loadSavedXtreamServers(); $('#xtreamServerNameInput, #xtreamHostInput, #xtreamUsernameInput, #xtreamPasswordInput').val(''); } catch (error) { if (typeof showNotification === 'function') showNotification(`Error al guardar servidor: ${error.message}`, 'error'); } finally { if (typeof showLoading === 'function') showLoading(false); } } async function loadXtreamServerToForm(id) { if (typeof showLoading === 'function') showLoading(true, "Cargando datos del servidor..."); try { const server = await getXtreamServerFromDB(id); if (server) { $('#xtreamServerNameInput').val(server.name || ''); $('#xtreamHostInput').val(server.host || ''); $('#xtreamUsernameInput').val(server.username || ''); $('#xtreamPasswordInput').val(server.password || ''); $('#xtreamOutputTypeSelect').val(server.outputType || 'm3u_plus'); $('#xtreamFetchEpgCheck').prop('checked', typeof server.fetchEpg === 'boolean' ? server.fetchEpg : true); if (typeof showNotification === 'function') showNotification(`Datos del servidor "${escapeHtml(server.name || server.host)}" cargados.`, 'info'); } else { if (typeof showNotification === 'function') showNotification('Servidor no encontrado.', 'error'); } } catch (error) { if (typeof showNotification === 'function') showNotification(`Error al cargar servidor: ${error.message}`, 'error'); } finally { if (typeof showLoading === 'function') showLoading(false); } } async function handleDeleteXtreamServer(id) { const serverToDelete = await getXtreamServerFromDB(id); const serverName = serverToDelete ? (serverToDelete.name || serverToDelete.host) : 'este servidor'; if (!confirm(`¿Estás seguro de eliminar el servidor Xtream "${escapeHtml(serverName)}"?`)) return; if (typeof showLoading === 'function') showLoading(true, `Eliminando servidor "${escapeHtml(serverName)}"...`); try { await deleteXtreamServerFromDB(id); if (typeof showNotification === 'function') showNotification(`Servidor Xtream "${escapeHtml(serverName)}" eliminado.`, 'success'); loadSavedXtreamServers(); } catch (error) { if (typeof showNotification === 'function') showNotification(`Error al eliminar servidor: ${error.message}`, 'error'); } finally { if (typeof showLoading === 'function') showLoading(false); } }