Initial commit

This commit is contained in:
2026-03-23 20:59:26 +01:00
commit 16c95f747b
56 changed files with 21177 additions and 0 deletions

901
app/static/js/latest.js Normal file
View File

@@ -0,0 +1,901 @@
/**
* Lycostorrent - Latest Releases
* Page des nouveautés avec enrichissement TMDb/Last.fm
*/
// Variables globales
let selectedCategory = 'movies';
let selectedTrackers = [];
let availableTrackers = [];
let allResults = [];
let selectedYears = ['all']; // Par défaut: tous
// Images par défaut en base64 (évite les problèmes d'échappement)
const DEFAULT_POSTER_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyODAiIGhlaWdodD0iNDIwIj48cmVjdCB3aWR0aD0iMjgwIiBoZWlnaHQ9IjQyMCIgZmlsbD0iIzMzMyIvPjx0ZXh0IHg9IjE0MCIgeT0iMjAwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iNDAiPvCfjqw8L3RleHQ+PHRleHQgeD0iMTQwIiB5PSIyNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM2NjYiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCI+Tm8gSW1hZ2U8L3RleHQ+PC9zdmc+';
const DEFAULT_COVER_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzMzMyIvPjx0ZXh0IHg9IjIwMCIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iNjAiPvCfjrU8L3RleHQ+PHRleHQgeD0iMjAwIiB5PSIyNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM2NjYiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxOCI+Tm8gQ292ZXI8L3RleHQ+PC9zdmc+';
const DEFAULT_BACKDROP_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iOTAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzIyMiIvPjwvc3ZnPg==';
function getDefaultPosterUrl() {
return DEFAULT_POSTER_B64;
}
function getDefaultCoverUrl() {
return DEFAULT_COVER_B64;
}
function getDefaultBackdropUrl() {
return DEFAULT_BACKDROP_B64;
}
// Initialisation
document.addEventListener('DOMContentLoaded', function() {
console.log('🚀 Page latest.js chargée');
initializeApp();
});
function initializeApp() {
// Vérifier le client torrent en premier
checkTorrentClient();
loadTrackers();
// Event listeners
document.getElementById('toggleTrackers').addEventListener('click', toggleTrackersPanel);
document.getElementById('selectAllTrackers').addEventListener('click', selectAllTrackers);
document.getElementById('deselectAllTrackers').addEventListener('click', deselectAllTrackers);
document.getElementById('loadLatestBtn').addEventListener('click', () => loadLatestReleases(true));
// Bouton refresh live (dans le header des résultats)
document.getElementById('refreshLiveBtn')?.addEventListener('click', () => loadLatestReleases(true));
// Pastilles d'années
document.querySelectorAll('.year-pill').forEach(pill => {
pill.addEventListener('click', function() {
handleYearPillClick(this);
});
});
// Catégories
document.querySelectorAll('.category-btn').forEach(btn => {
btn.addEventListener('click', function() {
selectCategory(this.dataset.category);
});
});
// Modal
document.querySelector('.modal-close').addEventListener('click', closeModal);
document.getElementById('detailsModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// Gestion erreurs images
document.addEventListener('error', function(e) {
if (e.target.tagName === 'IMG') {
const fallback = e.target.dataset.fallback;
if (fallback === 'poster') e.target.src = getDefaultPosterUrl();
else if (fallback === 'cover') e.target.src = getDefaultCoverUrl();
else if (fallback === 'backdrop') e.target.src = getDefaultBackdropUrl();
}
}, true);
// Charger depuis le cache au démarrage (après chargement des trackers)
setTimeout(() => {
loadFromCacheOrLive();
}, 500);
}
// Gestion des pastilles d'années
function handleYearPillClick(pill) {
const year = pill.dataset.year;
if (year === 'all') {
// Clic sur "Tous" -> désactive tout le reste
selectedYears = ['all'];
document.querySelectorAll('.year-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
} else {
// Clic sur une année spécifique
// Retirer "all" s'il était sélectionné
if (selectedYears.includes('all')) {
selectedYears = [];
document.querySelector('.year-pill[data-year="all"]').classList.remove('active');
}
// Toggle l'année cliquée
if (selectedYears.includes(year)) {
// Désactiver
selectedYears = selectedYears.filter(y => y !== year);
pill.classList.remove('active');
// Si plus rien de sélectionné, réactiver "Tous"
if (selectedYears.length === 0) {
selectedYears = ['all'];
document.querySelector('.year-pill[data-year="all"]').classList.add('active');
}
} else {
// Activer
selectedYears.push(year);
pill.classList.add('active');
}
}
// Re-filtrer les résultats
if (allResults.length > 0) {
displayResults(allResults);
}
}
// Chargement des trackers (inclut les RSS pour les nouveautés)
async function loadTrackers() {
try {
showLoader(true);
const response = await fetch('/api/trackers?include_rss=true');
const data = await response.json();
if (data.success) {
availableTrackers = data.trackers;
displayTrackers(availableTrackers);
} else {
showMessage('Erreur lors du chargement des trackers', 'error');
}
} catch (error) {
console.error('Erreur chargement trackers:', error);
showMessage('Impossible de charger les trackers', 'error');
} finally {
showLoader(false);
}
}
function displayTrackers(trackers) {
const trackersList = document.getElementById('trackersList');
if (trackers.length === 0) {
trackersList.innerHTML = '<p class="no-trackers">Aucun tracker configuré</p>';
return;
}
// Trackers sélectionnés par défaut
const defaultTrackers = ['yggtorrent', 'sharewood-api'];
trackersList.innerHTML = '';
trackers.forEach(tracker => {
const trackerItem = document.createElement('div');
trackerItem.className = 'tracker-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `tracker-${tracker.id}`;
checkbox.value = tracker.id;
checkbox.checked = defaultTrackers.includes(tracker.id.toLowerCase().replace(/\s+/g, '-'));
checkbox.addEventListener('change', updateSelectedTrackers);
const label = document.createElement('label');
label.htmlFor = `tracker-${tracker.id}`;
label.textContent = tracker.name;
// Badge de source
let sourceBadge = '';
if (tracker.sources && tracker.sources.length > 0) {
if (tracker.sources.includes('rss')) {
sourceBadge = '<span class="source-badge source-rss" title="Flux RSS">RSS</span>';
} else if (tracker.sources.includes('jackett') && tracker.sources.includes('prowlarr')) {
sourceBadge = '<span class="source-badge source-both" title="Jackett + Prowlarr">J+P</span>';
} else if (tracker.sources.includes('jackett')) {
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
} else if (tracker.sources.includes('prowlarr')) {
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
}
} else if (tracker.source) {
if (tracker.source === 'jackett') {
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
} else if (tracker.source === 'prowlarr') {
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
}
}
trackerItem.appendChild(checkbox);
trackerItem.appendChild(label);
if (sourceBadge) {
const badgeSpan = document.createElement('span');
badgeSpan.innerHTML = sourceBadge;
trackerItem.appendChild(badgeSpan.firstChild);
}
trackersList.appendChild(trackerItem);
});
updateSelectedTrackers();
}
function updateSelectedTrackers() {
selectedTrackers = Array.from(document.querySelectorAll('#trackersList input[type="checkbox"]:checked'))
.map(cb => cb.value);
}
function toggleTrackersPanel() {
document.getElementById('trackersPanel').classList.toggle('hidden');
}
function selectAllTrackers() {
document.querySelectorAll('#trackersList input[type="checkbox"]').forEach(cb => cb.checked = true);
updateSelectedTrackers();
}
function deselectAllTrackers() {
document.querySelectorAll('#trackersList input[type="checkbox"]').forEach(cb => cb.checked = false);
updateSelectedTrackers();
}
function selectCategory(category) {
selectedCategory = category;
document.querySelectorAll('.category-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
// Charger depuis le cache si disponible
loadFromCacheOrLive();
}
// Variable pour savoir si on utilise le cache
let usingCache = false;
// Vérifier et charger depuis le cache au démarrage
async function loadFromCacheOrLive() {
try {
// Vérifier si le cache existe pour cette catégorie
const response = await fetch(`/api/cache/data/latest/${selectedCategory}`);
const data = await response.json();
if (data.success && data.cached && data.data && data.data.length > 0) {
// Afficher les données du cache
usingCache = true;
allResults = data.data;
displayResults(allResults);
showCacheInfo(data.timestamp);
console.log(`📦 Chargé depuis le cache: ${data.data.length} résultats`);
}
} catch (error) {
console.log('Pas de cache disponible');
}
}
// Afficher les infos du cache
function showCacheInfo(timestamp) {
const cacheInfo = document.getElementById('cacheInfo');
const cacheTimestamp = document.getElementById('cacheTimestamp');
if (cacheInfo && timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diffMinutes = Math.floor((now - date) / 60000);
let timeAgo;
if (diffMinutes < 1) {
timeAgo = "à l'instant";
} else if (diffMinutes < 60) {
timeAgo = `il y a ${diffMinutes} min`;
} else {
const hours = Math.floor(diffMinutes / 60);
timeAgo = `il y a ${hours}h`;
}
cacheTimestamp.textContent = timeAgo;
cacheInfo.classList.remove('hidden');
}
}
// Masquer les infos du cache
function hideCacheInfo() {
const cacheInfo = document.getElementById('cacheInfo');
if (cacheInfo) {
cacheInfo.classList.add('hidden');
}
usingCache = false;
}
// Chargement des dernières sorties (en direct)
async function loadLatestReleases(forceRefresh = true) {
if (selectedTrackers.length === 0) {
showMessage('Veuillez sélectionner au moins un tracker', 'error');
return;
}
const limit = parseInt(document.getElementById('limitSelect').value);
try {
showLoader(true);
hideCacheInfo();
const response = await fetch('/api/latest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trackers: selectedTrackers,
category: selectedCategory,
limit: limit
})
});
const data = await response.json();
if (data.success) {
allResults = data.results;
displayResults(allResults);
if (allResults.length > 0) {
showMessage(`${allResults.length} nouveautés trouvées`, 'success');
} else {
showMessage('Aucune nouveauté trouvée', 'info');
}
} else {
showMessage(data.error || 'Erreur lors de la récupération', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showMessage('Erreur lors de la récupération des nouveautés', 'error');
} finally {
showLoader(false);
}
}
function displayResults(results) {
const resultsSection = document.getElementById('latestResults');
const resultsGrid = document.getElementById('resultsGrid');
const resultsCount = document.getElementById('resultsCount');
const yearFiltersSection = document.getElementById('yearFilters');
const filterCountSpan = document.getElementById('filterCount');
// Afficher la section des filtres
yearFiltersSection.classList.remove('hidden');
// Filtrer par années sélectionnées
let filteredResults = results;
if (!selectedYears.includes('all')) {
filteredResults = results.filter(result => {
const tmdb = result.tmdb || {};
const year = tmdb.year ? parseInt(tmdb.year) : null;
// Si pas d'année TMDb, on garde le résultat (on ne peut pas filtrer)
if (!year) return true;
// Vérifier si l'année correspond à une des années sélectionnées
for (const selectedYear of selectedYears) {
if (selectedYear === 'old') {
// ≤2022
if (year <= 2022) return true;
} else {
// Année spécifique
if (year === parseInt(selectedYear)) return true;
}
}
return false;
});
}
// Mettre à jour le compteur de filtre
if (!selectedYears.includes('all')) {
const yearsText = selectedYears.map(y => y === 'old' ? '≤2022' : y).join(', ');
filterCountSpan.textContent = `(${filteredResults.length}/${results.length})`;
} else {
filterCountSpan.textContent = '';
}
if (filteredResults.length === 0) {
resultsSection.classList.remove('hidden');
resultsCount.textContent = `0 nouveauté (${results.length} total)`;
resultsGrid.innerHTML = '<p class="no-results">Aucun résultat pour les années sélectionnées</p>';
return;
}
resultsSection.classList.remove('hidden');
if (!selectedYears.includes('all')) {
resultsCount.textContent = `${filteredResults.length} nouveauté${filteredResults.length > 1 ? 's' : ''} sur ${results.length}`;
} else {
resultsCount.textContent = `${filteredResults.length} nouveauté${filteredResults.length > 1 ? 's' : ''}`;
}
resultsGrid.innerHTML = '';
filteredResults.forEach(result => {
const card = createCard(result);
resultsGrid.appendChild(card);
});
}
function createCard(group) {
const card = document.createElement('div');
card.className = 'release-card';
const mainTorrent = group.torrents[0];
const tmdb = group.tmdb || {};
const music = group.music || {};
const isMusic = group.is_music || false;
const isAnime = group.is_anime || false;
let title = tmdb.title || music.album || mainTorrent.Title || 'Sans titre';
let year = tmdb.year || '';
let overview = escapeHtml(tmdb.overview || '');
let posterUrl = sanitizeUrl(tmdb.poster_url || music.cover_url) || getDefaultPosterUrl();
let torrentUrl = sanitizeUrl(mainTorrent.Details || mainTorrent.Guid) || '';
let uniqueId = `result-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
let variantsCount = group.torrents.length;
let contentType = '🎬';
if (isMusic && music.artist) {
contentType = '🎵';
title = `${music.artist} - ${music.album}`;
overview = `
<strong>Artiste:</strong> ${escapeHtml(music.artist)}<br>
<strong>Album:</strong> ${escapeHtml(music.album)}<br>
${music.tags?.length ? `<strong>Genres:</strong> ${escapeHtml(music.tags.join(', '))}` : ''}
`;
} else if (isAnime) {
contentType = '🎌';
} else if (tmdb.type === 'tv') {
contentType = '📺';
}
card.innerHTML = `
<div class="card-poster">
<img src="${posterUrl}" alt="${escapeHtml(title)}" class="card-image" data-fallback="poster">
<div class="card-type">${contentType} ${isMusic ? 'Musique' : (isAnime ? 'Anime' : (tmdb.type === 'tv' ? 'Série' : 'Film'))}</div>
${!isMusic && tmdb.vote_average ? `<div class="card-rating">⭐ ${tmdb.vote_average.toFixed(1)}</div>` : ''}
${isMusic && music.listeners ? `<div class="card-rating">👥 ${formatNumber(music.listeners)}</div>` : ''}
${variantsCount > 1 ? `<div class="card-variants">📦 ${variantsCount} versions</div>` : ''}
<div class="card-seeders">🌱 ${mainTorrent.Seeders || 0}</div>
</div>
<div class="card-content">
<div class="card-title">${escapeHtml(title)}</div>
<div class="card-meta">
<span class="card-year">${year}</span>
<a href="${torrentUrl}" target="_blank" class="card-tracker-link" title="Voir sur ${escapeHtml(mainTorrent.Tracker)}">${escapeHtml(mainTorrent.Tracker)} 🔗</a>
</div>
<div class="card-overview">${overview}</div>
<div class="card-actions">
<button class="btn-details" data-result-id="${uniqueId}"> Détails ${variantsCount > 1 ? '(' + variantsCount + ')' : ''}</button>
${torrentUrl ? `<a href="${torrentUrl}" target="_blank" class="btn-tracker" title="Page du torrent">🔗</a>` : ''}
${mainTorrent.MagnetUri ? `<a href="${mainTorrent.MagnetUri}" class="btn-download-card" title="Magnet">🧲</a>` : ''}
</div>
</div>
`;
card.dataset.resultId = uniqueId;
card.dataset.resultData = JSON.stringify(group);
card.querySelector('.btn-details').addEventListener('click', function(e) {
e.preventDefault();
showDetails(this.getAttribute('data-result-id'));
});
return card;
}
function showDetails(resultId) {
const card = document.querySelector(`[data-result-id="${resultId}"]`);
if (!card) return;
const group = JSON.parse(card.dataset.resultData);
const isMusic = group.is_music || false;
const isAnime = group.is_anime || false;
const modal = document.getElementById('detailsModal');
const modalBody = document.getElementById('modalBody');
if (isMusic) {
showMusicDetails(group, modalBody);
} else {
showVideoDetails(group, modalBody, isAnime);
}
modal.classList.remove('hidden');
}
function showMusicDetails(group, modalBody) {
const mainTorrent = group.torrents[0];
const music = group.music || {};
const coverUrl = sanitizeUrl(music.cover_url) || '';
const artist = music.artist || mainTorrent.Title?.split(' - ')[0] || 'Artiste inconnu';
const album = music.album || mainTorrent.Title?.split(' - ')[1] || mainTorrent.Title || 'Album inconnu';
const listeners = formatNumber(music.listeners || 0);
const playcount = formatNumber(music.playcount || 0);
const tags = music.tags || [];
const url = music.url || '';
// Vérifier si on a des infos Last.fm
const hasLastFmData = music.artist && music.album;
// Si pas de cover, utiliser un placeholder
const displayCover = coverUrl || 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzMzMyIvPjx0ZXh0IHg9IjIwMCIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iNjAiPvCfjrU8L3RleHQ+PHRleHQgeD0iMjAwIiB5PSIyNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM2NjYiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxOCI+Tm8gQ292ZXI8L3RleHQ+PC9zdmc+';
modalBody.innerHTML = `
<div class="modal-header music-modal-header">
<img src="${displayCover}" alt="Album art" class="modal-album-art" data-fallback="cover">
<div class="modal-header-content music-modal-header-content">
<h2 class="modal-title">🎵 ${escapeHtml(album)}</h2>
<p class="modal-artist">${escapeHtml(artist)}</p>
${hasLastFmData ? `
<div class="modal-meta music-modal-meta">
<span>👥 ${listeners} auditeurs</span>
<span>▶️ ${playcount} lectures</span>
</div>
` : `
<div class="modal-meta music-modal-meta">
<span class="no-data"> Infos Last.fm non disponibles</span>
</div>
`}
</div>
</div>
<div class="modal-body-content">
${tags.length > 0 ? `
<div class="modal-section">
<h3>🏷️ Genres</h3>
<div class="tags-cloud">
${tags.map(tag => `<span class="tag-item">${escapeHtml(tag)}</span>`).join('')}
</div>
</div>
` : ''}
${url ? `<p><a href="${url}" target="_blank" class="external-link">🔗 Voir sur Last.fm</a></p>` : ''}
<div class="modal-section">
<h3>💾 Versions disponibles (${group.torrents.length})</h3>
<div class="torrents-list">
${createTorrentsTable(group.torrents, true)}
</div>
</div>
</div>
`;
}
function showVideoDetails(group, modalBody, isAnime) {
const mainTorrent = group.torrents[0];
const tmdb = group.tmdb || {};
const backdropUrl = tmdb.backdrop_url || tmdb.poster_url || getDefaultBackdropUrl();
const title = tmdb.title || mainTorrent.Title;
const originalTitle = tmdb.original_title || '';
const overview = tmdb.overview || 'Synopsis non disponible';
const year = tmdb.year || '';
const rating = tmdb.vote_average ? tmdb.vote_average.toFixed(1) : 'N/A';
const trailerUrl = tmdb.trailer_url || '';
let youtubeId = '';
if (trailerUrl) {
const match = trailerUrl.match(/[?&]v=([^&]+)/);
youtubeId = match ? match[1] : '';
}
let modalType = isAnime ? '🎌 Anime' : (tmdb.type === 'tv' ? '📺 Série' : '🎬 Film');
modalBody.innerHTML = `
<div class="modal-header">
<img src="${backdropUrl}" alt="" class="modal-backdrop" data-fallback="backdrop">
<div class="modal-header-content">
<h2 class="modal-title">${escapeHtml(title)}</h2>
${originalTitle && originalTitle !== title ? `<p style="opacity: 0.8;">${escapeHtml(originalTitle)}</p>` : ''}
<div class="modal-meta">
<span>${year}</span>
${tmdb.vote_average ? `<span class="modal-rating">⭐ ${rating}/10</span>` : ''}
<span>${modalType}</span>
<span>📦 ${group.torrents.length} version(s)</span>
</div>
</div>
</div>
<div class="modal-body-content">
<div class="modal-section">
<h3>📖 Synopsis</h3>
<p class="modal-overview">${escapeHtml(overview)}</p>
</div>
${youtubeId ? `
<div class="modal-section">
<h3>🎬 Bande-annonce</h3>
<div class="modal-trailer">
<iframe src="https://www.youtube.com/embed/${youtubeId}" allowfullscreen></iframe>
</div>
</div>
` : ''}
<div class="modal-section">
<h3>💾 Versions disponibles (${group.torrents.length})</h3>
<div class="torrents-list">
${createTorrentsTable(group.torrents, false)}
</div>
</div>
</div>
`;
}
function createTorrentsTable(torrents, isMusic) {
// Sur mobile on utilise la même structure que discover
// Sur desktop on garde la table pour l'alignement
// Version avec divs (comme discover) - fonctionne partout
let html = `<div class="torrents-list-items">`;
torrents.forEach((torrent, index) => {
const quality = extractQuality(torrent.Title);
const language = extractLanguage(torrent.Title);
const torrentUrl = torrent.Details || torrent.Guid || '';
html += `
<div class="torrent-item ${index === 0 ? 'best-torrent' : ''}">
<div class="torrent-info">
<div class="torrent-name" title="${escapeHtml(torrent.Title)}">
${torrentUrl
? `<a href="${torrentUrl}" target="_blank" class="torrent-name-link">${escapeHtml(torrent.Title)}</a>`
: escapeHtml(torrent.Title)
}
</div>
<div class="torrent-meta">
<span class="tracker">📡 ${escapeHtml(torrent.Tracker)}</span>
<span class="size">💾 ${torrent.SizeFormatted || 'N/A'}</span>
<span class="seeds">🌱 ${torrent.Seeders || 0}</span>
${quality ? `<span class="quality">${quality}</span>` : ''}
${language ? `<span class="language">${language}</span>` : ''}
${index === 0 ? '<span class="best">👑 Meilleur</span>' : ''}
</div>
</div>
<div class="torrent-actions">
${torrentUrl ? `<a href="${torrentUrl}" target="_blank" class="btn-link" title="Page du torrent">🔗</a>` : ''}
${torrent.MagnetUri ? `<a href="${torrent.MagnetUri}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
${torrent.Link ? `<a href="${torrent.Link}" target="_blank" class="btn-download" title="Télécharger">⬇️</a>` : ''}
${torrentClientEnabled && (torrent.MagnetUri || (torrentClientSupportsTorrentFiles && torrent.Link)) ? `<button class="btn-send" title="Envoyer au client" onclick="sendToTorrentClient('${sanitizeUrl(torrent.MagnetUri || torrent.Link)}', this)">📥</button>` : ''}
</div>
</div>
`;
});
html += `</div>`;
return html;
}
function extractQuality(title) {
const qualities = ['2160p', '4K', '1080p', '720p', '480p'];
for (const q of qualities) {
if (title.toLowerCase().includes(q.toLowerCase())) return q;
}
return null;
}
function extractLanguage(title) {
const languages = { 'FRENCH': 'VF', 'TRUEFRENCH': 'VFF', 'VFF': 'VFF', 'VOSTFR': 'VOSTFR', 'MULTI': 'MULTI' };
const upper = title.toUpperCase();
for (const [key, val] of Object.entries(languages)) {
if (upper.includes(key)) return val;
}
return null;
}
function formatNumber(num) {
if (!num) return '0';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
function closeModal() {
document.getElementById('detailsModal').classList.add('hidden');
}
function showLoader(show) {
document.getElementById('loader').classList.toggle('hidden', !show);
}
function showMessage(message, type = 'info') {
const messageBox = document.getElementById('messageBox');
messageBox.textContent = message;
messageBox.className = `message-box ${type}`;
messageBox.classList.remove('hidden');
setTimeout(() => messageBox.classList.add('hidden'), 4000);
}
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 '';
}
}
// ============================================================
// 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) {
showMessage('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 = `
<div class="torrent-options-content">
<h3>📥 Options de téléchargement</h3>
<div class="torrent-option-group">
<label for="torrentCategory">Catégorie</label>
<select id="torrentCategory">
<option value="">-- Aucune --</option>
</select>
</div>
<div class="torrent-option-group">
<label for="torrentSavePath">Dossier (optionnel)</label>
<input type="text" id="torrentSavePath" placeholder="/chemin/vers/dossier">
</div>
<div class="torrent-option-group checkbox-group">
<input type="checkbox" id="torrentPaused">
<label for="torrentPaused">Démarrer en pause</label>
</div>
<div class="torrent-options-buttons">
<button class="btn-cancel" onclick="closeTorrentOptionsModal()">Annuler</button>
<button class="btn-confirm" id="confirmTorrentAdd">Envoyer</button>
</div>
</div>
`;
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 = '<option value="">-- Chargement... --</option>';
let categoriesWithPaths = {};
try {
const response = await fetch('/api/torrent-client/categories');
const data = await response.json();
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
if (data.success && data.categories) {
data.categories.forEach(cat => {
categorySelect.innerHTML += `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`;
});
// Stocker les chemins personnalisés
categoriesWithPaths = data.custom_categories || {};
}
} catch (error) {
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
}
// 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 = '✅';
showMessage('Torrent envoyé !', 'success');
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
} else {
button.textContent = '❌';
showMessage(data.error || 'Erreur', 'error');
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
} catch (error) {
button.textContent = '❌';
showMessage('Erreur de connexion', 'error');
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
}
// Vérifier le client torrent au chargement
checkTorrentClient();