309 lines
13 KiB
JavaScript
309 lines
13 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;
|
|
}
|
|
|
|
export async function fetchLibraries(accessToken, publicUrl, localUrl) {
|
|
const url = `${localUrl || publicUrl}/library/sections?X-Plex-Token=${accessToken}`;
|
|
const xmlDoc = await fetchSectionContent(url, new AbortController().signal);
|
|
return Array.from(xmlDoc.querySelectorAll('Directory')).map(dir => ({
|
|
key: dir.getAttribute('key'),
|
|
title: dir.getAttribute('title'),
|
|
type: dir.getAttribute('type'),
|
|
}));
|
|
}
|
|
|
|
export async function fetchLibraryContents(accessToken, publicUrl, localUrl, libraryKey, libraryType, timeout = 7000) {
|
|
const endpoint = libraryType === 'show' ? 'allLeaves' : 'all';
|
|
const url = `${localUrl || publicUrl}/library/sections/${libraryKey}/${endpoint}?X-Plex-Token=${accessToken}`;
|
|
const contentXml = await fetchSectionContent(url, new AbortController().signal, timeout);
|
|
const items = [];
|
|
const baseUrl = localUrl || publicUrl;
|
|
|
|
const processItems = (selector, type) => {
|
|
contentXml.querySelectorAll(selector).forEach(el => {
|
|
const media = el.querySelector('Media Part');
|
|
if (media) {
|
|
const mediaUrl = `${baseUrl}${media.getAttribute('key')}?X-Plex-Token=${accessToken}`;
|
|
let thumbUrl = el.getAttribute('thumb') || el.getAttribute('parentThumb') || el.getAttribute('grandparentThumb');
|
|
if (thumbUrl) {
|
|
thumbUrl = `${baseUrl}/photo/:/transcode?width=400&height=600&minSize=1&upscale=1&url=${encodeURIComponent(thumbUrl)}&X-Plex-Token=${accessToken}`;
|
|
}
|
|
|
|
const finalTitle = el.getAttribute('title');
|
|
|
|
items.push({
|
|
title: finalTitle,
|
|
duration: el.getAttribute('duration'),
|
|
url: mediaUrl,
|
|
thumb: thumbUrl,
|
|
seriesTitle: el.getAttribute('type') === 'episode' ? el.getAttribute('grandparentTitle') : null,
|
|
seasonNumber: el.getAttribute('type') === 'episode' ? el.getAttribute('parentIndex') : null,
|
|
grandparentTitle: el.getAttribute('grandparentTitle') || (type === 'music' ? el.getAttribute('parentTitle') : null),
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
processItems('Video', 'video'); // Handles both movies and episodes
|
|
processItems('Track', 'music'); // Handles music
|
|
|
|
return items;
|
|
}
|
|
|
|
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 => {
|
|
const media = el.querySelector('Media');
|
|
const part = media?.querySelector('Part');
|
|
const container = part?.getAttribute('container');
|
|
const videoResolution = media?.getAttribute('videoResolution');
|
|
|
|
return {
|
|
id: el.getAttribute('ratingKey'),
|
|
title: el.getAttribute('title'),
|
|
year: el.getAttribute('year'),
|
|
genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre'),
|
|
type: type,
|
|
container: container,
|
|
resolution: videoResolution
|
|
};
|
|
});
|
|
} 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();
|
|
}
|
|
}
|
|
|
|
export function stopPlexScan() {
|
|
if (state.plexScanAbortController) {
|
|
state.plexScanAbortController.abort();
|
|
logToConsole(_('stoppingPlexScan'));
|
|
}
|
|
}
|
|
|
|
export async function updateAllTokens() {
|
|
const tipos = ['movies', 'series', 'artists', 'photos'];
|
|
await startPlexScan(tipos);
|
|
}
|
|
|
|
export async function addPlexToken(token) {
|
|
if (!token || typeof token !== 'string') {
|
|
showNotification(_('invalidTokenProvided'), 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const existingTokens = await getFromDB('tokens');
|
|
if (existingTokens.some(t => t.token === token)) {
|
|
showNotification(_('tokenAlreadyExists'), 'warning');
|
|
return;
|
|
}
|
|
await addItemsToStore('tokens', [{ token: token }]);
|
|
showNotification(_('tokenAddedSuccessfully'), 'success');
|
|
emitirEventoActualizacion();
|
|
} catch (error) {
|
|
showNotification(_('errorAddingToken', error.message), 'error');
|
|
}
|
|
}
|