/** * Lycostorrent - Search & Filter * Filtrage, tri et pagination 100% côté client */ // État global let allResults = []; // Tous les résultats de la recherche let filteredResults = []; // Résultats après filtrage let activeFilters = {}; // Filtres actifs { quality: ['1080p'], language: ['FRENCH', 'MULTI'], ... } let availableFilters = {}; // Filtres disponibles extraits des résultats // Pagination let currentPage = 1; const RESULTS_PER_PAGE = 50; // Tri let currentSort = { field: 'Seeders', order: 'desc' }; // Configuration des filtres (chargée dynamiquement) let FILTER_CONFIG = { // Fallback si l'API ne répond pas Tracker: { name: 'Tracker', icon: '🌐', order: 999, fromRoot: true }, }; // ============================================================ // INITIALISATION // ============================================================ document.addEventListener('DOMContentLoaded', () => { loadFiltersConfig(); // Charger les filtres depuis l'API loadTrackers(); setupEventListeners(); // Re-render lors du changement de taille de fenêtre let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (filteredResults.length > 0) { renderResults(); } }, 250); }); }); function setupEventListeners() { // Recherche au clic ou Entrée document.getElementById('search-btn').addEventListener('click', performSearch); document.getElementById('search-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') performSearch(); }); // Effacer les filtres document.getElementById('clear-filters').addEventListener('click', clearAllFilters); // Toggle panel trackers document.getElementById('toggleTrackers').addEventListener('click', () => { const panel = document.getElementById('trackersPanel'); panel.classList.toggle('hidden'); }); // Tout sélectionner / désélectionner document.getElementById('selectAllTrackers').addEventListener('click', () => { document.querySelectorAll('#trackers-list input[type="checkbox"]').forEach(cb => cb.checked = true); }); document.getElementById('deselectAllTrackers').addEventListener('click', () => { document.querySelectorAll('#trackers-list input[type="checkbox"]').forEach(cb => cb.checked = false); }); // Toggle filtres document.getElementById('toggle-filters')?.addEventListener('click', () => { const btn = document.getElementById('toggle-filters'); const content = document.getElementById('filters-content'); btn.classList.toggle('collapsed'); content.classList.toggle('collapsed'); btn.textContent = content.classList.contains('collapsed') ? '▶' : '▼'; }); } // ============================================================ // CHARGEMENT DE LA CONFIG DES FILTRES // ============================================================ async function loadFiltersConfig() { try { const response = await fetch('/api/filters'); const data = await response.json(); if (data.success && data.filters) { // Construire FILTER_CONFIG depuis l'API FILTER_CONFIG = {}; let order = 1; for (const [key, filter] of Object.entries(data.filters)) { FILTER_CONFIG[key] = { name: filter.name || key, icon: filter.icon || '🏷️', order: order++ }; } // Toujours ajouter Tracker à la fin FILTER_CONFIG['Tracker'] = { name: 'Tracker', icon: '🌐', order: 999, fromRoot: true }; console.log('✅ Filtres chargés:', Object.keys(FILTER_CONFIG).length); } } catch (error) { console.error('Erreur chargement config filtres:', error); // Garder le fallback par défaut } } // ============================================================ // CHARGEMENT DES TRACKERS // ============================================================ async function loadTrackers() { try { const response = await fetch('/api/trackers'); const data = await response.json(); if (data.success && data.trackers) { renderTrackers(data.trackers); } else { showError('Impossible de charger les trackers'); } } catch (error) { console.error('Erreur chargement trackers:', error); showError('Erreur de connexion au serveur'); } } function renderTrackers(trackers) { const container = document.getElementById('trackers-list'); if (trackers.length === 0) { container.innerHTML = '

Aucun tracker configuré

