218 lines
9.1 KiB
JavaScript
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();
|
|
}
|
|
} |