569 lines
28 KiB
JavaScript
569 lines
28 KiB
JavaScript
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('<li class="list-group-item text-secondary text-center">No hay servidores guardados.</li>');
|
|
} else {
|
|
servers.sort((a,b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
servers.forEach(server => {
|
|
const serverDisplayName = server.name || server.host;
|
|
$list.append(`
|
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
<div style="flex-grow: 1; margin-right: 1rem; overflow: hidden; cursor:pointer;" class="load-xtream-server-btn" data-id="${server.id}">
|
|
<strong title="${escapeHtml(serverDisplayName)}">${escapeHtml(serverDisplayName)}</strong>
|
|
<small class="text-secondary d-block">${escapeHtml(server.host)}</small>
|
|
</div>
|
|
<button class="btn-control btn-sm delete-xtream-server-btn" data-id="${server.id}" title="Eliminar servidor"></button>
|
|
</li>`);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (typeof showNotification === 'function') showNotification(`Error cargando servidores Xtream: ${error.message}`, 'error');
|
|
$('#savedXtreamServersList').empty().append('<li class="list-group-item text-danger text-center">Error al cargar servidores.</li>');
|
|
} 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(`<li class="list-group-item"><div class="form-check"><input class="form-check-input" type="checkbox" value="${cat.category_id}" id="xtream_${type}_${cat.category_id}" checked><label class="form-check-label" for="xtream_${type}_${cat.category_id}">${escapeHtml(cat.category_name)}</label></div></li>`));
|
|
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('<li class="list-group-item text-secondary">No disponible</li>');
|
|
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);
|
|
}
|
|
} |