'; return; } container.innerHTML = trackers.map(tracker => { // Créer le badge de source let sourceBadge = ''; if (tracker.sources && tracker.sources.length > 0) { if (tracker.sources.includes('jackett') && tracker.sources.includes('prowlarr')) { sourceBadge = 'J+P'; } else if (tracker.sources.includes('jackett')) { sourceBadge = 'J'; } else if (tracker.sources.includes('prowlarr')) { sourceBadge = 'P'; } else if (tracker.sources.includes('rss')) { sourceBadge = 'RSS'; } } else { if (tracker.source === 'jackett') { sourceBadge = 'J'; } else if (tracker.source === 'prowlarr') { sourceBadge = 'P'; } } return `
${sourceBadge}
`; }).join(''); } function getSelectedTrackers() { const checkboxes = document.querySelectorAll('#trackers-list input[type="checkbox"]:checked'); return Array.from(checkboxes).map(cb => cb.value); } // ============================================================ // RECHERCHE // ============================================================ async function performSearch() { const query = document.getElementById('search-input').value.trim(); const category = document.getElementById('category-select').value; const trackers = getSelectedTrackers(); // Validation if (!query) { showError('Veuillez entrer une recherche'); return; } if (trackers.length === 0) { showError('Veuillez sélectionner au moins un tracker'); return; } // Afficher le loading showLoading(true); // Reset activeFilters = {}; availableFilters = {}; currentPage = 1; currentSort = { field: 'Seeders', order: 'desc' }; try { const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, category, trackers }) }); const data = await response.json(); if (data.success) { allResults = data.results; filteredResults = [...allResults]; // Trier par seeders par défaut sortResults(); // Extraire les filtres disponibles depuis les résultats extractAvailableFilters(); // Afficher les filtres et les résultats renderFilters(); renderResults(); // Afficher la section des filtres document.getElementById('filters-section').classList.remove('hidden'); } else { showError(data.error || 'Erreur lors de la recherche'); } } catch (error) { console.error('Erreur recherche:', error); showError('Erreur de connexion au serveur'); } finally { showLoading(false); } } // ============================================================ // TRI // ============================================================ function sortResults() { const { field, order } = currentSort; filteredResults.sort((a, b) => { let valA, valB; switch (field) { case 'Title': valA = (a.Title || '').toLowerCase(); valB = (b.Title || '').toLowerCase(); break; case 'Tracker': valA = (a.Tracker || '').toLowerCase(); valB = (b.Tracker || '').toLowerCase(); break; case 'Size': valA = a.Size || 0; valB = b.Size || 0; break; case 'Seeders': valA = a.Seeders || 0; valB = b.Seeders || 0; break; case 'PublishDate': valA = a.PublishDateRaw || ''; valB = b.PublishDateRaw || ''; break; default: valA = a[field] || 0; valB = b[field] || 0; } if (valA < valB) return order === 'asc' ? -1 : 1; if (valA > valB) return order === 'asc' ? 1 : -1; return 0; }); } function onSortChange(field) { if (currentSort.field === field) { // Inverser l'ordre si on clique sur la même colonne currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc'; } else { currentSort.field = field; // Ordre par défaut selon le champ currentSort.order = (field === 'Title' || field === 'Tracker') ? 'asc' : 'desc'; } sortResults(); renderResults(); } // ============================================================ // EXTRACTION DES FILTRES DISPONIBLES // ============================================================ function extractAvailableFilters() { availableFilters = {}; for (const torrent of allResults) { const parsed = torrent.parsed || {}; for (const [key, config] of Object.entries(FILTER_CONFIG)) { if (!availableFilters[key]) { availableFilters[key] = {}; } let values; if (config.fromRoot) { // Valeur directement sur le torrent (ex: Tracker) values = torrent[key] ? [torrent[key]] : []; } else { // Valeur dans parsed values = parsed[key] || []; } const valueArray = Array.isArray(values) ? values : [values]; for (const value of valueArray) { if (value) { availableFilters[key][value] = (availableFilters[key][value] || 0) + 1; } } } } console.log('Filtres disponibles:', availableFilters); } // ============================================================ // RENDU DES FILTRES // ============================================================ function renderFilters() { const container = document.getElementById('filters-container'); container.innerHTML = ''; // Trier les filtres par ordre défini const sortedFilters = Object.keys(availableFilters) .filter(key => Object.keys(availableFilters[key]).length > 0) .sort((a, b) => (FILTER_CONFIG[a]?.order || 99) - (FILTER_CONFIG[b]?.order || 99)); for (const filterKey of sortedFilters) { const filterConfig = FILTER_CONFIG[filterKey]; const values = availableFilters[filterKey]; // Trier les valeurs par nombre d'occurrences const sortedValues = Object.entries(values) .sort((a, b) => b[1] - a[1]); const filterHTML = `

${filterConfig.icon} ${filterConfig.name}

