CinePlex/js/plex.js
2025-07-02 14:16:25 +02:00

218 lines
9.1 KiB
JavaScript

import { state } from './state.js';
import { getFromDB, clearStore, addItemsToStore } from './db.js';
import { logToConsole, emitirEventoActualizacion, mostrarSpinner, ocultarSpinner, showNotification, fetchWithTimeout, TimeoutError, _ } from './utils.js';
function mapSectionTypeToObjectName(sectionType) {
switch (sectionType) {
case 'movie': return 'movies';
case 'show': return 'series';
case 'artist': return 'artists';
case 'photo': return 'photos';
default: return null;
}
}
async function fetchPlexResources(token, signal) {
const response = await fetchWithTimeout(`https://plex.tv/api/resources?X-Plex-Token=${token}`, { signal }, 10000);
if (!response.ok) throw new Error(`Error ${response.status} obteniendo recursos (token ${token.substring(0, 4)})`);
const data = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(data, "application/xml");
if (xmlDoc.querySelector('parsererror')) throw new Error('Error parseando XML de recursos Plex');
return xmlDoc.querySelectorAll('Device[product="Plex Media Server"]');
}
async function fetchSectionContent(url, signal, timeout = 7000) {
const response = await fetchWithTimeout(url, { signal }, timeout);
if (!response.ok) throw new Error(`Error ${response.status} obteniendo contenido`);
const data = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(data, "text/xml");
if (xmlDoc.querySelector('parsererror')) throw new Error('Error parseando XML de contenido');
return xmlDoc;
}
async function parseAndStoreSectionItems(contentXml, storeName, serverData) {
const type = storeName === 'movies' ? 'movie' : (storeName === 'series' ? 'show' : (storeName === 'artists' ? 'artist' : 'photo'));
let items;
if (type === 'movie' || type === 'show') {
const itemSelector = type === 'movie' ? 'Video' : 'Directory';
items = Array.from(contentXml.querySelectorAll(itemSelector)).map(el => ({
title: el.getAttribute('title'),
year: el.getAttribute('year'),
genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre')
}));
} else if (type === 'artist' || type === 'photo') {
items = Array.from(contentXml.querySelectorAll('Directory')).map(el => ({
id: el.getAttribute('ratingKey'),
title: el.getAttribute('title'),
thumb: el.getAttribute('thumb')
}));
}
const validItems = items.filter(i => i && i.title);
logToConsole(` Encontrados ${validItems.length} items válidos en "${serverData.sectionTitle}".`);
if (validItems.length > 0) {
const entry = {
ip: serverData.ip,
puerto: serverData.puerto,
token: serverData.token,
protocolo: serverData.protocolo,
serverName: serverData.nombre,
titulos: validItems,
tokenPrincipal: serverData.tokenPrincipal
};
await addItemsToStore(storeName, [entry]);
}
}
async function processServer(device, token, tipos, signal) {
const serverName = device.getAttribute('name') || 'Servidor Sin Nombre';
const accessToken = device.getAttribute('accessToken');
if (!accessToken) {
throw new Error(`Servidor: ${serverName} - Sin token de acceso.`);
}
const connections = Array.from(device.querySelectorAll('Connection'));
const connection = connections.find(c => c.getAttribute('local') === '0') || connections.find(c => c.getAttribute('local') === '1');
if (!connection) {
throw new Error(`Servidor: ${serverName} - Sin conexión válida.`);
}
const protocol = connection.getAttribute('protocol');
const address = connection.getAttribute('address');
const port = connection.getAttribute('port');
if (!address || !port) {
throw new Error(`Servidor: ${serverName} - IP o Puerto no encontrados.`);
}
logToConsole(` -> Procesando servidor: ${serverName} (${address}:${port})`);
const connectionData = { ip: address, puerto: port, token: accessToken, protocolo: protocol, tokenPrincipal: token, nombre: serverName };
await addItemsToStore('conexiones_locales', [connectionData]);
const sectionsUrl = `${protocol}://${address}:${port}/library/sections?X-Plex-Token=${accessToken}`;
const sectionsXml = await fetchSectionContent(sectionsUrl, signal);
const directories = sectionsXml.querySelectorAll('Directory');
logToConsole(` Encontradas ${directories.length} secciones en ${serverName}.`);
const sectionPromises = Array.from(directories).map(async (dir) => {
const type = dir.getAttribute('type');
const storeName = mapSectionTypeToObjectName(type);
if (storeName && tipos.includes(storeName)) {
const sectionId = dir.getAttribute('key');
const sectionTitle = dir.getAttribute('title');
logToConsole(` Procesando sección: "${sectionTitle}" (Tipo: ${type})`);
const contentUrl = `${protocol}://${address}:${port}/library/sections/${sectionId}/all?X-Plex-Token=${accessToken}`;
try {
const contentXml = await fetchSectionContent(contentUrl, signal);
const serverData = { ...connectionData, sectionTitle };
await parseAndStoreSectionItems(contentXml, storeName, serverData);
} catch (e) {
if (e instanceof TimeoutError) {
logToConsole(` [REINTENTO PENDIENTE] La sección "${sectionTitle}" tardó demasiado en responder.`);
state.plexScanRetryQueue.push({
url: contentUrl,
storeName: storeName,
serverData: { ...connectionData, sectionTitle }
});
} else {
logToConsole(` Error procesando sección "${sectionTitle}": ${e.message}`);
}
}
}
});
await Promise.all(sectionPromises);
}
async function processRetryQueue(signal) {
if (state.plexScanRetryQueue.length === 0) {
logToConsole(_('noRetriesPending'));
return;
}
logToConsole(_('startingRetryPhase', state.plexScanRetryQueue.length));
const queue = [...state.plexScanRetryQueue];
state.plexScanRetryQueue = [];
for (const item of queue) {
if (signal.aborted) {
logToConsole(_('retryPhaseCancelled'));
break;
}
const { url, storeName, serverData } = item;
logToConsole(_('retyingSection', serverData.sectionTitle));
try {
const contentXml = await fetchSectionContent(url, signal, 30000);
await parseAndStoreSectionItems(contentXml, storeName, serverData);
logToConsole(_('retrySuccess', serverData.sectionTitle));
} catch (e) {
logToConsole(_('retryError', [serverData.sectionTitle, e.message]));
}
}
logToConsole(_('retryPhaseFinished'));
}
export async function startPlexScan(tipos) {
if (state.isScanningPlex) {
showNotification(_('plexScanInProgress'), "warning");
return;
}
state.isScanningPlex = true;
state.plexScanAbortController = new AbortController();
state.plexScanRetryQueue = [];
const signal = state.plexScanAbortController.signal;
mostrarSpinner();
logToConsole(_('plexScanStarting'));
try {
const tokensData = await getFromDB('tokens');
const tokens = tokensData.map(item => item.token).filter(Boolean);
if (tokens.length === 0) throw new Error(_('noPlexTokens'));
logToConsole(_('clearingSections', tipos.join(', ')));
await Promise.all(tipos.map(tipo => clearStore(tipo)));
await clearStore('conexiones_locales');
logToConsole(_('sectionsCleared'));
const tokenPromises = tokens.map(async (token) => {
if (signal.aborted) return;
try {
const devices = await fetchPlexResources(token, signal);
logToConsole(_('tokenFoundServers', [token.substring(0,4), devices.length]));
const serverPromises = Array.from(devices).map(device => processServer(device, token, tipos, signal));
await Promise.allSettled(serverPromises);
} catch (e) {
logToConsole(_('errorProcessingToken', [token.substring(0,4), e.message]));
}
});
await Promise.all(tokenPromises);
logToConsole(_('initialScanPhaseComplete'));
await processRetryQueue(signal);
logToConsole(_('plexScanFinished'));
showNotification(_('plexScanFinished'), 'success');
emitirEventoActualizacion();
} catch (error) {
if (error.name !== 'AbortError') {
logToConsole(_('plexScanFatalError', error.message));
showNotification(_('errorDuringScan', error.message), 'error');
} else {
logToConsole(_('scanCancelled'));
showNotification(_('scanCancelledInfo'), 'info');
}
} finally {
state.isScanningPlex = false;
state.plexScanAbortController = null;
ocultarSpinner();
}
}