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

1672
app/static/js/admin.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,365 @@
/**
* Lycostorrent - Admin Latest Categories
* Configuration des catégories par tracker pour les nouveautés
*/
let allTrackers = [];
let selectedTracker = null;
let trackerCategories = {}; // Catégories disponibles par tracker
let config = {}; // Configuration sauvegardée
// Initialisation
document.addEventListener('DOMContentLoaded', function() {
loadTrackers();
loadConfig();
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
document.getElementById('resetConfigBtn').addEventListener('click', resetCurrentTracker);
});
// ============================================================
// CHARGEMENT DES DONNÉES
// ============================================================
async function loadTrackers() {
try {
const response = await fetch('/api/trackers');
const data = await response.json();
if (data.success) {
allTrackers = data.trackers;
displayTrackerSelector(allTrackers);
} else {
showMessage('Erreur lors du chargement des trackers', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showMessage('Impossible de charger les trackers', 'error');
}
}
async function loadConfig() {
try {
const response = await fetch('/api/admin/latest-config');
const data = await response.json();
if (data.success) {
config = data.config || {};
displayConfigSummary();
}
} catch (error) {
console.error('Erreur chargement config:', error);
config = {};
}
}
async function loadTrackerCategories(trackerId) {
try {
document.getElementById('availableCategories').innerHTML = '<p class="loading">Chargement des catégories...</p>';
const response = await fetch(`/api/admin/tracker-categories/${trackerId}`);
const data = await response.json();
if (data.success) {
trackerCategories[trackerId] = data.categories;
displayAvailableCategories(data.categories);
} else {
document.getElementById('availableCategories').innerHTML = '<p class="error">Impossible de charger les catégories</p>';
}
} catch (error) {
console.error('Erreur:', error);
document.getElementById('availableCategories').innerHTML = '<p class="error">Erreur de connexion</p>';
}
}
// ============================================================
// AFFICHAGE
// ============================================================
function displayTrackerSelector(trackers) {
const container = document.getElementById('trackerSelector');
if (trackers.length === 0) {
container.innerHTML = '<p class="no-data">Aucun tracker configuré dans Jackett</p>';
return;
}
container.innerHTML = trackers.map(tracker => `
<button class="tracker-btn" data-tracker-id="${tracker.id}" data-tracker-name="${tracker.name}">
${tracker.name}
${config[tracker.id] ? '<span class="configured-badge">✓</span>' : ''}
</button>
`).join('');
// Event listeners
container.querySelectorAll('.tracker-btn').forEach(btn => {
btn.addEventListener('click', function() {
selectTracker(this.dataset.trackerId, this.dataset.trackerName);
});
});
}
function selectTracker(trackerId, trackerName) {
selectedTracker = trackerId;
// Mettre à jour l'UI
document.querySelectorAll('.tracker-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector(`[data-tracker-id="${trackerId}"]`).classList.add('active');
document.getElementById('selectedTrackerName').textContent = trackerName;
document.getElementById('configTrackerName').textContent = trackerName;
// Afficher les sections
document.getElementById('categoriesSection').classList.remove('hidden');
document.getElementById('configSection').classList.remove('hidden');
// Charger les catégories du tracker
loadTrackerCategories(trackerId);
// Remplir les inputs avec la config existante
fillConfigInputs(trackerId);
}
function displayAvailableCategories(categories) {
const container = document.getElementById('availableCategories');
if (!categories || categories.length === 0) {
container.innerHTML = '<p class="no-data">Aucune catégorie trouvée pour ce tracker</p>';
return;
}
// Grouper par type (milliers)
const grouped = {};
categories.forEach(cat => {
const prefix = Math.floor(parseInt(cat.id) / 1000) * 1000;
if (!grouped[prefix]) grouped[prefix] = [];
grouped[prefix].push(cat);
});
const prefixNames = {
1000: '🎮 Console/Jeux',
2000: '🎥 Films',
3000: '🎵 Audio/Musique',
4000: '💻 PC/Logiciels',
5000: '📺 TV/Séries',
6000: '📦 Autre',
7000: '📚 Livres',
8000: '📦 Autre'
};
let html = '<div class="categories-grid">';
for (const [prefix, cats] of Object.entries(grouped).sort((a, b) => a[0] - b[0])) {
html += `
<div class="category-group">
<h4>${prefixNames[prefix] || `Catégorie ${prefix}`}</h4>
<div class="category-list">
${cats.map(cat => `
<div class="category-item" data-id="${cat.id}">
<span class="cat-id">${cat.id}</span>
<span class="cat-name">${escapeHtml(cat.name)}</span>
<button class="btn-add-cat" title="Ajouter">+</button>
</div>
`).join('')}
</div>
</div>
`;
}
html += '</div>';
container.innerHTML = html;
// Event listeners pour les boutons d'ajout
container.querySelectorAll('.btn-add-cat').forEach(btn => {
btn.addEventListener('click', function() {
const catId = this.parentElement.dataset.id;
showAddCategoryModal(catId);
});
});
// Mettre à jour les quick-add buttons
updateQuickAddButtons(categories);
}
function updateQuickAddButtons(categories) {
const targets = ['movies', 'tv', 'anime', 'music'];
targets.forEach(target => {
const container = document.querySelector(`.quick-add[data-target="${target}"]`);
if (!container) return;
// Filtrer les catégories pertinentes
let relevantCats = [];
switch (target) {
case 'movies':
relevantCats = categories.filter(c => c.id.startsWith('2'));
break;
case 'tv':
relevantCats = categories.filter(c => c.id.startsWith('5') && !c.name.toLowerCase().includes('anime'));
break;
case 'anime':
relevantCats = categories.filter(c => c.name.toLowerCase().includes('anime'));
break;
case 'music':
relevantCats = categories.filter(c => c.id.startsWith('3'));
break;
}
if (relevantCats.length > 0) {
container.innerHTML = `
<div class="quick-add-label">Ajout rapide:</div>
${relevantCats.slice(0, 6).map(cat => `
<button class="quick-add-btn" data-id="${cat.id}" data-target="${target}" title="${escapeHtml(cat.name)}">
${cat.id}
</button>
`).join('')}
`;
container.querySelectorAll('.quick-add-btn').forEach(btn => {
btn.addEventListener('click', function() {
addCategoryToInput(this.dataset.target, this.dataset.id);
});
});
} else {
container.innerHTML = '';
}
});
}
function addCategoryToInput(target, catId) {
const input = document.getElementById(`config-${target}`);
const currentValue = input.value.trim();
const categories = currentValue ? currentValue.split(',').map(c => c.trim()) : [];
if (!categories.includes(catId)) {
categories.push(catId);
input.value = categories.join(',');
}
}
function showAddCategoryModal(catId) {
const target = prompt(`Ajouter la catégorie ${catId} à quel type ?\n\nOptions: movies, tv, anime, music`);
if (target && ['movies', 'tv', 'anime', 'music'].includes(target.toLowerCase())) {
addCategoryToInput(target.toLowerCase(), catId);
showMessage(`Catégorie ${catId} ajoutée à ${target}`, 'success');
}
}
function fillConfigInputs(trackerId) {
const trackerConfig = config[trackerId] || {};
document.getElementById('config-movies').value = trackerConfig.movies || '';
document.getElementById('config-tv').value = trackerConfig.tv || '';
document.getElementById('config-anime').value = trackerConfig.anime || '';
document.getElementById('config-music').value = trackerConfig.music || '';
}
function displayConfigSummary() {
const container = document.getElementById('configSummary');
if (Object.keys(config).length === 0) {
container.innerHTML = '<p class="no-data">Aucune configuration sauvegardée. Les catégories par défaut seront utilisées.</p>';
return;
}
let html = '<table class="summary-table"><thead><tr><th>Tracker</th><th>Films</th><th>Séries</th><th>Anime</th><th>Musique</th></tr></thead><tbody>';
for (const [trackerId, trackerConfig] of Object.entries(config)) {
const tracker = allTrackers.find(t => t.id === trackerId);
const trackerName = tracker ? tracker.name : trackerId;
html += `
<tr>
<td><strong>${escapeHtml(trackerName)}</strong></td>
<td>${trackerConfig.movies || '-'}</td>
<td>${trackerConfig.tv || '-'}</td>
<td>${trackerConfig.anime || '-'}</td>
<td>${trackerConfig.music || '-'}</td>
</tr>
`;
}
html += '</tbody></table>';
container.innerHTML = html;
// Mettre à jour les badges "configuré"
displayTrackerSelector(allTrackers);
}
// ============================================================
// SAUVEGARDE
// ============================================================
async function saveConfig() {
if (!selectedTracker) {
showMessage('Veuillez sélectionner un tracker', 'error');
return;
}
const trackerConfig = {
movies: document.getElementById('config-movies').value.trim(),
tv: document.getElementById('config-tv').value.trim(),
anime: document.getElementById('config-anime').value.trim(),
music: document.getElementById('config-music').value.trim()
};
// Supprimer les entrées vides
Object.keys(trackerConfig).forEach(key => {
if (!trackerConfig[key]) delete trackerConfig[key];
});
config[selectedTracker] = trackerConfig;
try {
const response = await fetch('/api/admin/latest-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
});
const data = await response.json();
if (data.success) {
showMessage('Configuration sauvegardée !', 'success');
displayConfigSummary();
} else {
showMessage(data.error || 'Erreur lors de la sauvegarde', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showMessage('Erreur de connexion', 'error');
}
}
function resetCurrentTracker() {
if (!selectedTracker) return;
if (confirm('Réinitialiser la configuration de ce tracker ?')) {
document.getElementById('config-movies').value = '';
document.getElementById('config-tv').value = '';
document.getElementById('config-anime').value = '';
document.getElementById('config-music').value = '';
delete config[selectedTracker];
showMessage('Configuration réinitialisée (pensez à sauvegarder)', 'info');
}
}
// ============================================================
// UTILITAIRES
// ============================================================
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;
}

View File

@@ -0,0 +1,227 @@
/**
* Lycostorrent - Admin Parsing Tags
* Gestion des tags de coupure pour le parsing des titres
*/
let currentTags = [];
// Présets de tags
const PRESETS = {
langues: ['MULTi', 'MULTI', 'VOSTFR', 'VOST', 'VFF', 'VFQ', 'VFI', 'FRENCH', 'TRUEFRENCH', 'SUBFRENCH'],
resolutions: ['1080p', '720p', '480p', '2160p', '4K', 'UHD'],
sources: ['WEB', 'WEBRIP', 'WEBDL', 'WEB-DL', 'HDTV', 'BLURAY', 'BDRIP', 'BRRIP', 'DVDRIP', 'HDRip', 'REMUX'],
codecs: ['x264', 'x265', 'HEVC', 'H264', 'H265', 'AV1'],
audio: ['HDR', 'HDR10', 'DV', 'DOLBY', 'ATMOS', 'DTS', 'AC3', 'AAC', 'FLAC', 'TrueHD']
};
// Tags par défaut (copie de tmdb_api.py)
const DEFAULT_TAGS = [
"MULTi", "MULTI", "VOSTFR", "VOST", "VFF", "VFQ", "VFI",
"FRENCH", "TRUEFRENCH", "SUBFRENCH",
"1080p", "720p", "480p", "2160p", "4K", "UHD",
"WEB", "WEBRIP", "WEBDL", "WEB-DL", "HDTV", "BLURAY", "BDRIP", "BRRIP", "DVDRIP", "HDRip", "REMUX",
"x264", "x265", "HEVC", "H264", "H265", "AV1",
"HDR", "HDR10", "DV", "DOLBY", "ATMOS", "DTS", "AC3", "AAC", "FLAC", "TrueHD",
"PROPER", "REPACK"
];
// Initialisation
document.addEventListener('DOMContentLoaded', function() {
loadTags();
setupEventListeners();
});
function setupEventListeners() {
document.getElementById('addTagBtn').addEventListener('click', addNewTag);
document.getElementById('newTagInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') addNewTag();
});
document.getElementById('saveTagsBtn').addEventListener('click', saveTags);
document.getElementById('resetTagsBtn').addEventListener('click', resetToDefault);
document.getElementById('testParsingBtn').addEventListener('click', testParsing);
document.getElementById('testTitleInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') testParsing();
});
// Présets
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', function() {
addPreset(this.dataset.preset);
});
});
}
// ============================================================
// CHARGEMENT / SAUVEGARDE
// ============================================================
async function loadTags() {
try {
const response = await fetch('/api/admin/parsing-tags');
const data = await response.json();
if (data.success) {
currentTags = data.tags || [];
renderTags();
} else {
showMessage('Erreur lors du chargement des tags', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showMessage('Impossible de charger les tags', 'error');
}
}
async function saveTags() {
try {
const response = await fetch('/api/admin/parsing-tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: currentTags })
});
const data = await response.json();
if (data.success) {
showMessage(`${currentTags.length} tags sauvegardés !`, 'success');
} else {
showMessage(data.error || 'Erreur lors de la sauvegarde', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showMessage('Erreur de connexion', 'error');
}
}
function resetToDefault() {
if (confirm('Réinitialiser tous les tags aux valeurs par défaut ?')) {
currentTags = [...DEFAULT_TAGS];
renderTags();
showMessage('Tags réinitialisés (pensez à sauvegarder)', 'info');
}
}
// ============================================================
// GESTION DES TAGS
// ============================================================
function renderTags() {
const container = document.getElementById('tagsList');
if (currentTags.length === 0) {
container.innerHTML = '<p class="no-data">Aucun tag configuré</p>';
return;
}
// Trier alphabétiquement
const sortedTags = [...currentTags].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
container.innerHTML = sortedTags.map(tag => `
<span class="tag-item-editor">
${escapeHtml(tag)}
<button class="tag-remove" data-tag="${escapeHtml(tag)}" title="Supprimer">×</button>
</span>
`).join('');
// Event listeners pour supprimer
container.querySelectorAll('.tag-remove').forEach(btn => {
btn.addEventListener('click', function() {
removeTag(this.dataset.tag);
});
});
}
function addNewTag() {
const input = document.getElementById('newTagInput');
const tag = input.value.trim();
if (!tag) {
showMessage('Veuillez entrer un tag', 'error');
return;
}
if (currentTags.some(t => t.toLowerCase() === tag.toLowerCase())) {
showMessage('Ce tag existe déjà', 'error');
return;
}
currentTags.push(tag);
renderTags();
input.value = '';
showMessage(`Tag "${tag}" ajouté`, 'success');
}
function removeTag(tag) {
currentTags = currentTags.filter(t => t !== tag);
renderTags();
}
function addPreset(presetName) {
const presetTags = PRESETS[presetName];
if (!presetTags) return;
let added = 0;
presetTags.forEach(tag => {
if (!currentTags.some(t => t.toLowerCase() === tag.toLowerCase())) {
currentTags.push(tag);
added++;
}
});
renderTags();
showMessage(`${added} tags ajoutés depuis le préset "${presetName}"`, 'success');
}
// ============================================================
// TEST DE PARSING
// ============================================================
async function testParsing() {
const input = document.getElementById('testTitleInput');
const title = input.value.trim();
if (!title) {
showMessage('Veuillez entrer un titre à tester', 'error');
return;
}
try {
const response = await fetch('/api/admin/test-parsing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
const data = await response.json();
if (data.success) {
document.getElementById('testOriginal').textContent = data.original;
document.getElementById('testCleaned').textContent = data.cleaned;
document.getElementById('testResult').classList.remove('hidden');
} else {
showMessage(data.error || 'Erreur de test', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showMessage('Erreur de connexion', 'error');
}
}
// ============================================================
// UTILITAIRES
// ============================================================
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;
}

288
app/static/js/admin_rss.js Normal file
View File

@@ -0,0 +1,288 @@
/**
* Lycostorrent - Admin RSS
* Gestion des flux RSS pour les nouveautés
*/
document.addEventListener('DOMContentLoaded', () => {
loadFeeds();
setupEventListeners();
});
function setupEventListeners() {
// Formulaire d'ajout
document.getElementById('add-feed-form').addEventListener('submit', addFeed);
// Bouton test
document.getElementById('test-feed-btn').addEventListener('click', testFeed);
}
// ============================================================
// CHARGEMENT DES FLUX
// ============================================================
async function loadFeeds() {
try {
const response = await fetch('/api/admin/rss');
const data = await response.json();
if (data.success) {
renderFeeds(data.feeds);
} else {
showError('Erreur lors du chargement des flux RSS');
}
} catch (error) {
console.error('Erreur:', error);
showError('Impossible de charger les flux RSS');
}
}
function renderFeeds(feeds) {
const container = document.getElementById('feeds-list');
if (feeds.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>🔗 Aucun flux RSS configuré</p>
<p>Ajoutez votre premier flux ci-dessus pour commencer</p>
</div>
`;
return;
}
container.innerHTML = feeds.map(feed => `
<div class="feed-card ${feed.enabled ? '' : 'disabled'}" data-id="${feed.id}">
<div class="feed-header">
<div class="feed-info">
<h3>${escapeHtml(feed.name)}</h3>
<span class="feed-category badge-${feed.category}">${getCategoryLabel(feed.category)}</span>
${feed.use_flaresolverr ? '<span class="feed-badge flaresolverr">🛡️ Flaresolverr</span>' : ''}
${feed.has_cookies ? '<span class="feed-badge cookies">🍪 Cookies</span>' : ''}
</div>
<div class="feed-actions">
<button class="btn-icon" onclick="toggleFeed('${feed.id}')" title="${feed.enabled ? 'Désactiver' : 'Activer'}">
${feed.enabled ? '✅' : '⏸️'}
</button>
<button class="btn-icon" onclick="testExistingFeed('${feed.id}')" title="Tester">🧪</button>
<button class="btn-icon btn-danger" onclick="deleteFeed('${feed.id}')" title="Supprimer">🗑️</button>
</div>
</div>
<div class="feed-url">
<code>${maskUrl(feed.url)}</code>
</div>
${feed.passkey ? '<div class="feed-passkey">🔑 Passkey configuré</div>' : ''}
</div>
`).join('');
}
function getCategoryLabel(category) {
const labels = {
'movies': '🎬 Films',
'tv': '📺 Séries',
'anime': '🎌 Anime',
'music': '🎵 Musique',
'all': '📦 Toutes'
};
return labels[category] || category;
}
function maskUrl(url) {
// Masquer le passkey dans l'URL pour l'affichage
return url.replace(/passkey=[^&]+/gi, 'passkey=***')
.replace(/apikey=[^&]+/gi, 'apikey=***')
.replace(/key=[^&]+/gi, 'key=***');
}
// ============================================================
// AJOUT DE FLUX
// ============================================================
async function addFeed(e) {
e.preventDefault();
const feed = {
name: document.getElementById('feed-name').value.trim(),
url: document.getElementById('feed-url').value.trim(),
category: document.getElementById('feed-category').value,
passkey: document.getElementById('feed-passkey').value.trim(),
use_flaresolverr: document.getElementById('feed-flaresolverr').checked,
cookies: document.getElementById('feed-cookies').value.trim()
};
if (!feed.name || !feed.url || !feed.category) {
showError('Veuillez remplir tous les champs obligatoires');
return;
}
try {
const response = await fetch('/api/admin/rss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feed)
});
const data = await response.json();
if (data.success) {
// Réinitialiser le formulaire
document.getElementById('add-feed-form').reset();
document.getElementById('test-result').classList.add('hidden');
// Recharger la liste
loadFeeds();
showSuccess('Flux RSS ajouté avec succès !');
} else {
showError(data.error || 'Erreur lors de l\'ajout');
}
} catch (error) {
console.error('Erreur:', error);
showError('Erreur de connexion au serveur');
}
}
// ============================================================
// TEST DE FLUX
// ============================================================
async function testFeed() {
const url = document.getElementById('feed-url').value.trim();
const passkey = document.getElementById('feed-passkey').value.trim();
const use_flaresolverr = document.getElementById('feed-flaresolverr').checked;
const cookies = document.getElementById('feed-cookies').value.trim();
if (!url) {
showError('Veuillez entrer une URL');
return;
}
const resultDiv = document.getElementById('test-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="loading">🔄 Test en cours...</p>';
try {
const response = await fetch('/api/admin/rss/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, passkey, use_flaresolverr, cookies })
});
const data = await response.json();
if (data.success && data.count > 0) {
resultDiv.innerHTML = `
<div class="test-success">
<h4>✅ Test réussi ! ${data.count} résultats trouvés</h4>
<div class="test-samples">
${data.sample.map(item => `
<div class="test-item">
<span class="test-title">${escapeHtml(item.Title)}</span>
<span class="test-meta">${item.SizeFormatted}${item.Seeders} seeders</span>
</div>
`).join('')}
</div>
</div>
`;
} else {
resultDiv.innerHTML = `
<div class="test-error">
<h4>❌ Aucun résultat trouvé</h4>
<p>Vérifiez l'URL et les cookies. Si erreur 403, activez Flaresolverr et ajoutez vos cookies.</p>
</div>
`;
}
} catch (error) {
console.error('Erreur:', error);
resultDiv.innerHTML = `
<div class="test-error">
<h4>❌ Erreur lors du test</h4>
<p>${error.message}</p>
</div>
`;
}
}
async function testExistingFeed(feedId) {
try {
const response = await fetch(`/api/admin/rss/${feedId}/test`, {
method: 'POST'
});
const data = await response.json();
if (data.success && data.count > 0) {
alert(`✅ Test réussi !\n${data.count} résultats trouvés\n\nExemple: ${data.sample[0]?.Title || 'N/A'}`);
} else {
alert('❌ Aucun résultat trouvé.\nVérifiez que le flux est toujours valide.');
}
} catch (error) {
console.error('Erreur:', error);
alert('❌ Erreur lors du test');
}
}
// ============================================================
// GESTION DES FLUX
// ============================================================
async function toggleFeed(feedId) {
try {
const response = await fetch(`/api/admin/rss/${feedId}/toggle`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
loadFeeds();
} else {
showError(data.error || 'Erreur lors de la modification');
}
} catch (error) {
console.error('Erreur:', error);
showError('Erreur de connexion');
}
}
async function deleteFeed(feedId) {
if (!confirm('Supprimer ce flux RSS ?')) {
return;
}
try {
const response = await fetch(`/api/admin/rss/${feedId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
loadFeeds();
showSuccess('Flux RSS supprimé');
} else {
showError(data.error || 'Erreur lors de la suppression');
}
} catch (error) {
console.error('Erreur:', error);
showError('Erreur de connexion');
}
}
// ============================================================
// UTILITAIRES
// ============================================================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showError(message) {
alert('❌ ' + message);
}
function showSuccess(message) {
// Simple alert pour l'instant
console.log('✅ ' + message);
}

629
app/static/js/discover.js Normal file
View File

@@ -0,0 +1,629 @@
/**
* Lycostorrent - Page Découvrir (Version simplifiée)
* 2 catégories : Films récents / Séries en cours
* Avec pré-cache des torrents
*/
// ============================================================
// ÉTAT GLOBAL
// ============================================================
let currentCategory = 'movies';
let currentMedia = null;
let torrentClientEnabled = false;
let cachedData = {}; // Cache local des données
// ============================================================
// INITIALISATION
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
initTabs();
checkTorrentClient();
loadCategory('movies');
});
function initTabs() {
document.querySelectorAll('.discover-tab').forEach(tab => {
tab.addEventListener('click', () => {
const category = tab.dataset.category;
// Mettre à jour l'UI
document.querySelectorAll('.discover-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Charger la catégorie
loadCategory(category);
});
});
}
async function checkTorrentClient() {
try {
const response = await fetch('/api/torrent-client/status');
const data = await response.json();
torrentClientEnabled = data.success && data.enabled && data.connected;
} catch (error) {
torrentClientEnabled = false;
}
}
// ============================================================
// CHARGEMENT DES DONNÉES
// ============================================================
async function loadCategory(category) {
currentCategory = category;
const grid = document.getElementById('discoverGrid');
const loader = document.getElementById('discoverLoader');
const empty = document.getElementById('discoverEmpty');
// Afficher le loader
grid.innerHTML = '';
loader.classList.remove('hidden');
empty.classList.add('hidden');
hideCacheInfo();
// Essayer de charger depuis le cache d'abord
try {
const cacheResponse = await fetch(`/api/cache/data/discover/${category}`);
const cacheData = await cacheResponse.json();
if (cacheData.success && cacheData.cached && cacheData.data && cacheData.data.length > 0) {
loader.classList.add('hidden');
cachedData[category] = cacheData.data;
const mediaType = category === 'movies' ? 'movie' : 'tv';
renderGrid(cacheData.data, mediaType, true);
showCacheInfo(cacheData.timestamp);
console.log(`📦 Discover ${category} chargé depuis le cache: ${cacheData.data.length} résultats`);
return;
}
} catch (error) {
console.log('Pas de cache disponible, chargement en direct...');
}
// Si pas de cache, charger en direct
await loadCategoryLive(category);
}
async function loadCategoryLive(category) {
const grid = document.getElementById('discoverGrid');
const loader = document.getElementById('discoverLoader');
const empty = document.getElementById('discoverEmpty');
grid.innerHTML = '';
loader.classList.remove('hidden');
empty.classList.add('hidden');
hideCacheInfo();
try {
const response = await fetch(`/api/discover/${category}`);
const data = await response.json();
loader.classList.add('hidden');
if (data.success && data.results && data.results.length > 0) {
cachedData[category] = data.results;
renderGrid(data.results, data.media_type, false);
} else {
empty.classList.remove('hidden');
empty.querySelector('p').textContent = data.error || 'Aucun résultat trouvé';
}
} catch (error) {
loader.classList.add('hidden');
empty.classList.remove('hidden');
empty.querySelector('p').textContent = 'Erreur de chargement';
console.error('Erreur:', error);
}
}
function renderGrid(results, mediaType, fromCache) {
const grid = document.getElementById('discoverGrid');
grid.innerHTML = results.map((item, index) => {
const posterUrl = item.poster_path
? `https://image.tmdb.org/t/p/w300${item.poster_path}`
: null;
const title = item.title || item.name;
const year = (item.release_date || item.first_air_date || '').substring(0, 4);
const rating = item.vote_average ? item.vote_average.toFixed(1) : '--';
const type = mediaType === 'movie' ? '🎬' : '📺';
// Indicateur de torrents disponibles (si depuis le cache)
const torrentCount = item.torrent_count || 0;
const torrentBadge = fromCache && torrentCount > 0
? `<span class="torrent-badge">🧲 ${torrentCount}</span>`
: '';
return `
<div class="discover-card" onclick="openDetail(${item.id}, '${mediaType}', ${index})">
<div class="poster-container">
${posterUrl
? `<img src="${posterUrl}" alt="${escapeHtml(title)}" class="poster" loading="lazy">`
: `<div class="poster-placeholder">${type}</div>`
}
<span class="rating-badge">⭐ ${rating}</span>
${torrentBadge}
</div>
<div class="card-info">
<div class="card-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div>
<div class="card-meta">
<span class="card-year">${year || 'N/A'}</span>
<span class="card-type">${type}</span>
</div>
</div>
</div>
`;
}).join('');
}
// Afficher les infos du cache
function showCacheInfo(timestamp) {
const cacheInfo = document.getElementById('cacheInfo');
const cacheTimestampEl = 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`;
}
cacheTimestampEl.textContent = timeAgo;
cacheInfo.classList.remove('hidden');
}
}
function hideCacheInfo() {
const cacheInfo = document.getElementById('cacheInfo');
if (cacheInfo) {
cacheInfo.classList.add('hidden');
}
}
function refreshLive() {
loadCategoryLive(currentCategory);
}
// ============================================================
// MODAL DÉTAILS
// ============================================================
async function openDetail(id, mediaType, index) {
const modal = document.getElementById('detailModal');
const listEl = document.getElementById('torrentsList');
const loadingEl = document.getElementById('torrentsLoading');
const emptyEl = document.getElementById('torrentsEmpty');
// Réinitialiser
listEl.innerHTML = '';
loadingEl.classList.add('hidden');
emptyEl.classList.add('hidden');
modal.classList.remove('hidden');
// Vérifier si on a les données en cache local (avec détails + torrents pré-chargés)
const category = mediaType === 'movie' ? 'movies' : 'tv';
const cachedItem = cachedData[category] ? cachedData[category][index] : null;
// Si les détails sont pré-cachés, on les utilise directement (INSTANTANÉ)
if (cachedItem && cachedItem.details_cached) {
console.log(`📦 Détails + torrents depuis le cache pour: ${cachedItem.title || cachedItem.name}`);
currentMedia = cachedItem;
currentMedia.media_type = mediaType;
// Afficher les détails depuis le cache
renderDetailFromCache(cachedItem, mediaType);
// Afficher les torrents depuis le cache
if (cachedItem.torrents && cachedItem.torrents.length > 0) {
renderTorrents(cachedItem.torrents);
} else {
emptyEl.classList.remove('hidden');
}
return;
}
// Sinon, fallback : charger depuis l'API (pour les items sans cache)
try {
const response = await fetch(`/api/discover/detail/${mediaType}/${id}`);
const data = await response.json();
if (data.success) {
currentMedia = data.detail;
currentMedia.media_type = mediaType;
renderDetail(data.detail, mediaType);
// Si on a des torrents pré-cachés, les afficher
if (cachedItem && cachedItem.torrents && cachedItem.torrents.length > 0) {
renderTorrents(cachedItem.torrents);
} else {
// Sinon, rechercher en direct
searchTorrents(data.detail, mediaType);
}
} else {
closeDetailModal();
alert('Erreur lors du chargement des détails');
}
} catch (error) {
console.error('Erreur:', error);
closeDetailModal();
}
}
// Afficher les détails depuis le cache (nouvelle fonction)
function renderDetailFromCache(item, mediaType) {
const title = item.title || item.name;
const year = (item.release_date || item.first_air_date || '').substring(0, 4);
const posterUrl = item.poster_path
? `https://image.tmdb.org/t/p/w300${item.poster_path}`
: '/static/icons/icon-192x192.png';
document.getElementById('detailPoster').src = posterUrl;
document.getElementById('detailPoster').alt = title;
document.getElementById('detailTitle').textContent = title;
document.getElementById('detailYear').textContent = year;
document.getElementById('detailRating').textContent = `${item.vote_average ? item.vote_average.toFixed(1) : '--'}`;
document.getElementById('detailOverview').textContent = item.overview || 'Aucune description disponible.';
// Genres
const genresContainer = document.getElementById('detailGenres');
if (item.genres && item.genres.length > 0) {
genresContainer.innerHTML = item.genres.map(g => `<span>${g.name}</span>`).join('');
} else {
genresContainer.innerHTML = '';
}
// Bande-annonce YouTube
const trailerSection = document.getElementById('detailTrailer');
const trailerFrame = document.getElementById('trailerFrame');
if (item.trailer_url) {
trailerFrame.src = item.trailer_url;
trailerSection.classList.remove('hidden');
} else {
trailerFrame.src = '';
trailerSection.classList.add('hidden');
}
}
function renderDetail(detail, mediaType) {
const title = detail.title || detail.name;
const year = (detail.release_date || detail.first_air_date || '').substring(0, 4);
const posterUrl = detail.poster_path
? `https://image.tmdb.org/t/p/w300${detail.poster_path}`
: '/static/icons/icon-192x192.png';
document.getElementById('detailPoster').src = posterUrl;
document.getElementById('detailPoster').alt = title;
document.getElementById('detailTitle').textContent = title;
document.getElementById('detailYear').textContent = year;
document.getElementById('detailRating').textContent = `${detail.vote_average ? detail.vote_average.toFixed(1) : '--'}`;
document.getElementById('detailOverview').textContent = detail.overview || 'Aucune description disponible.';
// Genres
const genresContainer = document.getElementById('detailGenres');
if (detail.genres && detail.genres.length > 0) {
genresContainer.innerHTML = detail.genres.map(g => `<span>${g.name}</span>`).join('');
} else {
genresContainer.innerHTML = '';
}
// Bande-annonce YouTube
const trailerSection = document.getElementById('detailTrailer');
const trailerFrame = document.getElementById('trailerFrame');
if (detail.trailer_url) {
trailerFrame.src = detail.trailer_url;
trailerSection.classList.remove('hidden');
} else {
trailerFrame.src = '';
trailerSection.classList.add('hidden');
}
}
function closeDetailModal() {
document.getElementById('detailModal').classList.add('hidden');
// Arrêter la vidéo YouTube
document.getElementById('trailerFrame').src = '';
currentMedia = null;
}
// Fermer le modal en cliquant à l'extérieur
document.getElementById('detailModal')?.addEventListener('click', (e) => {
if (e.target.id === 'detailModal') {
closeDetailModal();
}
});
// Fermer avec Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDetailModal();
}
});
// ============================================================
// RECHERCHE DE TORRENTS (fallback si pas en cache)
// ============================================================
async function searchTorrents(detail, mediaType) {
const loadingEl = document.getElementById('torrentsLoading');
const listEl = document.getElementById('torrentsList');
const emptyEl = document.getElementById('torrentsEmpty');
loadingEl.classList.remove('hidden');
listEl.innerHTML = '';
emptyEl.classList.add('hidden');
const title = detail.title || detail.name;
const originalTitle = detail.original_title || detail.original_name || '';
const year = (detail.release_date || detail.first_air_date || '').substring(0, 4);
try {
const response = await fetch('/api/discover/search-torrents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
original_title: originalTitle,
year: year,
media_type: mediaType,
tmdb_id: detail.id
})
});
const data = await response.json();
loadingEl.classList.add('hidden');
if (data.success && data.results && data.results.length > 0) {
renderTorrents(data.results);
} else {
emptyEl.classList.remove('hidden');
}
} catch (error) {
loadingEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
console.error('Erreur recherche torrents:', error);
}
}
function renderTorrents(torrents) {
const listEl = document.getElementById('torrentsList');
listEl.innerHTML = torrents.slice(0, 20).map((torrent, index) => {
const size = torrent.Size ? formatSize(torrent.Size) : 'N/A';
const seeds = torrent.Seeders || 0;
const quality = torrent.parsed?.quality || '';
const tracker = torrent.Tracker || torrent.TrackerName || 'Unknown';
const magnetUrl = torrent.MagnetUri || '';
const downloadUrl = torrent.Link || '';
const detailsUrl = torrent.Details || torrent.Guid || '';
const torrentUrl = magnetUrl || downloadUrl;
return `
<div class="torrent-item">
<div class="torrent-info">
<div class="torrent-name" title="${escapeHtml(torrent.Title)}">${escapeHtml(torrent.Title)}</div>
<div class="torrent-meta">
<span class="tracker">📡 ${escapeHtml(tracker)}</span>
<span class="size">💾 ${size}</span>
<span class="seeds">🌱 ${seeds}</span>
${quality ? `<span class="quality">${escapeHtml(quality)}</span>` : ''}
</div>
</div>
<div class="torrent-actions">
${detailsUrl ? `<a href="${detailsUrl}" target="_blank" class="btn-link" title="Voir sur le tracker">🔗</a>` : ''}
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
${downloadUrl ? `<a href="${downloadUrl}" target="_blank" class="btn-download" title="Télécharger .torrent">⬇️</a>` : ''}
${torrentClientEnabled && torrentUrl ?
`<button class="btn-send" id="send-btn-${index}" onclick="handleSendToClient('${escapeHtml(torrentUrl)}', 'send-btn-${index}')" title="Envoyer au client">📥</button>`
: ''}
</div>
</div>
`;
}).join('');
}
function handleSendToClient(url, buttonId) {
const button = document.getElementById(buttonId);
sendToClient(url, button);
}
// ============================================================
// ENVOI AU CLIENT TORRENT
// ============================================================
async function sendToClient(url, buttonElement) {
if (!url) return;
showTorrentOptionsModal(url, buttonElement);
}
async function showTorrentOptionsModal(url, button) {
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);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeTorrentOptionsModal();
});
}
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>`;
});
categoriesWithPaths = data.custom_categories || {};
}
} catch (error) {
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
}
categorySelect.onchange = () => {
const selectedCat = categorySelect.value;
if (selectedCat && categoriesWithPaths[selectedCat]) {
savePathInput.value = categoriesWithPaths[selectedCat];
} else {
savePathInput.value = '';
}
};
savePathInput.value = '';
document.getElementById('torrentPaused').checked = false;
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);
};
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) {
if (button) {
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) {
if (button) {
button.textContent = '✅';
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
showToast('Torrent envoyé !', 'success');
} else {
if (button) {
button.textContent = '❌';
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
showToast(data.error || 'Erreur', 'error');
}
} catch (error) {
if (button) {
button.textContent = '❌';
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
showToast('Erreur de connexion', 'error');
}
}
// ============================================================
// UTILITAIRES
// ============================================================
function formatSize(bytes) {
if (!bytes) return 'N/A';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
let toast = document.getElementById('toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast';
toast.className = 'toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = `toast ${type}`;
toast.classList.remove('hidden');
setTimeout(() => {
toast.classList.add('hidden');
}, 3000);
}

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();

57
app/static/js/nav.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* Lycostorrent - Navigation dynamique
* Génère la navigation en fonction des modules activés
*/
(async function() {
const nav = document.getElementById('mainNav');
if (!nav) return;
// Déterminer la page actuelle
const currentPath = window.location.pathname;
try {
const response = await fetch('/api/modules');
const data = await response.json();
const modules = data.success ? data.modules : { search: true, latest: true, discover: false };
let navHTML = '';
// Module Recherche
if (modules.search !== false) {
const isActive = currentPath === '/' || currentPath === '/index' ? 'active' : '';
navHTML += `<a href="/" class="${isActive}">🔍 Recherche</a>`;
}
// Module Nouveautés
if (modules.latest !== false) {
const isActive = currentPath === '/latest' ? 'active' : '';
navHTML += `<a href="/latest" class="${isActive}">🎬 Nouveautés</a>`;
}
// Module Découvrir
if (modules.discover === true) {
const isActive = currentPath === '/discover' ? 'active' : '';
navHTML += `<a href="/discover" class="${isActive}">🌟 Découvrir</a>`;
}
// Admin toujours visible
const isAdminActive = currentPath === '/admin' ? 'active' : '';
navHTML += `<a href="/admin" class="${isAdminActive}">⚙️ Admin</a>`;
// Déconnexion
navHTML += `<a href="/logout" class="nav-logout" title="Déconnexion">🚪</a>`;
nav.innerHTML = navHTML;
} catch (error) {
// Fallback si erreur
nav.innerHTML = `
<a href="/" class="${currentPath === '/' ? 'active' : ''}">🔍 Recherche</a>
<a href="/latest" class="${currentPath === '/latest' ? 'active' : ''}">🎬 Nouveautés</a>
<a href="/admin" class="${currentPath === '/admin' ? 'active' : ''}">⚙️ Admin</a>
<a href="/logout" class="nav-logout" title="Déconnexion">🚪</a>
`;
}
})();

985
app/static/js/search.js Normal file
View File

@@ -0,0 +1,985 @@
/**
* 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 = '<p class="no-trackers">Aucun tracker configuré</p>';
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 = '<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.sources.includes('rss')) {
sourceBadge = '<span class="source-badge source-rss" title="RSS">RSS</span>';
}
} else {
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>';
}
}
return `
<div class="tracker-item">
<input type="checkbox" id="tracker-${escapeHtml(tracker.id)}" value="${escapeHtml(tracker.id)}" checked>
<label for="tracker-${escapeHtml(tracker.id)}">${escapeHtml(tracker.name)}</label>
${sourceBadge}
</div>
`;
}).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 = `
<div class="filter-group" data-filter="${filterKey}">
<h4>${filterConfig.icon} ${filterConfig.name}</h4>
<div class="filter-values">
${sortedValues.map(([value, count]) => `
<label class="filter-checkbox">
<input
type="checkbox"
data-filter="${filterKey}"
data-value="${escapeHtml(value)}"
${isFilterActive(filterKey, value) ? 'checked' : ''}
>
<span class="filter-label">${escapeHtml(value)}</span>
<span class="filter-count">(${count})</span>
</label>
`).join('')}
</div>
</div>
`;
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 `
<div class="pagination">
<div class="pagination-info">
Résultats ${startResult} - ${endResult} sur ${filteredResults.length}
</div>
<div class="pagination-controls">
<button class="pagination-btn" onclick="goToPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
← Précédent
</button>
${pages.map(page => {
if (page === '...') {
return '<span class="pagination-ellipsis">...</span>';
}
return `<button class="pagination-btn ${page === currentPage ? 'active' : ''}" onclick="goToPage(${page})">${page}</button>`;
}).join('')}
<button class="pagination-btn" onclick="goToPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>
Suivant
</button>
</div>
</div>
`;
}
// ============================================================
// RENDU DES RÉSULTATS
// ============================================================
function renderResults() {
const container = document.getElementById('results-container');
if (filteredResults.length === 0) {
if (allResults.length === 0) {
container.innerHTML = '<p class="no-results">Aucun résultat trouvé</p>';
} else {
container.innerHTML = '<p class="no-results">Aucun résultat ne correspond aux filtres sélectionnés</p>';
}
return;
}
const pageResults = getPageResults();
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// Mode cards pour mobile
container.innerHTML = `
${renderPagination()}
<div class="results-cards">
${pageResults.map(torrent => renderTorrentCard(torrent)).join('')}
</div>
${renderPagination()}
`;
} else {
// Mode table pour desktop
container.innerHTML = `
${renderPagination()}
<table class="results-table">
<thead>
<tr>
<th class="col-name sortable" onclick="onSortChange('Title')">
Nom ${getSortIcon('Title')}
</th>
<th class="col-tracker sortable" onclick="onSortChange('Tracker')">
Tracker ${getSortIcon('Tracker')}
</th>
<th class="col-size sortable" onclick="onSortChange('Size')">
Taille ${getSortIcon('Size')}
</th>
<th class="col-seeders sortable" onclick="onSortChange('Seeders')">
Seeders ${getSortIcon('Seeders')}
</th>
<th class="col-date sortable" onclick="onSortChange('PublishDate')">
Date ${getSortIcon('PublishDate')}
</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
${pageResults.map(torrent => renderTorrentRow(torrent)).join('')}
</tbody>
</table>
${renderPagination()}
`;
}
}
function renderTorrentCard(torrent) {
const parsed = torrent.parsed || {};
const badges = [];
if (parsed.quality?.length) {
badges.push(...parsed.quality.map(q => `<span class="badge badge-quality">${escapeHtml(q)}</span>`));
}
if (parsed.source?.length) {
badges.push(...parsed.source.map(s => `<span class="badge badge-source">${escapeHtml(s)}</span>`));
}
if (parsed.language?.length) {
badges.push(...parsed.language.map(l => `<span class="badge badge-language">${escapeHtml(l)}</span>`));
}
const seedersClass = getSeedersClass(torrent.Seeders);
// Sanitize URLs
const magnetUrl = sanitizeUrl(torrent.MagnetUri);
const downloadUrl = sanitizeUrl(torrent.Link);
const detailsUrl = sanitizeUrl(torrent.Details);
return `
<div class="result-card-mobile">
<div class="torrent-title">${escapeHtml(torrent.Title)}</div>
<div class="torrent-badges">${badges.join('')}</div>
<div class="result-meta">
<span>📁 ${escapeHtml(torrent.SizeFormatted || 'N/A')}</span>
<span class="${seedersClass}">🌱 ${parseInt(torrent.Seeders) || 0}</span>
<span>🏷️ ${escapeHtml(torrent.Tracker)}</span>
</div>
<div class="result-actions">
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet-mobile" title="Magnet">🧲</a>` : ''}
${downloadUrl ? `<a href="${downloadUrl}" class="btn-download-mobile" title="Télécharger" target="_blank">⬇️</a>` : ''}
${detailsUrl ? `<a href="${detailsUrl}" class="btn-details-mobile" title="Détails" target="_blank">🔗</a>` : ''}
${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `<button class="btn-send-client-mobile" title="Envoyer au client" onclick="sendToTorrentClient('${magnetUrl || downloadUrl}', this)">📥</button>` : ''}
</div>
</div>
`;
}
function getSortIcon(field) {
if (currentSort.field !== field) {
return '<span class="sort-icon">⇅</span>';
}
return currentSort.order === 'asc'
? '<span class="sort-icon active">↑</span>'
: '<span class="sort-icon active">↓</span>';
}
function renderTorrentRow(torrent) {
const parsed = torrent.parsed || {};
const badges = [];
if (parsed.quality?.length) {
badges.push(...parsed.quality.map(q => `<span class="badge badge-quality">${escapeHtml(q)}</span>`));
}
if (parsed.source?.length) {
badges.push(...parsed.source.map(s => `<span class="badge badge-source">${escapeHtml(s)}</span>`));
}
if (parsed.video_codec?.length) {
badges.push(...parsed.video_codec.map(c => `<span class="badge badge-codec">${escapeHtml(c)}</span>`));
}
if (parsed.language?.length) {
badges.push(...parsed.language.map(l => `<span class="badge badge-language">${escapeHtml(l)}</span>`));
}
if (parsed.hdr?.length) {
badges.push(...parsed.hdr.map(h => `<span class="badge badge-hdr">${escapeHtml(h)}</span>`));
}
const seedersClass = getSeedersClass(torrent.Seeders);
// Sanitize URLs
const magnetUrl = sanitizeUrl(torrent.MagnetUri);
const downloadUrl = sanitizeUrl(torrent.Link);
const detailsUrl = sanitizeUrl(torrent.Details);
return `
<tr>
<td class="col-name">
<div class="torrent-title">${escapeHtml(torrent.Title)}</div>
<div class="torrent-badges">${badges.join('')}</div>
</td>
<td class="col-tracker">${escapeHtml(torrent.Tracker)}</td>
<td class="col-size">${escapeHtml(torrent.SizeFormatted || 'N/A')}</td>
<td class="col-seeders ${seedersClass}">${parseInt(torrent.Seeders) || 0}</td>
<td class="col-date">${escapeHtml(torrent.PublishDate || 'N/A')}</td>
<td class="col-actions">
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
${downloadUrl ? `<a href="${downloadUrl}" class="btn-download" title="Télécharger" target="_blank">⬇️</a>` : ''}
${detailsUrl ? `<a href="${detailsUrl}" class="btn-details" title="Détails" target="_blank">🔗</a>` : ''}
${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `<button class="btn-send-client" title="Envoyer au client" onclick="sendToTorrentClient('${magnetUrl || downloadUrl}', this)">📥</button>` : ''}
</td>
</tr>
`;
}
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 = `
<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 = '✅';
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();

View File

@@ -0,0 +1,8 @@
/**
* Lycostorrent - Chargement du thème
* Ce script doit être chargé en premier pour éviter le flash de thème
*/
(function() {
const savedTheme = localStorage.getItem('lycostorrent-theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
})();