${sortedValues.map(([value, count]) => ` `).join('')}
`; container.innerHTML += filterHTML; } // Ajouter les event listeners sur les checkboxes container.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { checkbox.addEventListener('change', onFilterChange); }); updateResultsCount(); } function isFilterActive(filterKey, value) { return activeFilters[filterKey]?.includes(value) || false; } // ============================================================ // GESTION DES FILTRES // ============================================================ function onFilterChange(event) { const checkbox = event.target; const filterKey = checkbox.dataset.filter; const value = checkbox.dataset.value; if (!activeFilters[filterKey]) { activeFilters[filterKey] = []; } if (checkbox.checked) { if (!activeFilters[filterKey].includes(value)) { activeFilters[filterKey].push(value); } } else { activeFilters[filterKey] = activeFilters[filterKey].filter(v => v !== value); if (activeFilters[filterKey].length === 0) { delete activeFilters[filterKey]; } } console.log('Filtres actifs:', activeFilters); // Appliquer les filtres applyFilters(); } function applyFilters() { // Reset pagination currentPage = 1; if (Object.keys(activeFilters).length === 0) { filteredResults = [...allResults]; } else { filteredResults = allResults.filter(torrent => { const parsed = torrent.parsed || {}; for (const [filterKey, selectedValues] of Object.entries(activeFilters)) { if (selectedValues.length === 0) continue; let torrentValues; if (FILTER_CONFIG[filterKey]?.fromRoot) { torrentValues = torrent[filterKey] ? [torrent[filterKey]] : []; } else { torrentValues = parsed[filterKey] || []; } const torrentValuesArray = Array.isArray(torrentValues) ? torrentValues : [torrentValues]; const hasMatch = selectedValues.some(val => torrentValuesArray.includes(val)); if (!hasMatch) { return false; } } return true; }); } // Réappliquer le tri sortResults(); renderResults(); updateResultsCount(); } function clearAllFilters() { activeFilters = {}; currentPage = 1; document.querySelectorAll('#filters-container input[type="checkbox"]').forEach(cb => { cb.checked = false; }); filteredResults = [...allResults]; sortResults(); renderResults(); updateResultsCount(); } function updateResultsCount() { const countEl = document.getElementById('results-count'); const total = allResults.length; const filtered = filteredResults.length; if (total === filtered) { countEl.textContent = `(${total} résultats)`; } else { countEl.textContent = `(${filtered} / ${total} résultats)`; } } // ============================================================ // PAGINATION // ============================================================ function getTotalPages() { return Math.ceil(filteredResults.length / RESULTS_PER_PAGE); } function getPageResults() { const start = (currentPage - 1) * RESULTS_PER_PAGE; const end = start + RESULTS_PER_PAGE; return filteredResults.slice(start, end); } function goToPage(page) { const totalPages = getTotalPages(); if (page < 1 || page > totalPages) return; currentPage = page; renderResults(); // Scroll vers le haut des résultats document.getElementById('results-section').scrollIntoView({ behavior: 'smooth' }); } function renderPagination() { const totalPages = getTotalPages(); if (totalPages <= 1) return ''; const pages = []; const maxVisiblePages = 7; // Toujours afficher la première page pages.push(1); if (totalPages <= maxVisiblePages) { for (let i = 2; i <= totalPages; i++) { pages.push(i); } } else { // Logique pour afficher les pages autour de la page courante let start = Math.max(2, currentPage - 2); let end = Math.min(totalPages - 1, currentPage + 2); if (currentPage <= 3) { end = 5; } if (currentPage >= totalPages - 2) { start = totalPages - 4; } if (start > 2) { pages.push('...'); } for (let i = start; i <= end; i++) { pages.push(i); } if (end < totalPages - 1) { pages.push('...'); } pages.push(totalPages); } const startResult = (currentPage - 1) * RESULTS_PER_PAGE + 1; const endResult = Math.min(currentPage * RESULTS_PER_PAGE, filteredResults.length); return ` `; } // ============================================================ // RENDU DES RÉSULTATS // ============================================================ function renderResults() { const container = document.getElementById('results-container'); if (filteredResults.length === 0) { if (allResults.length === 0) { container.innerHTML = '

Aucun résultat trouvé

'; } else { container.innerHTML = '

Aucun résultat ne correspond aux filtres sélectionnés

'; } return; } const pageResults = getPageResults(); const isMobile = window.innerWidth <= 768; if (isMobile) { // Mode cards pour mobile container.innerHTML = ` ${renderPagination()}
${pageResults.map(torrent => renderTorrentCard(torrent)).join('')}
${renderPagination()} `; } else { // Mode table pour desktop container.innerHTML = ` ${renderPagination()} ${pageResults.map(torrent => renderTorrentRow(torrent)).join('')}
Nom ${getSortIcon('Title')} Tracker ${getSortIcon('Tracker')} Taille ${getSortIcon('Size')} Seeders ${getSortIcon('Seeders')} Date ${getSortIcon('PublishDate')} Actions
${renderPagination()} `; } } function renderTorrentCard(torrent) { const parsed = torrent.parsed || {}; const badges = []; if (parsed.quality?.length) { badges.push(...parsed.quality.map(q => `${escapeHtml(q)}`)); } if (parsed.source?.length) { badges.push(...parsed.source.map(s => `${escapeHtml(s)}`)); } if (parsed.language?.length) { badges.push(...parsed.language.map(l => `${escapeHtml(l)}`)); } const seedersClass = getSeedersClass(torrent.Seeders); // Sanitize URLs const magnetUrl = sanitizeUrl(torrent.MagnetUri); const downloadUrl = sanitizeUrl(torrent.Link); const detailsUrl = sanitizeUrl(torrent.Details); return `
${escapeHtml(torrent.Title)}
${badges.join('')}
📁 ${escapeHtml(torrent.SizeFormatted || 'N/A')} 🌱 ${parseInt(torrent.Seeders) || 0} 🏷️ ${escapeHtml(torrent.Tracker)}
${magnetUrl ? `🧲` : ''} ${downloadUrl ? `⬇️` : ''} ${detailsUrl ? `🔗` : ''} ${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `` : ''}
`; } function getSortIcon(field) { if (currentSort.field !== field) { return ''; } return currentSort.order === 'asc' ? '' : ''; } function renderTorrentRow(torrent) { const parsed = torrent.parsed || {}; const badges = []; if (parsed.quality?.length) { badges.push(...parsed.quality.map(q => `${escapeHtml(q)}`)); } if (parsed.source?.length) { badges.push(...parsed.source.map(s => `${escapeHtml(s)}`)); } if (parsed.video_codec?.length) { badges.push(...parsed.video_codec.map(c => `${escapeHtml(c)}`)); } if (parsed.language?.length) { badges.push(...parsed.language.map(l => `${escapeHtml(l)}`)); } if (parsed.hdr?.length) { badges.push(...parsed.hdr.map(h => `${escapeHtml(h)}`)); } const seedersClass = getSeedersClass(torrent.Seeders); // Sanitize URLs const magnetUrl = sanitizeUrl(torrent.MagnetUri); const downloadUrl = sanitizeUrl(torrent.Link); const detailsUrl = sanitizeUrl(torrent.Details); return `
${escapeHtml(torrent.Title)}
${badges.join('')}
${escapeHtml(torrent.Tracker)} ${escapeHtml(torrent.SizeFormatted || 'N/A')} ${parseInt(torrent.Seeders) || 0} ${escapeHtml(torrent.PublishDate || 'N/A')} ${magnetUrl ? `🧲` : ''} ${downloadUrl ? `⬇️` : ''} ${detailsUrl ? `🔗` : ''} ${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `` : ''} `; } function getSeedersClass(seeders) { if (!seeders || seeders === 0) return 'seeders-none'; if (seeders < 5) return 'seeders-low'; if (seeders < 20) return 'seeders-medium'; return 'seeders-high'; } // ============================================================ // UTILITAIRES // ============================================================ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function sanitizeUrl(url) { if (!url) return ''; // Autoriser uniquement http, https, et magnet const allowedProtocols = ['http:', 'https:', 'magnet:']; try { // Pour les URLs magnet, vérifier le préfixe if (url.startsWith('magnet:')) { return url; } const parsed = new URL(url); if (!allowedProtocols.includes(parsed.protocol)) { console.warn('URL avec protocole non autorisé:', parsed.protocol); return ''; } return url; } catch (e) { // Si ce n'est pas une URL valide, retourner vide console.warn('URL invalide:', url); return ''; } } function showLoading(show) { const overlay = document.getElementById('loading-overlay'); if (show) { overlay.classList.remove('hidden'); } else { overlay.classList.add('hidden'); } } function showError(message) { alert(message); } // ============================================================ // CLIENT TORRENT // ============================================================ let torrentClientEnabled = false; let torrentClientSupportsTorrentFiles = false; async function checkTorrentClient() { try { const response = await fetch('/api/torrent-client/status'); const data = await response.json(); torrentClientEnabled = data.success && data.enabled && data.connected; // Par défaut true si non spécifié (qBittorrent supporte les .torrent) torrentClientSupportsTorrentFiles = data.supportsTorrentFiles !== false; console.log('🔌 Client torrent:', torrentClientEnabled ? 'connecté' : 'non connecté', '| Supporte .torrent:', torrentClientSupportsTorrentFiles); } catch (error) { torrentClientEnabled = false; torrentClientSupportsTorrentFiles = false; console.log('🔌 Client torrent: erreur de connexion'); } } async function sendToTorrentClient(url, button) { if (!url) { showToast('Aucun lien disponible', 'error'); return; } // Afficher le modal de sélection showTorrentOptionsModal(url, button); } async function showTorrentOptionsModal(url, button) { // Créer le modal s'il n'existe pas let modal = document.getElementById('torrentOptionsModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'torrentOptionsModal'; modal.className = 'torrent-options-modal'; modal.innerHTML = `

