/**
* 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 = '';
}
} 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 = `
`;
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()}
|
Nom ${getSortIcon('Title')}
|
Tracker ${getSortIcon('Tracker')}
|
Taille ${getSortIcon('Size')}
|
Seeders ${getSortIcon('Seeders')}
|
Date ${getSortIcon('PublishDate')}
|
Actions |
${pageResults.map(torrent => renderTorrentRow(torrent)).join('')}
${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 = `
`;
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();