📥 Options de téléchargement

`; document.body.appendChild(modal); // Fermer en cliquant à l'extérieur modal.addEventListener('click', (e) => { if (e.target === modal) closeTorrentOptionsModal(); }); } // Charger les catégories const categorySelect = document.getElementById('torrentCategory'); const savePathInput = document.getElementById('torrentSavePath'); categorySelect.innerHTML = ''; let categoriesWithPaths = {}; try { const response = await fetch('/api/torrent-client/categories'); const data = await response.json(); categorySelect.innerHTML = ''; if (data.success && data.categories) { data.categories.forEach(cat => { categorySelect.innerHTML += ``; }); // Stocker les chemins personnalisés categoriesWithPaths = data.custom_categories || {}; } } catch (error) { categorySelect.innerHTML = ''; } // Auto-remplir le chemin quand on sélectionne une catégorie categorySelect.onchange = () => { const selectedCat = categorySelect.value; if (selectedCat && categoriesWithPaths[selectedCat]) { savePathInput.value = categoriesWithPaths[selectedCat]; } else { savePathInput.value = ''; } }; // Reset les champs savePathInput.value = ''; document.getElementById('torrentPaused').checked = false; // Configurer le bouton de confirmation const confirmBtn = document.getElementById('confirmTorrentAdd'); confirmBtn.onclick = async () => { const category = document.getElementById('torrentCategory').value; const savePath = document.getElementById('torrentSavePath').value.trim(); const paused = document.getElementById('torrentPaused').checked; closeTorrentOptionsModal(); await doSendToTorrentClient(url, button, category, savePath, paused); }; // Afficher le modal modal.classList.add('visible'); } function closeTorrentOptionsModal() { const modal = document.getElementById('torrentOptionsModal'); if (modal) { modal.classList.remove('visible'); } } async function doSendToTorrentClient(url, button, category, savePath, paused) { const originalText = button.textContent; button.textContent = '⏳'; button.disabled = true; try { const body = { url: url }; if (category) body.category = category; if (savePath) body.save_path = savePath; if (paused) body.paused = paused; const response = await fetch('/api/torrent-client/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await response.json(); if (data.success) { button.textContent = '✅'; showToast('Torrent envoyé !', 'success'); setTimeout(() => { button.textContent = '📥'; button.disabled = false; }, 2000); } else { button.textContent = '❌'; showToast(data.error || 'Erreur', 'error'); setTimeout(() => { button.textContent = '📥'; button.disabled = false; }, 2000); } } catch (error) { button.textContent = '❌'; showToast('Erreur de connexion', 'error'); setTimeout(() => { button.textContent = '📥'; button.disabled = false; }, 2000); } } function showToast(message, type = 'info') { // Créer le toast s'il n'existe pas let toast = document.getElementById('toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'toast'; toast.className = 'toast hidden'; document.body.appendChild(toast); } toast.textContent = message; toast.className = `toast ${type}`; setTimeout(() => toast.classList.add('hidden'), 3000); } // Vérifier le client torrent au chargement checkTorrentClient();