From 16c95f747bc8a957c4b10b83525de01da0e242f3 Mon Sep 17 00:00:00 2001 From: zogzog Date: Mon, 23 Mar 2026 20:59:26 +0100 Subject: [PATCH] Initial commit --- CHANGELOG.md | 946 ++++++ Dockerfile | 23 + README.md | 198 ++ app/VERSION | 1 + app/cache_manager.py | 435 +++ app/config.py | 57 + app/config/filters_config.json | 74 + app/indexer_manager.py | 269 ++ app/jackett_api.py | 202 ++ app/lastfm_api.py | 212 ++ app/main.py | 2945 +++++++++++++++++++ app/plugins/torrent_clients/README.md | 154 + app/plugins/torrent_clients/__init__.py | 262 ++ app/plugins/torrent_clients/base.py | 219 ++ app/plugins/torrent_clients/qbittorrent.py | 484 +++ app/plugins/torrent_clients/transmission.py | 427 +++ app/prowlarr_api.py | 265 ++ app/rss_source.py | 559 ++++ app/security.py | 375 +++ app/static/css/admin.css | 1424 +++++++++ app/static/css/cache-info.css | 48 + app/static/css/discover.css | 699 +++++ app/static/css/latest.css | 1678 +++++++++++ app/static/css/style.css | 1168 ++++++++ app/static/css/themes.css | 168 ++ app/static/icons/icon-128x128.png | Bin 0 -> 1214 bytes app/static/icons/icon-144x144.png | Bin 0 -> 1344 bytes app/static/icons/icon-152x152.png | Bin 0 -> 1464 bytes app/static/icons/icon-192x192.png | Bin 0 -> 1887 bytes app/static/icons/icon-384x384.png | Bin 0 -> 3846 bytes app/static/icons/icon-512x512.png | Bin 0 -> 5439 bytes app/static/icons/icon-72x72.png | Bin 0 -> 671 bytes app/static/icons/icon-96x96.png | Bin 0 -> 886 bytes app/static/js/admin.js | 1672 +++++++++++ app/static/js/admin_latest.js | 365 +++ app/static/js/admin_parsing.js | 227 ++ app/static/js/admin_rss.js | 288 ++ app/static/js/discover.js | 629 ++++ app/static/js/latest.js | 901 ++++++ app/static/js/nav.js | 57 + app/static/js/search.js | 985 +++++++ app/static/js/theme-loader.js | 8 + app/static/manifest.json | 77 + app/static/sw.js | 143 + app/templates/admin.html | 880 ++++++ app/templates/admin_latest.html | 105 + app/templates/admin_parsing.html | 108 + app/templates/admin_rss.html | 126 + app/templates/discover.html | 129 + app/templates/index.html | 133 + app/templates/latest.html | 145 + app/templates/login.html | 232 ++ app/tmdb_api.py | 371 +++ app/torrent_parser.py | 268 ++ docker-compose.yml | 32 + requirements.txt | 4 + 56 files changed, 21177 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/VERSION create mode 100644 app/cache_manager.py create mode 100644 app/config.py create mode 100644 app/config/filters_config.json create mode 100644 app/indexer_manager.py create mode 100644 app/jackett_api.py create mode 100644 app/lastfm_api.py create mode 100644 app/main.py create mode 100644 app/plugins/torrent_clients/README.md create mode 100644 app/plugins/torrent_clients/__init__.py create mode 100644 app/plugins/torrent_clients/base.py create mode 100644 app/plugins/torrent_clients/qbittorrent.py create mode 100644 app/plugins/torrent_clients/transmission.py create mode 100644 app/prowlarr_api.py create mode 100644 app/rss_source.py create mode 100644 app/security.py create mode 100644 app/static/css/admin.css create mode 100644 app/static/css/cache-info.css create mode 100644 app/static/css/discover.css create mode 100644 app/static/css/latest.css create mode 100644 app/static/css/style.css create mode 100644 app/static/css/themes.css create mode 100644 app/static/icons/icon-128x128.png create mode 100644 app/static/icons/icon-144x144.png create mode 100644 app/static/icons/icon-152x152.png create mode 100644 app/static/icons/icon-192x192.png create mode 100644 app/static/icons/icon-384x384.png create mode 100644 app/static/icons/icon-512x512.png create mode 100644 app/static/icons/icon-72x72.png create mode 100644 app/static/icons/icon-96x96.png create mode 100644 app/static/js/admin.js create mode 100644 app/static/js/admin_latest.js create mode 100644 app/static/js/admin_parsing.js create mode 100644 app/static/js/admin_rss.js create mode 100644 app/static/js/discover.js create mode 100644 app/static/js/latest.js create mode 100644 app/static/js/nav.js create mode 100644 app/static/js/search.js create mode 100644 app/static/js/theme-loader.js create mode 100644 app/static/manifest.json create mode 100644 app/static/sw.js create mode 100644 app/templates/admin.html create mode 100644 app/templates/admin_latest.html create mode 100644 app/templates/admin_parsing.html create mode 100644 app/templates/admin_rss.html create mode 100644 app/templates/discover.html create mode 100644 app/templates/index.html create mode 100644 app/templates/latest.html create mode 100644 app/templates/login.html create mode 100644 app/tmdb_api.py create mode 100644 app/torrent_parser.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c06ba38 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,946 @@ +# Changelog - Lycostorrent + +Toutes les modifications notables de ce projet sont documentées dans ce fichier. + +Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/), +et ce projet adhère au [Semantic Versioning](https://semver.org/lang/fr/). + +--- + +## [1.9.25] - 2025-02-05 + +### 🏷️ Filtres par année avec pastilles + +#### Ajouté +- **Pastilles cliquables** : `[2026]` `[2025]` `[2024]` `[2023]` `[≤2022]` `[Tous]` +- **Sélection multiple** - Possibilité de sélectionner plusieurs années +- **Filtrage instantané** - Pas besoin de recharger +- **Option 100 résultats** dans le sélecteur + +#### Comportement +- Clic sur une année → toggle on/off +- Clic sur "Tous" → désactive les autres filtres +- Par défaut : "Tous" actif +- Compteur affiché : `(15/42)` + +#### Fichiers modifiés +- `app/templates/latest.html` - Pastilles HTML + option 100 résultats +- `app/static/js/latest.js` - Logique de filtrage multiple +- `app/static/css/latest.css` - Styles pastilles (desktop + mobile) + +--- + +## [1.9.24] - 2025-12-28 + +### 🔌 Support chemin (path) pour client torrent + +#### Problème résolu +- URLs avec reverse proxy comme `https://geco.useed.me/qbittorrent` ne fonctionnaient pas +- Erreur `invalid literal for int()` quand le port était vide + +#### Ajouté +- **Champ "Chemin"** dans l'admin pour les reverse proxy +- **Port optionnel** - Laisser vide pour utiliser le port par défaut (80/443) +- **Parsing intelligent** - Coller une URL complète la découpe automatiquement + +#### Rétrocompatibilité +| Configuration | URL générée | +|--------------|-------------| +| `host: "192.168.1.100", port: 8080` | `http://192.168.1.100:8080` | +| `host: "geco.useed.me", path: "/qbittorrent", use_ssl: true` | `https://geco.useed.me/qbittorrent` | +| `host: "https://example.com/qbit"` | `https://example.com/qbit` | + +#### Fichiers modifiés +- `app/plugins/torrent_clients/base.py` - TorrentClientConfig avec path +- `app/plugins/torrent_clients/__init__.py` - Parsing URL intelligent +- `app/main.py` - Gestion port vide + path +- `app/templates/admin.html` - Nouveau champ chemin +- `app/static/js/admin.js` - Support path dans formulaire + +--- + +## [1.9.23] - 2025-12-28 + +### 🗓️ Filtre par année dans Latest + +#### Ajouté +- **Sélecteur d'année** avec options : Toutes, 2026, 2025, 2024, 2023, 2022 +- **Filtrage instantané** - Changer l'année sans recharger les données +- **Compteur intelligent** - Affiche "X nouveautés de 2025" + +#### Fonctionnement +- Filtre basé sur l'année TMDb (`tmdb.year`) +- Si pas d'info TMDb, le résultat reste affiché +- Re-filtrage instantané quand on change l'année + +#### Fichiers modifiés +- `app/templates/latest.html` - Sélecteur année +- `app/static/js/latest.js` - Fonction displayResults avec filtre +- `app/static/css/latest.css` - Style .no-results + +--- + +## [1.9.22] - 2025-12-28 + +### 📱 Refonte complète modal torrents Latest (mobile) + +#### Modifié +- **Structure HTML** - Passage de `` à `
` (comme Discover) +- **Boutons adaptatifs** - `flex: 1` avec `flex-wrap` pour s'adapter à l'écran +- **Taille tactile** - Boutons correctement dimensionnés pour smartphone + +#### Structure torrent (identique à Discover) +```html +
+
+
...
+
...
+
+
+ 🔗 + 🧲 + ... +
+
+``` + +--- + +## [1.9.21] - 2025-12-28 + +### 🔧 Tentative correction boutons modal (grille CSS) + +#### Modifié +- Passage à CSS Grid pour les boutons d'action +- `grid-template-columns: repeat(2, 1fr)` pour forcer 2x2 + +--- + +## [1.9.20] - 2025-12-28 + +### 🔧 Correction affichage infos torrents mobile + +#### Corrigé +- **Cellules visibles** - `display: block` au lieu de `flex` pour les `
+ + + + + + + + `; +} + +function getSeedersClass(seeders) { + if (!seeders || seeders === 0) return 'seeders-none'; + if (seeders < 5) return 'seeders-low'; + if (seeders < 20) return 'seeders-medium'; + return 'seeders-high'; +} + +// ============================================================ +// UTILITAIRES +// ============================================================ + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function sanitizeUrl(url) { + if (!url) return ''; + + // Autoriser uniquement http, https, et magnet + const allowedProtocols = ['http:', 'https:', 'magnet:']; + + try { + // Pour les URLs magnet, vérifier le préfixe + if (url.startsWith('magnet:')) { + return url; + } + + const parsed = new URL(url); + if (!allowedProtocols.includes(parsed.protocol)) { + console.warn('URL avec protocole non autorisé:', parsed.protocol); + return ''; + } + return url; + } catch (e) { + // Si ce n'est pas une URL valide, retourner vide + console.warn('URL invalide:', url); + return ''; + } +} + +function showLoading(show) { + const overlay = document.getElementById('loading-overlay'); + if (show) { + overlay.classList.remove('hidden'); + } else { + overlay.classList.add('hidden'); + } +} + +function showError(message) { + alert(message); +} + +// ============================================================ +// CLIENT TORRENT +// ============================================================ + +let torrentClientEnabled = false; +let torrentClientSupportsTorrentFiles = false; + +async function checkTorrentClient() { + try { + const response = await fetch('/api/torrent-client/status'); + const data = await response.json(); + torrentClientEnabled = data.success && data.enabled && data.connected; + // Par défaut true si non spécifié (qBittorrent supporte les .torrent) + torrentClientSupportsTorrentFiles = data.supportsTorrentFiles !== false; + console.log('🔌 Client torrent:', torrentClientEnabled ? 'connecté' : 'non connecté', + '| Supporte .torrent:', torrentClientSupportsTorrentFiles); + } catch (error) { + torrentClientEnabled = false; + torrentClientSupportsTorrentFiles = false; + console.log('🔌 Client torrent: erreur de connexion'); + } +} + +async function sendToTorrentClient(url, button) { + if (!url) { + showToast('Aucun lien disponible', 'error'); + return; + } + + // Afficher le modal de sélection + showTorrentOptionsModal(url, button); +} + +async function showTorrentOptionsModal(url, button) { + // Créer le modal s'il n'existe pas + let modal = document.getElementById('torrentOptionsModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'torrentOptionsModal'; + modal.className = 'torrent-options-modal'; + modal.innerHTML = ` +
+

📥 Options de téléchargement

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + document.body.appendChild(modal); + + // Fermer en cliquant à l'extérieur + modal.addEventListener('click', (e) => { + if (e.target === modal) closeTorrentOptionsModal(); + }); + } + + // Charger les catégories + const categorySelect = document.getElementById('torrentCategory'); + const savePathInput = document.getElementById('torrentSavePath'); + categorySelect.innerHTML = ''; + + let categoriesWithPaths = {}; + + try { + const response = await fetch('/api/torrent-client/categories'); + const data = await response.json(); + + categorySelect.innerHTML = ''; + if (data.success && data.categories) { + data.categories.forEach(cat => { + categorySelect.innerHTML += ``; + }); + // Stocker les chemins personnalisés + categoriesWithPaths = data.custom_categories || {}; + } + } catch (error) { + categorySelect.innerHTML = ''; + } + + // Auto-remplir le chemin quand on sélectionne une catégorie + categorySelect.onchange = () => { + const selectedCat = categorySelect.value; + if (selectedCat && categoriesWithPaths[selectedCat]) { + savePathInput.value = categoriesWithPaths[selectedCat]; + } else { + savePathInput.value = ''; + } + }; + + // Reset les champs + savePathInput.value = ''; + document.getElementById('torrentPaused').checked = false; + + // Configurer le bouton de confirmation + const confirmBtn = document.getElementById('confirmTorrentAdd'); + confirmBtn.onclick = async () => { + const category = document.getElementById('torrentCategory').value; + const savePath = document.getElementById('torrentSavePath').value.trim(); + const paused = document.getElementById('torrentPaused').checked; + + closeTorrentOptionsModal(); + await doSendToTorrentClient(url, button, category, savePath, paused); + }; + + // Afficher le modal + modal.classList.add('visible'); +} + +function closeTorrentOptionsModal() { + const modal = document.getElementById('torrentOptionsModal'); + if (modal) { + modal.classList.remove('visible'); + } +} + +async function doSendToTorrentClient(url, button, category, savePath, paused) { + const originalText = button.textContent; + button.textContent = '⏳'; + button.disabled = true; + + try { + const body = { url: url }; + if (category) body.category = category; + if (savePath) body.save_path = savePath; + if (paused) body.paused = paused; + + const response = await fetch('/api/torrent-client/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + + if (data.success) { + button.textContent = '✅'; + showToast('Torrent envoyé !', 'success'); + setTimeout(() => { + button.textContent = '📥'; + button.disabled = false; + }, 2000); + } else { + button.textContent = '❌'; + showToast(data.error || 'Erreur', 'error'); + setTimeout(() => { + button.textContent = '📥'; + button.disabled = false; + }, 2000); + } + } catch (error) { + button.textContent = '❌'; + showToast('Erreur de connexion', 'error'); + setTimeout(() => { + button.textContent = '📥'; + button.disabled = false; + }, 2000); + } +} + +function showToast(message, type = 'info') { + // Créer le toast s'il n'existe pas + let toast = document.getElementById('toast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'toast'; + toast.className = 'toast hidden'; + document.body.appendChild(toast); + } + + toast.textContent = message; + toast.className = `toast ${type}`; + + setTimeout(() => toast.classList.add('hidden'), 3000); +} + +// Vérifier le client torrent au chargement +checkTorrentClient(); \ No newline at end of file diff --git a/app/static/js/theme-loader.js b/app/static/js/theme-loader.js new file mode 100644 index 0000000..6631f8a --- /dev/null +++ b/app/static/js/theme-loader.js @@ -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); +})(); \ No newline at end of file diff --git a/app/static/manifest.json b/app/static/manifest.json new file mode 100644 index 0000000..0182abe --- /dev/null +++ b/app/static/manifest.json @@ -0,0 +1,77 @@ +{ + "name": "Lycostorrent", + "short_name": "Lycostorrent", + "description": "Recherche de torrents avec enrichissement TMDb", + "start_url": "/", + "display": "standalone", + "background_color": "#0f0f1a", + "theme_color": "#e63946", + "orientation": "any", + "icons": [ + { + "src": "/static/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["entertainment", "utilities"], + "shortcuts": [ + { + "name": "Recherche", + "short_name": "Recherche", + "description": "Rechercher des torrents", + "url": "/", + "icons": [{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Nouveautés", + "short_name": "Nouveautés", + "description": "Voir les dernières sorties", + "url": "/latest", + "icons": [{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96" }] + } + ] +} \ No newline at end of file diff --git a/app/static/sw.js b/app/static/sw.js new file mode 100644 index 0000000..04509bd --- /dev/null +++ b/app/static/sw.js @@ -0,0 +1,143 @@ +// Lycostorrent Service Worker +const CACHE_NAME = 'lycostorrent-v1'; + +// Assets à mettre en cache +const STATIC_ASSETS = [ + '/', + '/latest', + '/static/css/style.css', + '/static/css/latest.css', + '/static/js/search.js', + '/static/js/latest.js', + '/static/icons/icon-192x192.png', + '/static/icons/icon-512x512.png' +]; + +// Installation - mise en cache des assets statiques +self.addEventListener('install', (event) => { + console.log('🔧 Service Worker: Installation...'); + + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => { + console.log('📦 Service Worker: Mise en cache des assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => { + // Activer immédiatement sans attendre + return self.skipWaiting(); + }) + .catch((error) => { + console.error('❌ Service Worker: Erreur de cache', error); + }) + ); +}); + +// Activation - nettoyage des anciens caches +self.addEventListener('activate', (event) => { + console.log('✅ Service Worker: Activation'); + + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => { + console.log('🗑️ Service Worker: Suppression ancien cache', name); + return caches.delete(name); + }) + ); + }) + .then(() => { + // Prendre le contrôle immédiatement + return self.clients.claim(); + }) + ); +}); + +// Fetch - stratégie Network First avec fallback cache +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Ne pas cacher les requêtes API (toujours aller sur le réseau) + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(event.request) + .catch(() => { + return new Response( + JSON.stringify({ success: false, error: 'Hors ligne' }), + { headers: { 'Content-Type': 'application/json' } } + ); + }) + ); + return; + } + + // Pour les assets statiques - Cache First + if (url.pathname.startsWith('/static/')) { + event.respondWith( + caches.match(event.request) + .then((cachedResponse) => { + if (cachedResponse) { + // Retourner le cache et mettre à jour en arrière-plan + fetch(event.request) + .then((response) => { + if (response.ok) { + caches.open(CACHE_NAME) + .then((cache) => cache.put(event.request, response)); + } + }) + .catch(() => {}); + + return cachedResponse; + } + + // Pas en cache, récupérer du réseau + return fetch(event.request) + .then((response) => { + if (response.ok) { + const responseClone = response.clone(); + caches.open(CACHE_NAME) + .then((cache) => cache.put(event.request, responseClone)); + } + return response; + }); + }) + ); + return; + } + + // Pour les pages HTML - Network First + event.respondWith( + fetch(event.request) + .then((response) => { + // Mettre en cache la réponse + if (response.ok && event.request.method === 'GET') { + const responseClone = response.clone(); + caches.open(CACHE_NAME) + .then((cache) => cache.put(event.request, responseClone)); + } + return response; + }) + .catch(() => { + // Fallback sur le cache + return caches.match(event.request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + // Page hors ligne par défaut + return caches.match('/'); + }); + }) + ); +}); + +// Gestion des messages +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); \ No newline at end of file diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..4bf109e --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,880 @@ + + + + + + Lycostorrent - Administration + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

⚙️ Administration

+

Configuration de Lycostorrent

+ +
+ + +
+ + + + + + + + +
+ + +
+ + +
+
+

🧩 Modules

+

Activez ou désactivez les fonctionnalités de Lycostorrent.

+
+ +
+

📱 Fonctionnalités disponibles

+

Cochez les modules que vous souhaitez activer. La navigation s'adaptera automatiquement.

+ +
+
+
+ + +
+
+
+ 🔍 + Recherche +
+

Recherche de torrents sur vos trackers configurés (Jackett/Prowlarr).

+
+
+ +
+
+ + +
+
+
+ 🎬 + Nouveautés +
+

Dernières sorties depuis vos trackers (Films, Séries, Anime, Musique).

+
+
+ +
+
+ + +
+
+
+ 🌟 + Découvrir + Nouveau +
+

Explorez les nouveautés cinéma et TV depuis TMDb, puis trouvez les torrents disponibles.

+
+
+
+ +
+ +
+
+ + +
+

🌟 Trackers pour Découvrir

+

Sélectionnez les trackers à utiliser pour la recherche de torrents dans la page Découvrir.

+ +
+

Chargement des trackers...

+
+ +
+ + + +
+
+ +
+

ℹ️ À propos des modules

+
+

🔍 Recherche : Page d'accueil par défaut. Permet de rechercher des torrents par mots-clés sur tous vos trackers.

+

🎬 Nouveautés : Affiche les derniers torrents publiés sur vos trackers, enrichis avec les métadonnées TMDb/Last.fm.

+

🌟 Découvrir : Inverse la logique - part des nouveautés TMDb (films/séries récents) et recherche les torrents disponibles.

+
+
+
+ + +
+
+

📂 Configuration des Catégories

+

Associez les catégories Jackett/Prowlarr à chaque type de contenu pour chaque tracker.

+
+ + +
+

1. Sélectionner un tracker

+
+

Chargement des trackers...

+
+
+ + + + + + + + +
+

📋 Configuration actuelle

+
+

Chargement...

+
+
+
+ + +
+
+

🏷️ Tags de Parsing

+

Ces tags sont utilisés pour nettoyer les titres avant la recherche TMDb/Last.fm.

+
+ + +
+

Tags de coupure

+

Le titre sera coupé au premier tag rencontré. Ex: Avatar.2009.MULTi.1080pAvatar 2009

+ +
+

Chargement...

+
+ +
+ + +
+ +
+ + +
+
+ + +
+

📦 Ajouter des présets

+
+ + + + + +
+
+ + +
+

🧪 Tester le parsing

+
+ + +
+ +
+
+ + +
+
+

🎛️ Filtres de Recherche

+

Configurez les mots-clés détectés dans les titres de torrents pour créer les filtres.

+
+ + +
+

Filtres configurés

+

Cliquez sur un filtre pour modifier ses valeurs. Les valeurs sont détectées dans les titres de torrents.

+ +
+

Chargement...

+
+ +
+ + + +
+
+ + +
+

🧪 Tester la détection

+

Entrez un titre de torrent pour voir les filtres détectés.

+
+ + +
+ +
+ + + +
+ + +
+
+

📡 Flux RSS

+

Ajoutez des flux RSS pour les trackers non disponibles dans Jackett/Prowlarr.

+
+ + +
+

➕ Ajouter un flux

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + Utilisez {passkey} comme placeholder +
+ +
+ + +
+ +
+
+ + Anti-Cloudflare +
+
+ +
+ + + Récupérez-les depuis DevTools (F12) → Application → Cookies +
+ +
+ + +
+ + + +
+ + +
+

📋 Flux configurés

+
+

Chargement...

+
+
+ + +
+

+ ❓ Aide - Comment configurer un flux RSS + +

+
+
+

YGGTorrent

+
    +
  1. Connectez-vous à YGG
  2. +
  3. Profil → "Mon RSS"
  4. +
  5. Copiez l'URL avec votre passkey
  6. +
+ +

Catégories YGG

+
+ id=2145 Films + id=2184 Séries + id=2179 Anime + id=2139 Musique +
+
+
+
+
+ + +
+
+

🔄 Cache des données

+

Pré-chargez les données Latest et Discover pour un affichage instantané.

+
+ + +
+

📊 Statut du cache

+
+
+ État : + Chargement... +
+
+ Dernier refresh : + - +
+
+ Prochain refresh : + - +
+
+ Taille du cache : + - +
+
+
+ + +
+
+ + +
+

⚙️ Configuration

+ +
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+

📥 Cache Latest (Nouveautés)

+ +
+
+ +
+
+ +
+
+ +
+ + + + +
+
+
+ +
+
+ + +
+
+ +
+
+ +

Laissez vide pour utiliser tous les trackers actifs

+
+ +
+
+
+
+ + +
+

🎬 Cache Discover

+ +
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ + +
+
+

⬇️ Client Torrent

+

Configurez votre client torrent pour envoyer directement les téléchargements.

+
+ + +
+

📊 Statut

+
+ Chargement... +
+
+ + +
+

⚙️ Configuration

+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ + + Domaine ou IP (sans http://) +
+
+ + + Laisser vide si port par défaut +
+
+ +
+
+ + + Si derrière un reverse proxy +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+ + +
+ + +
+ + +
+

📁 Catégories & Dossiers

+

Définissez vos catégories avec leur dossier de destination par défaut.

+ +
+ +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + +
+
+ + + +
+ + +
+
+

🎨 Apparence

+

Personnalisez l'apparence de Lycostorrent.

+
+ + +
+

🎭 Thème

+

Choisissez un thème pour l'interface.

+ +
+
+
+
+
+
+
+
+
+ 🌙 Sombre +
+ +
+
+
+
+
+
+
+
+ ☀️ Clair +
+ +
+
+
+
+
+
+
+
+ 🌊 Océan +
+ +
+
+
+
+
+
+
+
+ 💜 Violet +
+ +
+
+
+
+
+
+
+
+ 🌿 Nature +
+ +
+
+
+
+
+
+
+
+ 🌅 Sunset +
+ +
+
+
+
+
+
+
+
+ 🤖 Cyberpunk +
+ +
+
+
+
+
+
+
+
+ ❄️ Nord +
+
+
+
+
+ + + + + +
+ Lycostorrent v1.0.0 +
+
+ + + + + + diff --git a/app/templates/admin_latest.html b/app/templates/admin_latest.html new file mode 100644 index 0000000..2499897 --- /dev/null +++ b/app/templates/admin_latest.html @@ -0,0 +1,105 @@ + + + + + + Lycostorrent - Admin Nouveautés + + + + +
+ +
+

⚙️ Administration

+

Configuration des catégories pour les Nouveautés

+ +
+ + +
+

ℹ️ Comment ça marche

+

Configurez les catégories Jackett à utiliser pour chaque type de contenu et chaque tracker.

+

Les catégories sont les IDs numériques de Jackett (ex: 2000 = Films, 5000 = Séries).

+
+ + +
+

1. Sélectionner un tracker

+
+

Chargement des trackers...

+
+
+ + + + + + + + +
+

📋 Résumé de la configuration

+
+

Chargement...

+
+
+ + + +
+ + + + \ No newline at end of file diff --git a/app/templates/admin_parsing.html b/app/templates/admin_parsing.html new file mode 100644 index 0000000..10c1ad3 --- /dev/null +++ b/app/templates/admin_parsing.html @@ -0,0 +1,108 @@ + + + + + + Lycostorrent - Tags de Parsing + + + + +
+ +
+

🏷️ Tags de Parsing

+

Mots-clés utilisés pour couper les titres de torrents

+ +
+ + +
+

ℹ️ Comment ça marche

+

Ces tags sont utilisés pour couper les titres de torrents avant de chercher sur TMDb/Last.fm.

+

Exemple: Avatar.2009.MULTi.1080p.BluRay → si "MULTi" est dans la liste, le titre sera coupé à Avatar

+

⚠️ Attention: N'ajoutez pas de mots qui pourraient être des vrais titres (ex: "Intégrale", "Complete", "Extended").

+
+ + +
+

Tags de coupure actuels

+ +
+
+

Chargement...

+
+ +
+ + +
+
+ +
+ + +
+
+ + +
+

📦 Ajouter des présets

+

Cliquez pour ajouter un groupe de tags courants

+ +
+ + + + + +
+
+ + +
+

🧪 Tester le parsing

+

Entrez un titre de torrent pour voir le résultat du nettoyage

+ +
+ + +
+ + +
+ + + +
+ + + + \ No newline at end of file diff --git a/app/templates/admin_rss.html b/app/templates/admin_rss.html new file mode 100644 index 0000000..9588783 --- /dev/null +++ b/app/templates/admin_rss.html @@ -0,0 +1,126 @@ + + + + + + Lycostorrent - Flux RSS + + + + +
+
+

🔗 Gestion des Flux RSS

+

Ajoutez des flux RSS pour récupérer les nouveautés de trackers non supportés par Jackett/Prowlarr

+
+ + + + +
+

➕ Ajouter un flux RSS

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + Utilisez {passkey} comme placeholder pour le passkey +
+ +
+ + + Sera injecté à la place de {passkey} dans l'URL +
+ +
+ + Activer si le site est protégé par Cloudflare (erreur 403) +
+ +
+ + + Format: nom1=valeur1; nom2=valeur2 - Récupérez-les depuis les DevTools (F12) → Application → Cookies +
+ +
+ + +
+ + + + +
+ + +
+

📋 Flux RSS configurés

+ +
+

Chargement...

+
+
+ + +
+

❓ Comment trouver l'URL RSS ?

+ +
+

YGGTorrent

+
    +
  1. Connectez-vous à YGG
  2. +
  3. Allez dans votre profil → "Mon RSS"
  4. +
  5. Copiez l'URL avec votre passkey
  6. +
  7. Format: https://www3.yggtorrent.xxx/rss?cat=XXX&passkey=VOTRE_PASSKEY
  8. +
+ +

Autres trackers privés

+
    +
  1. Cherchez "RSS" dans les paramètres du tracker
  2. +
  3. Générez un flux personnalisé avec les catégories souhaitées
  4. +
  5. Copiez l'URL (contient généralement un passkey ou token)
  6. +
+ +

Catégories YGG courantes

+
` +- **Labels** - Affichage du `data-label` avec `: ` après + +--- + +## [1.9.19] - 2025-12-28 + +### 🔧 CSS harmonisé + bouton déconnexion + +#### Corrigé +- **Bouton déconnexion** - Séparé des autres liens de navigation +- **Taille fixe** - 50px sur tablette, 40px sur mobile +- **Navigation** - Styles identiques sur Search, Latest et Discover + +--- + +## [1.9.18] - 2025-12-28 + +### 📱 Harmonisation CSS Latest avec Search/Discover + +#### Modifié +- **Navigation** - Flex wrap responsive identique +- **Catégories** - Grid auto-fit +- **Cards** - Poster 200px, actions en ligne +- **Modal** - Plein écran sur mobile + +--- + +## [1.9.17] - 2025-12-27 + +### 📱 CSS mobile amélioré pour Latest et Discover + +#### Latest - Menus mobile +- **Catégories** - Grille 2 colonnes au lieu de scroll horizontal invisible +- **Navigation** - Grille responsive au lieu de flex wrap cassé +- **Boutons trackers** - Grille 2 colonnes, taille correcte +- **Liste trackers** - Scrollable avec grille propre + +#### Discover - Torrents mobile +- **Torrent item** - Layout en colonne avec actions en bas +- **Nom du torrent** - Multi-ligne avec word-break +- **Métadonnées** - Badges avec background distincts +- **Boutons actions** - Flex avec bordure séparatrice, taille tactile + +--- + +## [1.9.16] - 2025-12-27 + +### 🎯 Filtrage par année et numéro de suite + +#### Ajouté +- **Détection des suites** - Les numéros (2, 3, II, III...) dans le titre sont détectés +- **Filtrage par année** - Tolérance de ±1 an, exclut les années trop différentes + +#### Exemples +- "Zootopie 2" (2025) → exclut "Zootopia (2016)" car années différentes ET pas de "2" +- "Sisu 2" → exclut "Sisu (2023)" car pas de numéro de suite + +--- + +## [1.9.15] - 2025-12-27 + +### 🔍 Filtrage multi-mots amélioré + +#### Corrigé +- **Problème CrazySpirits** - "Thérapie de choc" retournait "Police Academy...choc" +- **Nouvelle règle** : 2-3 mots significatifs → 2 matches minimum requis + +#### Règles de filtrage +| Mots significatifs | Minimum requis | +|-------------------|----------------| +| 1 mot | 1 match | +| 2-3 mots | 2 matches | +| 4+ mots | 50% des mots | + +--- + +## [1.9.14] - 2025-12-27 + +### 🔍 Filtrage simplifié + +#### Modifié +- Algorithme de filtrage simplifié : vérifie si au moins un mot significatif (4+ caractères) est présent dans le titre du torrent +- Mots stop exclus : the, les, der, das, die, and, for, with, etc. + +--- + +## [1.9.13] - 2025-12-27 + +### 🔍 Recherche avec tous les titres + +#### Corrigé +- **Recherche complète** - Essaie TOUTES les requêtes (titre original + titre français) au lieu de s'arrêter au premier résultat +- Exemple : "Thérapie de choc" cherche maintenant avec "You Hurt My Feelings" ET "Thérapie de choc" + +--- + +## [1.9.12] - 2025-12-27 + +### 🔍 Filtrage avec mot-clé principal + +#### Ajouté +- **Détection du mot principal** - Premier mot de 4+ caractères du titre +- **Match flexible** - Accepte les torrents contenant le mot principal + +#### Exemple +- "Sisu 2" → mot principal = "sisu" +- Torrent "Sisu.2023.FRENCH" → match car contient "sisu" + +--- + +## [1.9.11] - 2025-12-27 + +### 🔍 Filtrage amélioré pour titres courts + +#### Corrigé +- **Titres courts** (comme "Sisu") maintenant correctement filtrés +- Seuil d'inclusion réduit à 3 caractères (au lieu de 5) +- Seuil de similarité réduit à 0.7 (au lieu de 0.8) +- Mots-clés courts : 1 match minimum suffit + +--- + +## [1.9.10] - 2025-12-27 + +### 🐛 Suppression du fallback + +#### Corrigé +- **Plus de faux résultats** - Si aucun torrent pertinent n'est trouvé, affiche "Aucun torrent trouvé" au lieu de résultats non pertinents +- Suppression du fallback qui retournait les 10 premiers résultats bruts + +--- + +## [1.9.9] - 2025-12-27 + +### 📥 Modal d'options dans Discover + +#### Ajouté +- **Modal de téléchargement** - Même interface que Search/Latest +- **Choix de la catégorie** - Liste des catégories qBittorrent +- **Auto-remplissage du chemin** - Selon la catégorie sélectionnée +- **Option pause** - Démarrer en pause +- **Feedback visuel** - Bouton ⏳ → ✅/❌ + +--- + +## [1.9.8] - 2025-12-27 + +### ⚙️ Sélection des trackers pour Discover + +#### Ajouté +- **Section Admin → Modules → "Trackers pour Découvrir"** +- **Sélection visuelle** - Cocher/décocher chaque tracker +- **Boutons tout/rien** - Sélectionner ou désélectionner tous +- **Persistance** - Sauvegardé dans `/app/config/discover_trackers.json` + +#### API +- `GET /api/admin/discover-trackers` - Récupère les trackers configurés +- `POST /api/admin/discover-trackers` - Sauvegarde la sélection + +--- + +## [1.9.7] - 2025-12-27 + +### 🎬 Bande-annonce YouTube + Lien tracker + +#### Ajouté +- **Bande-annonce YouTube** - Intégrée dans le modal de détails Discover +- **Recherche multilingue** - Trailer FR d'abord, puis EN +- **Priorité** - Trailer > Teaser +- **Auto-stop** - La vidéo s'arrête à la fermeture du modal +- **Bouton 🔗** - Lien vers la page du torrent sur le tracker + +--- + +## [1.9.6] - 2025-12-27 + +### 🔍 Filtrage plus strict + +#### Modifié +- **Seuil de similarité** augmenté à 0.8 (au lieu de 0.7) +- **Mots-clés** - Vérifie que 60% des mots-clés sont présents avec minimum 2 matches + +--- + +## [1.9.5] - 2025-12-27 + +### 🔍 Filtrage des résultats Discover + +#### Ajouté +- **Fonction `_filter_relevant_torrents()`** - Filtre les résultats par titre +- **Normalisation des titres** - Suppression accents, caractères spéciaux +- **Comparaison intelligente** - Inclusion, similarité, mots-clés + +#### Corrigé +- Les résultats non pertinents de CrazySpirits sont maintenant filtrés + +--- + +## [1.9.4] - 2025-12-27 + +### 🔍 Correction recherche Discover - Titres non-latins + +#### Corrigé +- **Titres thaï/chinois/etc.** - Utilise le titre anglais original au lieu du titre non-latin +- **Recherches successives** - Essaie original_title + year, puis original_title, puis title + year, puis title +- **Détection caractères latins** - Fonction `_is_latin_text()` pour vérifier si le titre est utilisable + +#### Exemple +- Film thaï "ปัง" → recherche avec "Bang 2025" (titre anglais) au lieu de "ปัง 2025" + +--- + +## [1.9.3] - 2025-12-27 + +### 🐛 Correction regroupement - Années entre parenthèses + +#### Corrigé +- **Support des années entre parenthèses** - `(2016)` maintenant correctement reconnu +- Les films comme `L'Age.De.Glace.5.(2016)` et `L'Age.De.Glace.4.(2012)` sont maintenant séparés + +#### Technique +- Regex modifiée pour inclure `\(` et `\)` dans la détection d'année + +--- + +## [1.9.2] - 2025-12-27 + +### 🐛 Correction regroupement - Suites de films + +#### Corrigé +- **Séparation par année** - Les films avec des années différentes ne sont plus groupés +- Exemple : "Les Schtroumpfs 2 (2013)" et "Les Schtroumpfs (2011)" maintenant séparés + +#### Technique +- Nouvelle fonction `_extract_base_title_and_year()` qui extrait titre ET année +- Règle stricte : années différentes = pas de regroupement + +--- + +## [1.9.1] - 2025-12-27 + +### 🐛 Correction regroupement Latest + +#### Corrigé +- **Regroupement plus strict** - Évite les faux positifs (ex: "The Empire Strikes Back" vs "Exists") +- Seuil de similarité augmenté à 0.85 + +#### Technique +- Nouvelle fonction `_extract_base_title()` qui extrait uniquement le titre avant les métadonnées +- Double vérification : similarité du titre de base ET similarité globale + +--- + +## [1.9.0] - 2025-12-27 + +### 🌟 Page Découvrir + Système de Modules + +#### Page Découvrir +- **Nouvelle page `/discover`** - Explore les nouveautés cinéma et TV depuis TMDb +- **5 catégories disponibles** : + - 🎬 Au cinéma (films actuellement en salle) + - 🔥 Films populaires + - 📺 Séries en cours de diffusion + - ⭐ Séries populaires + - 📅 Films à venir +- **Affichage en grille** avec affiches, notes et années +- **Modal de détails** avec synopsis, genres et note +- **Recherche automatique de torrents** sur tous vos trackers +- **Envoi direct au client torrent** depuis les résultats + +#### Système de Modules +- **Nouvel onglet Admin → Modules** - Activer/désactiver les fonctionnalités +- **3 modules disponibles** : + - 🔍 Recherche (activé par défaut) + - 🎬 Nouveautés (activé par défaut) + - 🌟 Découvrir (désactivé par défaut) +- **Navigation dynamique** - Le menu s'adapte aux modules activés +- **Persistance** - Configuration sauvegardée en JSON + +#### Fichiers ajoutés +- `templates/discover.html` - Page Découvrir +- `static/css/discover.css` - Styles Découvrir +- `static/js/discover.js` - Logique Découvrir +- `static/js/nav.js` - Navigation dynamique + +#### API ajoutées +- `GET /api/modules` - Récupère les modules activés +- `GET /api/admin/modules` - Config modules pour admin +- `POST /api/admin/modules` - Sauvegarde modules +- `GET /api/discover/` - Liste TMDb par catégorie +- `GET /api/discover/detail//` - Détails TMDb +- `POST /api/discover/search-torrents` - Recherche torrents + +--- + +## [1.8.0] - 2025-12-27 + +### 🎨 Système de thèmes + +#### Ajouté +- **8 thèmes disponibles** : + - 🌙 Sombre (par défaut) + - ☀️ Clair + - 🌊 Océan (bleu-vert) + - 💜 Violet + - 🌿 Nature (vert) + - 🌅 Sunset (orange) + - 🤖 Cyberpunk (néon) + - ❄️ Nord (palette nordique) + +- **Onglet Apparence dans Admin** - Sélection visuelle des thèmes avec aperçu +- **Persistance du thème** - Sauvegardé en localStorage +- **Chargement instantané** - Pas de flash blanc au chargement +- **Transitions fluides** - Changement de thème animé + +#### Fichiers ajoutés +- `static/css/themes.css` - Définition de tous les thèmes +- `static/js/theme-loader.js` - Chargement du thème au démarrage + +#### Fichiers modifiés +- `templates/admin.html` - Onglet Apparence +- `templates/index.html` - Support des thèmes +- `templates/latest.html` - Support des thèmes +- `templates/login.html` - Support des thèmes +- `static/css/admin.css` - Styles des cartes de thèmes +- `static/js/admin.js` - Gestion des thèmes + +--- + +## [1.7.1] - 2025-12-27 + +### ⚡ Recherche parallèle + +#### Ajouté +- **Recherche parallèle par tracker** - Chaque tracker est interrogé en parallèle +- Avant : Jackett recevait 10 trackers et les faisait séquentiellement (~17s) +- Après : 10 requêtes parallèles vers Jackett (~5s) + +#### Logs améliorés +``` +🔍 Recherche: 'fallout' | Catégorie: tv | Trackers: 10 +✅ jackett:sharewood-api: 45 résultats +✅ jackett:yggtorrent: 38 résultats +... +📦 Recherche parallèle: 220 résultats bruts (en 5.24s) +``` + +--- + +## [1.7.0] - 2025-12-27 + +### ⚡ Requêtes parallèles - Performance améliorée + +#### Ajouté +- **Requêtes parallèles pour Latest** - Tous les trackers sont interrogés simultanément +- **Requêtes parallèles Jackett/Prowlarr** - Les deux sources sont interrogées en même temps +- **Logging du temps d'exécution** - Affiche le temps total des recherches + +#### Gain de performance +| Avant (séquentiel) | Après (parallèle) | Gain | +|-------------------|-------------------|------| +| 4 trackers × 3s = 12s | max(3s) = 3s | **~75%** | +| 6 trackers × 2s = 12s | max(2s) = 2s | **~85%** | + +#### Technique +- Utilisation de `ThreadPoolExecutor` avec max 10 workers +- Timeout de 60s par requête +- Les erreurs d'un tracker n'affectent pas les autres + +#### Fichiers modifiés +- `main.py` - Parallélisation de `/api/latest` +- `indexer_manager.py` - Parallélisation Jackett + Prowlarr + +--- + +## [1.6.1] - 2025-12-27 + +### 📁 Gestion des catégories dans l'administration + +#### Ajouté +- **Section "Catégories & Dossiers"** dans Admin → Client Torrent +- **Catégories personnalisées** avec chemin de destination par défaut +- **Auto-remplissage du chemin** quand on sélectionne une catégorie dans le modal +- **Synchronisation avec qBittorrent** - crée les catégories directement dans le client +- **API `/api/admin/torrent-client/categories`** - GET/POST pour gérer les catégories +- **API `/api/admin/torrent-client/sync-categories`** - Synchronise avec le client + +#### Fonctionnalités admin +- ➕ Ajouter une catégorie avec son chemin +- ✏️ Modifier le nom et le chemin +- 🗑️ Supprimer une catégorie +- 🔄 Synchroniser avec qBittorrent (crée les catégories manquantes) +- 💾 Sauvegarder la configuration + +#### Plugin qBittorrent v1.2.0 +- `create_category(name, path)` - Crée une catégorie +- `edit_category(name, path)` - Modifie le chemin d'une catégorie +- `get_categories_with_paths()` - Récupère les catégories avec leurs chemins + +--- + +## [1.6.0] - 2025-12-27 + +### 📥 Modal d'options de téléchargement + +#### Ajouté +- **Modal de sélection** quand on clique sur 📥 +- **Choix de la catégorie** - Liste déroulante des catégories du client +- **Chemin personnalisé** - Spécifier un dossier de destination +- **Option "Démarrer en pause"** - Ajouter le torrent sans le lancer +- **API `/api/torrent-client/categories`** - Récupère les catégories disponibles + +#### Interface du modal +``` +┌─────────────────────────────┐ +│ 📥 Options de téléchargement │ +├─────────────────────────────┤ +│ Catégorie: [Films ▼] │ +│ Dossier: [/downloads/films] │ +│ ☐ Démarrer en pause │ +│ [Annuler] [Envoyer] │ +└─────────────────────────────┘ +``` + +#### Support par client +| Option | qBittorrent | Transmission | +|--------|-------------|--------------| +| Catégorie | ✅ | ❌ (non supporté) | +| Dossier | ✅ | ✅ | +| Pause | ✅ | ✅ | + +--- + +## [1.5.2] - 2025-12-27 + +### 🔧 Correction plugin qBittorrent + +#### Corrigé +- **Téléchargement local des .torrent** - qBittorrent distant n'a pas accès aux URLs Jackett internes +- **Détection des redirections magnet** - Gère les URLs qui redirigent vers un magnet +- **Gestion des erreurs améliorée** - Meilleurs messages d'erreur + +#### Plugin qBittorrent v1.2.0 +- Télécharge les fichiers .torrent localement avant envoi +- Détecte les redirections HTTP vers magnet +- Vérifie le format bencode avant envoi + +--- + +## [1.5.1] - 2025-12-26 + +### 📱 Amélioration affichage mobile des modals + +#### Corrigé +- **Modal plein écran** sur mobile (plus de scroll horizontal) +- **Table des torrents responsive** - affichage en cartes sur mobile +- **Boutons d'action** bien alignés et accessibles +- **Labels visibles** pour chaque donnée (Tracker, Taille, Seeds, Date) + +#### Changements CSS +- Modal en plein écran sur mobile (100vh) +- Table transformée en liste de cartes +- Boutons d'action avec flex-wrap +- Suppression du min-width qui causait le scroll + +--- + +## [1.5.0] - 2025-12-26 + +### 📱 Progressive Web App (PWA) + +#### Ajouté +- **Manifest PWA** - L'application est maintenant installable +- **Service Worker** - Cache des assets pour un chargement rapide +- **Icônes** - 8 tailles d'icônes (72x72 à 512x512) +- **Support iOS** - Meta tags pour Apple mobile web app +- **Raccourcis** - Accès rapide à Recherche et Nouveautés + +#### Fonctionnalités PWA +- 📱 **Installable** sur mobile (Android/iOS) et desktop +- 🖥️ **Mode standalone** - Plein écran sans barre de navigateur +- ⚡ **Cache intelligent** - Assets statiques mis en cache +- 🎨 **Thème** - Couleur #e63946 (rouge Lycostorrent) + +#### Comment installer +- **Android** : Menu Chrome → "Ajouter à l'écran d'accueil" +- **iOS** : Safari → Partager → "Sur l'écran d'accueil" +- **Desktop** : Icône d'installation dans la barre d'adresse + +#### Fichiers ajoutés +``` +app/static/ +├── manifest.json # Configuration PWA +├── sw.js # Service Worker +└── icons/ # Icônes PNG + ├── icon-72x72.png + ├── icon-96x96.png + ├── icon-128x128.png + ├── icon-144x144.png + ├── icon-152x152.png + ├── icon-192x192.png + ├── icon-384x384.png + └── icon-512x512.png +``` + +--- + +## [1.4.1] - 2025-12-26 + +### 📥 Bouton "Envoyer au client torrent" + +#### Ajouté +- **Bouton 📥** sur chaque résultat de recherche +- **Bouton 📥** sur chaque torrent dans les nouveautés +- **Notification toast** de succès/erreur +- **Support HTTP Basic Auth** pour les seedbox (reverse proxy) + +#### Fonctionnement +- Le bouton n'apparaît que si un client torrent est configuré et connecté +- Clique sur 📥 → envoie le magnet/torrent au client +- Feedback visuel : ⏳ → ✅ ou ❌ + +#### Technique +- `checkTorrentClient()` vérifie le statut au chargement +- `sendToTorrentClient(url, button)` envoie via l'API +- Plugin qBittorrent v1.1.0 avec support HTTP Basic Auth + +--- + +## [1.4.0] - 2025-12-26 + +### ⬇️ Système de plugins Client Torrent + +#### Ajouté +- **Architecture de plugins** pour les clients torrent +- **Plugin qBittorrent** inclus et fonctionnel +- **Onglet "Client Torrent"** dans l'administration +- **Configuration via interface web** : + - Sélection du plugin + - Host, port, utilisateur, mot de passe + - Option SSL + - Test de connexion +- **API endpoints** : + - `GET /api/admin/torrent-client/plugins` - Liste des plugins + - `GET /api/admin/torrent-client/config` - Configuration actuelle + - `POST /api/admin/torrent-client/config` - Sauvegarder config + - `POST /api/admin/torrent-client/test` - Tester connexion + - `POST /api/torrent-client/add` - Envoyer un torrent + - `GET /api/torrent-client/status` - Statut du client + +#### Structure des plugins +``` +app/plugins/torrent_clients/ +├── __init__.py # Gestionnaire auto-découverte +├── base.py # Classe abstraite +├── qbittorrent.py # Plugin qBittorrent +└── README.md # Guide création plugins +``` + +#### Pour créer un nouveau plugin +1. Créer un fichier `.py` dans `plugins/torrent_clients/` +2. Hériter de `TorrentClientPlugin` +3. Implémenter les méthodes requises +4. Définir `PLUGIN_CLASS = VotreClasse` + +Le plugin sera automatiquement détecté au démarrage. + +--- + +## [1.3.1] - 2025-12-26 + +### 🔧 RSS uniquement pour les Nouveautés + +#### Modifié +- **Les flux RSS n'apparaissent plus dans la page Recherche** +- Les RSS sont maintenant uniquement disponibles dans la page Nouveautés +- Paramètre `?include_rss=true` sur `/api/trackers` pour inclure les RSS + +#### Technique +- `/api/trackers` : par défaut sans RSS (pour la recherche) +- `/api/trackers?include_rss=true` : avec RSS (pour les nouveautés) + +--- + +## [1.3.0] - 2025-12-26 + +### 🔐 Système d'authentification + +#### Ajouté +- **Page de login** avec nom d'utilisateur et mot de passe +- **Protection de toutes les routes** - authentification requise pour accéder au site +- **Sessions persistantes** - reste connecté 7 jours par défaut +- **Bouton de déconnexion** 🚪 dans la navigation +- **Variables d'environnement** pour configurer : + - `AUTH_USERNAME` : nom d'utilisateur (défaut: `admin`) + - `AUTH_PASSWORD` : mot de passe (vide = pas d'auth requise) + - `SESSION_LIFETIME` : durée de session en secondes (défaut: 604800 = 7 jours) + - `SECRET_KEY` : clé secrète pour les sessions (auto-générée si non définie) + +#### Technique +- Décorateur `@login_required` sur toutes les routes principales +- Sessions Flask sécurisées avec cookie permanent +- Redirection automatique vers `/login` si non authentifié +- Logs des connexions/déconnexions + +#### Notes +- Si `AUTH_PASSWORD` est vide ou non défini, l'authentification est **désactivée** +- Compatible avec l'ancien système (ADMIN_PASSWORD toujours supporté) + +--- + +## [1.2.6] - 2024-12-25 + +### 🐛 Fix images placeholder + +#### Corrigé +- **Bug affichage SVG cassé** : Les images placeholder utilisent maintenant du base64 au lieu de SVG inline +- Plus de problèmes d'échappement de caractères dans les attributs HTML +- Les placeholders affichent une icône 🎵 pour la musique et 🎬 pour les films + +--- + +## [1.2.5] - 2024-12-25 + +### 🎵 Amélioration affichage Musique + +#### Corrigé +- **Bug affichage "No Album Art" en texte brut** : l'image de fallback s'affiche maintenant correctement +- Utilisation de `sanitizeUrl()` pour l'URL de la cover +- Ajout d'un handler `onerror` sur l'image pour le fallback + +#### Amélioré +- Affichage "ℹ️ Infos Last.fm non disponibles" quand l'album n'est pas trouvé sur Last.fm +- Tentative d'extraire artiste/album du titre du torrent si Last.fm ne trouve pas +- Style amélioré pour le placeholder de l'image album + +--- + +## [1.2.4] - 2024-12-25 + +### 🐛 Correction sauvegarde catégories + +#### Corrigé +- **La sauvegarde des catégories des trackers fonctionne à nouveau !** +- Le format des données envoyées par le JavaScript était incorrect +- La fonction de sauvegarde fusionne maintenant correctement avec la config existante + +#### Technique +- `admin.js`: Envoi de `{ config: { tracker, categories } }` au lieu de `{ tracker, categories }` +- `main.py`: La route POST `/api/admin/latest-config` fusionne les données au lieu d'écraser tout le fichier + +--- + +## [1.2.3] - 2024-12-25 + +### 🔄 Rechargement automatique des filtres + +#### Corrigé +- **Plus besoin de redémarrer Docker !** Le parser détecte automatiquement les modifications du fichier `filters_config.json` +- Les nouveaux filtres sont appliqués immédiatement à la prochaine recherche + +#### Technique +- Le parser vérifie la date de modification du fichier config avant chaque parsing +- Si le fichier a changé, il recharge automatiquement la configuration +- Log "🔄 Config des filtres modifiée, rechargement..." dans les logs quand ça se produit + +--- + +## [1.2.2] - 2024-12-25 + +### 🔄 Filtres dynamiques dans la recherche + +#### Corrigé +- **Les filtres personnalisés apparaissent maintenant dans la recherche !** +- La page de recherche charge dynamiquement les filtres depuis l'API +- Plus besoin de modifier le code JS pour ajouter de nouveaux filtres + +#### Ajouté +- Route publique `/api/filters` pour charger la config des filtres +- Les filtres créés dans Admin → Filtres sont immédiatement disponibles dans la recherche + +#### Technique +- `FILTER_CONFIG` dans search.js est maintenant dynamique (chargé au démarrage) +- Fallback sur une config minimale si l'API ne répond pas + +--- + +## [1.2.1] - 2024-12-25 + +### 🎮 Plus de catégories de filtres + +#### Ajouté +- **Nouveaux filtres par défaut** : + - 🎮 **Plateforme** : PC, Windows, Linux, Mac, PS5, PS4, Xbox, Switch, Steam, GOG... + - 💻 **Type Logiciel** : Portable, Repack, ISO, Setup, Crack, Patch, x64, x86... + - 📚 **Format Ebook** : EPUB, PDF, MOBI, CBR, CBZ, DJVU... + - 📖 **Type Ebook** : Roman, BD, Comics, Manga, Magazine, Audiobook... + - 🕹️ **Type Jeu** : RPG, FPS, Action, Adventure, Strategy, Simulation... + +#### Amélioré +- **Ajout de filtre simplifié** : plus besoin de connaître le nom technique, juste le nom et l'emoji +- Interface plus intuitive pour créer de nouveaux filtres + +--- + +## [1.2.0] - 2024-12-25 + +### 🎛️ Gestion dynamique des filtres + +#### Ajouté +- **Nouvel onglet "Filtres"** dans le panneau d'administration +- **Éditeur de filtres** : ajouter, modifier, supprimer des filtres +- **Configuration des valeurs** : définir les mots-clés à détecter pour chaque filtre +- **Test de détection** : tester un titre pour voir les filtres détectés +- **Fichier JSON** : `config/filters_config.json` pour persister la configuration +- **API endpoints** : + - `GET /api/admin/filters` - Récupérer la config + - `POST /api/admin/filters` - Sauvegarder la config + - `POST /api/admin/filters/reset` - Réinitialiser + - `POST /api/admin/filters/test` - Tester un titre + +#### Modifié +- **torrent_parser.py** : charge les filtres depuis le JSON au lieu du code en dur +- Le parser se recharge automatiquement après sauvegarde + +#### Technique +- Patterns regex construits dynamiquement à partir de la config +- Singleton pattern pour le parser avec reload + +--- + +## [1.1.2] - 2024-12-25 + +### 🎵 Filtres Musique + +#### Ajouté +- **Nouveaux filtres pour la musique** dans la recherche : + - **Format Audio** : FLAC, MP3, AAC, Lossless, 320, V0, 24bit... + - **Type** : Album, Single, EP, Live, Concert, Discography, Soundtrack... + - **Source Audio** : CD, Vinyl, WEB, SACD... + +#### Modifié +- Parser de titres enrichi pour détecter les métadonnées musicales + +--- + +## [1.1.1] - 2024-12-25 + +### 🎨 Amélioration interface Recherche + +#### Modifié +- **Sélecteur de trackers** : nouveau design identique à la page Nouveautés + - Panneau dépliable "🔧 Sélectionner les trackers" + - Boutons "Tout sélectionner" / "Tout désélectionner" +- **Filtres masquables** : bouton ▼/▶ pour afficher/masquer les filtres +- Uniformisation du style entre les pages Recherche et Nouveautés + +--- + +## [1.1.0] - 2024-12-25 + +### ✨ Nouveau panneau d'administration unifié + +#### Ajouté +- **Page `/admin` unifiée** avec 3 onglets (Catégories, Tags, RSS) +- Design moderne avec onglets animés +- Toast notifications pour les actions +- Section aide repliable pour RSS +- Route `/api/admin/parsing-tags/reset` pour réinitialiser les tags + +#### Modifié +- **Navigation simplifiée** : 3 liens au lieu de 5 +- **CSS admin refait** : design cohérent et moderne +- Interface responsive améliorée pour mobile + +#### Sécurité (v1.1.0) +- Headers HTTP de sécurité (X-Content-Type-Options, X-Frame-Options, etc.) +- Validation renforcée des entrées (longueur, nombre de trackers) +- Protection XSS avec `sanitizeUrl()` et `escapeHtml()` +- Protection SSRF dans les flux RSS (validation des URLs) +- Blocage des URLs locales (localhost, 127.0.0.1) + +#### Technique +- Export `DEFAULT_PARSING_TAGS` dans tmdb_api.py +- Route alternative `/api/admin/tracker-categories?tracker=xxx` + +--- + +## [1.0.0] - 2024-12-25 + +### 🎉 Version initiale stable + +#### Fonctionnalités principales +- **Recherche multi-trackers** via Jackett et Prowlarr +- **Page Nouveautés** avec enrichissement TMDb (films/séries) et Last.fm (musique) +- **Parsing intelligent** des titres de torrents (qualité, codec, langue, HDR, etc.) +- **Interface responsive** adaptée aux smartphones +- **Filtres dynamiques** côté client (qualité, source, langue, tracker) +- **Tri et pagination** des résultats + +#### Sources supportées +- Jackett (indexers configurés) +- Prowlarr (indexers configurés) +- Flux RSS génériques (avec support Flaresolverr + cookies) + +#### Administration +- `/admin/latest` - Configuration des catégories par tracker +- `/admin/parsing` - Gestion des tags de nettoyage des titres +- `/admin/rss` - Gestion des flux RSS + +#### Intégrations +- TMDb API - Métadonnées films/séries +- Last.fm API - Métadonnées musique +- Flaresolverr - Bypass Cloudflare pour RSS + +--- + +## Versioning + +- **MAJOR** (X.0.0) : Changements incompatibles, refonte majeure +- **MINOR** (0.X.0) : Nouvelles fonctionnalités rétrocompatibles +- **PATCH** (0.0.X) : Corrections de bugs, sécurité \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..690c699 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Installer les dépendances système +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Copier les requirements et installer +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copier l'application +COPY app/ . + +# Créer les dossiers nécessaires +RUN mkdir -p /app/config /app/logs + +# Exposer le port +EXPOSE 5097 + +# Lancer l'application +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7abb4c9 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# 🐺 Lycostorrent + +**Version 1.0.0** | Interface de recherche de torrents multi-sources + +![Version](https://img.shields.io/badge/version-1.0.0-blue) +![Python](https://img.shields.io/badge/python-3.11+-green) +![Docker](https://img.shields.io/badge/docker-ready-blue) + +--- + +## 📋 Description + +Lycostorrent est une interface web unifiée pour rechercher des torrents sur plusieurs sources : +- **Jackett** - Agrégateur d'indexers +- **Prowlarr** - Gestionnaire d'indexers +- **Flux RSS** - Sources personnalisées + +L'application enrichit automatiquement les résultats avec les métadonnées de **TMDb** (films/séries) et **Last.fm** (musique). + +--- + +## ✨ Fonctionnalités + +### Recherche +- 🔍 Recherche multi-trackers simultanée +- 🏷️ Parsing intelligent des titres (qualité, codec, langue, HDR) +- 🔄 Filtres dynamiques côté client +- 📊 Tri par seeders, taille, date, nom +- 📱 Interface responsive (mobile-friendly) + +### Nouveautés +- 🎬 Dernières sorties Films/Séries avec affiches TMDb +- 🎵 Dernières sorties Musique avec pochettes Last.fm +- 🎌 Catégorie Anime dédiée +- 📦 Regroupement intelligent des versions + +### Administration +- ⚙️ Configuration des catégories par tracker +- 🏷️ Gestion des tags de parsing +- 📡 Gestion des flux RSS avec Flaresolverr + +--- + +## 🚀 Installation + +### Prérequis +- Docker & Docker Compose +- Jackett et/ou Prowlarr configurés +- Clés API : TMDb, Last.fm (optionnel) + +### Docker Compose + +```yaml +version: '3.8' + +services: + lycostorrent: + build: . + container_name: lycostorrent + ports: + - "5555:5000" + environment: + # Sources (au moins une requise) + - JACKETT_URL=http://jackett:9117 + - JACKETT_API_KEY=votre_api_key + - PROWLARR_URL=http://prowlarr:9696 + - PROWLARR_API_KEY=votre_api_key + + # Enrichissement (optionnel mais recommandé) + - TMDB_API_KEY=votre_api_key + - LASTFM_API_KEY=votre_api_key + + # Flaresolverr pour RSS protégés (optionnel) + - FLARESOLVERR_URL=http://flaresolverr:8191 + + # Logs + - LOG_LEVEL=INFO + volumes: + - ./config:/app/config + - ./logs:/app/logs + restart: unless-stopped +``` + +### Lancement + +```bash +docker-compose up -d +``` + +Accéder à : `http://votre-ip:5555` + +--- + +## 📁 Structure + +``` +lycostorrent/ +├── app/ +│ ├── main.py # Application Flask +│ ├── config.py # Configuration +│ ├── indexer_manager.py # Gestion Jackett/Prowlarr +│ ├── jackett_api.py # API Jackett +│ ├── prowlarr_api.py # API Prowlarr +│ ├── tmdb_api.py # API TMDb +│ ├── lastfm_api.py # API Last.fm +│ ├── torrent_parser.py # Parsing des titres +│ ├── rss_source.py # Gestion flux RSS +│ ├── templates/ # Templates HTML +│ └── static/ # CSS, JS +├── config/ # Configuration persistante +├── logs/ # Logs applicatifs +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── VERSION +├── CHANGELOG.md +└── README.md +``` + +--- + +## 🔧 Configuration + +### Variables d'environnement + +| Variable | Requis | Description | +|----------|--------|-------------| +| `JACKETT_URL` | * | URL de Jackett | +| `JACKETT_API_KEY` | * | Clé API Jackett | +| `PROWLARR_URL` | * | URL de Prowlarr | +| `PROWLARR_API_KEY` | * | Clé API Prowlarr | +| `TMDB_API_KEY` | Non | Clé API TMDb (enrichissement) | +| `LASTFM_API_KEY` | Non | Clé API Last.fm (enrichissement) | +| `FLARESOLVERR_URL` | Non | URL Flaresolverr (RSS protégés) | +| `LOG_LEVEL` | Non | Niveau de log (INFO, DEBUG, WARNING) | + +\* Au moins Jackett OU Prowlarr requis + +### Obtenir les clés API + +- **TMDb** : https://www.themoviedb.org/settings/api +- **Last.fm** : https://www.last.fm/api/account/create + +--- + +## 📱 Pages + +| Route | Description | +|-------|-------------| +| `/` | Page de recherche | +| `/latest` | Nouveautés (films, séries, anime, musique) | +| `/admin/latest` | Config catégories par tracker | +| `/admin/parsing` | Config tags de parsing | +| `/admin/rss` | Config flux RSS | + +--- + +## 🔒 Sécurité + +- ✅ Headers HTTP de sécurité +- ✅ Validation des entrées +- ✅ Protection XSS +- ✅ Protection SSRF +- ✅ Masquage des secrets + +Voir [SECURITY_AUDIT.md](SECURITY_AUDIT.md) pour les détails. + +--- + +## 📝 Changelog + +Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique des versions. + +--- + +## 🐛 Dépannage + +### Aucun tracker trouvé +- Vérifier les URLs et clés API +- Vérifier que Jackett/Prowlarr sont accessibles + +### Pas d'enrichissement TMDb +- Vérifier la clé API TMDb +- Les logs montrent les erreurs de recherche + +### RSS retourne 403 +- Activer Flaresolverr +- Ajouter les cookies de session + +--- + +## 📄 Licence + +Projet personnel - Usage privé uniquement. + +--- + +**Développé avec ❤️ et l'aide de Claude (Anthropic)** \ No newline at end of file diff --git a/app/VERSION b/app/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/app/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/app/cache_manager.py b/app/cache_manager.py new file mode 100644 index 0000000..91465ab --- /dev/null +++ b/app/cache_manager.py @@ -0,0 +1,435 @@ +""" +Lycostorrent - Système de cache pour Latest et Discover +Permet de pré-charger les données en arrière-plan +""" + +import os +import json +import logging +import threading +import time +from datetime import datetime, timedelta +from typing import Dict, Any, Optional, List +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger + +logger = logging.getLogger(__name__) + +# Chemins des fichiers +CACHE_DIR = '/app/config/cache' +CONFIG_FILE = '/app/config/cache_config.json' +META_FILE = os.path.join(CACHE_DIR, 'cache_meta.json') + +# Scheduler global +_scheduler: Optional[BackgroundScheduler] = None +_cache_lock = threading.Lock() +_is_refreshing = False + +# Configuration par défaut +DEFAULT_CONFIG = { + 'enabled': False, + 'interval_minutes': 60, # 1h par défaut + 'latest': { + 'enabled': True, + 'categories': ['movies', 'tv'], + 'trackers': [], # Tous si vide + 'limit': 50 + }, + 'discover': { + 'enabled': True, + 'limit': 30 + } +} + +# ============================================================ +# GESTION DE LA CONFIGURATION +# ============================================================ + +def get_cache_config() -> Dict[str, Any]: + """Récupère la configuration du cache""" + try: + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + # Fusionner avec les valeurs par défaut + return {**DEFAULT_CONFIG, **config} + except Exception as e: + logger.error(f"Erreur lecture config cache: {e}") + return DEFAULT_CONFIG.copy() + + +def save_cache_config(config: Dict[str, Any]) -> bool: + """Sauvegarde la configuration du cache""" + try: + os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + return True + except Exception as e: + logger.error(f"Erreur sauvegarde config cache: {e}") + return False + + +# ============================================================ +# GESTION DES MÉTADONNÉES +# ============================================================ + +def get_cache_meta() -> Dict[str, Any]: + """Récupère les métadonnées du cache (timestamps, etc.)""" + try: + if os.path.exists(META_FILE): + with open(META_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Erreur lecture meta cache: {e}") + return { + 'last_refresh': None, + 'next_refresh': None, + 'status': 'never', + 'latest': {}, + 'discover': {} + } + + +def save_cache_meta(meta: Dict[str, Any]) -> bool: + """Sauvegarde les métadonnées du cache""" + try: + os.makedirs(CACHE_DIR, exist_ok=True) + with open(META_FILE, 'w') as f: + json.dump(meta, f, indent=2) + return True + except Exception as e: + logger.error(f"Erreur sauvegarde meta cache: {e}") + return False + + +def get_cache_status() -> Dict[str, Any]: + """Retourne le statut du cache pour l'affichage""" + global _is_refreshing + + config = get_cache_config() + meta = get_cache_meta() + + # Calculer la taille du cache + cache_size = 0 + try: + if os.path.exists(CACHE_DIR): + for f in os.listdir(CACHE_DIR): + filepath = os.path.join(CACHE_DIR, f) + if os.path.isfile(filepath): + cache_size += os.path.getsize(filepath) + except: + pass + + # Calculer "il y a X minutes" + last_refresh_ago = None + if meta.get('last_refresh'): + try: + last_dt = datetime.fromisoformat(meta['last_refresh']) + delta = datetime.now() - last_dt + minutes = int(delta.total_seconds() / 60) + if minutes < 1: + last_refresh_ago = "à l'instant" + elif minutes < 60: + last_refresh_ago = f"il y a {minutes} min" + else: + hours = minutes // 60 + last_refresh_ago = f"il y a {hours}h" + except: + pass + + return { + 'enabled': config.get('enabled', False), + 'interval_minutes': config.get('interval_minutes', 60), + 'last_refresh': meta.get('last_refresh'), + 'last_refresh_ago': last_refresh_ago, + 'next_refresh': meta.get('next_refresh'), + 'status': meta.get('status', 'never'), + 'is_refreshing': _is_refreshing, + 'cache_size_bytes': cache_size, + 'cache_size_mb': round(cache_size / (1024 * 1024), 2), + 'latest_categories': config.get('latest', {}).get('categories', []), + 'discover_enabled': config.get('discover', {}).get('enabled', True) + } + + +# ============================================================ +# LECTURE/ÉCRITURE DU CACHE +# ============================================================ + +def get_cached_data(cache_type: str, category: str = None) -> Optional[Dict[str, Any]]: + """ + Récupère les données en cache + + Args: + cache_type: 'latest' ou 'discover' + category: Pour latest: 'movies', 'tv', 'anime', 'music' + Pour discover: 'movies', 'tv' + """ + try: + if category: + filename = f"{cache_type}_{category}.json" + else: + filename = f"{cache_type}.json" + + filepath = os.path.join(CACHE_DIR, filename) + + if os.path.exists(filepath): + with open(filepath, 'r') as f: + data = json.load(f) + return data + except Exception as e: + logger.error(f"Erreur lecture cache {cache_type}/{category}: {e}") + + return None + + +def save_cached_data(cache_type: str, category: str, data: Any) -> bool: + """Sauvegarde les données en cache""" + try: + os.makedirs(CACHE_DIR, exist_ok=True) + + filename = f"{cache_type}_{category}.json" + filepath = os.path.join(CACHE_DIR, filename) + + cache_data = { + 'timestamp': datetime.now().isoformat(), + 'type': cache_type, + 'category': category, + 'data': data + } + + with open(filepath, 'w') as f: + json.dump(cache_data, f, ensure_ascii=False) + + return True + except Exception as e: + logger.error(f"Erreur sauvegarde cache {cache_type}/{category}: {e}") + return False + + +def clear_cache() -> bool: + """Vide tout le cache""" + try: + if os.path.exists(CACHE_DIR): + for f in os.listdir(CACHE_DIR): + filepath = os.path.join(CACHE_DIR, f) + if os.path.isfile(filepath) and f.endswith('.json'): + os.remove(filepath) + return True + except Exception as e: + logger.error(f"Erreur suppression cache: {e}") + return False + + +# ============================================================ +# JOB DE REFRESH DU CACHE +# ============================================================ + +def refresh_cache(app=None): + """ + Job principal de refresh du cache + Appelé par le scheduler ou manuellement + """ + global _is_refreshing + + if _is_refreshing: + logger.info("⏳ Refresh déjà en cours, ignoré") + return + + with _cache_lock: + _is_refreshing = True + + try: + logger.info("🔄 Début du refresh du cache...") + config = get_cache_config() + meta = get_cache_meta() + + meta['status'] = 'refreshing' + meta['last_refresh_start'] = datetime.now().isoformat() + save_cache_meta(meta) + + # Refresh Latest + if config.get('latest', {}).get('enabled', True): + refresh_latest_cache(config, app) + + # Refresh Discover + if config.get('discover', {}).get('enabled', True): + refresh_discover_cache(config, app) + + # Mettre à jour les métadonnées + now = datetime.now() + interval = config.get('interval_minutes', 60) + next_refresh = now + timedelta(minutes=interval) + + meta['last_refresh'] = now.isoformat() + meta['next_refresh'] = next_refresh.isoformat() + meta['status'] = 'success' + save_cache_meta(meta) + + logger.info(f"✅ Cache rafraîchi avec succès. Prochain refresh: {next_refresh.strftime('%H:%M')}") + + except Exception as e: + logger.error(f"❌ Erreur refresh cache: {e}") + meta = get_cache_meta() + meta['status'] = 'error' + meta['last_error'] = str(e) + save_cache_meta(meta) + + finally: + with _cache_lock: + _is_refreshing = False + + +def refresh_latest_cache(config: Dict, app=None): + """Refresh le cache des nouveautés (Latest)""" + from main import fetch_latest_releases_internal + + latest_config = config.get('latest', {}) + categories = latest_config.get('categories', ['movies', 'tv']) + trackers = latest_config.get('trackers', []) + limit = latest_config.get('limit', 50) + + logger.info(f"📥 Refresh Latest: catégories={categories}, limit={limit}") + + for category in categories: + try: + logger.info(f" → Chargement {category}...") + + # Appeler la fonction interne de récupération + results = fetch_latest_releases_internal( + trackers_list=trackers if trackers else None, + category=category, + limit=limit + ) + + if results: + save_cached_data('latest', category, results) + logger.info(f" ✅ {category}: {len(results)} résultats cachés") + else: + logger.warning(f" ⚠️ {category}: aucun résultat") + + except Exception as e: + logger.error(f" ❌ Erreur {category}: {e}", exc_info=True) + + +def refresh_discover_cache(config: Dict, app=None): + """Refresh le cache Discover (TMDb + torrents)""" + from main import fetch_discover_internal + + discover_config = config.get('discover', {}) + limit = discover_config.get('limit', 30) + + logger.info(f"📥 Refresh Discover: limit={limit}") + + for media_type in ['movies', 'tv']: + try: + logger.info(f" → Chargement {media_type}...") + + # Appeler la fonction interne de récupération + results = fetch_discover_internal( + media_type=media_type, + limit=limit + ) + + if results: + save_cached_data('discover', media_type, results) + # results est un dict avec les catégories + total = sum(len(v) for v in results.values()) if isinstance(results, dict) else len(results) + logger.info(f" ✅ {media_type}: {total} résultats cachés") + else: + logger.warning(f" ⚠️ {media_type}: aucun résultat") + + except Exception as e: + logger.error(f" ❌ Erreur {media_type}: {e}", exc_info=True) + + +# ============================================================ +# SCHEDULER +# ============================================================ + +def init_scheduler(app=None): + """Initialise le scheduler de cache""" + global _scheduler + + config = get_cache_config() + + if not config.get('enabled', False): + logger.info("ℹ️ Cache désactivé, scheduler non démarré") + return + + if _scheduler is not None: + logger.info("⚠️ Scheduler déjà initialisé") + return + + try: + _scheduler = BackgroundScheduler() + + interval_minutes = config.get('interval_minutes', 60) + + # Ajouter le job de refresh + _scheduler.add_job( + func=lambda: refresh_cache(app), + trigger=IntervalTrigger(minutes=interval_minutes), + id='cache_refresh', + name='Refresh du cache Latest/Discover', + replace_existing=True + ) + + _scheduler.start() + logger.info(f"✅ Scheduler démarré (intervalle: {interval_minutes} min)") + + # Mettre à jour le prochain refresh + meta = get_cache_meta() + next_refresh = datetime.now() + timedelta(minutes=interval_minutes) + meta['next_refresh'] = next_refresh.isoformat() + save_cache_meta(meta) + + # Lancer un refresh initial si le cache est vide ou ancien + if should_refresh_now(config): + logger.info("🔄 Lancement du refresh initial...") + threading.Thread(target=lambda: refresh_cache(app), daemon=True).start() + + except Exception as e: + logger.error(f"❌ Erreur démarrage scheduler: {e}") + + +def should_refresh_now(config: Dict) -> bool: + """Détermine si un refresh immédiat est nécessaire""" + meta = get_cache_meta() + + # Si jamais rafraîchi + if not meta.get('last_refresh'): + return True + + # Si le dernier refresh est plus vieux que l'intervalle + try: + last_refresh = datetime.fromisoformat(meta['last_refresh']) + interval_minutes = config.get('interval_minutes', 60) + if datetime.now() - last_refresh > timedelta(minutes=interval_minutes): + return True + except: + return True + + return False + + +def stop_scheduler(): + """Arrête le scheduler""" + global _scheduler + + if _scheduler: + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info("🛑 Scheduler arrêté") + + +def restart_scheduler(app=None): + """Redémarre le scheduler avec la nouvelle config""" + stop_scheduler() + init_scheduler(app) + + +def is_scheduler_running() -> bool: + """Vérifie si le scheduler est actif""" + return _scheduler is not None and _scheduler.running diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..93f7893 --- /dev/null +++ b/app/config.py @@ -0,0 +1,57 @@ +import os +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class Config: + """Gestion de la configuration de l'application""" + + def __init__(self): + # Configuration Jackett + self.jackett_url = os.getenv('JACKETT_URL', '') + self.jackett_api_key = os.getenv('JACKETT_API_KEY', '') + + # Configuration Prowlarr + self.prowlarr_url = os.getenv('PROWLARR_URL', '') + self.prowlarr_api_key = os.getenv('PROWLARR_API_KEY', '') + + # Configuration TMDb + self.tmdb_api_key = os.getenv('TMDB_API_KEY', '') + + # Configuration Last.fm + self.lastfm_api_key = os.getenv('LASTFM_API_KEY', '') + + # Chemins + self.config_dir = Path('/app/config') + self.logs_dir = Path('/app/logs') + + # Création des dossiers si nécessaire + self.config_dir.mkdir(parents=True, exist_ok=True) + self.logs_dir.mkdir(parents=True, exist_ok=True) + + # Validation + self._validate() + + def _validate(self): + """Valide la configuration""" + has_jackett = self.jackett_url and self.jackett_api_key + has_prowlarr = self.prowlarr_url and self.prowlarr_api_key + + if not has_jackett and not has_prowlarr: + logger.warning("⚠️ Aucun indexer configuré ! Configurer JACKETT ou PROWLARR") + + if has_jackett: + logger.info(f"✅ Jackett configuré: {self.jackett_url}") + + if has_prowlarr: + logger.info(f"✅ Prowlarr configuré: {self.prowlarr_url}") + + @property + def has_jackett(self): + return bool(self.jackett_url and self.jackett_api_key) + + @property + def has_prowlarr(self): + return bool(self.prowlarr_url and self.prowlarr_api_key) \ No newline at end of file diff --git a/app/config/filters_config.json b/app/config/filters_config.json new file mode 100644 index 0000000..b315d21 --- /dev/null +++ b/app/config/filters_config.json @@ -0,0 +1,74 @@ +{ + "filters": { + "quality": { + "name": "Qualité", + "icon": "📺", + "values": ["2160p", "1080p", "720p", "480p", "360p", "4K", "UHD"] + }, + "source": { + "name": "Source", + "icon": "📀", + "values": ["BluRay", "Blu-Ray", "BLURAY", "BDRip", "BRRip", "WEB-DL", "WEBDL", "WEBRip", "WEB", "HDTV", "DVDRip", "DVD", "Remux", "REMUX", "CAM", "TS", "TELESYNC", "HC", "HDCAM"] + }, + "video_codec": { + "name": "Codec Vidéo", + "icon": "🎬", + "values": ["x265", "x264", "H265", "H264", "HEVC", "AVC", "AV1", "VP9", "MPEG2", "VC1", "XviD", "DivX"] + }, + "audio": { + "name": "Audio", + "icon": "🔊", + "values": ["DTS-HD MA", "DTS-HD", "DTS", "DDP", "DD", "DD+", "Atmos", "ATMOS", "TrueHD", "AAC", "AC3", "EAC3", "FLAC", "MP3", "LPCM", "PCM"] + }, + "language": { + "name": "Langue", + "icon": "🗣️", + "values": ["FRENCH", "TRUEFRENCH", "VFF", "VFI", "VFQ", "VF", "VOSTFR", "SUBFRENCH", "MULTI", "MULTi", "ENGLISH", "ENG", "GERMAN", "GER", "SPANISH", "ESP", "ITALIAN", "ITA", "JAPANESE", "JAP", "KOREAN", "KOR"] + }, + "hdr": { + "name": "HDR", + "icon": "✨", + "values": ["HDR10+", "HDR10", "HDR", "DV", "DoVi", "Dolby Vision"] + }, + "audio_format": { + "name": "Format Audio", + "icon": "🎵", + "values": ["FLAC", "MP3", "AAC", "ALAC", "OGG", "OPUS", "WAV", "APE", "WMA", "M4A", "320", "256", "192", "V0", "V2", "24bit", "16bit", "Lossless", "Lossy"] + }, + "music_type": { + "name": "Type Musique", + "icon": "💿", + "values": ["Album", "Single", "EP", "Discography", "Discographie", "Compilation", "Live", "Concert", "Bootleg", "Demo", "Mixtape", "OST", "Soundtrack", "Score", "Integral", "Integrale", "Complete"] + }, + "music_source": { + "name": "Source Musique", + "icon": "📻", + "values": ["CD", "Vinyl", "WEB", "Cassette", "DAT", "SACD", "DVD-Audio", "Blu-ray Audio"] + }, + "platform": { + "name": "Plateforme", + "icon": "🎮", + "values": ["PC", "Windows", "Linux", "Mac", "MacOS", "Android", "iOS", "PS5", "PS4", "PS3", "Xbox", "Switch", "Nintendo", "Steam", "GOG", "Epic"] + }, + "software_type": { + "name": "Type Logiciel", + "icon": "💻", + "values": ["Portable", "Repack", "ISO", "Setup", "Crack", "Keygen", "Patch", "Update", "DLC", "Addon", "Plugin", "x64", "x86", "64bit", "32bit"] + }, + "ebook_format": { + "name": "Format Ebook", + "icon": "📚", + "values": ["EPUB", "PDF", "MOBI", "AZW3", "AZW", "CBR", "CBZ", "DJVU", "FB2", "LIT", "PDB", "RTF", "TXT", "DOC", "DOCX"] + }, + "ebook_type": { + "name": "Type Ebook", + "icon": "📖", + "values": ["Roman", "BD", "Comics", "Manga", "Magazine", "Journal", "Guide", "Manuel", "Tutoriel", "Cours", "Formation", "Audiobook", "Livre Audio"] + }, + "game_type": { + "name": "Type Jeu", + "icon": "🕹️", + "values": ["RPG", "FPS", "Action", "Adventure", "Strategy", "Simulation", "Sport", "Racing", "Puzzle", "Indie", "VR", "Multiplayer", "Coop", "Singleplayer"] + } + } +} \ No newline at end of file diff --git a/app/indexer_manager.py b/app/indexer_manager.py new file mode 100644 index 0000000..1d97798 --- /dev/null +++ b/app/indexer_manager.py @@ -0,0 +1,269 @@ +import logging +from jackett_api import JackettAPI +from prowlarr_api import ProwlarrAPI + +logger = logging.getLogger(__name__) + + +class IndexerManager: + """ + Gestionnaire unifié pour Jackett et Prowlarr. + Permet d'utiliser les deux sources simultanément ou séparément. + """ + + def __init__(self, config): + self.config = config + self.jackett = None + self.prowlarr = None + + # Initialiser Jackett si configuré + if config.has_jackett: + self.jackett = JackettAPI(config.jackett_url, config.jackett_api_key) + logger.info("✅ Jackett initialisé") + + # Initialiser Prowlarr si configuré + if config.has_prowlarr: + self.prowlarr = ProwlarrAPI(config.prowlarr_url, config.prowlarr_api_key) + logger.info("✅ Prowlarr initialisé") + + def get_indexers(self): + """ + Récupère la liste de tous les indexers (Jackett + Prowlarr). + Fusionne les doublons (même nom) et indique les sources disponibles. + Priorité à Jackett si disponible sur les deux. + """ + jackett_indexers = [] + prowlarr_indexers = [] + + # Récupérer les indexers Jackett + if self.jackett: + try: + jackett_indexers = self.jackett.get_indexers() + for idx in jackett_indexers: + idx['source'] = 'jackett' + idx['original_id'] = idx['id'] + idx['id'] = f"jackett:{idx['id']}" + except Exception as e: + logger.error(f"❌ Erreur récupération indexers Jackett: {e}") + + # Récupérer les indexers Prowlarr + if self.prowlarr: + try: + prowlarr_indexers = self.prowlarr.get_indexers() + for idx in prowlarr_indexers: + idx['source'] = 'prowlarr' + idx['original_id'] = idx['id'] + idx['id'] = f"prowlarr:{idx['id']}" + except Exception as e: + logger.error(f"❌ Erreur récupération indexers Prowlarr: {e}") + + # Fusionner les doublons par nom (normalisé) + merged = {} + + # D'abord Jackett (prioritaire) + for idx in jackett_indexers: + name_key = idx['name'].lower().strip() + merged[name_key] = { + 'id': idx['id'], + 'name': idx['name'], + 'type': idx.get('type', 'public'), + 'source': 'jackett', + 'sources': ['jackett'], + 'jackett_id': idx['id'], + 'prowlarr_id': None + } + + # Ensuite Prowlarr + for idx in prowlarr_indexers: + name_key = idx['name'].lower().strip() + if name_key in merged: + # Doublon trouvé - ajouter Prowlarr comme source alternative + merged[name_key]['sources'].append('prowlarr') + merged[name_key]['prowlarr_id'] = idx['id'] + # Garder Jackett comme source principale (déjà fait) + else: + # Nouveau tracker (Prowlarr uniquement) + merged[name_key] = { + 'id': idx['id'], + 'name': idx['name'], + 'type': idx.get('type', 'public'), + 'source': 'prowlarr', + 'sources': ['prowlarr'], + 'jackett_id': None, + 'prowlarr_id': idx['id'] + } + + # Convertir en liste et trier par nom + all_indexers = list(merged.values()) + all_indexers.sort(key=lambda x: x['name'].lower()) + + logger.info(f"📊 Total: {len(all_indexers)} indexers uniques (Jackett: {len(jackett_indexers)}, Prowlarr: {len(prowlarr_indexers)})") + return all_indexers + + def search(self, query, indexers=None, category=None, max_results=2000): + """ + Effectue une recherche sur les indexers spécifiés. + Utilise des requêtes parallèles pour Jackett et Prowlarr. + + Args: + query: Terme de recherche + indexers: Liste des IDs d'indexers (format: "jackett:id" ou "prowlarr:id") + category: Catégorie de recherche + max_results: Nombre max de résultats + + Returns: + Liste combinée de résultats + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + import time + + all_results = [] + start_time = time.time() + + # Séparer les indexers par source + jackett_indexers = [] + prowlarr_indexers = [] + + if indexers: + for idx in indexers: + if idx.startswith('jackett:'): + jackett_indexers.append(idx.replace('jackett:', '')) + elif idx.startswith('prowlarr:'): + prowlarr_indexers.append(idx.replace('prowlarr:', '')) + else: + # Format ancien (compatibilité) - supposer Jackett + jackett_indexers.append(idx) + else: + # Si aucun indexer spécifié, chercher sur tous + jackett_indexers = None + prowlarr_indexers = None + + def search_jackett(): + """Recherche sur Jackett""" + if not self.jackett: + return [] + if jackett_indexers is not None and len(jackett_indexers) == 0: + return [] + try: + results = self.jackett.search( + query, + indexers=jackett_indexers if jackett_indexers else None, + category=category, + max_results=max_results + ) + for r in results: + r['Source'] = 'jackett' + logger.info(f"📦 Jackett: {len(results)} résultats") + return results + except Exception as e: + logger.error(f"❌ Erreur recherche Jackett: {e}") + return [] + + def search_prowlarr(): + """Recherche sur Prowlarr""" + if not self.prowlarr: + return [] + if prowlarr_indexers is not None and len(prowlarr_indexers) == 0: + return [] + try: + results = self.prowlarr.search( + query, + indexers=prowlarr_indexers if prowlarr_indexers else None, + category=category, + max_results=max_results + ) + for r in results: + r['Source'] = 'prowlarr' + logger.info(f"📦 Prowlarr: {len(results)} résultats") + return results + except Exception as e: + logger.error(f"❌ Erreur recherche Prowlarr: {e}") + return [] + + # Exécuter les recherches en parallèle + with ThreadPoolExecutor(max_workers=2) as executor: + futures = { + executor.submit(search_jackett): 'jackett', + executor.submit(search_prowlarr): 'prowlarr' + } + + for future in as_completed(futures, timeout=120): + source = futures[future] + try: + results = future.result() + all_results.extend(results) + except Exception as e: + logger.error(f"❌ Erreur {source}: {e}") + + # Trier par seeders (descending) et limiter + all_results.sort(key=lambda x: x.get('Seeders', 0), reverse=True) + + elapsed = time.time() - start_time + logger.info(f"✅ Total combiné: {len(all_results)} résultats (en {elapsed:.2f}s)") + return all_results[:max_results] + + def get_indexer_categories(self, indexer_id): + """Récupère les catégories d'un indexer spécifique""" + if indexer_id.startswith('jackett:'): + tracker_id = indexer_id.replace('jackett:', '') + if self.jackett: + # Utiliser l'endpoint caps de Jackett + return self._get_jackett_categories(tracker_id) + elif indexer_id.startswith('prowlarr:'): + idx_id = indexer_id.replace('prowlarr:', '') + if self.prowlarr: + return self.prowlarr.get_indexer_categories(idx_id) + + return [] + + def _get_jackett_categories(self, tracker_id): + """Récupère les catégories via l'API Jackett""" + try: + import xml.etree.ElementTree as ET + + url = f"{self.config.jackett_url}/api/v2.0/indexers/{tracker_id}/results/torznab/api" + params = { + 'apikey': self.config.jackett_api_key, + 't': 'caps' + } + + response = self.jackett.session.get(url, params=params, timeout=15) + response.raise_for_status() + + root = ET.fromstring(response.content) + + categories = [] + for cat in root.findall('.//category'): + cat_id = cat.get('id') + cat_name = cat.get('name') or cat.text + if cat_id: + categories.append({ + 'id': cat_id, + 'name': cat_name or f'Catégorie {cat_id}' + }) + + categories.sort(key=lambda x: int(x['id']) if x['id'].isdigit() else 999999) + return categories + + except Exception as e: + logger.error(f"❌ Erreur récupération catégories Jackett: {e}") + return [] + + @property + def has_any_source(self): + """Vérifie si au moins une source est configurée""" + return self.jackett is not None or self.prowlarr is not None + + @property + def sources_status(self): + """Retourne le statut des sources""" + return { + 'jackett': { + 'enabled': self.jackett is not None, + 'url': self.config.jackett_url if self.jackett else None + }, + 'prowlarr': { + 'enabled': self.prowlarr is not None, + 'url': self.config.prowlarr_url if self.prowlarr else None + } + } \ No newline at end of file diff --git a/app/jackett_api.py b/app/jackett_api.py new file mode 100644 index 0000000..13a6705 --- /dev/null +++ b/app/jackett_api.py @@ -0,0 +1,202 @@ +import requests +import logging +import xml.etree.ElementTree as ET +from datetime import datetime +from dateutil import parser as date_parser + +logger = logging.getLogger(__name__) + + +class JackettAPI: + """Classe pour interagir avec l'API Jackett""" + + def __init__(self, base_url, api_key): + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': 'Lycostorrent/2.0' + }) + + def get_indexers(self): + """Récupère la liste des indexers (trackers) configurés dans Jackett""" + try: + url = f"{self.base_url}/api/v2.0/indexers/all/results/torznab/api" + params = { + 'apikey': self.api_key, + 't': 'indexers', + 'configured': 'true' + } + + response = self.session.get(url, params=params, timeout=10) + response.raise_for_status() + + root = ET.fromstring(response.content) + + indexers = [] + for indexer in root.findall('.//indexer'): + indexer_data = { + 'id': indexer.get('id'), + 'name': indexer.find('title').text if indexer.find('title') is not None else 'Unknown', + 'type': indexer.get('type', 'public'), + } + indexers.append(indexer_data) + + logger.info(f"✅ {len(indexers)} indexers récupérés depuis Jackett") + return indexers + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur connexion Jackett: {e}") + return self._get_indexers_fallback() + except Exception as e: + logger.error(f"❌ Erreur récupération indexers: {e}") + return [] + + def _get_indexers_fallback(self): + """Méthode alternative pour récupérer les indexers""" + try: + url = f"{self.base_url}/api/v2.0/indexers" + params = {'apikey': self.api_key} + + response = self.session.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + indexers = [] + for indexer in data: + if indexer.get('configured', False): + indexers.append({ + 'id': indexer.get('id'), + 'name': indexer.get('name', 'Unknown'), + 'type': indexer.get('type', 'public'), + }) + + return indexers + except Exception as e: + logger.error(f"❌ Erreur fallback indexers: {e}") + return [] + + def search(self, query, indexers=None, category=None, max_results=2000): + """ + Effectue une recherche sur Jackett avec pagination. + + Args: + query: Terme de recherche + indexers: Liste des trackers à utiliser + category: ID de catégorie Jackett (2000=films, 5000=séries, etc.) + max_results: Nombre max de résultats + + Returns: + Liste de résultats formatés + """ + try: + url = f"{self.base_url}/api/v2.0/indexers/all/results" + all_results = [] + offset = 0 + limit_per_request = 500 + + while len(all_results) < max_results: + params = { + 'apikey': self.api_key, + 'Query': query, + 'limit': limit_per_request, + 'offset': offset, + } + + # Ajouter les trackers si spécifiés + if indexers and len(indexers) > 0: + params['Tracker[]'] = indexers + + # Ajouter la catégorie si spécifiée + if category: + params['Category'] = category + + logger.info(f"🔍 Requête Jackett: query='{query}', offset={offset}, limit={limit_per_request}") + + response = self.session.get(url, params=params, timeout=60) + response.raise_for_status() + + data = response.json() + results = data.get('Results', []) + + if not results: + break + + all_results.extend(results) + + # Si on a moins de résultats que la limite, c'est qu'on a tout récupéré + if len(results) < limit_per_request: + break + + offset += limit_per_request + + # Formater les résultats + formatted_results = [self._format_result(r) for r in all_results[:max_results]] + + logger.info(f"✅ {len(formatted_results)} résultats récupérés au total") + return formatted_results + + except requests.exceptions.Timeout: + logger.error("⏱️ Timeout lors de la recherche Jackett") + return [] + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur connexion Jackett: {e}") + return [] + except Exception as e: + logger.error(f"❌ Erreur recherche: {e}", exc_info=True) + return [] + + def _format_result(self, result): + """Formate un résultat Jackett""" + try: + # Parser la date + publish_date = result.get('PublishDate', '') + try: + if publish_date: + dt = date_parser.parse(publish_date) + formatted_date = dt.strftime('%Y-%m-%d %H:%M') + else: + formatted_date = 'N/A' + except: + formatted_date = 'N/A' + + return { + 'Title': result.get('Title', 'Sans titre'), + 'Tracker': result.get('Tracker', 'Unknown'), + 'Category': result.get('CategoryDesc', 'N/A'), + 'PublishDate': formatted_date, + 'PublishDateRaw': publish_date, + 'Size': result.get('Size', 0), + 'SizeFormatted': self._format_size(result.get('Size', 0)), + 'Seeders': result.get('Seeders', 0), + 'Peers': result.get('Peers', 0), + 'Link': result.get('Link', ''), + 'MagnetUri': result.get('MagnetUri', ''), + 'Guid': result.get('Guid', ''), + 'Details': result.get('Details', ''), + } + except Exception as e: + logger.warning(f"⚠️ Erreur formatage résultat: {e}") + return result + + def _format_size(self, size_bytes): + """Convertit une taille en bytes vers un format lisible""" + try: + size_bytes = int(size_bytes) + if size_bytes == 0: + return "0 B" + + units = ['B', 'KB', 'MB', 'GB', 'TB'] + unit_index = 0 + size = float(size_bytes) + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if unit_index >= 2: # MB et plus + return f"{size:.2f} {units[unit_index]}" + else: + return f"{size:.0f} {units[unit_index]}" + except: + return "N/A" \ No newline at end of file diff --git a/app/lastfm_api.py b/app/lastfm_api.py new file mode 100644 index 0000000..37ad753 --- /dev/null +++ b/app/lastfm_api.py @@ -0,0 +1,212 @@ +import requests +import logging +import re + +logger = logging.getLogger(__name__) + + +class LastFmAPI: + """Classe pour interagir avec l'API Last.fm""" + + def __init__(self, api_key=None): + self.api_key = api_key + self.base_url = "http://ws.audioscrobbler.com/2.0/" + self.session = requests.Session() + + def search_album(self, query): + """Recherche un album sur Last.fm""" + try: + clean_query = self._clean_music_title(query) + artist, album = self._extract_artist_album(clean_query) + + logger.info(f"🎵 Recherche Last.fm: Artiste='{artist}' Album='{album}'") + + if not album: + return None + + params = { + 'method': 'album.search', + 'album': album, + 'api_key': self.api_key, + 'format': 'json', + 'limit': 1 + } + + response = self.session.get(self.base_url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + results = data.get('results', {}).get('albummatches', {}).get('album', []) + + if results: + album_data = results[0] + return self.get_album_info(album_data['artist'], album_data['name']) + + return None + + except Exception as e: + logger.error(f"Erreur recherche album Last.fm: {e}") + return None + + def get_album_info(self, artist, album): + """Récupère les infos complètes d'un album""" + try: + params = { + 'method': 'album.getinfo', + 'artist': artist, + 'album': album, + 'api_key': self.api_key, + 'format': 'json' + } + + response = self.session.get(self.base_url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + # Vérifier si erreur Last.fm + if 'error' in data: + logger.debug(f"Last.fm error: {data.get('message', 'Unknown error')}") + return None + + album_info = data.get('album') + + # Vérifier que album_info est un dict et non une string + if album_info and isinstance(album_info, dict): + return self._format_album(album_info) + + return None + + except Exception as e: + logger.error(f"Erreur info album: {e}") + return None + + def _format_album(self, album): + """Formate les données d'un album""" + try: + # Récupérer la plus grande image disponible + images = album.get('image', []) + cover_url = None + + if isinstance(images, list): + for img in reversed(images): + if isinstance(img, dict) and img.get('size') in ['extralarge', 'large', 'medium']: + cover_url = img.get('#text') + if cover_url: + break + + # Récupérer les tags + tags_data = album.get('tags', {}) + tags = [] + if isinstance(tags_data, dict): + tag_list = tags_data.get('tag', []) + if isinstance(tag_list, list): + tags = [tag.get('name') for tag in tag_list[:5] if isinstance(tag, dict)] + + # Récupérer le wiki + wiki = album.get('wiki', {}) + summary = '' + published = '' + if isinstance(wiki, dict): + summary = wiki.get('summary', '') + published = wiki.get('published', '') + + return { + 'artist': album.get('artist', ''), + 'album': album.get('name', ''), + 'cover_url': cover_url, + 'summary': summary, + 'published': published, + 'listeners': album.get('listeners', 0), + 'playcount': album.get('playcount', 0), + 'tags': tags, + 'url': album.get('url', ''), + 'type': 'album' + } + except Exception as e: + logger.error(f"Erreur formatage album: {e}") + return None + + def _clean_music_title(self, title): + """Nettoie un titre de torrent musical""" + original = title + + # Supprimer les préfixes comme [Request] + title = re.sub(r'^\s*\[.*?\]\s*', '', title) + + # Remplacer les points et underscores par des espaces SAUF le tiret + title = title.replace('.', ' ').replace('_', ' ') + + # Supprimer les tags de qualité + title = re.sub(r'\b(FLAC|MP3|AAC|WAV|OGG|ALAC|DSD|WEB|CD|VINYL)\b', '', title, flags=re.IGNORECASE) + title = re.sub(r'\b(320|256|192|128|24bit|16bit)\s*(kbps|khz)?\b', '', title, flags=re.IGNORECASE) + title = re.sub(r'\b(CBR|VBR|Lossless)\b', '', title, flags=re.IGNORECASE) + + # Supprimer les infos de format entre parenthèses ou crochets à la fin + # mais garder le contenu principal + title = re.sub(r'\s*\([^)]*(?:FLAC|MP3|Lossless|kbps|Vinyl|CD|WEB)[^)]*\)\s*', '', title, flags=re.IGNORECASE) + title = re.sub(r'\s*\[[^\]]*(?:FLAC|MP3|Lossless|kbps|Vinyl|CD|WEB)[^\]]*\]\s*', '', title, flags=re.IGNORECASE) + + # Supprimer les années entre parenthèses (2025) ou à la fin + title = re.sub(r'\s*\(\s*(19|20)\d{2}\s*\)\s*', ' ', title) + title = re.sub(r'\s+(19|20)\d{2}\s*$', '', title) + + # Supprimer (EP), (Single), (Remaster), etc. + title = re.sub(r'\s*\(\s*(EP|Single|Remaster|Remastered|Deluxe|Edition|Upconvert)\s*\)\s*', '', title, flags=re.IGNORECASE) + + # Supprimer Discography, Anthology, etc. + title = re.sub(r'\s*[-–]\s*Discography.*$', '', title, flags=re.IGNORECASE) + title = re.sub(r'\s+Discography.*$', '', title, flags=re.IGNORECASE) + + # Supprimer les groupes de release à la fin (-GROUPE) + title = re.sub(r'\s*-\s*[A-Z0-9]{2,}$', '', title) + + # Supprimer les parenthèses/crochets incomplets (ex: "(20" ou "[FLA") + title = re.sub(r'\s*\([^)]*$', '', title) # Parenthèse ouvrante sans fermante + title = re.sub(r'\s*\[[^\]]*$', '', title) # Crochet ouvrant sans fermant + + # Supprimer les "..." à la fin + title = re.sub(r'\s*\.{2,}\s*$', '', title) + + # Nettoyer les espaces multiples + title = re.sub(r'\s+', ' ', title).strip() + + # Supprimer les tirets en début ou fin + title = re.sub(r'^-+\s*|\s*-+$', '', title) + + logger.debug(f"Music title cleaned: '{original[:50]}' → '{title}'") + + return title + + def _extract_artist_album(self, title): + """Extrait l'artiste et l'album du titre""" + # Chercher le séparateur " - " (artiste - album) + if ' - ' in title: + parts = title.split(' - ', 1) + artist = parts[0].strip() + album = parts[1].strip() + + # Si l'album est vide, utiliser le titre entier comme album + if not album: + return '', title.strip() + + return artist, album + + # Pas de séparateur trouvé - essayer de deviner + # Parfois le format est "Artiste Album" sans séparateur + return '', title.strip() + + def enrich_torrent(self, torrent_title): + """Enrichit un torrent musical avec les données Last.fm""" + try: + album_data = self.search_album(torrent_title) + + if album_data: + logger.info(f"✅ Album trouvé: {album_data['artist']} - {album_data['album']}") + return album_data + else: + logger.warning(f"❌ Album non trouvé: {torrent_title[:60]}") + return None + + except Exception as e: + logger.error(f"Erreur enrichissement musique: {e}") + return None \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7fdead3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,2945 @@ +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from functools import wraps +import logging +import os +import re +import json +import difflib +import secrets +from datetime import datetime, timedelta +from pathlib import Path + +# Version de l'application +APP_VERSION = "2.0.0" +try: + # Chercher VERSION dans plusieurs emplacements possibles + possible_paths = [ + Path(__file__).parent / 'VERSION', # /app/VERSION + Path(__file__).parent.parent / 'VERSION', # /VERSION + Path('/app/VERSION'), + Path('/VERSION'), + ] + for version_file in possible_paths: + if version_file.exists(): + APP_VERSION = version_file.read_text().strip() + break +except: + pass + +from config import Config +from indexer_manager import IndexerManager +from torrent_parser import TorrentParser +from tmdb_api import TMDbAPI +from lastfm_api import LastFmAPI +from rss_source import RSSManager + +# Module de sécurité +import security +from security import ( + hash_password, verify_password, is_password_hashed, migrate_password_if_needed, + rate_limiter, generate_csrf_token, validate_csrf_token, + get_security_headers, sanitize_input, get_client_ip, + load_security_config, save_security_config, log_security_event +) + +# Configuration du logging +log_level = os.getenv('LOG_LEVEL', 'INFO') +logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/app/logs/lycostorrent.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Initialisation Flask +app = Flask(__name__) +app.config['JSON_AS_ASCII'] = False + +# Constantes de sécurité +MAX_QUERY_LENGTH = 200 +MAX_TRACKERS = 50 +MAX_LIMIT = 100 + +# Configuration sécurité et authentification +app.secret_key = os.getenv('SECRET_KEY', secrets.token_hex(32)) +app.config['PERMANENT_SESSION_LIFETIME'] = int(os.getenv('SESSION_LIFETIME', 86400 * 7)) # 7 jours par défaut +app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true' +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + +# Identifiants (via variables d'environnement) +AUTH_USERNAME = os.getenv('AUTH_USERNAME', 'admin') +AUTH_PASSWORD_ENV = os.getenv('AUTH_PASSWORD', '') # Mot de passe depuis env + +# Charger ou migrer le mot de passe hashé +_security_config = load_security_config() +if AUTH_PASSWORD_ENV: + # Si le mot de passe env n'est pas hashé, le hasher et sauvegarder + if not is_password_hashed(AUTH_PASSWORD_ENV): + _hashed, _migrated = migrate_password_if_needed(AUTH_PASSWORD_ENV) + if _migrated: + _security_config['password_hash'] = _hashed + _security_config['password_migrated'] = True + _security_config['last_password_change'] = datetime.now().isoformat() + save_security_config(_security_config) + logger.info("🔐 Mot de passe migré vers format sécurisé (hashé)") + AUTH_PASSWORD_HASH = _hashed + else: + AUTH_PASSWORD_HASH = AUTH_PASSWORD_ENV +else: + AUTH_PASSWORD_HASH = _security_config.get('password_hash', '') + +# Pour compatibilité (ne jamais utiliser directement) +AUTH_PASSWORD = AUTH_PASSWORD_HASH +ADMIN_PASSWORD = AUTH_PASSWORD_HASH + + +# ============================================================ +# SÉCURITÉ & AUTHENTIFICATION +# ============================================================ + +def login_required(f): + """Décorateur pour protéger les routes - nécessite une connexion""" + @wraps(f) + def decorated_function(*args, **kwargs): + # Si pas de mot de passe configuré, pas d'auth requise + if not AUTH_PASSWORD_HASH: + return f(*args, **kwargs) + + # Vérifier la session + if not session.get('authenticated'): + if request.is_json: + return jsonify({'success': False, 'error': 'Authentification requise'}), 401 + return redirect(url_for('login')) + + # Vérifier l'expiration de session + if session.get('expires_at'): + if datetime.fromisoformat(session['expires_at']) < datetime.now(): + session.clear() + if request.is_json: + return jsonify({'success': False, 'error': 'Session expirée'}), 401 + return redirect(url_for('login')) + + return f(*args, **kwargs) + return decorated_function + + +def admin_required(f): + """Décorateur pour protéger les routes admin (alias de login_required)""" + return login_required(f) + + +@app.before_request +def check_rate_limit(): + """Vérifie le rate limiting avant chaque requête""" + # Exclure les ressources statiques + if request.path.startswith('/static/'): + return None + + ip = get_client_ip(request) + + # Vérifier si l'IP est rate-limitée + if rate_limiter.is_rate_limited(ip): + log_security_event('RATE_LIMITED', ip, f"Path: {request.path}") + return jsonify({'success': False, 'error': 'Trop de requêtes. Réessayez plus tard.'}), 429 + + return None + + +@app.after_request +def add_security_headers(response): + """Ajoute les headers de sécurité HTTP""" + headers = get_security_headers() + for header, value in headers.items(): + # Ne pas écraser si déjà défini + if header not in response.headers: + response.headers[header] = value + return response + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """Page de connexion sécurisée""" + # Si pas de mot de passe configuré, rediriger vers l'accueil + if not AUTH_PASSWORD_HASH: + return redirect(url_for('index')) + + # Si déjà connecté, rediriger vers l'accueil + if session.get('authenticated'): + return redirect(url_for('index')) + + ip = get_client_ip(request) + error = None + locked_message = None + + # Vérifier si l'IP est bloquée + is_locked, remaining = rate_limiter.is_locked_out(ip) + if is_locked: + locked_message = f"Trop de tentatives. Réessayez dans {remaining} secondes." + log_security_event('LOCKOUT_ACTIVE', ip) + return render_template('login.html', error=None, locked_message=locked_message) + + if request.method == 'POST': + username = sanitize_input(request.form.get('username', ''), 50) + password = request.form.get('password', '') + csrf_token = request.form.get('csrf_token', '') + + # Vérifier le token CSRF + if not validate_csrf_token(session.get('csrf_token'), csrf_token): + log_security_event('CSRF_INVALID', ip, f"User: {username}") + error = "Token de sécurité invalide. Rechargez la page." + # Générer un nouveau token + session['csrf_token'] = generate_csrf_token() + return render_template('login.html', error=error, csrf_token=session['csrf_token']) + + # Vérifier les identifiants + if username == AUTH_USERNAME and verify_password(password, AUTH_PASSWORD_HASH): + # Connexion réussie + session['authenticated'] = True + session['username'] = username + session['login_time'] = datetime.now().isoformat() + session['expires_at'] = (datetime.now() + timedelta(seconds=app.config['PERMANENT_SESSION_LIFETIME'])).isoformat() + session['ip'] = ip + session.permanent = True + + # Réinitialiser les tentatives échouées + rate_limiter.record_successful_login(ip) + + log_security_event('LOGIN_SUCCESS', ip, f"User: {username}") + logger.info(f"🔐 Connexion réussie: {username} depuis {ip}") + + # Rediriger vers la page demandée ou l'accueil + next_page = request.args.get('next', url_for('index')) + # Sécurité : ne pas rediriger vers des URLs externes + if not next_page.startswith('/'): + next_page = url_for('index') + return redirect(next_page) + else: + # Connexion échouée + rate_limiter.record_failed_attempt(ip, username) + log_security_event('LOGIN_FAILED', ip, f"User: {username}") + + # Vérifier si maintenant bloqué + is_locked, remaining = rate_limiter.is_locked_out(ip) + if is_locked: + locked_message = f"Trop de tentatives. Réessayez dans {remaining} secondes." + return render_template('login.html', error=None, locked_message=locked_message) + + error = "Identifiants incorrects" + + # Générer un token CSRF pour le formulaire + if 'csrf_token' not in session: + session['csrf_token'] = generate_csrf_token() + + return render_template('login.html', error=error, csrf_token=session.get('csrf_token'), locked_message=locked_message) + + +@app.route('/logout') +def logout(): + """Déconnexion""" + ip = get_client_ip(request) + username = session.get('username', 'unknown') + session.clear() + log_security_event('LOGOUT', ip, f"User: {username}") + logger.info(f"🔓 Déconnexion: {username}") + return redirect(url_for('login')) + + +# Routes legacy pour compatibilité +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + """Redirection vers le nouveau login""" + return redirect(url_for('login')) + + +@app.route('/admin/logout') +def admin_logout(): + """Redirection vers le nouveau logout""" + return redirect(url_for('logout')) + +# Initialisation des services +config = Config() +indexer_manager = IndexerManager(config) +parser = TorrentParser() +tmdb = TMDbAPI(config.tmdb_api_key) +lastfm = LastFmAPI(config.lastfm_api_key) +rss_manager = RSSManager() + +# Alias pour compatibilité avec le code existant +jackett = indexer_manager + +# Mapping des catégories Jackett +CATEGORIES = { + 'all': {'name': 'Tout', 'id': None}, + 'console': {'name': 'Console', 'id': '1000'}, + 'movies': {'name': 'Films', 'id': '2000'}, + 'audio': {'name': 'Audio', 'id': '3000'}, + 'pc': {'name': 'PC', 'id': '4000'}, + 'tv': {'name': 'Séries TV', 'id': '5000'}, + 'books': {'name': 'Livres', 'id': '7000'}, + 'other': {'name': 'Autre', 'id': '8000'}, +} + + +@app.route('/') +@login_required +def index(): + """Page principale de recherche""" + return render_template('index.html') + + +@app.route('/discover') +@login_required +def discover(): + """Page Découvrir - Nouveautés TMDb""" + # Vérifier si le module est activé + modules = load_modules_config() + if not modules.get('discover', False): + return redirect(url_for('index')) + return render_template('discover.html') + + +@app.route('/api/trackers', methods=['GET']) +@login_required +def get_trackers(): + """Récupère la liste des trackers disponibles (Jackett + Prowlarr + optionnellement RSS)""" + try: + trackers = indexer_manager.get_indexers() + + # Paramètre pour inclure les RSS (par défaut: non pour la recherche) + include_rss = request.args.get('include_rss', 'false').lower() == 'true' + + # Ajouter les flux RSS uniquement si demandé + if include_rss: + rss_feeds = rss_manager.get_feeds() + for feed in rss_feeds: + if feed.get('enabled', True): + trackers.append({ + 'id': f"rss:{feed['id']}", + 'name': feed['name'], + 'sources': ['rss'], + 'category': feed.get('category', 'all'), + 'type': 'rss' + }) + logger.info(f"✅ {len(trackers)} trackers récupérés (dont {len(rss_feeds)} RSS)") + else: + logger.info(f"✅ {len(trackers)} trackers récupérés (sans RSS)") + + return jsonify({ + 'success': True, + 'trackers': trackers, + 'sources': indexer_manager.sources_status + }) + except Exception as e: + logger.error(f"❌ Erreur récupération trackers: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/status', methods=['GET']) +@login_required +def get_status(): + """Retourne le statut des sources d'indexation""" + return jsonify({ + 'success': True, + 'sources': indexer_manager.sources_status, + 'has_any_source': indexer_manager.has_any_source + }) + + +@app.route('/api/modules', methods=['GET']) +def get_modules(): + """Récupère la configuration des modules activés""" + try: + modules = load_modules_config() + return jsonify({ + 'success': True, + 'modules': modules + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +@app.route('/api/admin/modules', methods=['GET']) +@login_required +def get_admin_modules(): + """Récupère la configuration des modules pour l'admin""" + try: + modules = load_modules_config() + return jsonify({ + 'success': True, + 'modules': modules + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +@app.route('/api/admin/modules', methods=['POST']) +@login_required +def save_admin_modules(): + """Sauvegarde la configuration des modules""" + try: + data = request.json + modules = data.get('modules', {}) + + save_modules_config(modules) + logger.info(f"✅ Modules sauvegardés: {modules}") + + return jsonify({ + 'success': True, + 'message': 'Modules sauvegardés' + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +def load_modules_config(): + """Charge la configuration des modules""" + import json + config_path = '/app/config/modules.json' + + default_modules = { + 'search': True, + 'latest': True, + 'discover': False + } + + try: + if os.path.exists(config_path): + with open(config_path, 'r') as f: + return json.load(f) + except: + pass + + return default_modules + + +def save_modules_config(modules): + """Sauvegarde la configuration des modules""" + import json + config_path = '/app/config/modules.json' + + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + with open(config_path, 'w') as f: + json.dump(modules, f, indent=2) + + +@app.route('/api/admin/discover-trackers', methods=['GET']) +@login_required +def get_discover_trackers(): + """Récupère la liste des trackers configurés pour Discover""" + try: + trackers = load_discover_trackers_config() + return jsonify({ + 'success': True, + 'trackers': trackers + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +@app.route('/api/admin/discover-trackers', methods=['POST']) +@login_required +def save_discover_trackers(): + """Sauvegarde la liste des trackers pour Discover""" + try: + data = request.json + trackers = data.get('trackers', []) + + save_discover_trackers_config(trackers) + logger.info(f"✅ Trackers Discover sauvegardés: {len(trackers)} trackers") + + return jsonify({ + 'success': True, + 'message': 'Trackers sauvegardés' + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +def load_discover_trackers_config(): + """Charge la configuration des trackers pour Discover""" + config_path = '/app/config/discover_trackers.json' + + try: + if os.path.exists(config_path): + with open(config_path, 'r') as f: + return json.load(f) + except: + pass + + return None # None = tous les trackers + + +def save_discover_trackers_config(trackers): + """Sauvegarde la configuration des trackers pour Discover""" + config_path = '/app/config/discover_trackers.json' + + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + with open(config_path, 'w') as f: + json.dump(trackers, f, indent=2) + + +@app.route('/api/categories', methods=['GET']) +@login_required +def get_categories(): + """Récupère la liste des catégories disponibles""" + return jsonify({ + 'success': True, + 'categories': CATEGORIES + }) + + +# ============================================================ +# API DISCOVER - TMDb +# ============================================================ + +@app.route('/api/discover/', methods=['GET']) +@login_required +def discover_category(category): + """Récupère les films/séries depuis TMDb selon la catégorie (nouvelle version simplifiée)""" + try: + if not config.tmdb_api_key: + return jsonify({ + 'success': False, + 'error': 'Clé API TMDb non configurée' + }) + + import requests + from datetime import datetime, timedelta + + base_url = 'https://api.themoviedb.org/3' + + if category == 'movies': + # Films récents : cinéma + streaming < 3 mois + results = _fetch_recent_movies() + media_type = 'movie' + elif category == 'tv': + # Séries populaires en cours + results = _fetch_popular_tv() + media_type = 'tv' + else: + return jsonify({ + 'success': False, + 'error': 'Catégorie invalide. Utilisez "movies" ou "tv"' + }) + + return jsonify({ + 'success': True, + 'results': results, + 'media_type': media_type, + 'total': len(results) + }) + + except Exception as e: + logger.error(f"Erreur Discover TMDb: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +def _fetch_recent_movies(): + """Récupère les films récents : cinéma + streaming < 3 mois""" + import requests + from datetime import datetime, timedelta + + base_url = 'https://api.themoviedb.org/3' + params = { + 'api_key': config.tmdb_api_key, + 'language': 'fr-FR', + 'region': 'FR' + } + + all_movies = [] + seen_ids = set() + + # Date limite : 3 mois en arrière + three_months_ago = (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d') + today = datetime.now().strftime('%Y-%m-%d') + + # 1. Films au cinéma (now_playing) + try: + response = requests.get(f'{base_url}/movie/now_playing', params=params, timeout=10) + if response.status_code == 200: + data = response.json() + for movie in data.get('results', [])[:20]: + if movie['id'] not in seen_ids: + movie['source'] = 'cinema' + all_movies.append(movie) + seen_ids.add(movie['id']) + except Exception as e: + logger.warning(f"Erreur now_playing: {e}") + + # 2. Sorties streaming récentes (discover avec filtres) + try: + discover_params = { + **params, + 'sort_by': 'popularity.desc', + 'with_release_type': '4|5|6', # 4=Digital, 5=Physical, 6=TV + 'release_date.gte': three_months_ago, + 'release_date.lte': today, + 'vote_count.gte': 10 # Au moins quelques votes pour filtrer les obscurs + } + response = requests.get(f'{base_url}/discover/movie', params=discover_params, timeout=10) + if response.status_code == 200: + data = response.json() + for movie in data.get('results', [])[:30]: + if movie['id'] not in seen_ids: + movie['source'] = 'streaming' + all_movies.append(movie) + seen_ids.add(movie['id']) + except Exception as e: + logger.warning(f"Erreur discover streaming: {e}") + + # Trier par popularité + all_movies.sort(key=lambda x: x.get('popularity', 0), reverse=True) + + logger.info(f"📽️ Films récents: {len(all_movies)} (cinéma + streaming < 3 mois)") + + return all_movies[:30] + + +def _fetch_popular_tv(): + """Récupère les séries populaires en cours de diffusion""" + import requests + + base_url = 'https://api.themoviedb.org/3' + params = { + 'api_key': config.tmdb_api_key, + 'language': 'fr-FR' + } + + all_series = [] + seen_ids = set() + + # 1. Séries en cours de diffusion (on_the_air) + try: + response = requests.get(f'{base_url}/tv/on_the_air', params=params, timeout=10) + if response.status_code == 200: + data = response.json() + for serie in data.get('results', [])[:20]: + if serie['id'] not in seen_ids: + serie['source'] = 'on_air' + all_series.append(serie) + seen_ids.add(serie['id']) + except Exception as e: + logger.warning(f"Erreur on_the_air: {e}") + + # 2. Séries populaires (pour compléter) + try: + response = requests.get(f'{base_url}/tv/popular', params=params, timeout=10) + if response.status_code == 200: + data = response.json() + for serie in data.get('results', [])[:20]: + if serie['id'] not in seen_ids: + serie['source'] = 'popular' + all_series.append(serie) + seen_ids.add(serie['id']) + except Exception as e: + logger.warning(f"Erreur tv/popular: {e}") + + # Trier par popularité + all_series.sort(key=lambda x: x.get('popularity', 0), reverse=True) + + logger.info(f"📺 Séries en cours: {len(all_series)}") + + return all_series[:30] + + +@app.route('/api/discover/detail//', methods=['GET']) +@login_required +def discover_detail(media_type, tmdb_id): + """Récupère les détails d'un film/série depuis TMDb avec bande-annonce""" + try: + if not config.tmdb_api_key: + return jsonify({ + 'success': False, + 'error': 'Clé API TMDb non configurée' + }) + + import requests + + if media_type not in ['movie', 'tv']: + return jsonify({ + 'success': False, + 'error': 'Type de média invalide' + }) + + # Récupérer les détails avec les vidéos + url = f'https://api.themoviedb.org/3/{media_type}/{tmdb_id}' + params = { + 'api_key': config.tmdb_api_key, + 'language': 'fr-FR', + 'append_to_response': 'videos' + } + + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + detail = response.json() + + # Chercher la bande-annonce YouTube + trailer_url = None + videos = detail.get('videos', {}).get('results', []) + + # Priorité : trailer FR > trailer EN > teaser + for video_type in ['Trailer', 'Teaser']: + for video in videos: + if video.get('site') == 'YouTube' and video.get('type') == video_type: + trailer_url = f"https://www.youtube.com/embed/{video.get('key')}" + break + if trailer_url: + break + + # Si pas de vidéo FR, chercher en anglais + if not trailer_url: + url_en = f'https://api.themoviedb.org/3/{media_type}/{tmdb_id}/videos' + params_en = { + 'api_key': config.tmdb_api_key, + 'language': 'en-US' + } + try: + response_en = requests.get(url_en, params=params_en, timeout=5) + if response_en.ok: + videos_en = response_en.json().get('results', []) + for video_type in ['Trailer', 'Teaser']: + for video in videos_en: + if video.get('site') == 'YouTube' and video.get('type') == video_type: + trailer_url = f"https://www.youtube.com/embed/{video.get('key')}" + break + if trailer_url: + break + except: + pass + + detail['trailer_url'] = trailer_url + + return jsonify({ + 'success': True, + 'detail': detail + }) + + except Exception as e: + logger.error(f"Erreur détail TMDb: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +@app.route('/api/discover/search-torrents', methods=['POST']) +@login_required +def discover_search_torrents(): + """Recherche des torrents pour un film/série depuis la page Discover""" + try: + data = request.json + title = data.get('title', '') + original_title = data.get('original_title', '') + year = data.get('year', '') + media_type = data.get('media_type', 'movie') + tmdb_id = data.get('tmdb_id', '') + + if not title and not original_title: + return jsonify({ + 'success': False, + 'error': 'Requête vide' + }) + + # Catégorie Jackett selon le type + cat_id = '2000' if media_type == 'movie' else '5000' + + # Construire les requêtes de recherche à essayer + # On va essayer TOUTES les requêtes pour maximiser les résultats + search_queries = [] + + # Titre original (souvent en anglais) + if original_title and _is_latin_text(original_title): + if year: + search_queries.append(f"{original_title} {year}") + search_queries.append(original_title) + + # Titre localisé (français) + if title and _is_latin_text(title) and title != original_title: + if year: + search_queries.append(f"{title} {year}") + search_queries.append(title) + + # Si aucun titre latin, essayer quand même + if not search_queries: + if original_title: + search_queries.append(original_title) + if title and title != original_title: + search_queries.append(title) + + logger.info(f"🔍 Discover search: queries={search_queries} (type: {media_type})") + + # Recherche sur les trackers configurés + from concurrent.futures import ThreadPoolExecutor, as_completed + + all_trackers = indexer_manager.get_indexers() + + # Filtrer par les trackers configurés pour Discover + configured_trackers = load_discover_trackers_config() + if configured_trackers is not None: + all_trackers = [t for t in all_trackers if t.get('id') in configured_trackers] + logger.info(f"🎯 Discover: {len(all_trackers)} trackers configurés") + + all_results = [] + seen_titles = set() # Pour éviter les doublons + + def search_tracker_with_query(tracker, query): + try: + tracker_id = tracker.get('id', '') + results = jackett.search(query, indexers=[tracker_id], category=cat_id, max_results=30) + return results + except: + return [] + + # Essayer TOUTES les requêtes pour maximiser les résultats + for search_query in search_queries: + logger.info(f"🔍 Essai avec: '{search_query}'") + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = {executor.submit(search_tracker_with_query, t, search_query): t for t in all_trackers} + + try: + for future in as_completed(futures, timeout=20): + try: + results = future.result(timeout=1) + for r in results: + # Éviter les doublons + title_key = r.get('Title', '').lower() + if title_key not in seen_titles: + seen_titles.add(title_key) + all_results.append(r) + except Exception: + pass + except TimeoutError: + # Timeout global - on continue avec les résultats qu'on a déjà + logger.warning(f"⏱️ Timeout recherche Discover, {len(all_results)} résultats récupérés") + pass + + # Si on a assez de résultats, on peut s'arrêter + if len(all_results) >= 30: + logger.info(f"✅ Assez de résultats ({len(all_results)}), arrêt des recherches") + break + + # Parser et enrichir les résultats + for torrent in all_results: + parser.enrich_torrent(torrent) + + # Filtrer les résultats pour ne garder que ceux qui correspondent au titre + filtered_results = _filter_relevant_torrents(all_results, title, original_title, year) + + # Trier par seeders + filtered_results.sort(key=lambda x: x.get('Seeders', 0) or 0, reverse=True) + + logger.info(f"✅ Discover: {len(filtered_results)}/{len(all_results)} torrents pertinents") + + return jsonify({ + 'success': True, + 'results': filtered_results[:30], # Limiter à 30 + 'total': len(filtered_results) + }) + + except Exception as e: + logger.error(f"Erreur recherche torrents Discover: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +def _filter_relevant_torrents(torrents, title, original_title, year): + """Filtre les torrents pour ne garder que ceux qui correspondent au titre recherché""" + if not torrents: + return [] + + # Préparer les titres de référence (normalisés) + ref_titles = [] + if title: + ref_titles.append(_normalize_for_matching(title)) + if original_title and original_title != title: + ref_titles.append(_normalize_for_matching(original_title)) + + if not ref_titles: + return torrents + + # Extraire les mots significatifs (4+ caractères, pas les mots communs) + stop_words = {'the', 'les', 'der', 'das', 'die', 'and', 'for', 'with', 'dans', 'pour', 'avec', + 'from', 'that', 'this', 'une', 'des', 'aux', 'sur', 'par', 'tout', 'tous'} + + significant_words = set() + for ref in ref_titles: + words = ref.split() + for w in words: + if len(w) >= 4 and w not in stop_words: + significant_words.add(w) + + # Détecter les numéros de suite (2, 3, II, III, etc.) dans le titre + sequel_number = None + for ref in ref_titles: + # Chercher un chiffre ou chiffre romain à la fin ou après le titre principal + match = re.search(r'\b([2-9]|ii|iii|iv|v|vi|vii|viii|ix|x)\b', ref, re.IGNORECASE) + if match: + sequel_number = match.group(1).lower() + break + + # Si pas de mots significatifs, prendre le premier mot de 3+ caractères + if not significant_words: + for ref in ref_titles: + words = [w for w in ref.split() if len(w) >= 3 and w not in stop_words] + if words: + significant_words.add(words[0]) + break + + # Calculer le nombre minimum de mots à matcher + num_significant = len(significant_words) + if num_significant <= 1: + min_matches = 1 + elif num_significant <= 3: + min_matches = 2 + else: + min_matches = max(2, num_significant // 2) + + logger.info(f"🔍 Filtrage: titres={ref_titles}, mots_clés={significant_words}, min_requis={min_matches}, année={year}, suite={sequel_number}") + + if not significant_words: + return torrents + + relevant = [] + + for torrent in torrents: + torrent_title = torrent.get('Title', '') + if not torrent_title: + continue + + torrent_normalized = _normalize_for_matching(torrent_title) + + # Compter combien de mots significatifs sont présents + matches = 0 + for word in significant_words: + if word in torrent_normalized: + matches += 1 + + # Vérifier le nombre minimum de matches + if matches < min_matches: + continue + + # Si c'est une suite (2, 3, etc.), vérifier que le numéro est présent + if sequel_number: + # Convertir les chiffres romains en arabes pour la comparaison + roman_to_arabic = {'ii': '2', 'iii': '3', 'iv': '4', 'v': '5', 'vi': '6', 'vii': '7', 'viii': '8', 'ix': '9', 'x': '10'} + sequel_variants = [sequel_number] + if sequel_number in roman_to_arabic: + sequel_variants.append(roman_to_arabic[sequel_number]) + elif sequel_number.isdigit(): + # Ajouter la version romaine + arabic_to_roman = {'2': 'ii', '3': 'iii', '4': 'iv', '5': 'v', '6': 'vi', '7': 'vii', '8': 'viii', '9': 'ix', '10': 'x'} + if sequel_number in arabic_to_roman: + sequel_variants.append(arabic_to_roman[sequel_number]) + + has_sequel_number = False + for variant in sequel_variants: + if re.search(rf'\b{variant}\b', torrent_normalized, re.IGNORECASE): + has_sequel_number = True + break + + if not has_sequel_number: + continue + + # Vérifier l'année si disponible (tolérance de 1 an) + if year: + try: + search_year = int(year) + # Extraire l'année du torrent + year_match = re.search(r'\b(19\d{2}|20\d{2})\b', torrent_title) + if year_match: + torrent_year = int(year_match.group(1)) + # Si les années sont trop différentes (plus de 1 an), exclure + if abs(torrent_year - search_year) > 1: + continue + except (ValueError, TypeError): + pass + + relevant.append(torrent) + + if not relevant: + logger.info(f"ℹ️ Aucun torrent pertinent trouvé pour: {ref_titles}") + else: + logger.info(f"✅ Filtrage: {len(relevant)}/{len(torrents)} torrents pertinents") + + return relevant + + +def _normalize_for_matching(text): + """Normalise un texte pour la comparaison""" + if not text: + return '' + # Minuscules, supprimer accents et caractères spéciaux + import unicodedata + text = unicodedata.normalize('NFD', text) + text = ''.join(c for c in text if unicodedata.category(c) != 'Mn') + text = text.lower() + text = re.sub(r'[^a-z0-9\s]', ' ', text) + text = re.sub(r'\s+', ' ', text).strip() + return text + + +def _is_latin_text(text): + """Vérifie si le texte utilise principalement des caractères latins""" + if not text: + return False + latin_count = sum(1 for c in text if c.isascii() or c in 'àâäéèêëïîôùûüÿœæçÀÂÄÉÈÊËÏÎÔÙÛÜŸŒÆÇ') + return latin_count / len(text) > 0.5 + + +@app.route('/api/search', methods=['POST']) +@login_required +def search(): + """ + Recherche sur Jackett et retourne les résultats avec métadonnées parsées. + Utilise des requêtes parallèles pour accélérer la recherche. + Le filtrage se fait côté frontend. + """ + try: + data = request.json + if not data: + return jsonify({ + 'success': False, + 'error': 'Données JSON invalides' + }), 400 + + query = data.get('query', '').strip() + trackers = data.get('trackers', []) + category = data.get('category', 'all') + + # Validation renforcée + if not query: + return jsonify({ + 'success': False, + 'error': 'Requête de recherche vide' + }), 400 + + if len(query) > MAX_QUERY_LENGTH: + return jsonify({ + 'success': False, + 'error': f'Requête trop longue (max {MAX_QUERY_LENGTH} caractères)' + }), 400 + + if not trackers: + return jsonify({ + 'success': False, + 'error': 'Aucun tracker sélectionné' + }), 400 + + if not isinstance(trackers, list) or len(trackers) > MAX_TRACKERS: + return jsonify({ + 'success': False, + 'error': f'Nombre de trackers invalide (max {MAX_TRACKERS})' + }), 400 + + # Valider que la catégorie existe + if category not in CATEGORIES: + category = 'all' + + logger.info(f"🔍 Recherche: '{query}' | Catégorie: {category} | Trackers: {len(trackers)}") + + # Récupérer l'ID de catégorie Jackett + cat_id = CATEGORIES.get(category, {}).get('id') + + # ============================================================ + # REQUÊTES PARALLÈLES PAR TRACKER + # ============================================================ + from concurrent.futures import ThreadPoolExecutor, as_completed + import time + + start_time = time.time() + all_results = [] + + def search_tracker(tracker): + """Recherche sur un tracker spécifique""" + try: + results = jackett.search(query, indexers=[tracker], category=cat_id, max_results=500) + logger.info(f"✅ {tracker}: {len(results)} résultats") + return results + except Exception as e: + logger.warning(f"⚠️ Erreur {tracker}: {e}") + return [] + + # Exécuter les recherches en parallèle + max_workers = min(10, len(trackers)) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(search_tracker, t): t for t in trackers} + + for future in as_completed(futures, timeout=120): + tracker = futures[future] + try: + results = future.result() + all_results.extend(results) + except Exception as e: + logger.warning(f"⚠️ Erreur {tracker}: {e}") + + elapsed = time.time() - start_time + logger.info(f"📦 Recherche parallèle: {len(all_results)} résultats bruts (en {elapsed:.2f}s)") + + # Parser chaque torrent pour extraire les métadonnées + for torrent in all_results: + parser.enrich_torrent(torrent) + + # Déduplication : garder le torrent avec le plus de seeders pour chaque titre + seen = {} + for torrent in all_results: + # Créer une clé normalisée pour comparer les titres + title = torrent.get('Title', '') + key = _normalize_title(title) + + current_seeders = torrent.get('Seeders', 0) or 0 + + if key not in seen: + seen[key] = torrent + else: + existing_seeders = seen[key].get('Seeders', 0) or 0 + if current_seeders > existing_seeders: + seen[key] = torrent + + unique_results = list(seen.values()) + + # Trier par seeders décroissant + unique_results.sort(key=lambda x: x.get('Seeders', 0) or 0, reverse=True) + + logger.info(f"✅ Recherche terminée: {len(unique_results)} résultats uniques") + + return jsonify({ + 'success': True, + 'results': unique_results, + 'total': len(unique_results), + 'query': query + }) + + except Exception as e: + logger.error(f"❌ Erreur recherche: {e}", exc_info=True) + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +def _normalize_title(title): + """Normalise un titre pour la comparaison/déduplication""" + import re + # Minuscules + title = title.lower() + # Remplacer les séparateurs par des espaces + title = re.sub(r'[.\-_]', ' ', title) + # Supprimer les espaces multiples + title = re.sub(r'\s+', ' ', title) + return title.strip() + + +@app.route('/health', methods=['GET']) +def health(): + """Endpoint de santé pour Docker""" + return jsonify({ + 'status': 'healthy', + 'version': APP_VERSION, + 'timestamp': datetime.now().isoformat() + }) + + +@app.route('/api/version', methods=['GET']) +def get_version(): + """Retourne la version de l'application""" + return jsonify({ + 'success': True, + 'version': APP_VERSION, + 'name': 'Lycostorrent' + }) + + +@app.route('/latest') +@login_required +def latest(): + """Page des nouveautés""" + return render_template('latest.html') + + +@app.route('/api/indexers', methods=['GET']) +@login_required +def get_indexers(): + """Récupère la liste des indexers (alias pour /api/trackers)""" + return get_trackers() + + +@app.route('/api/latest', methods=['POST']) +@login_required +def get_latest(): + """Récupère les dernières sorties avec enrichissement TMDb/Last.fm""" + try: + data = request.json + if not data: + return jsonify({ + 'success': False, + 'error': 'Données JSON invalides' + }), 400 + + trackers = data.get('trackers', []) + category = data.get('category', 'video') + limit = data.get('limit', 20) + + # Validation renforcée + if not isinstance(trackers, list) or len(trackers) > MAX_TRACKERS: + return jsonify({ + 'success': False, + 'error': f'Nombre de trackers invalide (max {MAX_TRACKERS})' + }), 400 + + # Valider et limiter le nombre de résultats + try: + limit = int(limit) + limit = min(max(1, limit), MAX_LIMIT) + except (ValueError, TypeError): + limit = 20 + + # Valider la catégorie + valid_categories = ['video', 'movies', 'tv', 'anime', 'music'] + if category not in valid_categories: + category = 'video' + + logger.info(f"📥 Nouveautés: catégorie={category}, limite={limit}, trackers={len(trackers)}") + + # Séparer les trackers Jackett/Prowlarr des flux RSS + indexer_trackers = [t for t in trackers if not t.startswith('rss:')] + rss_trackers = [t.replace('rss:', '') for t in trackers if t.startswith('rss:')] + + # Charger la configuration admin + latest_config = _load_latest_config() + + # Catégories par défaut + default_categories = { + 'video': '2000,5000', + 'movies': '2000', + 'tv': '5000', + 'anime': '5070', + 'music': '3000' + } + + all_results = [] + + # ============================================================ + # REQUÊTES PARALLÈLES + # ============================================================ + from concurrent.futures import ThreadPoolExecutor, as_completed + import time + + start_time = time.time() + + def fetch_tracker(tracker): + """Fonction pour récupérer les résultats d'un tracker""" + tracker_config = latest_config.get(tracker, {}) + cat_id = tracker_config.get(category) or default_categories.get(category, '2000,5000') + + logger.info(f"🔍 {tracker}: catégorie {category} → IDs {cat_id}") + + try: + results = jackett.search('', indexers=[tracker], category=cat_id, max_results=limit * 2) + logger.info(f"✅ {tracker}: {len(results)} résultats") + return results + except Exception as e: + logger.warning(f"⚠️ Erreur {tracker}: {e}") + return [] + + def fetch_rss(rss_id): + """Fonction pour récupérer les résultats d'un flux RSS""" + try: + for feed in rss_manager.feeds: + if feed.get('id') == rss_id: + logger.info(f"📡 RSS {feed['name']}: récupération...") + rss_results = rss_manager.rss_source.fetch_feed(feed, max_results=limit * 2) + if rss_results: + logger.info(f"✅ RSS {feed['name']}: {len(rss_results)} résultats") + return rss_results + return [] + except Exception as e: + logger.warning(f"⚠️ Erreur RSS {rss_id}: {e}") + return [] + + # Exécuter toutes les requêtes en parallèle + max_workers = min(10, len(indexer_trackers) + len(rss_trackers)) # Max 10 threads + + if max_workers > 0: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {} + + # Soumettre les requêtes trackers + for tracker in indexer_trackers: + future = executor.submit(fetch_tracker, tracker) + futures[future] = f"tracker:{tracker}" + + # Soumettre les requêtes RSS + for rss_id in rss_trackers: + future = executor.submit(fetch_rss, rss_id) + futures[future] = f"rss:{rss_id}" + + # Récupérer les résultats au fur et à mesure + for future in as_completed(futures, timeout=60): + source = futures[future] + try: + results = future.result() + if results: + all_results.extend(results) + except Exception as e: + logger.warning(f"⚠️ Erreur {source}: {e}") + + elapsed = time.time() - start_time + logger.info(f"📦 Total: {len(all_results)} résultats bruts (en {elapsed:.2f}s)") + + if not all_results: + return jsonify({ + 'success': True, + 'results': [], + 'total': 0 + }) + + # Filtrer les animes/documentaires si catégorie = tv + if category == 'tv': + all_results = _filter_tv_results(all_results) + + # Trier par date + all_results.sort(key=lambda x: x.get('PublishDateRaw', ''), reverse=True) + + # Regrouper les torrents similaires + grouped = _group_similar_torrents(all_results) + + # Enrichir avec TMDb ou Last.fm + enriched = [] + for group in grouped[:limit]: + main_torrent = group['torrents'][0] + + if category == 'music': + music_data = lastfm.enrich_torrent(main_torrent['Title']) + if music_data: + group['music'] = music_data + group['is_music'] = True + else: + cat = 'movie' if category == 'movies' else ('tv' if category in ['tv', 'anime'] else None) + tmdb_data = tmdb.enrich_torrent(main_torrent['Title'], cat) + if tmdb_data: + group['tmdb'] = tmdb_data + if category == 'anime': + group['is_anime'] = True + + enriched.append(group) + + logger.info(f"✅ {len(enriched)} nouveautés enrichies") + + return jsonify({ + 'success': True, + 'results': enriched, + 'total': len(enriched) + }) + + except Exception as e: + logger.error(f"❌ Erreur nouveautés: {e}", exc_info=True) + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +def _filter_tv_results(results): + """Filtre les résultats TV pour exclure animes/documentaires""" + filtered = [] + + anime_patterns = [ + r'\bintégrale\b', + r'\bcomplete\b|\bintegral\b', + r'(?:subfrench|vostfr).*(?:1080p|720p)', + r'(?:adn|crunchyroll)', + ] + + doc_patterns = [ + r'\bdocument', + r'\bsport', + r'\brugby\b|\bfootball\b|\bfoot\b', + r'\bligue\b|\bcoupe\b', + ] + + for r in results: + title_lower = r.get('Title', '').lower() + cat_str = str(r.get('Category', [])).lower() + + is_anime = any(re.search(p, title_lower) for p in anime_patterns) + is_doc = any(re.search(p, title_lower) for p in doc_patterns) + is_anime_cat = 'anime' in cat_str + + if not (is_anime or is_doc or is_anime_cat): + filtered.append(r) + + logger.info(f"📺 Filtrage TV: {len(filtered)}/{len(results)} conservés") + return filtered + + +def _group_similar_torrents(torrents): + """Regroupe les torrents similaires par titre""" + groups = [] + used_indices = set() + + for i, torrent in enumerate(torrents): + if i in used_indices: + continue + + group = { + 'torrents': [torrent], + 'title': torrent['Title'] + } + used_indices.add(i) + + base_title_1, year_1 = _extract_base_title_and_year(torrent['Title']) + + for j, other in enumerate(torrents[i+1:], start=i+1): + if j in used_indices: + continue + + base_title_2, year_2 = _extract_base_title_and_year(other['Title']) + + # Si les deux ont des années différentes, ne pas grouper + if year_1 and year_2 and year_1 != year_2: + continue + + # Comparer les titres de base + base_similarity = difflib.SequenceMatcher(None, base_title_1, base_title_2).ratio() + + # Seuil strict pour éviter les faux positifs + if base_similarity > 0.85: + group['torrents'].append(other) + used_indices.add(j) + + # Trier par seeders + group['torrents'].sort(key=lambda x: x.get('Seeders', 0) or 0, reverse=True) + group['title'] = group['torrents'][0]['Title'] + groups.append(group) + + # Trier par date + groups.sort(key=lambda g: g['torrents'][0].get('PublishDateRaw', ''), reverse=True) + + logger.info(f"📦 Regroupement: {len(torrents)} → {len(groups)} groupes") + return groups + + +def _extract_base_title_and_year(title): + """Extrait le titre de base et l'année""" + # Supprimer l'extension + title = re.sub(r'\.(mkv|avi|mp4|torrent)$', '', title, flags=re.IGNORECASE) + + # Chercher l'année (1900-2099) - avec ou sans parenthèses + year_match = re.search(r'[\.\s\-_\(](19\d{2}|20\d{2})[\.\s\-_\)]', title) + year = year_match.group(1) if year_match else None + + # Chercher où commencent les métadonnées (après l'année ou qualité) + # Pattern pour année avec ou sans parenthèses + match = re.search(r'[\.\s\-_\(]+(19|20)\d{2}[\.\s\-_\)]', title, flags=re.IGNORECASE) + + if match: + base_title = title[:match.start()] + else: + # Sinon chercher la qualité + match = re.search(r'[\.\s\-_]+(720p|1080p|2160p|4K|HDTV|WEB|BluRay|BDRip|DVDRip|FRENCH|MULTi|VFi|VOSTFR)', title, flags=re.IGNORECASE) + if match: + base_title = title[:match.start()] + else: + base_title = title + + # Nettoyer le titre + base_title = base_title.replace('.', ' ').replace('_', ' ').replace('-', ' ') + base_title = re.sub(r'\s+', ' ', base_title).strip().lower() + + return base_title, year + + +def _extract_base_title(title): + """Extrait le titre de base avant les métadonnées (année, qualité, etc.)""" + base_title, _ = _extract_base_title_and_year(title) + return base_title + + +def _clean_title_for_comparison(title): + """Nettoie un titre pour la comparaison""" + title = re.sub(r'[Ss]\d{2}[Ee]\d{2}.*', '', title) + title = re.sub(r'[Ss]\d{2}.*', '', title) + title = re.sub(r'\b(19|20)\d{2}\b', '', title) + + patterns = [ + r'\b1080p\b', r'\b720p\b', r'\b2160p\b', r'\b480p\b', + r'\b4K\b', r'\bUHD\b', r'\bHEVC\b', r'\bx264\b', r'\bx265\b', + r'\bBluRay\b', r'\bWEB\b', r'\bFRENCH\b', r'\bMULTI\b' + ] + + for pattern in patterns: + title = re.sub(pattern, '', title, flags=re.IGNORECASE) + + title = re.sub(r'-[A-Z0-9]+$', '', title, flags=re.IGNORECASE) + title = title.replace('.', ' ').replace('_', ' ').replace('-', ' ') + title = re.sub(r'\s+', ' ', title).strip().lower() + + return title + + +# ============================================================ +# ADMINISTRATION - Catégories Latest +# ============================================================ + +LATEST_CONFIG_PATH = '/app/config/latest_categories.json' +PARSING_TAGS_PATH = '/app/config/parsing_tags.json' + + +def _load_latest_config(): + """Charge la configuration des catégories pour les nouveautés""" + try: + if os.path.exists(LATEST_CONFIG_PATH): + with open(LATEST_CONFIG_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"Erreur chargement config latest: {e}") + return {} + + +def _save_latest_config(config_data): + """Sauvegarde la configuration des catégories""" + try: + os.makedirs(os.path.dirname(LATEST_CONFIG_PATH), exist_ok=True) + with open(LATEST_CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump(config_data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"Erreur sauvegarde config latest: {e}") + return False + + +@app.route('/admin') +@admin_required +def admin(): + """Page d'administration unifiée""" + return render_template('admin.html') + + +@app.route('/admin/latest') +@admin_required +def admin_latest(): + """Page d'administration des catégories pour les nouveautés""" + return render_template('admin_latest.html') + + +@app.route('/api/admin/latest-config', methods=['GET']) +@admin_required +def get_latest_config(): + """Récupère la configuration des catégories latest""" + try: + config_data = _load_latest_config() + return jsonify({ + 'success': True, + 'config': config_data + }) + except Exception as e: + logger.error(f"Erreur récupération config: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/latest-config', methods=['POST']) +@admin_required +def save_latest_config(): + """Sauvegarde la configuration des catégories latest""" + try: + data = request.json + config_data = data.get('config', {}) + + tracker_id = config_data.get('tracker') + categories = config_data.get('categories', {}) + + if not tracker_id: + return jsonify({ + 'success': False, + 'error': 'Tracker ID requis' + }), 400 + + # Charger la config existante + existing_config = _load_latest_config() + + # Mettre à jour la config pour ce tracker + existing_config[tracker_id] = categories + + # Sauvegarder + if _save_latest_config(existing_config): + logger.info(f"✅ Configuration sauvegardée pour {tracker_id}") + return jsonify({ + 'success': True, + 'message': 'Configuration sauvegardée' + }) + else: + return jsonify({ + 'success': False, + 'error': 'Erreur lors de la sauvegarde' + }), 500 + except Exception as e: + logger.error(f"Erreur sauvegarde config: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/tracker-categories/', methods=['GET']) +@admin_required +def get_tracker_categories(tracker_id): + """Récupère les catégories disponibles pour un tracker (Jackett ou Prowlarr)""" + try: + categories = indexer_manager.get_indexer_categories(tracker_id) + + logger.info(f"✅ {len(categories)} catégories trouvées pour {tracker_id}") + + return jsonify({ + 'success': True, + 'categories': categories + }) + + except Exception as e: + logger.error(f"Erreur récupération catégories {tracker_id}: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'categories': [] + }), 500 + + +@app.route('/api/admin/tracker-categories', methods=['GET']) +@admin_required +def get_tracker_categories_query(): + """Récupère les catégories via query param""" + tracker_id = request.args.get('tracker', '') + if not tracker_id: + return jsonify({'success': False, 'error': 'Tracker requis', 'categories': []}), 400 + return get_tracker_categories(tracker_id) + + +# ============================================================ +# ADMINISTRATION - Tags de parsing +# ============================================================ + +@app.route('/admin/parsing') +@admin_required +def admin_parsing(): + """Page d'administration des tags de parsing""" + return render_template('admin_parsing.html') + + +@app.route('/api/admin/parsing-tags', methods=['GET']) +@admin_required +def get_parsing_tags(): + """Récupère les tags de parsing actuels""" + try: + from tmdb_api import _load_parsing_tags + tags = _load_parsing_tags() + return jsonify({ + 'success': True, + 'tags': tags + }) + except Exception as e: + logger.error(f"Erreur récupération tags: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/parsing-tags', methods=['POST']) +@admin_required +def save_parsing_tags(): + """Sauvegarde les tags de parsing""" + try: + from tmdb_api import _save_parsing_tags + data = request.json + tags = data.get('tags', []) + + # Nettoyer les tags (supprimer les vides, les espaces) + tags = [tag.strip() for tag in tags if tag.strip()] + + if _save_parsing_tags(tags): + logger.info(f"✅ {len(tags)} tags de parsing sauvegardés") + return jsonify({ + 'success': True, + 'message': f'{len(tags)} tags sauvegardés' + }) + else: + return jsonify({ + 'success': False, + 'error': 'Erreur lors de la sauvegarde' + }), 500 + except Exception as e: + logger.error(f"Erreur sauvegarde tags: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/parsing-tags/reset', methods=['POST']) +@admin_required +def reset_parsing_tags(): + """Réinitialise les tags de parsing aux valeurs par défaut""" + try: + from tmdb_api import _save_parsing_tags, DEFAULT_PARSING_TAGS + + if _save_parsing_tags(DEFAULT_PARSING_TAGS): + logger.info("✅ Tags de parsing réinitialisés") + return jsonify({ + 'success': True, + 'tags': DEFAULT_PARSING_TAGS + }) + else: + return jsonify({ + 'success': False, + 'error': 'Erreur lors de la réinitialisation' + }), 500 + except Exception as e: + logger.error(f"Erreur reset tags: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/test-parsing', methods=['POST']) +@admin_required +def test_parsing(): + """Teste le parsing d'un titre""" + try: + data = request.json + title = data.get('title', '') + + if not title: + return jsonify({ + 'success': False, + 'error': 'Titre requis' + }), 400 + + # Utiliser la fonction de nettoyage de TMDb + cleaned = tmdb._clean_title(title) + + return jsonify({ + 'success': True, + 'original': title, + 'cleaned': cleaned + }) + except Exception as e: + logger.error(f"Erreur test parsing: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================================ +# ADMIN FILTRES +# ============================================================ + +@app.route('/api/filters', methods=['GET']) +@login_required +def get_filters_public(): + """Récupère la configuration des filtres (route pour la recherche)""" + try: + from torrent_parser import load_filters_config + filters = load_filters_config() + return jsonify({ + 'success': True, + 'filters': filters + }) + except Exception as e: + logger.error(f"Erreur récupération filtres: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/filters', methods=['GET']) +@admin_required +def get_filters_config(): + """Récupère la configuration des filtres (admin)""" + try: + from torrent_parser import load_filters_config + filters = load_filters_config() + return jsonify({ + 'success': True, + 'filters': filters + }) + except Exception as e: + logger.error(f"Erreur récupération filtres: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/filters', methods=['POST']) +@admin_required +def save_filters_config_route(): + """Sauvegarde la configuration des filtres""" + try: + from torrent_parser import save_filters_config, reload_parser + data = request.json + filters = data.get('filters', {}) + + if save_filters_config(filters): + # Recharger le parser avec la nouvelle config + reload_parser() + logger.info("✅ Configuration des filtres sauvegardée") + return jsonify({ + 'success': True, + 'message': 'Filtres sauvegardés' + }) + else: + return jsonify({ + 'success': False, + 'error': 'Erreur lors de la sauvegarde' + }), 500 + except Exception as e: + logger.error(f"Erreur sauvegarde filtres: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/filters/reset', methods=['POST']) +@admin_required +def reset_filters_config(): + """Réinitialise les filtres aux valeurs par défaut""" + try: + from torrent_parser import save_filters_config, get_default_filters, reload_parser + default_filters = get_default_filters() + + if save_filters_config(default_filters): + reload_parser() + logger.info("✅ Filtres réinitialisés") + return jsonify({ + 'success': True, + 'filters': default_filters + }) + else: + return jsonify({ + 'success': False, + 'error': 'Erreur lors de la réinitialisation' + }), 500 + except Exception as e: + logger.error(f"Erreur reset filtres: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/filters/test', methods=['POST']) +@admin_required +def test_filters_parsing(): + """Teste le parsing d'un titre avec les filtres actuels""" + try: + data = request.json + title = data.get('title', '') + + if not title: + return jsonify({ + 'success': False, + 'error': 'Titre requis' + }), 400 + + # Parser le titre + parsed = parser.parse(title) + + return jsonify({ + 'success': True, + 'title': title, + 'parsed': parsed + }) + except Exception as e: + logger.error(f"Erreur test filtres: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================================ +# ADMIN RSS +# ============================================================ + +@app.route('/admin/rss') +@admin_required +def admin_rss(): + """Page d'administration des flux RSS""" + return render_template('admin_rss.html') + + +@app.route('/api/admin/rss', methods=['GET']) +@admin_required +def get_rss_feeds(): + """Récupère la liste des flux RSS configurés""" + try: + feeds = rss_manager.get_feeds() + # Masquer les passkeys et cookies dans la réponse + safe_feeds = [] + for feed in feeds: + safe_feed = feed.copy() + if safe_feed.get('passkey'): + safe_feed['passkey'] = '***' + # Indiquer si des cookies sont configurés sans les exposer + safe_feed['has_cookies'] = bool(safe_feed.get('cookies')) + if 'cookies' in safe_feed: + del safe_feed['cookies'] + safe_feeds.append(safe_feed) + + return jsonify({ + 'success': True, + 'feeds': safe_feeds + }) + except Exception as e: + logger.error(f"Erreur récupération flux RSS: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/rss', methods=['POST']) +@admin_required +def add_rss_feed(): + """Ajoute un nouveau flux RSS""" + try: + data = request.get_json() + + if not data.get('name') or not data.get('url') or not data.get('category'): + return jsonify({ + 'success': False, + 'error': 'Nom, URL et catégorie requis' + }), 400 + + feed = { + 'name': data['name'], + 'url': data['url'], + 'category': data['category'], + 'passkey': data.get('passkey', ''), + 'use_flaresolverr': data.get('use_flaresolverr', False), + 'cookies': data.get('cookies', ''), + 'enabled': True + } + + result = rss_manager.add_feed(feed) + + logger.info(f"✅ Flux RSS ajouté: {feed['name']}") + + return jsonify({ + 'success': True, + 'feed': result + }) + except Exception as e: + logger.error(f"Erreur ajout flux RSS: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/rss/', methods=['DELETE']) +@admin_required +def delete_rss_feed(feed_id): + """Supprime un flux RSS""" + try: + rss_manager.delete_feed(feed_id) + logger.info(f"🗑️ Flux RSS supprimé: {feed_id}") + + return jsonify({ + 'success': True + }) + except Exception as e: + logger.error(f"Erreur suppression flux RSS: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/rss//toggle', methods=['POST']) +@admin_required +def toggle_rss_feed(feed_id): + """Active/désactive un flux RSS""" + try: + for feed in rss_manager.feeds: + if feed.get('id') == feed_id: + feed['enabled'] = not feed.get('enabled', True) + rss_manager.save_config() + logger.info(f"🔄 Flux RSS {feed_id} {'activé' if feed['enabled'] else 'désactivé'}") + return jsonify({ + 'success': True, + 'enabled': feed['enabled'] + }) + + return jsonify({ + 'success': False, + 'error': 'Flux non trouvé' + }), 404 + except Exception as e: + logger.error(f"Erreur toggle flux RSS: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/rss/test', methods=['POST']) +@admin_required +def test_rss_feed(): + """Teste un flux RSS""" + try: + data = request.get_json() + url = data.get('url', '') + passkey = data.get('passkey', '') + use_flaresolverr = data.get('use_flaresolverr', False) + cookies = data.get('cookies', '') + + if not url: + return jsonify({ + 'success': False, + 'error': 'URL requise' + }), 400 + + result = rss_manager.test_feed(url, passkey, use_flaresolverr, cookies) + + return jsonify(result) + except Exception as e: + logger.error(f"Erreur test flux RSS: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/rss//test', methods=['POST']) +@admin_required +def test_existing_rss_feed(feed_id): + """Teste un flux RSS existant""" + try: + for feed in rss_manager.feeds: + if feed.get('id') == feed_id: + result = rss_manager.test_feed(feed['url'], feed.get('passkey', '')) + return jsonify(result) + + return jsonify({ + 'success': False, + 'error': 'Flux non trouvé' + }), 404 + except Exception as e: + logger.error(f"Erreur test flux RSS: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================================ +# CLIENT TORRENT (Plugins) +# ============================================================ + +@app.route('/api/admin/torrent-client/plugins', methods=['GET']) +@admin_required +def get_torrent_client_plugins(): + """Liste les plugins de clients torrent disponibles""" + try: + from plugins.torrent_clients import get_available_plugins + plugins = get_available_plugins() + return jsonify({ + 'success': True, + 'plugins': plugins + }) + except Exception as e: + logger.error(f"Erreur récupération plugins: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/torrent-client/config', methods=['GET']) +@admin_required +def get_torrent_client_config(): + """Récupère la configuration du client torrent""" + try: + from plugins.torrent_clients import get_active_config, get_active_client + + config = get_active_config() or {} + client = get_active_client() + + # Masquer le mot de passe + safe_config = config.copy() if config else {} + if 'password' in safe_config and safe_config['password']: + safe_config['password'] = '********' + + return jsonify({ + 'success': True, + 'config': safe_config, + 'connected': client.is_connected() if client else False + }) + except Exception as e: + logger.error(f"Erreur récupération config client: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/torrent-client/config', methods=['POST']) +@admin_required +def save_torrent_client_config(): + """Sauvegarde et active la configuration du client torrent""" + try: + from plugins.torrent_clients import create_client, set_active_client, save_client_config + + data = request.json + + # Gérer le port vide ou invalide + port_str = str(data.get('port', '')).strip() + port = int(port_str) if port_str and port_str.isdigit() else 0 + + config = { + 'enabled': data.get('enabled', False), + 'plugin': data.get('plugin', ''), + 'host': data.get('host', 'localhost'), + 'port': port, + 'username': data.get('username', ''), + 'password': data.get('password', ''), + 'use_ssl': data.get('use_ssl', False), + 'path': data.get('path', '') # Chemin optionnel (ex: /qbittorrent) + } + + # Sauvegarder la config + if not save_client_config(config): + return jsonify({ + 'success': False, + 'error': 'Erreur de sauvegarde' + }), 500 + + # Si activé, créer et connecter le client + if config['enabled'] and config['plugin']: + client = create_client(config['plugin'], config) + + if client and client.connect(): + set_active_client(client, config) + logger.info(f"✅ Client torrent configuré: {config['plugin']}") + return jsonify({ + 'success': True, + 'message': 'Configuration sauvegardée et client connecté' + }) + else: + return jsonify({ + 'success': True, + 'message': 'Configuration sauvegardée mais connexion échouée', + 'warning': 'Vérifiez les paramètres de connexion' + }) + else: + set_active_client(None, config) + return jsonify({ + 'success': True, + 'message': 'Client torrent désactivé' + }) + + except Exception as e: + logger.error(f"Erreur sauvegarde config client: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/admin/torrent-client/test', methods=['POST']) +@admin_required +def test_torrent_client(): + """Teste la connexion au client torrent""" + try: + from plugins.torrent_clients import create_client + + data = request.json + + # Gérer le port vide ou invalide + port_str = str(data.get('port', '')).strip() + port = int(port_str) if port_str and port_str.isdigit() else 0 + + client = create_client(data.get('plugin', ''), { + 'host': data.get('host', 'localhost'), + 'port': port, + 'username': data.get('username', ''), + 'password': data.get('password', ''), + 'use_ssl': data.get('use_ssl', False), + 'path': data.get('path', '') + }) + + if not client: + return jsonify({ + 'success': False, + 'error': 'Plugin non trouvé' + }), 400 + + result = client.test_connection() + return jsonify(result) + + except Exception as e: + logger.error(f"Erreur test client: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/torrent-client/add', methods=['POST']) +@login_required +def add_torrent_to_client(): + """Envoie un torrent au client configuré""" + try: + from plugins.torrent_clients import get_active_client + + client = get_active_client() + + if not client: + return jsonify({ + 'success': False, + 'error': 'Aucun client torrent configuré' + }), 400 + + if not client.is_connected(): + if not client.connect(): + return jsonify({ + 'success': False, + 'error': 'Impossible de se connecter au client torrent' + }), 500 + + data = request.json + url = data.get('url', '') # Magnet ou URL .torrent + category = data.get('category', None) + save_path = data.get('save_path', None) + paused = data.get('paused', False) + + if not url: + return jsonify({ + 'success': False, + 'error': 'URL requise' + }), 400 + + if client.add_torrent_url(url, save_path=save_path, category=category, paused=paused): + logger.info(f"✅ Torrent envoyé au client: {url[:50]}...") + return jsonify({ + 'success': True, + 'message': 'Torrent ajouté avec succès' + }) + else: + return jsonify({ + 'success': False, + 'error': 'Échec de l\'ajout du torrent' + }), 500 + + except Exception as e: + logger.error(f"Erreur ajout torrent: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/torrent-client/categories', methods=['GET']) +@login_required +def get_torrent_client_categories(): + """Récupère les catégories du client torrent et les catégories personnalisées""" + try: + from plugins.torrent_clients import get_active_client + + # Charger les catégories personnalisées + custom_categories = load_custom_categories() + + client = get_active_client() + client_categories = [] + + if client and client.is_connected(): + client_categories = client.get_categories() + + # Fusionner : priorité aux catégories personnalisées + all_categories = list(custom_categories.keys()) + for cat in client_categories: + if cat not in all_categories: + all_categories.append(cat) + + return jsonify({ + 'success': True, + 'categories': all_categories, + 'custom_categories': custom_categories, + 'client_categories': client_categories + }) + + except Exception as e: + logger.error(f"Erreur récupération catégories: {e}") + return jsonify({ + 'success': False, + 'categories': [], + 'custom_categories': {}, + 'client_categories': [] + }) + + +@app.route('/api/admin/torrent-client/categories', methods=['GET']) +@login_required +def get_admin_custom_categories(): + """Récupère les catégories personnalisées pour l'admin""" + try: + custom_categories = load_custom_categories() + return jsonify({ + 'success': True, + 'categories': custom_categories + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +@app.route('/api/admin/torrent-client/categories', methods=['POST']) +@login_required +def save_admin_custom_categories(): + """Sauvegarde les catégories personnalisées""" + try: + data = request.json + categories = data.get('categories', {}) + + save_custom_categories(categories) + + return jsonify({ + 'success': True, + 'message': 'Catégories sauvegardées' + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +@app.route('/api/admin/torrent-client/sync-categories', methods=['POST']) +@login_required +def sync_categories_with_client(): + """Synchronise les catégories avec le client torrent (crée les catégories manquantes)""" + try: + from plugins.torrent_clients import get_active_client + + client = get_active_client() + if not client: + return jsonify({ + 'success': False, + 'error': 'Aucun client torrent configuré' + }) + + if not client.is_connected(): + return jsonify({ + 'success': False, + 'error': 'Client torrent non connecté' + }) + + custom_categories = load_custom_categories() + + # Pour qBittorrent, on peut créer les catégories + if hasattr(client, 'create_category'): + created = [] + for name, path in custom_categories.items(): + if client.create_category(name, path): + created.append(name) + + return jsonify({ + 'success': True, + 'message': f'{len(created)} catégories créées', + 'created': created + }) + else: + return jsonify({ + 'success': False, + 'error': 'Ce client ne supporte pas la création de catégories' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }) + + +def load_custom_categories(): + """Charge les catégories personnalisées depuis le fichier JSON""" + import json + config_path = '/app/config/torrent_categories.json' + + try: + if os.path.exists(config_path): + with open(config_path, 'r') as f: + return json.load(f) + except: + pass + + return {} + + +def save_custom_categories(categories): + """Sauvegarde les catégories personnalisées""" + import json + config_path = '/app/config/torrent_categories.json' + + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + with open(config_path, 'w') as f: + json.dump(categories, f, indent=2) + + +@app.route('/api/torrent-client/status', methods=['GET']) +@login_required +def get_torrent_client_status(): + """Vérifie si un client torrent est configuré et connecté""" + try: + from plugins.torrent_clients import get_active_client, get_active_config + + client = get_active_client() + config = get_active_config() + + if not client or not config or not config.get('enabled'): + return jsonify({ + 'success': True, + 'enabled': False, + 'connected': False + }) + + return jsonify({ + 'success': True, + 'enabled': True, + 'connected': client.is_connected(), + 'plugin': config.get('plugin', ''), + 'supportsTorrentFiles': getattr(client, 'SUPPORTS_TORRENT_FILES', True) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================================ +# API CACHE +# ============================================================ + +@app.route('/api/cache/status', methods=['GET']) +@login_required +def api_cache_status(): + """Récupère le statut du cache""" + try: + import cache_manager + status = cache_manager.get_cache_status() + return jsonify({'success': True, **status}) + except Exception as e: + logger.error(f"Erreur statut cache: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/cache/config', methods=['GET']) +@admin_required +def api_get_cache_config(): + """Récupère la configuration du cache""" + try: + import cache_manager + config = cache_manager.get_cache_config() + return jsonify({'success': True, 'config': config}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/cache/config', methods=['POST']) +@admin_required +def api_save_cache_config(): + """Sauvegarde la configuration du cache""" + try: + import cache_manager + + data = request.json + config = cache_manager.get_cache_config() + + # Mettre à jour la config + config['enabled'] = data.get('enabled', False) + config['interval_minutes'] = int(data.get('interval_minutes', 60)) + + # Config Latest + config['latest'] = { + 'enabled': data.get('latest_enabled', True), + 'categories': data.get('latest_categories', ['movies', 'tv']), + 'trackers': data.get('latest_trackers', []), + 'limit': int(data.get('latest_limit', 50)) + } + + # Config Discover + config['discover'] = { + 'enabled': data.get('discover_enabled', True), + 'limit': int(data.get('discover_limit', 30)) + } + + if cache_manager.save_cache_config(config): + # Redémarrer le scheduler avec la nouvelle config + cache_manager.restart_scheduler(app) + return jsonify({'success': True, 'message': 'Configuration sauvegardée'}) + else: + return jsonify({'success': False, 'error': 'Erreur de sauvegarde'}), 500 + + except Exception as e: + logger.error(f"Erreur sauvegarde config cache: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/cache/refresh', methods=['POST']) +@admin_required +def api_refresh_cache(): + """Force un refresh du cache""" + try: + import cache_manager + import threading + + # Lancer le refresh en arrière-plan + thread = threading.Thread(target=lambda: cache_manager.refresh_cache(app), daemon=True) + thread.start() + + return jsonify({'success': True, 'message': 'Refresh lancé en arrière-plan'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/cache/clear', methods=['POST']) +@admin_required +def api_clear_cache(): + """Vide le cache""" + try: + import cache_manager + if cache_manager.clear_cache(): + return jsonify({'success': True, 'message': 'Cache vidé'}) + else: + return jsonify({'success': False, 'error': 'Erreur'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/cache/data//', methods=['GET']) +@login_required +def api_get_cached_data(cache_type, category): + """Récupère les données en cache""" + try: + import cache_manager + + data = cache_manager.get_cached_data(cache_type, category) + + if data: + return jsonify({ + 'success': True, + 'cached': True, + 'timestamp': data.get('timestamp'), + 'data': data.get('data', []) + }) + else: + return jsonify({ + 'success': True, + 'cached': False, + 'data': [] + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# ============================================================ +# FONCTIONS INTERNES POUR LE CACHE +# ============================================================ + +def fetch_latest_releases_internal(trackers_list=None, category='movies', limit=50): + """ + Fonction interne pour récupérer les dernières sorties + Utilisée par le cache manager - copie de la logique de get_latest() + """ + try: + from concurrent.futures import ThreadPoolExecutor, as_completed + import time + + logger.info(f"📦 Cache: fetch_latest_releases_internal category={category}, limit={limit}") + + # Si pas de trackers spécifiés, récupérer tous les trackers actifs + if not trackers_list: + all_trackers = jackett.get_indexers() + trackers_list = [t.get('id') for t in all_trackers if t.get('configured', False)] + + if not trackers_list: + logger.warning("Cache: Aucun tracker disponible") + return [] + + # Séparer les trackers Jackett/Prowlarr des flux RSS + indexer_trackers = [t for t in trackers_list if not str(t).startswith('rss:')] + rss_trackers = [t.replace('rss:', '') for t in trackers_list if str(t).startswith('rss:')] + + # Charger la configuration admin + latest_config = _load_latest_config() + + # Catégories par défaut + default_categories = { + 'video': '2000,5000', + 'movies': '2000', + 'tv': '5000', + 'anime': '5070', + 'music': '3000' + } + + all_results = [] + start_time = time.time() + + def fetch_tracker(tracker): + """Fonction pour récupérer les résultats d'un tracker""" + tracker_config = latest_config.get(tracker, {}) + cat_id = tracker_config.get(category) or default_categories.get(category, '2000,5000') + + try: + results = jackett.search('', indexers=[tracker], category=cat_id, max_results=limit * 2) + return results + except Exception as e: + logger.warning(f"⚠️ Cache erreur {tracker}: {e}") + return [] + + def fetch_rss(rss_id): + """Fonction pour récupérer les résultats d'un flux RSS""" + try: + for feed in rss_manager.feeds: + if feed.get('id') == rss_id: + rss_results = rss_manager.rss_source.fetch_feed(feed, max_results=limit * 2) + return rss_results if rss_results else [] + except Exception as e: + logger.warning(f"⚠️ Cache erreur RSS {rss_id}: {e}") + return [] + + # Exécuter les requêtes en parallèle + max_workers = min(10, len(indexer_trackers) + len(rss_trackers)) + + if max_workers > 0: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {} + + for tracker in indexer_trackers: + future = executor.submit(fetch_tracker, tracker) + futures[future] = f"tracker:{tracker}" + + for rss_id in rss_trackers: + future = executor.submit(fetch_rss, rss_id) + futures[future] = f"rss:{rss_id}" + + for future in as_completed(futures, timeout=60): + try: + results = future.result() + if results: + all_results.extend(results) + except Exception as e: + pass + + elapsed = time.time() - start_time + logger.info(f"📦 Cache: {len(all_results)} résultats bruts (en {elapsed:.2f}s)") + + if not all_results: + return [] + + # Filtrer les animes/documentaires si catégorie = tv + if category == 'tv': + all_results = _filter_tv_results(all_results) + + # Trier par date + all_results.sort(key=lambda x: x.get('PublishDateRaw', ''), reverse=True) + + # Regrouper les torrents similaires + grouped = _group_similar_torrents(all_results) + + # Enrichir avec TMDb ou Last.fm + enriched = [] + for group in grouped[:limit]: + main_torrent = group['torrents'][0] + + if category == 'music': + music_data = lastfm.enrich_torrent(main_torrent['Title']) + if music_data: + group['music'] = music_data + group['is_music'] = True + else: + cat = 'movie' if category == 'movies' else ('tv' if category in ['tv', 'anime'] else None) + tmdb_data = tmdb.enrich_torrent(main_torrent['Title'], cat) + if tmdb_data: + group['tmdb'] = tmdb_data + if category == 'anime': + group['is_anime'] = True + + enriched.append(group) + + logger.info(f"📦 Cache: {len(enriched)} résultats enrichis pour {category}") + return enriched + + except Exception as e: + logger.error(f"❌ Cache fetch_latest_releases_internal: {e}", exc_info=True) + return [] + + +def fetch_discover_internal(media_type='movies', limit=30): + """ + Fonction interne pour récupérer les contenus Discover AVEC détails TMDb ET torrents pré-cachés + Utilisée par le cache manager + """ + try: + from concurrent.futures import ThreadPoolExecutor, as_completed + import requests + import time + + logger.info(f"📦 Cache Discover: fetch {media_type}, limit={limit}") + + if not config.tmdb_api_key: + logger.warning("Cache: Clé API TMDb non configurée") + return [] + + # 1. Récupérer les films/séries depuis TMDb + if media_type == 'movies': + items = _fetch_recent_movies() + else: + items = _fetch_popular_tv() + + items = items[:limit] + logger.info(f"📦 Cache Discover: {len(items)} {media_type} récupérés depuis TMDb") + + if not items: + return [] + + # 2. Pré-charger les détails TMDb ET les torrents pour chaque item + start_time = time.time() + + def fetch_details_and_torrents(item): + """Récupère les détails TMDb ET recherche les torrents""" + try: + tmdb_id = item.get('id') + title = item.get('title') or item.get('name', '') + original_title = item.get('original_title') or item.get('original_name', '') + year = '' + if item.get('release_date'): + year = item['release_date'][:4] + elif item.get('first_air_date'): + year = item['first_air_date'][:4] + + item_media_type = 'movie' if media_type == 'movies' else 'tv' + + # ======================================== + # A. Récupérer les détails TMDb (genres, synopsis, trailer) + # ======================================== + try: + url = f'https://api.themoviedb.org/3/{item_media_type}/{tmdb_id}' + params = { + 'api_key': config.tmdb_api_key, + 'language': 'fr-FR', + 'append_to_response': 'videos' + } + + response = requests.get(url, params=params, timeout=10) + if response.status_code == 200: + detail = response.json() + + # Ajouter les genres + item['genres'] = detail.get('genres', []) + + # Ajouter le synopsis complet + if detail.get('overview'): + item['overview'] = detail['overview'] + + # Ajouter le runtime/nombre d'épisodes + if item_media_type == 'movie': + item['runtime'] = detail.get('runtime') + else: + item['number_of_seasons'] = detail.get('number_of_seasons') + item['number_of_episodes'] = detail.get('number_of_episodes') + + # Chercher la bande-annonce YouTube + trailer_url = None + videos = detail.get('videos', {}).get('results', []) + + for video_type in ['Trailer', 'Teaser']: + for video in videos: + if video.get('site') == 'YouTube' and video.get('type') == video_type: + trailer_url = f"https://www.youtube.com/embed/{video.get('key')}" + break + if trailer_url: + break + + # Si pas de vidéo FR, chercher en anglais + if not trailer_url: + try: + url_en = f'https://api.themoviedb.org/3/{item_media_type}/{tmdb_id}/videos' + params_en = {'api_key': config.tmdb_api_key, 'language': 'en-US'} + response_en = requests.get(url_en, params=params_en, timeout=5) + if response_en.ok: + videos_en = response_en.json().get('results', []) + for video_type in ['Trailer', 'Teaser']: + for video in videos_en: + if video.get('site') == 'YouTube' and video.get('type') == video_type: + trailer_url = f"https://www.youtube.com/embed/{video.get('key')}" + break + if trailer_url: + break + except: + pass + + item['trailer_url'] = trailer_url + + except Exception as e: + logger.warning(f"Erreur détails TMDb pour {title}: {e}") + + # ======================================== + # B. Rechercher les torrents + # ======================================== + cat_id = '2000' if item_media_type == 'movie' else '5000' + + search_queries = [] + if original_title and _is_latin_text(original_title): + search_queries.append(f"{original_title} {year}" if year else original_title) + if title and _is_latin_text(title) and title != original_title: + search_queries.append(f"{title} {year}" if year else title) + + if not search_queries: + return item, [] + + all_torrents = [] + seen_titles = set() + + configured_trackers = load_discover_trackers_config() + all_trackers = indexer_manager.get_indexers() + if configured_trackers: + all_trackers = [t for t in all_trackers if t.get('id') in configured_trackers] + + # Limiter à 3 trackers pour la vitesse + all_trackers = all_trackers[:3] + + for query in search_queries[:2]: + for tracker in all_trackers: + try: + tracker_id = tracker.get('id', '') + results = jackett.search(query, indexers=[tracker_id], category=cat_id, max_results=10) + for r in results: + title_key = r.get('Title', '').lower() + if title_key not in seen_titles: + seen_titles.add(title_key) + all_torrents.append(r) + except: + pass + + if len(all_torrents) >= 10: + break + + # Parser et enrichir + for torrent in all_torrents: + parser.enrich_torrent(torrent) + + # Filtrer les pertinents + filtered = _filter_relevant_torrents(all_torrents, title, original_title, year) + filtered.sort(key=lambda x: x.get('Seeders', 0) or 0, reverse=True) + + return item, filtered[:10] + + except Exception as e: + logger.warning(f"Erreur pour {item.get('title', '?')}: {e}") + return item, [] + + # Exécuter en parallèle (5 items en même temps) + results_with_data = [] + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = {executor.submit(fetch_details_and_torrents, item): item for item in items} + + try: + for future in as_completed(futures, timeout=180): # 3 min max + try: + item, torrents = future.result(timeout=10) + item['torrents'] = torrents + item['torrent_count'] = len(torrents) + item['details_cached'] = True # Marquer que les détails sont en cache + results_with_data.append(item) + logger.info(f" ✅ {item.get('title') or item.get('name')}: {len(torrents)} torrents, détails OK") + except Exception as e: + item = futures[future] + item['torrents'] = [] + item['torrent_count'] = 0 + item['details_cached'] = False + results_with_data.append(item) + except TimeoutError: + logger.warning(f"⏱️ Timeout cache Discover, {len(results_with_data)}/{len(items)} traités") + for future in futures: + if not future.done(): + item = futures[future] + if item not in [r for r in results_with_data]: + item['torrents'] = [] + item['torrent_count'] = 0 + item['details_cached'] = False + results_with_data.append(item) + + elapsed = time.time() - start_time + total_torrents = sum(item.get('torrent_count', 0) for item in results_with_data) + logger.info(f"📦 Cache Discover: {len(results_with_data)} {media_type}, {total_torrents} torrents (en {elapsed:.1f}s)") + + return results_with_data + + except Exception as e: + logger.error(f"❌ Cache fetch_discover_internal: {e}", exc_info=True) + return [] + + +if __name__ == '__main__': + # Charger le client torrent au démarrage + try: + from plugins.torrent_clients import load_client_from_config + load_client_from_config() + except Exception as e: + logger.warning(f"⚠️ Client torrent non chargé: {e}") + + # Initialiser le cache scheduler + try: + import cache_manager + cache_manager.init_scheduler(app) + except Exception as e: + logger.warning(f"⚠️ Cache scheduler non démarré: {e}") + + logger.info("🚀 Démarrage de Lycostorrent...") + logger.info(f"📡 Jackett URL: {config.jackett_url}") + app.run(host='0.0.0.0', port=5097, debug=False) diff --git a/app/plugins/torrent_clients/README.md b/app/plugins/torrent_clients/README.md new file mode 100644 index 0000000..236e4c7 --- /dev/null +++ b/app/plugins/torrent_clients/README.md @@ -0,0 +1,154 @@ +# Plugins Clients Torrent - Lycostorrent + +Ce dossier contient les plugins pour les clients torrent. + +## Plugins disponibles + +| Plugin | Fichier | Description | +|--------|---------|-------------| +| qBittorrent | `qbittorrent.py` | Client qBittorrent via API Web | + +## Créer un nouveau plugin + +### 1. Créer le fichier + +Créer un fichier `mon_client.py` dans ce dossier. + +### 2. Implémenter la classe + +```python +from .base import TorrentClientPlugin, TorrentClientConfig, TorrentInfo +from typing import Optional, List + +class MonClientPlugin(TorrentClientPlugin): + """Plugin pour MonClient.""" + + # Métadonnées (obligatoires) + PLUGIN_NAME = "MonClient" + PLUGIN_DESCRIPTION = "Support pour MonClient" + PLUGIN_VERSION = "1.0.0" + PLUGIN_AUTHOR = "VotreNom" + + def connect(self) -> bool: + """Établit la connexion.""" + # Votre code ici + pass + + def disconnect(self) -> None: + """Ferme la connexion.""" + pass + + def is_connected(self) -> bool: + """Vérifie la connexion.""" + pass + + def add_torrent_url(self, url: str, save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """Ajoute un torrent via URL.""" + pass + + def add_torrent_file(self, file_content: bytes, filename: str, + save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """Ajoute un torrent via fichier.""" + pass + + def get_torrents(self) -> List[TorrentInfo]: + """Liste tous les torrents.""" + pass + + def get_torrent(self, torrent_hash: str) -> Optional[TorrentInfo]: + """Récupère un torrent par son hash.""" + pass + + def pause_torrent(self, torrent_hash: str) -> bool: + """Met en pause.""" + pass + + def resume_torrent(self, torrent_hash: str) -> bool: + """Reprend le téléchargement.""" + pass + + def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool: + """Supprime un torrent.""" + pass + + def get_categories(self) -> List[str]: + """Liste les catégories.""" + pass + + +# Important : exposer la classe pour l'auto-découverte +PLUGIN_CLASS = MonClientPlugin +``` + +### 3. Méthodes obligatoires + +| Méthode | Description | +|---------|-------------| +| `connect()` | Connexion au client | +| `disconnect()` | Déconnexion | +| `is_connected()` | Vérifie la connexion | +| `add_torrent_url()` | Ajoute via magnet/URL | +| `add_torrent_file()` | Ajoute via fichier .torrent | +| `get_torrents()` | Liste les torrents | +| `get_torrent()` | Info d'un torrent | +| `pause_torrent()` | Pause | +| `resume_torrent()` | Reprise | +| `delete_torrent()` | Suppression | +| `get_categories()` | Liste catégories | + +### 4. Structure TorrentInfo + +```python +@dataclass +class TorrentInfo: + hash: str # Hash du torrent + name: str # Nom + size: int # Taille en bytes + progress: float # 0.0 à 1.0 + status: str # downloading, seeding, paused, error, queued, checking + download_speed: int # bytes/s + upload_speed: int # bytes/s + seeds: int # Nombre de seeds + peers: int # Nombre de peers + save_path: str # Chemin de sauvegarde +``` + +### 5. Configuration + +La configuration est passée via `TorrentClientConfig` : + +```python +@dataclass +class TorrentClientConfig: + host: str # Adresse (ex: "192.168.1.100") + port: int # Port (ex: 8080) + username: str # Utilisateur + password: str # Mot de passe + use_ssl: bool # Utiliser HTTPS +``` + +## Plugins à créer + +- [ ] Transmission (`transmission.py`) +- [ ] Deluge (`deluge.py`) +- [ ] ruTorrent (`rutorrent.py`) +- [ ] Vuze (`vuze.py`) +- [ ] µTorrent (`utorrent.py`) + +## Test du plugin + +```python +from plugins.torrent_clients import create_client + +client = create_client('monclient', { + 'host': 'localhost', + 'port': 8080, + 'username': 'admin', + 'password': 'password' +}) + +result = client.test_connection() +print(result) +``` \ No newline at end of file diff --git a/app/plugins/torrent_clients/__init__.py b/app/plugins/torrent_clients/__init__.py new file mode 100644 index 0000000..fa4b4d8 --- /dev/null +++ b/app/plugins/torrent_clients/__init__.py @@ -0,0 +1,262 @@ +""" +Gestionnaire de plugins pour les clients torrent. +Découvre automatiquement les plugins disponibles. +""" + +import os +import importlib +import logging +from typing import Dict, List, Optional, Type, Any +from .base import TorrentClientPlugin, TorrentClientConfig + +logger = logging.getLogger(__name__) + +# Registre des plugins disponibles +_plugins: Dict[str, Type[TorrentClientPlugin]] = {} + +# Instance active du client +_active_client: Optional[TorrentClientPlugin] = None +_active_config: Optional[Dict[str, Any]] = None + + +def discover_plugins() -> Dict[str, Type[TorrentClientPlugin]]: + """ + Découvre automatiquement tous les plugins dans le dossier. + + Returns: + Dictionnaire {nom_plugin: classe_plugin} + """ + global _plugins + _plugins = {} + + plugins_dir = os.path.dirname(__file__) + + for filename in os.listdir(plugins_dir): + if filename.endswith('.py') and filename not in ('__init__.py', 'base.py'): + module_name = filename[:-3] + + try: + module = importlib.import_module(f'.{module_name}', package=__package__) + + # Chercher PLUGIN_CLASS ou une classe héritant de TorrentClientPlugin + if hasattr(module, 'PLUGIN_CLASS'): + plugin_class = module.PLUGIN_CLASS + _plugins[plugin_class.PLUGIN_NAME.lower()] = plugin_class + logger.info(f"✅ Plugin chargé: {plugin_class.PLUGIN_NAME} v{plugin_class.PLUGIN_VERSION}") + + except Exception as e: + logger.warning(f"⚠️ Impossible de charger le plugin {module_name}: {e}") + + return _plugins + + +def get_available_plugins() -> List[Dict[str, str]]: + """ + Retourne la liste des plugins disponibles. + + Returns: + Liste de dictionnaires avec les infos de chaque plugin + """ + if not _plugins: + discover_plugins() + + return [ + { + "id": name, + "name": plugin.PLUGIN_NAME, + "description": plugin.PLUGIN_DESCRIPTION, + "version": plugin.PLUGIN_VERSION, + "author": plugin.PLUGIN_AUTHOR + } + for name, plugin in _plugins.items() + ] + + +def get_plugin(name: str) -> Optional[Type[TorrentClientPlugin]]: + """ + Récupère une classe de plugin par son nom. + + Args: + name: Nom du plugin (insensible à la casse) + + Returns: + Classe du plugin ou None + """ + if not _plugins: + discover_plugins() + + return _plugins.get(name.lower()) + + +def create_client(plugin_name: str, config: Dict[str, Any]) -> Optional[TorrentClientPlugin]: + """ + Crée une instance de client torrent. + + Args: + plugin_name: Nom du plugin à utiliser + config: Configuration (host, port, username, password, use_ssl, path) + + Returns: + Instance du client ou None + """ + plugin_class = get_plugin(plugin_name) + + if not plugin_class: + logger.error(f"❌ Plugin non trouvé: {plugin_name}") + return None + + try: + from urllib.parse import urlparse + + # Nettoyer le host (enlever http:// ou https:// si présent) + host = config.get('host', 'localhost') + path = config.get('path', '') + use_ssl = config.get('use_ssl', False) + port = config.get('port', None) # None = pas spécifié + + # Si l'utilisateur a entré une URL complète, l'analyser + if host.startswith('http://') or host.startswith('https://'): + parsed = urlparse(host) + host = parsed.netloc or parsed.path.split('/')[0] + use_ssl = parsed.scheme == 'https' + + # Extraire le port de l'URL si présent (ex: host:8080) + if ':' in host: + host_part, port_part = host.rsplit(':', 1) + if port_part.isdigit(): + host = host_part + if port is None: # Seulement si pas déjà spécifié + port = int(port_part) + + # Extraire le chemin de l'URL si pas déjà fourni + if parsed.path and not path: + path = parsed.path.rstrip('/') + + # Enlever le trailing slash du host + host = host.rstrip('/') + + # Gérer le port + # - Si port est None ou vide: pas de port explicite (0) + # - Si port est un nombre valide: l'utiliser + # - Rétrocompatibilité: les anciennes configs avec port=8080 fonctionnent + if port is None or port == '': + port = 0 + elif isinstance(port, str): + port = int(port) if port.strip().isdigit() else 0 + else: + port = int(port) if port else 0 + + client_config = TorrentClientConfig( + host=host, + port=port, + username=config.get('username', ''), + password=config.get('password', ''), + use_ssl=use_ssl, + path=path + ) + + logger.info(f"🔧 Configuration client: {client_config.base_url}") + + client = plugin_class(client_config) + return client + + except Exception as e: + logger.error(f"❌ Erreur création client {plugin_name}: {e}") + return None + + +def get_active_client() -> Optional[TorrentClientPlugin]: + """Retourne le client actif ou None.""" + global _active_client + return _active_client + + +def set_active_client(client: Optional[TorrentClientPlugin], config: Optional[Dict[str, Any]] = None) -> None: + """Définit le client actif.""" + global _active_client, _active_config + + # Déconnecter l'ancien client + if _active_client: + try: + _active_client.disconnect() + except: + pass + + _active_client = client + _active_config = config + + +def get_active_config() -> Optional[Dict[str, Any]]: + """Retourne la configuration du client actif.""" + return _active_config + + +def load_client_from_config(config_path: str = '/app/config/torrent_client.json') -> Optional[TorrentClientPlugin]: + """ + Charge le client depuis un fichier de configuration. + + Args: + config_path: Chemin du fichier de configuration + + Returns: + Instance du client ou None + """ + import json + + try: + if not os.path.exists(config_path): + return None + + with open(config_path, 'r') as f: + config = json.load(f) + + if not config.get('enabled', False): + logger.info("ℹ️ Client torrent désactivé") + return None + + plugin_name = config.get('plugin', '') + if not plugin_name: + return None + + client = create_client(plugin_name, config) + + if client and client.connect(): + set_active_client(client, config) + logger.info(f"✅ Client torrent actif: {plugin_name}") + return client + + return None + + except Exception as e: + logger.error(f"❌ Erreur chargement config client torrent: {e}") + return None + + +def save_client_config(config: Dict[str, Any], config_path: str = '/app/config/torrent_client.json') -> bool: + """ + Sauvegarde la configuration du client torrent. + + Args: + config: Configuration à sauvegarder + config_path: Chemin du fichier + + Returns: + True si succès + """ + import json + + try: + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) + + return True + + except Exception as e: + logger.error(f"❌ Erreur sauvegarde config client torrent: {e}") + return False + + +# Découvrir les plugins au chargement du module +discover_plugins() \ No newline at end of file diff --git a/app/plugins/torrent_clients/base.py b/app/plugins/torrent_clients/base.py new file mode 100644 index 0000000..f8f6813 --- /dev/null +++ b/app/plugins/torrent_clients/base.py @@ -0,0 +1,219 @@ +""" +Classe de base pour les plugins de clients torrent. +Tous les plugins doivent hériter de cette classe. +""" + +from abc import ABC, abstractmethod +from typing import Optional, Dict, Any, List +from dataclasses import dataclass + + +@dataclass +class TorrentClientConfig: + """Configuration d'un client torrent""" + host: str + port: int = 0 # 0 = pas de port explicite (utiliser le port par défaut du protocole) + username: str = "" + password: str = "" + use_ssl: bool = False + path: str = "" # Chemin optionnel (ex: /qbittorrent) + + @property + def base_url(self) -> str: + protocol = "https" if self.use_ssl else "http" + + # Si le port est 0 ou vide, ne pas l'inclure dans l'URL + if self.port and self.port > 0: + url = f"{protocol}://{self.host}:{self.port}" + else: + url = f"{protocol}://{self.host}" + + # Ajouter le chemin s'il existe + if self.path: + # S'assurer que le chemin commence par / et ne finit pas par / + path = self.path.strip('/') + if path: + url = f"{url}/{path}" + + return url + + +@dataclass +class TorrentInfo: + """Informations sur un torrent""" + hash: str + name: str + size: int + progress: float # 0.0 à 1.0 + status: str # downloading, seeding, paused, error, etc. + download_speed: int # bytes/s + upload_speed: int # bytes/s + seeds: int + peers: int + save_path: str + + +class TorrentClientPlugin(ABC): + """ + Classe abstraite pour les plugins de clients torrent. + + Pour créer un nouveau plugin : + 1. Créer un fichier dans plugins/torrent_clients/ + 2. Hériter de TorrentClientPlugin + 3. Implémenter toutes les méthodes abstraites + 4. Définir PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION + """ + + # Métadonnées du plugin (à surcharger) + PLUGIN_NAME: str = "Base Plugin" + PLUGIN_DESCRIPTION: str = "Plugin de base" + PLUGIN_VERSION: str = "1.0.0" + PLUGIN_AUTHOR: str = "Unknown" + + # Capacités du plugin + SUPPORTS_TORRENT_FILES: bool = True # Supporte les URLs .torrent en plus des magnets + + def __init__(self, config: TorrentClientConfig): + self.config = config + self._connected = False + + @abstractmethod + def connect(self) -> bool: + """ + Établit la connexion avec le client torrent. + Retourne True si la connexion réussit. + """ + pass + + @abstractmethod + def disconnect(self) -> None: + """Ferme la connexion avec le client torrent.""" + pass + + @abstractmethod + def is_connected(self) -> bool: + """Vérifie si la connexion est active.""" + pass + + @abstractmethod + def add_torrent_url(self, url: str, save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """ + Ajoute un torrent via son URL (magnet ou .torrent). + + Args: + url: URL du torrent (magnet:// ou http://.torrent) + save_path: Chemin de sauvegarde (optionnel) + category: Catégorie/Label (optionnel) + paused: Démarrer en pause (défaut: False) + + Returns: + True si l'ajout réussit + """ + pass + + @abstractmethod + def add_torrent_file(self, file_content: bytes, filename: str, + save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """ + Ajoute un torrent via son contenu fichier. + + Args: + file_content: Contenu binaire du fichier .torrent + filename: Nom du fichier + save_path: Chemin de sauvegarde (optionnel) + category: Catégorie/Label (optionnel) + paused: Démarrer en pause (défaut: False) + + Returns: + True si l'ajout réussit + """ + pass + + @abstractmethod + def get_torrents(self) -> List[TorrentInfo]: + """ + Récupère la liste de tous les torrents. + + Returns: + Liste des torrents + """ + pass + + @abstractmethod + def get_torrent(self, torrent_hash: str) -> Optional[TorrentInfo]: + """ + Récupère les informations d'un torrent spécifique. + + Args: + torrent_hash: Hash du torrent + + Returns: + Informations du torrent ou None si non trouvé + """ + pass + + @abstractmethod + def pause_torrent(self, torrent_hash: str) -> bool: + """Met un torrent en pause.""" + pass + + @abstractmethod + def resume_torrent(self, torrent_hash: str) -> bool: + """Reprend un torrent en pause.""" + pass + + @abstractmethod + def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool: + """ + Supprime un torrent. + + Args: + torrent_hash: Hash du torrent + delete_files: Supprimer aussi les fichiers téléchargés + + Returns: + True si la suppression réussit + """ + pass + + @abstractmethod + def get_categories(self) -> List[str]: + """Récupère la liste des catégories/labels disponibles.""" + pass + + def get_info(self) -> Dict[str, Any]: + """Retourne les informations du plugin.""" + return { + "name": self.PLUGIN_NAME, + "description": self.PLUGIN_DESCRIPTION, + "version": self.PLUGIN_VERSION, + "author": self.PLUGIN_AUTHOR, + "connected": self.is_connected() + } + + def test_connection(self) -> Dict[str, Any]: + """ + Teste la connexion au client. + + Returns: + {"success": bool, "message": str, "version": str (si connecté)} + """ + try: + if self.connect(): + return { + "success": True, + "message": "Connexion réussie", + "client": self.PLUGIN_NAME + } + else: + return { + "success": False, + "message": "Échec de la connexion" + } + except Exception as e: + return { + "success": False, + "message": str(e) + } \ No newline at end of file diff --git a/app/plugins/torrent_clients/qbittorrent.py b/app/plugins/torrent_clients/qbittorrent.py new file mode 100644 index 0000000..18a8343 --- /dev/null +++ b/app/plugins/torrent_clients/qbittorrent.py @@ -0,0 +1,484 @@ +""" +Plugin qBittorrent pour Lycostorrent. +Utilise l'API Web de qBittorrent. +""" + +import requests +import logging +from typing import Optional, List, Dict, Any +from .base import TorrentClientPlugin, TorrentClientConfig, TorrentInfo + +logger = logging.getLogger(__name__) + + +class QBittorrentPlugin(TorrentClientPlugin): + """Plugin pour qBittorrent via son API Web.""" + + PLUGIN_NAME = "qBittorrent" + PLUGIN_DESCRIPTION = "Client torrent qBittorrent (API Web)" + PLUGIN_VERSION = "1.2.0" + PLUGIN_AUTHOR = "Lycostorrent" + + # Mapping des statuts qBittorrent vers statuts génériques + STATUS_MAP = { + 'downloading': 'downloading', + 'stalledDL': 'downloading', + 'metaDL': 'downloading', + 'forcedDL': 'downloading', + 'uploading': 'seeding', + 'stalledUP': 'seeding', + 'forcedUP': 'seeding', + 'pausedDL': 'paused', + 'pausedUP': 'paused', + 'queuedDL': 'queued', + 'queuedUP': 'queued', + 'checkingDL': 'checking', + 'checkingUP': 'checking', + 'checkingResumeData': 'checking', + 'moving': 'moving', + 'error': 'error', + 'missingFiles': 'error', + 'unknown': 'unknown' + } + + def __init__(self, config: TorrentClientConfig): + super().__init__(config) + self._session = requests.Session() + self._sid = None + + # Support HTTP Basic Auth (pour les seedbox avec reverse proxy) + if config.username and config.password: + self._session.auth = (config.username, config.password) + + @property + def api_url(self) -> str: + return f"{self.config.base_url}/api/v2" + + def connect(self) -> bool: + """Connexion à qBittorrent via l'API.""" + try: + # D'abord essayer sans login (si bypass auth ou HTTP Basic suffit) + test_response = self._session.get(f"{self.api_url}/app/version", timeout=10) + + if test_response.status_code == 200: + # HTTP Basic Auth a fonctionné, pas besoin de login qBittorrent + self._connected = True + logger.info(f"✅ Connecté à qBittorrent (HTTP Basic): {self.config.host}") + return True + + # Sinon, tenter le login qBittorrent classique + response = self._session.post( + f"{self.api_url}/auth/login", + data={ + 'username': self.config.username, + 'password': self.config.password + }, + timeout=10 + ) + + if response.status_code == 200 and response.text == "Ok.": + self._connected = True + self._sid = self._session.cookies.get('SID') + logger.info(f"✅ Connecté à qBittorrent: {self.config.host}") + return True + elif response.status_code == 200 and response.text == "Fails.": + logger.error(f"❌ qBittorrent: Identifiants incorrects (user: {self.config.username})") + return False + elif response.status_code == 401: + logger.error(f"❌ qBittorrent: Authentification requise - vérifiez user/password (user: {self.config.username})") + return False + elif response.status_code == 403: + logger.error("❌ qBittorrent: Accès interdit (IP bannie ou trop de tentatives)") + return False + else: + logger.error(f"❌ qBittorrent: Erreur {response.status_code} - {response.text[:100]}") + return False + + except requests.exceptions.ConnectionError: + logger.error(f"❌ qBittorrent: Impossible de se connecter à {self.config.base_url}") + return False + except Exception as e: + logger.error(f"❌ qBittorrent: {e}") + return False + + def disconnect(self) -> None: + """Déconnexion de qBittorrent.""" + try: + self._session.post(f"{self.api_url}/auth/logout", timeout=5) + except: + pass + self._connected = False + self._sid = None + + def is_connected(self) -> bool: + """Vérifie si la connexion est active.""" + if not self._connected: + return False + + try: + response = self._session.get(f"{self.api_url}/app/version", timeout=5) + return response.status_code == 200 + except: + self._connected = False + return False + + def _ensure_connected(self) -> bool: + """S'assure que la connexion est active, reconnecte si nécessaire.""" + if not self.is_connected(): + return self.connect() + return True + + def add_torrent_url(self, url: str, save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """Ajoute un torrent via URL (magnet ou .torrent).""" + if not self._ensure_connected(): + return False + + # Si c'est un magnet, l'envoyer directement + if url.startswith('magnet:'): + try: + data = {'urls': url} + + if save_path: + data['savepath'] = save_path + if category: + data['category'] = category + if paused: + data['paused'] = 'true' + + response = self._session.post( + f"{self.api_url}/torrents/add", + data=data, + timeout=30 + ) + + if response.status_code == 200: + logger.info(f"✅ Torrent ajouté: {url[:50]}...") + return True + else: + logger.error(f"❌ Erreur ajout torrent: {response.status_code}") + return False + + except Exception as e: + logger.error(f"❌ Erreur ajout torrent: {e}") + return False + + # Si c'est une URL .torrent, télécharger le fichier d'abord + # (qBittorrent distant n'a souvent pas accès aux URLs Jackett internes) + try: + logger.info(f"📥 Téléchargement du fichier torrent: {url[:80]}...") + + # Créer une session séparée pour télécharger le .torrent + import requests + download_session = requests.Session() + response = download_session.get(url, timeout=30, allow_redirects=False) + + # Gérer les redirections manuellement pour détecter les magnets + redirect_count = 0 + while response.status_code in (301, 302, 303, 307, 308) and redirect_count < 5: + redirect_url = response.headers.get('Location', '') + + # Si la redirection pointe vers un magnet, l'utiliser directement + if redirect_url.startswith('magnet:'): + logger.info(f"🔗 Redirection vers magnet détectée") + return self.add_torrent_url(redirect_url, save_path, category, paused) + + response = download_session.get(redirect_url, timeout=30, allow_redirects=False) + redirect_count += 1 + + if response.status_code != 200: + logger.error(f"❌ Impossible de télécharger le torrent: HTTP {response.status_code}") + return False + + content = response.content + content_type = response.headers.get('Content-Type', '') + + # Vérifier le Content-Type + if 'text/html' in content_type: + logger.error(f"❌ L'URL pointe vers une page web, pas un fichier torrent") + return False + + # Vérifier que c'est bien un fichier torrent + if not content.startswith(b'd'): + # Peut-être que le contenu est un magnet en texte? + try: + text_content = content.decode('utf-8').strip() + if text_content.startswith('magnet:'): + logger.info(f"🔗 Contenu magnet détecté") + return self.add_torrent_url(text_content, save_path, category, paused) + except: + pass + + logger.error("❌ Le fichier téléchargé n'est pas un torrent valide") + return False + + # Envoyer le fichier torrent à qBittorrent + return self.add_torrent_file(content, "download.torrent", save_path, category, paused) + + except Exception as e: + logger.error(f"❌ Erreur téléchargement torrent: {e}") + return False + + def add_torrent_file(self, file_content: bytes, filename: str, + save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """Ajoute un torrent via fichier.""" + if not self._ensure_connected(): + return False + + try: + files = {'torrents': (filename, file_content, 'application/x-bittorrent')} + data = {} + + if save_path: + data['savepath'] = save_path + if category: + data['category'] = category + if paused: + data['paused'] = 'true' + + response = self._session.post( + f"{self.api_url}/torrents/add", + files=files, + data=data, + timeout=30 + ) + + if response.status_code == 200: + logger.info(f"✅ Torrent ajouté: {filename}") + return True + else: + logger.error(f"❌ Erreur ajout torrent: {response.status_code}") + return False + + except Exception as e: + logger.error(f"❌ Erreur ajout torrent: {e}") + return False + + def get_torrents(self) -> List[TorrentInfo]: + """Récupère la liste de tous les torrents.""" + if not self._ensure_connected(): + return [] + + try: + response = self._session.get(f"{self.api_url}/torrents/info", timeout=10) + + if response.status_code != 200: + return [] + + torrents = [] + for t in response.json(): + torrents.append(TorrentInfo( + hash=t.get('hash', ''), + name=t.get('name', ''), + size=t.get('size', 0), + progress=t.get('progress', 0), + status=self.STATUS_MAP.get(t.get('state', 'unknown'), 'unknown'), + download_speed=t.get('dlspeed', 0), + upload_speed=t.get('upspeed', 0), + seeds=t.get('num_seeds', 0), + peers=t.get('num_leechs', 0), + save_path=t.get('save_path', '') + )) + + return torrents + + except Exception as e: + logger.error(f"❌ Erreur récupération torrents: {e}") + return [] + + def get_torrent(self, torrent_hash: str) -> Optional[TorrentInfo]: + """Récupère les informations d'un torrent spécifique.""" + if not self._ensure_connected(): + return None + + try: + response = self._session.get( + f"{self.api_url}/torrents/info", + params={'hashes': torrent_hash}, + timeout=10 + ) + + if response.status_code != 200: + return None + + data = response.json() + if not data: + return None + + t = data[0] + return TorrentInfo( + hash=t.get('hash', ''), + name=t.get('name', ''), + size=t.get('size', 0), + progress=t.get('progress', 0), + status=self.STATUS_MAP.get(t.get('state', 'unknown'), 'unknown'), + download_speed=t.get('dlspeed', 0), + upload_speed=t.get('upspeed', 0), + seeds=t.get('num_seeds', 0), + peers=t.get('num_leechs', 0), + save_path=t.get('save_path', '') + ) + + except Exception as e: + logger.error(f"❌ Erreur récupération torrent: {e}") + return None + + def pause_torrent(self, torrent_hash: str) -> bool: + """Met un torrent en pause.""" + if not self._ensure_connected(): + return False + + try: + response = self._session.post( + f"{self.api_url}/torrents/pause", + data={'hashes': torrent_hash}, + timeout=10 + ) + return response.status_code == 200 + except: + return False + + def resume_torrent(self, torrent_hash: str) -> bool: + """Reprend un torrent en pause.""" + if not self._ensure_connected(): + return False + + try: + response = self._session.post( + f"{self.api_url}/torrents/resume", + data={'hashes': torrent_hash}, + timeout=10 + ) + return response.status_code == 200 + except: + return False + + def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool: + """Supprime un torrent.""" + if not self._ensure_connected(): + return False + + try: + response = self._session.post( + f"{self.api_url}/torrents/delete", + data={ + 'hashes': torrent_hash, + 'deleteFiles': 'true' if delete_files else 'false' + }, + timeout=10 + ) + return response.status_code == 200 + except: + return False + + def get_categories(self) -> List[str]: + """Récupère la liste des catégories.""" + if not self._ensure_connected(): + return [] + + try: + response = self._session.get(f"{self.api_url}/torrents/categories", timeout=10) + + if response.status_code != 200: + return [] + + return list(response.json().keys()) + + except: + return [] + + def get_categories_with_paths(self) -> Dict[str, str]: + """Récupère les catégories avec leurs chemins.""" + if not self._ensure_connected(): + return {} + + try: + response = self._session.get(f"{self.api_url}/torrents/categories", timeout=10) + + if response.status_code != 200: + return {} + + categories = response.json() + return {name: info.get('savePath', '') for name, info in categories.items()} + + except: + return {} + + def create_category(self, name: str, save_path: str = '') -> bool: + """Crée une catégorie dans qBittorrent.""" + if not self._ensure_connected(): + return False + + try: + data = {'category': name} + if save_path: + data['savePath'] = save_path + + response = self._session.post( + f"{self.api_url}/torrents/createCategory", + data=data, + timeout=10 + ) + + if response.status_code == 200: + logger.info(f"✅ Catégorie créée: {name} -> {save_path}") + return True + elif response.status_code == 409: + # Catégorie existe déjà, essayer de la modifier + return self.edit_category(name, save_path) + else: + logger.error(f"❌ Erreur création catégorie: {response.status_code}") + return False + + except Exception as e: + logger.error(f"❌ Erreur création catégorie: {e}") + return False + + def edit_category(self, name: str, save_path: str) -> bool: + """Modifie le chemin d'une catégorie existante.""" + if not self._ensure_connected(): + return False + + try: + response = self._session.post( + f"{self.api_url}/torrents/editCategory", + data={'category': name, 'savePath': save_path}, + timeout=10 + ) + + if response.status_code == 200: + logger.info(f"✅ Catégorie modifiée: {name} -> {save_path}") + return True + return False + + except: + return False + + def get_version(self) -> Optional[str]: + """Récupère la version de qBittorrent.""" + if not self._ensure_connected(): + return None + + try: + response = self._session.get(f"{self.api_url}/app/version", timeout=5) + if response.status_code == 200: + return response.text + return None + except: + return None + + def test_connection(self) -> Dict[str, Any]: + """Teste la connexion et retourne des infos.""" + result = super().test_connection() + + if result["success"]: + version = self.get_version() + if version: + result["version"] = version + + return result + + +# Pour l'auto-découverte du plugin +PLUGIN_CLASS = QBittorrentPlugin \ No newline at end of file diff --git a/app/plugins/torrent_clients/transmission.py b/app/plugins/torrent_clients/transmission.py new file mode 100644 index 0000000..7b5baba --- /dev/null +++ b/app/plugins/torrent_clients/transmission.py @@ -0,0 +1,427 @@ +""" +Plugin Transmission pour Lycostorrent. +Utilise l'API RPC de Transmission. +""" + +import requests +import json +import base64 +import logging +from typing import Optional, List, Dict, Any +from .base import TorrentClientPlugin, TorrentClientConfig, TorrentInfo + +logger = logging.getLogger(__name__) + + +class TransmissionPlugin(TorrentClientPlugin): + """Plugin pour Transmission via son API RPC.""" + + PLUGIN_NAME = "Transmission" + PLUGIN_DESCRIPTION = "Client torrent Transmission (API RPC)" + PLUGIN_VERSION = "1.2.0" + PLUGIN_AUTHOR = "Lycostorrent" + + # Transmission supporte les fichiers .torrent (avec téléchargement préalable) + SUPPORTS_TORRENT_FILES = True + + # Mapping des statuts Transmission vers statuts génériques + # 0: stopped, 1: check pending, 2: checking, 3: download pending + # 4: downloading, 5: seed pending, 6: seeding + STATUS_MAP = { + 0: 'paused', + 1: 'checking', + 2: 'checking', + 3: 'queued', + 4: 'downloading', + 5: 'queued', + 6: 'seeding' + } + + def __init__(self, config: TorrentClientConfig): + super().__init__(config) + self._session = requests.Session() + self._session_id = None + + # Support HTTP Basic Auth (pour les seedbox avec reverse proxy) + if config.username and config.password: + self._session.auth = (config.username, config.password) + + @property + def rpc_url(self) -> str: + return f"{self.config.base_url}/transmission/rpc" + + def _get_session_id(self) -> bool: + """Récupère le X-Transmission-Session-Id requis par l'API.""" + try: + response = self._session.post(self.rpc_url, timeout=10) + + # Transmission retourne 409 avec le session ID dans les headers + if response.status_code == 409: + self._session_id = response.headers.get('X-Transmission-Session-Id') + if self._session_id: + self._session.headers['X-Transmission-Session-Id'] = self._session_id + return True + + # Si on a déjà un session ID valide + if response.status_code == 200: + return True + + return False + + except Exception as e: + logger.error(f"❌ Transmission: Erreur récupération session ID: {e}") + return False + + def _rpc_call(self, method: str, arguments: Dict = None) -> Optional[Dict]: + """Effectue un appel RPC à Transmission.""" + if not self._session_id: + if not self._get_session_id(): + return None + + payload = {"method": method} + if arguments: + payload["arguments"] = arguments + + try: + response = self._session.post( + self.rpc_url, + json=payload, + timeout=30 + ) + + # Si session expirée, renouveler et réessayer + if response.status_code == 409: + self._session_id = response.headers.get('X-Transmission-Session-Id') + self._session.headers['X-Transmission-Session-Id'] = self._session_id + response = self._session.post( + self.rpc_url, + json=payload, + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + if data.get('result') == 'success': + return data.get('arguments', {}) + else: + logger.error(f"❌ Transmission RPC error: {data.get('result')}") + return None + elif response.status_code == 401: + logger.error("❌ Transmission: Authentification requise") + return None + else: + logger.error(f"❌ Transmission: Erreur HTTP {response.status_code}") + return None + + except Exception as e: + logger.error(f"❌ Transmission RPC: {e}") + return None + + def connect(self) -> bool: + """Connexion à Transmission via l'API RPC.""" + try: + # Récupérer le session ID + if not self._get_session_id(): + logger.error(f"❌ Transmission: Impossible de se connecter à {self.config.base_url}") + return False + + # Tester avec un appel simple + result = self._rpc_call("session-get") + + if result: + self._connected = True + version = result.get('version', 'unknown') + logger.info(f"✅ Connecté à Transmission {version}: {self.config.host}") + return True + else: + return False + + except requests.exceptions.ConnectionError: + logger.error(f"❌ Transmission: Impossible de se connecter à {self.config.base_url}") + return False + except Exception as e: + logger.error(f"❌ Transmission: {e}") + return False + + def disconnect(self) -> None: + """Ferme la connexion avec Transmission.""" + self._connected = False + self._session_id = None + + def is_connected(self) -> bool: + """Vérifie si la connexion est active.""" + if not self._connected or not self._session_id: + return False + + result = self._rpc_call("session-get") + return result is not None + + def _ensure_connected(self) -> bool: + """S'assure que la connexion est active, reconnecte si nécessaire.""" + if not self._connected: + return self.connect() + return True + + def add_torrent_url(self, url: str, save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """Ajoute un torrent via URL (magnet ou .torrent).""" + if not self._ensure_connected(): + return False + + # Si c'est un magnet, l'envoyer directement + if url.startswith('magnet:'): + arguments = {"filename": url} + + if save_path: + arguments["download-dir"] = save_path + if paused: + arguments["paused"] = True + + result = self._rpc_call("torrent-add", arguments) + + if result: + if "torrent-added" in result: + logger.info(f"✅ Torrent ajouté: {result['torrent-added'].get('name', url[:50])}") + return True + elif "torrent-duplicate" in result: + logger.warning(f"⚠️ Torrent déjà présent: {result['torrent-duplicate'].get('name', url[:50])}") + return True + + return False + + # Si c'est une URL .torrent, télécharger le fichier et l'envoyer en base64 + try: + logger.info(f"📥 Téléchargement du fichier torrent: {url[:80]}...") + + # Télécharger le fichier torrent (sans suivre les redirections automatiquement) + response = self._session.get(url, timeout=30, allow_redirects=False) + + # Gérer les redirections manuellement pour détecter les magnets + redirect_count = 0 + while response.status_code in (301, 302, 303, 307, 308) and redirect_count < 5: + redirect_url = response.headers.get('Location', '') + + # Si la redirection pointe vers un magnet, l'utiliser directement + if redirect_url.startswith('magnet:'): + logger.info(f"🔗 Redirection vers magnet détectée") + return self.add_torrent_url(redirect_url, save_path, category, paused) + + # Sinon suivre la redirection + response = self._session.get(redirect_url, timeout=30, allow_redirects=False) + redirect_count += 1 + + if response.status_code != 200: + logger.error(f"❌ Impossible de télécharger le torrent: HTTP {response.status_code}") + return False + + content = response.content + content_type = response.headers.get('Content-Type', '') + + # Vérifier le Content-Type + if 'text/html' in content_type: + logger.error(f"❌ L'URL pointe vers une page web, pas un fichier torrent") + return False + + # Vérifier que c'est bien un fichier torrent (commence par 'd' pour dictionnaire bencode) + if not content.startswith(b'd'): + # Peut-être que le contenu est un magnet en texte? + try: + text_content = content.decode('utf-8').strip() + if text_content.startswith('magnet:'): + logger.info(f"🔗 Contenu magnet détecté") + return self.add_torrent_url(text_content, save_path, category, paused) + except: + pass + + logger.error("❌ Le fichier téléchargé n'est pas un torrent valide (format bencode)") + return False + + # Envoyer en base64 + metainfo = base64.b64encode(content).decode('utf-8') + + arguments = {"metainfo": metainfo} + + if save_path: + arguments["download-dir"] = save_path + if paused: + arguments["paused"] = True + + result = self._rpc_call("torrent-add", arguments) + + if result: + if "torrent-added" in result: + logger.info(f"✅ Torrent ajouté: {result['torrent-added'].get('name', 'unknown')}") + return True + elif "torrent-duplicate" in result: + logger.warning(f"⚠️ Torrent déjà présent: {result['torrent-duplicate'].get('name', 'unknown')}") + return True + + return False + + except Exception as e: + logger.error(f"❌ Erreur téléchargement torrent: {e}") + return False + + def add_torrent_file(self, file_content: bytes, filename: str, + save_path: Optional[str] = None, + category: Optional[str] = None, paused: bool = False) -> bool: + """Ajoute un torrent via fichier.""" + if not self._ensure_connected(): + return False + + # Encoder le fichier en base64 + metainfo = base64.b64encode(file_content).decode('utf-8') + + arguments = {"metainfo": metainfo} + + if save_path: + arguments["download-dir"] = save_path + if paused: + arguments["paused"] = True + + result = self._rpc_call("torrent-add", arguments) + + if result: + if "torrent-added" in result: + logger.info(f"✅ Torrent ajouté: {filename}") + return True + elif "torrent-duplicate" in result: + logger.warning(f"⚠️ Torrent déjà présent: {filename}") + return True + + return False + + def get_torrents(self) -> List[TorrentInfo]: + """Récupère la liste de tous les torrents.""" + if not self._ensure_connected(): + return [] + + fields = [ + "id", "hashString", "name", "totalSize", "percentDone", + "status", "rateDownload", "rateUpload", "seeders", "peersGettingFromUs", + "downloadDir", "error", "errorString" + ] + + result = self._rpc_call("torrent-get", {"fields": fields}) + + if not result or "torrents" not in result: + return [] + + torrents = [] + for t in result["torrents"]: + status = self.STATUS_MAP.get(t.get('status', 0), 'unknown') + if t.get('error', 0) > 0: + status = 'error' + + torrents.append(TorrentInfo( + hash=t.get('hashString', ''), + name=t.get('name', ''), + size=t.get('totalSize', 0), + progress=t.get('percentDone', 0), + status=status, + download_speed=t.get('rateDownload', 0), + upload_speed=t.get('rateUpload', 0), + seeds=t.get('seeders', 0) or 0, + peers=t.get('peersGettingFromUs', 0) or 0, + save_path=t.get('downloadDir', '') + )) + + return torrents + + def get_torrent(self, torrent_hash: str) -> Optional[TorrentInfo]: + """Récupère les informations d'un torrent spécifique.""" + if not self._ensure_connected(): + return None + + fields = [ + "id", "hashString", "name", "totalSize", "percentDone", + "status", "rateDownload", "rateUpload", "seeders", "peersGettingFromUs", + "downloadDir", "error", "errorString" + ] + + # Transmission utilise le hash pour identifier les torrents + result = self._rpc_call("torrent-get", { + "ids": [torrent_hash], + "fields": fields + }) + + if not result or "torrents" not in result or not result["torrents"]: + return None + + t = result["torrents"][0] + status = self.STATUS_MAP.get(t.get('status', 0), 'unknown') + if t.get('error', 0) > 0: + status = 'error' + + return TorrentInfo( + hash=t.get('hashString', ''), + name=t.get('name', ''), + size=t.get('totalSize', 0), + progress=t.get('percentDone', 0), + status=status, + download_speed=t.get('rateDownload', 0), + upload_speed=t.get('rateUpload', 0), + seeds=t.get('seeders', 0) or 0, + peers=t.get('peersGettingFromUs', 0) or 0, + save_path=t.get('downloadDir', '') + ) + + def pause_torrent(self, torrent_hash: str) -> bool: + """Met un torrent en pause.""" + if not self._ensure_connected(): + return False + + result = self._rpc_call("torrent-stop", {"ids": [torrent_hash]}) + return result is not None + + def resume_torrent(self, torrent_hash: str) -> bool: + """Reprend un torrent en pause.""" + if not self._ensure_connected(): + return False + + result = self._rpc_call("torrent-start", {"ids": [torrent_hash]}) + return result is not None + + def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool: + """Supprime un torrent.""" + if not self._ensure_connected(): + return False + + result = self._rpc_call("torrent-remove", { + "ids": [torrent_hash], + "delete-local-data": delete_files + }) + return result is not None + + def get_categories(self) -> List[str]: + """ + Récupère la liste des catégories. + Note: Transmission n'a pas de système de catégories natif, + on retourne une liste vide. + """ + return [] + + def get_version(self) -> Optional[str]: + """Récupère la version de Transmission.""" + if not self._ensure_connected(): + return None + + result = self._rpc_call("session-get") + if result: + return result.get('version') + return None + + def test_connection(self) -> Dict[str, Any]: + """Teste la connexion et retourne des infos.""" + result = super().test_connection() + + if result["success"]: + version = self.get_version() + if version: + result["version"] = version + + return result + + +# Pour l'auto-découverte du plugin +PLUGIN_CLASS = TransmissionPlugin \ No newline at end of file diff --git a/app/prowlarr_api.py b/app/prowlarr_api.py new file mode 100644 index 0000000..1449081 --- /dev/null +++ b/app/prowlarr_api.py @@ -0,0 +1,265 @@ +import requests +import logging +from datetime import datetime +from dateutil import parser as date_parser + +logger = logging.getLogger(__name__) + + +class ProwlarrAPI: + """Classe pour interagir avec l'API Prowlarr""" + + def __init__(self, base_url, api_key): + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': 'Lycostorrent/2.0', + 'X-Api-Key': api_key + }) + + def get_indexers(self): + """Récupère la liste des indexers configurés dans Prowlarr""" + try: + url = f"{self.base_url}/api/v1/indexer" + + response = self.session.get(url, timeout=10) + response.raise_for_status() + + data = response.json() + + indexers = [] + for indexer in data: + if indexer.get('enable', False): + indexers.append({ + 'id': str(indexer.get('id')), + 'name': indexer.get('name', 'Unknown'), + 'type': 'private' if indexer.get('privacy') == 'private' else 'public', + 'source': 'prowlarr' + }) + + logger.info(f"✅ {len(indexers)} indexers récupérés depuis Prowlarr") + return indexers + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur connexion Prowlarr: {e}") + return [] + except Exception as e: + logger.error(f"❌ Erreur récupération indexers Prowlarr: {e}") + return [] + + def search(self, query, indexers=None, category=None, max_results=2000): + """ + Effectue une recherche sur Prowlarr. + Note: Prowlarr ne filtre pas bien avec indexerIds/categories via l'API, + donc on fait la recherche globale et on filtre côté client si nécessaire. + """ + try: + # Si query vide, utiliser le fallback + if not query or query.strip() == '': + return self._get_latest(indexers, category, max_results) + + url = f"{self.base_url}/api/v1/search" + + # Prowlarr fonctionne mieux avec juste query + limit + # Les filtres indexerIds et categories semblent être ignorés ou mal interprétés + params = { + 'query': query, + 'limit': min(max_results, 100) + } + + logger.info(f"🔍 Prowlarr search: query='{query}'") + + response = self.session.get(url, params=params, timeout=60) + + if response.status_code != 200: + logger.error(f"❌ Prowlarr error {response.status_code}: {response.text[:500]}") + return [] + + results = response.json() + logger.info(f"📦 Prowlarr: {len(results)} résultats bruts") + + # Filtrer par indexer si spécifié + if indexers and len(indexers) > 0: + indexer_ids = [int(idx) if str(idx).isdigit() else idx for idx in indexers] + results = [r for r in results if r.get('indexerId') in indexer_ids] + logger.info(f"📦 Prowlarr après filtre indexers: {len(results)} résultats") + + # Formater les résultats + formatted_results = [] + for r in results[:max_results]: + try: + formatted = self._format_result(r) + formatted_results.append(formatted) + except Exception as e: + logger.warning(f"⚠️ Erreur formatage: {e}") + + return formatted_results + + except requests.exceptions.Timeout: + logger.error("⏱️ Timeout Prowlarr") + return [] + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur connexion Prowlarr: {e}") + return [] + except Exception as e: + logger.error(f"❌ Erreur recherche Prowlarr: {e}", exc_info=True) + return [] + + def _get_latest(self, indexers=None, category=None, max_results=100): + """ + Récupère les dernières releases via l'API Prowlarr. + Essaie plusieurs termes de recherche si nécessaire. + """ + try: + # Termes à essayer dans l'ordre + search_terms = ['', 'a', 'e', 'the'] # Termes très génériques + + url = f"{self.base_url}/api/v1/search" + + for term in search_terms: + # Construire les paramètres + params = [('limit', min(max_results, 100))] + + if term: + params.append(('query', term)) + + # Ajouter les indexerIds + if indexers and len(indexers) > 0: + for idx in indexers: + try: + params.append(('indexerIds', int(idx))) + except (ValueError, TypeError): + params.append(('indexerIds', idx)) + + # Ajouter les categories pour filtrer (films, séries, etc.) + if category: + cat_list = str(category).split(',') + for cat in cat_list: + cat_clean = cat.strip() + if cat_clean and cat_clean.isdigit(): + params.append(('categories', int(cat_clean))) + + logger.info(f"🔍 Prowlarr latest: term='{term}', indexerIds={indexers}, categories={category}") + + response = self.session.get(url, params=params, timeout=60) + + logger.debug(f"📡 Prowlarr URL: {response.url}") + + if response.status_code != 200: + logger.warning(f"⚠️ Prowlarr error: {response.status_code}") + continue + + results = response.json() + logger.info(f"📦 Prowlarr latest: {len(results)} résultats") + + # Si on a des résultats, on arrête + if len(results) > 0: + formatted_results = [] + for r in results[:max_results]: + try: + formatted = self._format_result(r) + formatted_results.append(formatted) + except Exception as e: + logger.warning(f"⚠️ Erreur formatage: {e}") + return formatted_results + + # Aucun terme n'a fonctionné + logger.warning(f"⚠️ Prowlarr: aucun résultat avec tous les termes essayés") + return [] + + return formatted_results + + except Exception as e: + logger.error(f"❌ Erreur Prowlarr latest: {e}") + return [] + + def _format_result(self, result): + """Formate un résultat Prowlarr pour être compatible avec le format Jackett""" + try: + # Parser la date + publish_date = result.get('publishDate', '') + try: + if publish_date: + dt = date_parser.parse(publish_date) + formatted_date = dt.strftime('%Y-%m-%d %H:%M') + else: + formatted_date = 'N/A' + except: + formatted_date = 'N/A' + + # Récupérer le nom de l'indexer + indexer = result.get('indexer', 'Unknown') + + return { + 'Title': result.get('title', 'Sans titre'), + 'Tracker': indexer, + 'Category': ', '.join(result.get('categories', [{'name': 'N/A'}])[0].get('name', 'N/A') if result.get('categories') else 'N/A'), + 'PublishDate': formatted_date, + 'PublishDateRaw': publish_date, + 'Size': result.get('size', 0), + 'SizeFormatted': self._format_size(result.get('size', 0)), + 'Seeders': result.get('seeders', 0), + 'Peers': result.get('leechers', 0), + 'Link': result.get('downloadUrl', ''), + 'MagnetUri': result.get('magnetUrl', ''), + 'Guid': result.get('guid', ''), + 'Details': result.get('infoUrl', ''), + 'Source': 'prowlarr' + } + except Exception as e: + logger.warning(f"⚠️ Erreur formatage résultat Prowlarr: {e}") + return result + + def _format_size(self, size_bytes): + """Convertit une taille en bytes vers un format lisible""" + try: + size_bytes = int(size_bytes) + if size_bytes == 0: + return "0 B" + + units = ['B', 'KB', 'MB', 'GB', 'TB'] + unit_index = 0 + size = float(size_bytes) + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if unit_index >= 2: # MB et plus + return f"{size:.2f} {units[unit_index]}" + else: + return f"{size:.0f} {units[unit_index]}" + except: + return "N/A" + + def get_indexer_categories(self, indexer_id): + """Récupère les catégories disponibles pour un indexer""" + try: + url = f"{self.base_url}/api/v1/indexer/{indexer_id}" + + response = self.session.get(url, timeout=10) + response.raise_for_status() + + data = response.json() + capabilities = data.get('capabilities', {}) + categories = capabilities.get('categories', []) + + result = [] + for cat in categories: + result.append({ + 'id': str(cat.get('id')), + 'name': cat.get('name', f"Catégorie {cat.get('id')}") + }) + # Sous-catégories + for subcat in cat.get('subCategories', []): + result.append({ + 'id': str(subcat.get('id')), + 'name': f" └ {subcat.get('name', '')}" + }) + + return result + + except Exception as e: + logger.error(f"❌ Erreur récupération catégories Prowlarr: {e}") + return [] \ No newline at end of file diff --git a/app/rss_source.py b/app/rss_source.py new file mode 100644 index 0000000..d11175d --- /dev/null +++ b/app/rss_source.py @@ -0,0 +1,559 @@ +""" +RSS Source - Parser générique pour flux RSS de trackers torrent +Permet d'ajouter n'importe quel tracker qui fournit un flux RSS +""" + +import os +import requests +import logging +import re +import xml.etree.ElementTree as ET +from datetime import datetime +from urllib.parse import urlparse, parse_qs, urlencode, urlunparse + +logger = logging.getLogger(__name__) + + +class RSSSource: + """Classe pour gérer les flux RSS de trackers torrent""" + + # Protocoles autorisés + ALLOWED_PROTOCOLS = ['http', 'https'] + + def __init__(self, flaresolverr_url=None): + self.session = requests.Session() + self.flaresolverr_url = flaresolverr_url or os.getenv('FLARESOLVERR_URL', '') + + # User-Agent réaliste pour éviter les blocages + self.session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'application/rss+xml, application/xml, text/xml, */*', + 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Cache-Control': 'no-cache' + }) + + if self.flaresolverr_url: + logger.info(f"✅ Flaresolverr configuré: {self.flaresolverr_url}") + + def _validate_url(self, url): + """Valide qu'une URL est sûre""" + try: + parsed = urlparse(url) + if parsed.scheme not in self.ALLOWED_PROTOCOLS: + logger.warning(f"⚠️ Protocole non autorisé: {parsed.scheme}") + return False + if not parsed.netloc: + logger.warning("⚠️ URL sans domaine") + return False + # Bloquer les URLs locales (sécurité SSRF) + if parsed.netloc in ['localhost', '127.0.0.1', '0.0.0.0']: + logger.warning("⚠️ URL locale bloquée") + return False + return True + except Exception as e: + logger.warning(f"⚠️ URL invalide: {e}") + return False + + def fetch_feed(self, feed_config, max_results=50): + """ + Récupère et parse un flux RSS. + + Args: + feed_config: dict avec {url, name, category, passkey, use_flaresolverr, cookies} + max_results: nombre max de résultats + + Returns: + Liste de résultats formatés comme Jackett/Prowlarr + """ + try: + url = feed_config.get('url', '') + name = feed_config.get('name', 'RSS') + passkey = feed_config.get('passkey', '') + use_flaresolverr = feed_config.get('use_flaresolverr', False) + cookies = feed_config.get('cookies', '') + + # Injecter le passkey si présent + if passkey and '{passkey}' in url: + url = url.replace('{passkey}', passkey) + + # Valider l'URL + if not self._validate_url(url): + logger.error(f"❌ RSS {name}: URL invalide ou non autorisée") + return [] + + logger.info(f"🔗 RSS {name}: Fetching {self._mask_url(url)}") + + # Utiliser Flaresolverr si activé et configuré + if use_flaresolverr and self.flaresolverr_url: + content = self._fetch_with_flaresolverr(url, cookies) + if content is None: + return [] + else: + # Requête directe avec cookies si fournis + if cookies: + cookie_dict = self._parse_cookies(cookies) + response = self.session.get(url, timeout=30, cookies=cookie_dict) + else: + response = self.session.get(url, timeout=30) + response.raise_for_status() + content = response.content + + # Parser le XML + results = self._parse_rss(content, name) + + logger.info(f"📦 RSS {name}: {len(results)} résultats") + + return results[:max_results] + + except requests.exceptions.Timeout: + logger.error(f"⏱️ RSS {feed_config.get('name', 'Unknown')}: Timeout") + return [] + except requests.exceptions.RequestException as e: + logger.error(f"❌ RSS {feed_config.get('name', 'Unknown')}: Erreur connexion - {e}") + return [] + except Exception as e: + logger.error(f"❌ RSS {feed_config.get('name', 'Unknown')}: Erreur - {e}", exc_info=True) + return [] + + def _parse_cookies(self, cookies_str): + """Parse une chaîne de cookies en dictionnaire""" + cookie_dict = {} + if not cookies_str: + return cookie_dict + + # Format: "nom1=valeur1; nom2=valeur2" + for part in cookies_str.split(';'): + part = part.strip() + if '=' in part: + key, value = part.split('=', 1) + cookie_dict[key.strip()] = value.strip() + + return cookie_dict + + def _fetch_with_flaresolverr(self, url, cookies=''): + """Récupère une URL via Flaresolverr pour bypass Cloudflare""" + try: + logger.info(f"🛡️ Utilisation de Flaresolverr pour: {self._mask_url(url)}") + + # Utiliser une session persistante pour garder les cookies + session_id = "lycostorrent_session" + + payload = { + "cmd": "request.get", + "url": url, + "session": session_id, + "maxTimeout": 60000 + } + + # Ajouter les cookies si fournis + if cookies: + cookie_list = [] + for part in cookies.split(';'): + part = part.strip() + if '=' in part: + key, value = part.split('=', 1) + cookie_list.append({ + "name": key.strip(), + "value": value.strip(), + "domain": self._extract_domain(url) + }) + if cookie_list: + payload["cookies"] = cookie_list + logger.info(f"🍪 {len(cookie_list)} cookies ajoutés à la requête") + + response = requests.post( + f"{self.flaresolverr_url}/v1", + json=payload, + timeout=65 + ) + response.raise_for_status() + + data = response.json() + + if data.get('status') == 'ok': + solution = data.get('solution', {}) + html_content = solution.get('response', '') + + logger.info(f"✅ Flaresolverr: succès (status {solution.get('status', 'N/A')})") + + # Vérifier si c'est du XML RSS + if html_content.strip().startswith(' 16 else date_str + + except Exception: + return date_str + + def _parse_date_to_iso(self, date_str): + """Convertit une date RSS en format ISO pour le tri""" + if not date_str: + return '' + + try: + # Formats de date courants + for fmt in [ + '%a, %d %b %Y %H:%M:%S %z', + '%a, %d %b %Y %H:%M:%S %Z', + '%a, %d %b %Y %H:%M:%S', + '%Y-%m-%dT%H:%M:%S%z', + '%Y-%m-%dT%H:%M:%SZ', + '%Y-%m-%d %H:%M:%S', + '%d/%m/%Y %H:%M:%S', + '%d/%m/%Y %H:%M', + ]: + try: + dt = datetime.strptime(date_str.strip(), fmt) + # Retourner en format ISO triable + return dt.strftime('%Y-%m-%dT%H:%M:%S') + except ValueError: + continue + + return '' + + except Exception: + return '' + + def _format_size(self, size_bytes): + """Formate une taille en bytes en format lisible""" + if not size_bytes or size_bytes == 0: + return 'N/A' + + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size_bytes < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + + return f"{size_bytes:.1f} PB" + + def _mask_url(self, url): + """Masque les informations sensibles dans l'URL pour les logs""" + try: + parsed = urlparse(url) + query = parse_qs(parsed.query) + + # Masquer passkey, apikey, etc. + sensitive_keys = ['passkey', 'apikey', 'api_key', 'key', 'token', 'auth'] + for key in sensitive_keys: + if key in query: + query[key] = ['***'] + + # Reconstruire l'URL + new_query = urlencode(query, doseq=True) + masked = urlunparse((parsed.scheme, parsed.netloc, parsed.path, + parsed.params, new_query, parsed.fragment)) + return masked + + except Exception: + return url[:50] + '...' + + +class RSSManager: + """Gestionnaire des flux RSS configurés""" + + def __init__(self, config_path='/app/config/rss_feeds.json'): + self.config_path = config_path + self.rss_source = RSSSource() + self.feeds = [] + self.load_config() + + def load_config(self): + """Charge la configuration des flux RSS""" + import json + import os + + try: + if os.path.exists(self.config_path): + with open(self.config_path, 'r') as f: + data = json.load(f) + self.feeds = data.get('feeds', []) + logger.info(f"✅ {len(self.feeds)} flux RSS configurés") + else: + self.feeds = [] + logger.info("📝 Aucun flux RSS configuré") + except Exception as e: + logger.error(f"❌ Erreur chargement config RSS: {e}") + self.feeds = [] + + def save_config(self): + """Sauvegarde la configuration des flux RSS""" + import json + import os + + try: + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + with open(self.config_path, 'w') as f: + json.dump({'feeds': self.feeds}, f, indent=2) + logger.info(f"✅ Configuration RSS sauvegardée") + return True + except Exception as e: + logger.error(f"❌ Erreur sauvegarde config RSS: {e}") + return False + + def get_feeds(self, category=None): + """Retourne les flux RSS, optionnellement filtrés par catégorie""" + if category: + return [f for f in self.feeds if f.get('category') == category or not f.get('category')] + return self.feeds + + def get_feeds_for_latest(self, category): + """Retourne les flux RSS configurés pour une catégorie de nouveautés""" + matching = [] + for feed in self.feeds: + if feed.get('enabled', True): + feed_cat = feed.get('category', '') + if feed_cat == category or feed_cat == 'all': + matching.append(feed) + return matching + + def add_feed(self, feed): + """Ajoute un nouveau flux RSS""" + # Générer un ID unique + import uuid + feed['id'] = str(uuid.uuid4())[:8] + feed['enabled'] = feed.get('enabled', True) + self.feeds.append(feed) + self.save_config() + return feed + + def update_feed(self, feed_id, updates): + """Met à jour un flux RSS existant""" + for feed in self.feeds: + if feed.get('id') == feed_id: + feed.update(updates) + self.save_config() + return feed + return None + + def delete_feed(self, feed_id): + """Supprime un flux RSS""" + self.feeds = [f for f in self.feeds if f.get('id') != feed_id] + self.save_config() + return True + + def test_feed(self, url, passkey='', use_flaresolverr=False, cookies=''): + """Teste un flux RSS et retourne un aperçu""" + test_config = { + 'url': url, + 'name': 'Test', + 'passkey': passkey, + 'use_flaresolverr': use_flaresolverr, + 'cookies': cookies + } + results = self.rss_source.fetch_feed(test_config, max_results=5) + return { + 'success': len(results) > 0, + 'count': len(results), + 'sample': results[:3] if results else [] + } + + def fetch_latest(self, category, max_results=50): + """Récupère les nouveautés de tous les flux RSS pour une catégorie""" + all_results = [] + feeds = self.get_feeds_for_latest(category) + + for feed in feeds: + try: + results = self.rss_source.fetch_feed(feed, max_results=max_results) + all_results.extend(results) + except Exception as e: + logger.error(f"❌ Erreur fetch RSS {feed.get('name')}: {e}") + + # Trier par date (plus récent en premier) + all_results.sort(key=lambda x: x.get('PublishDateRaw', ''), reverse=True) + + return all_results[:max_results] \ No newline at end of file diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..340dd66 --- /dev/null +++ b/app/security.py @@ -0,0 +1,375 @@ +""" +Lycostorrent - Module de sécurité +Version 2.0 + +Fonctionnalités : +- Hash des mots de passe (bcrypt) +- Rate limiting +- Protection CSRF +- Gestion sécurisée des sessions +- Logs de sécurité +""" + +import os +import json +import hashlib +import secrets +import logging +import time +from datetime import datetime, timedelta +from pathlib import Path +from functools import wraps +from threading import Lock + +logger = logging.getLogger(__name__) + +# ============================================================ +# CONFIGURATION +# ============================================================ + +CONFIG_DIR = Path('/app/config') +SECURITY_CONFIG_FILE = CONFIG_DIR / 'security.json' +FAILED_ATTEMPTS_FILE = CONFIG_DIR / 'failed_attempts.json' + +# Paramètres de sécurité +MAX_FAILED_ATTEMPTS = int(os.getenv('MAX_FAILED_ATTEMPTS', 5)) +LOCKOUT_DURATION = int(os.getenv('LOCKOUT_DURATION', 300)) # 5 minutes +RATE_LIMIT_WINDOW = int(os.getenv('RATE_LIMIT_WINDOW', 60)) # 1 minute +RATE_LIMIT_MAX_REQUESTS = int(os.getenv('RATE_LIMIT_MAX_REQUESTS', 30)) + +# Lock pour thread-safety +_lock = Lock() + + +# ============================================================ +# HASH DES MOTS DE PASSE +# ============================================================ + +def hash_password(password: str) -> str: + """ + Hash un mot de passe avec SHA-256 + salt + Format: salt$hash + """ + if not password: + return '' + + salt = secrets.token_hex(16) + hash_obj = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt.encode('utf-8'), + 100000 # iterations + ) + return f"{salt}${hash_obj.hex()}" + + +def verify_password(password: str, hashed: str) -> bool: + """ + Vérifie un mot de passe contre son hash + Supporte aussi la comparaison directe pour migration + """ + if not password or not hashed: + return False + + # Si le hash ne contient pas de $, c'est un mot de passe en clair (legacy) + if '$' not in hashed: + return password == hashed + + try: + salt, stored_hash = hashed.split('$', 1) + hash_obj = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt.encode('utf-8'), + 100000 + ) + return hash_obj.hex() == stored_hash + except Exception as e: + logger.error(f"Erreur vérification mot de passe: {e}") + return False + + +def is_password_hashed(password: str) -> bool: + """Vérifie si un mot de passe est déjà hashé""" + if not password: + return False + # Un hash valide contient un $ et fait au moins 64 caractères (32 salt + $ + 64 hash) + return '$' in password and len(password) >= 97 + + +def migrate_password_if_needed(current_password: str) -> tuple: + """ + Migre un mot de passe en clair vers un hash si nécessaire + Retourne (password_hash, was_migrated) + """ + if not current_password: + return '', False + + if is_password_hashed(current_password): + return current_password, False + + # Migrer le mot de passe + hashed = hash_password(current_password) + logger.info("🔐 Mot de passe migré vers format hashé") + return hashed, True + + +# ============================================================ +# RATE LIMITING & PROTECTION BRUTE-FORCE +# ============================================================ + +class RateLimiter: + """Gestionnaire de rate limiting en mémoire""" + + def __init__(self): + self.requests = {} # {ip: [(timestamp, count), ...]} + self.failed_attempts = {} # {ip: {'count': int, 'locked_until': timestamp}} + self._load_failed_attempts() + + def _load_failed_attempts(self): + """Charge les tentatives échouées depuis le fichier""" + try: + if FAILED_ATTEMPTS_FILE.exists(): + with open(FAILED_ATTEMPTS_FILE) as f: + data = json.load(f) + # Nettoyer les entrées expirées + now = time.time() + self.failed_attempts = { + ip: info for ip, info in data.items() + if info.get('locked_until', 0) > now or info.get('count', 0) > 0 + } + except Exception as e: + logger.warning(f"Erreur chargement tentatives échouées: {e}") + self.failed_attempts = {} + + def _save_failed_attempts(self): + """Sauvegarde les tentatives échouées""" + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(FAILED_ATTEMPTS_FILE, 'w') as f: + json.dump(self.failed_attempts, f) + except Exception as e: + logger.warning(f"Erreur sauvegarde tentatives échouées: {e}") + + def is_rate_limited(self, ip: str) -> bool: + """Vérifie si une IP est rate-limitée""" + now = time.time() + + with _lock: + # Nettoyer les anciennes entrées + if ip in self.requests: + self.requests[ip] = [ + (ts, count) for ts, count in self.requests[ip] + if now - ts < RATE_LIMIT_WINDOW + ] + + # Compter les requêtes récentes + if ip in self.requests: + total = sum(count for _, count in self.requests[ip]) + if total >= RATE_LIMIT_MAX_REQUESTS: + return True + + # Ajouter cette requête + if ip not in self.requests: + self.requests[ip] = [] + self.requests[ip].append((now, 1)) + + return False + + def is_locked_out(self, ip: str) -> tuple: + """ + Vérifie si une IP est bloquée après trop de tentatives échouées + Retourne (is_locked, remaining_seconds) + """ + with _lock: + if ip not in self.failed_attempts: + return False, 0 + + info = self.failed_attempts[ip] + locked_until = info.get('locked_until', 0) + + if locked_until > time.time(): + remaining = int(locked_until - time.time()) + return True, remaining + + # Le lockout a expiré, réinitialiser + if info.get('count', 0) >= MAX_FAILED_ATTEMPTS: + self.failed_attempts[ip] = {'count': 0, 'locked_until': 0} + self._save_failed_attempts() + + return False, 0 + + def record_failed_attempt(self, ip: str, username: str = None): + """Enregistre une tentative de connexion échouée""" + with _lock: + if ip not in self.failed_attempts: + self.failed_attempts[ip] = {'count': 0, 'locked_until': 0} + + self.failed_attempts[ip]['count'] = self.failed_attempts[ip].get('count', 0) + 1 + count = self.failed_attempts[ip]['count'] + + logger.warning(f"⚠️ Tentative échouée #{count} depuis {ip}" + + (f" (user: {username})" if username else "")) + + # Bloquer après trop de tentatives + if count >= MAX_FAILED_ATTEMPTS: + self.failed_attempts[ip]['locked_until'] = time.time() + LOCKOUT_DURATION + logger.warning(f"🔒 IP bloquée pour {LOCKOUT_DURATION}s: {ip}") + + self._save_failed_attempts() + + def record_successful_login(self, ip: str): + """Réinitialise les tentatives après une connexion réussie""" + with _lock: + if ip in self.failed_attempts: + del self.failed_attempts[ip] + self._save_failed_attempts() + + +# Instance globale +rate_limiter = RateLimiter() + + +# ============================================================ +# PROTECTION CSRF +# ============================================================ + +def generate_csrf_token() -> str: + """Génère un token CSRF""" + return secrets.token_hex(32) + + +def validate_csrf_token(session_token: str, form_token: str) -> bool: + """Valide un token CSRF""" + if not session_token or not form_token: + return False + return secrets.compare_digest(session_token, form_token) + + +# ============================================================ +# HEADERS DE SÉCURITÉ +# ============================================================ + +def get_security_headers() -> dict: + """Retourne les headers de sécurité HTTP recommandés""" + return { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()', + 'Content-Security-Policy': ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://www.youtube.com https://s.ytimg.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https://image.tmdb.org https://i.ytimg.com https://*.last.fm; " + "frame-src https://www.youtube.com https://www.youtube-nocookie.com; " + "connect-src 'self'; " + "font-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self';" + ), + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' + } + + +# ============================================================ +# VALIDATION DES ENTRÉES +# ============================================================ + +def sanitize_input(value: str, max_length: int = 200) -> str: + """Nettoie une entrée utilisateur""" + if not value: + return '' + + # Limiter la longueur + value = str(value)[:max_length] + + # Supprimer les caractères de contrôle + value = ''.join(char for char in value if ord(char) >= 32 or char in '\n\r\t') + + return value.strip() + + +def validate_username(username: str) -> bool: + """Valide un nom d'utilisateur""" + if not username: + return False + if len(username) < 3 or len(username) > 50: + return False + # Autoriser lettres, chiffres, underscores, tirets + import re + return bool(re.match(r'^[a-zA-Z0-9_-]+$', username)) + + +def get_client_ip(request) -> str: + """Récupère l'IP réelle du client (gère les proxies)""" + # Headers de proxy communs + headers_to_check = [ + 'X-Forwarded-For', + 'X-Real-IP', + 'CF-Connecting-IP', # Cloudflare + 'True-Client-IP', + ] + + for header in headers_to_check: + if header in request.headers: + # X-Forwarded-For peut contenir plusieurs IPs + ip = request.headers[header].split(',')[0].strip() + if ip: + return ip + + return request.remote_addr or '127.0.0.1' + + +# ============================================================ +# GESTION SÉCURISÉE DE LA CONFIG +# ============================================================ + +def load_security_config() -> dict: + """Charge la configuration de sécurité""" + default_config = { + 'password_hash': '', + 'password_migrated': False, + 'last_password_change': None, + 'created_at': datetime.now().isoformat() + } + + try: + if SECURITY_CONFIG_FILE.exists(): + with open(SECURITY_CONFIG_FILE) as f: + config = json.load(f) + return {**default_config, **config} + except Exception as e: + logger.warning(f"Erreur chargement config sécurité: {e}") + + return default_config + + +def save_security_config(config: dict): + """Sauvegarde la configuration de sécurité""" + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(SECURITY_CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + # Permissions restrictives + os.chmod(SECURITY_CONFIG_FILE, 0o600) + except Exception as e: + logger.error(f"Erreur sauvegarde config sécurité: {e}") + + +# ============================================================ +# LOGS DE SÉCURITÉ +# ============================================================ + +def log_security_event(event_type: str, ip: str, details: str = None): + """Log un événement de sécurité""" + message = f"[SECURITY] {event_type} - IP: {ip}" + if details: + message += f" - {details}" + + if event_type in ['LOGIN_FAILED', 'LOCKOUT', 'CSRF_INVALID', 'RATE_LIMITED']: + logger.warning(message) + else: + logger.info(message) diff --git a/app/static/css/admin.css b/app/static/css/admin.css new file mode 100644 index 0000000..db66e48 --- /dev/null +++ b/app/static/css/admin.css @@ -0,0 +1,1424 @@ +/* ============================================================ + LYCOSTORRENT - Admin Panel + ============================================================ */ + +/* Variables héritées de style.css */ + +/* ============================================================ + ONGLETS + ============================================================ */ + +.admin-tabs { + display: flex; + gap: 5px; + background: var(--bg-secondary); + padding: 10px; + border-radius: var(--radius); + margin-bottom: 20px; +} + +.tab-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 15px 20px; + background: transparent; + border: 2px solid transparent; + border-radius: var(--radius); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.tab-btn:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +.tab-btn.active { + background: var(--bg-card); + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.tab-icon { + font-size: 1.5rem; +} + +.tab-label { + font-size: 0.85rem; + font-weight: 600; +} + +/* ============================================================ + CONTENU DES ONGLETS + ============================================================ */ + +.admin-content { + min-height: 500px; +} + +.tab-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.tab-header { + margin-bottom: 25px; +} + +.tab-header h2 { + color: var(--accent-primary); + margin-bottom: 8px; +} + +.tab-header p { + color: var(--text-secondary); +} + +/* ============================================================ + CARTES ADMIN + ============================================================ */ + +.admin-card { + background: var(--bg-secondary); + padding: 25px; + border-radius: var(--radius); + margin-bottom: 20px; + box-shadow: var(--shadow); +} + +.admin-card h3 { + color: var(--text-primary); + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); + font-size: 1.1rem; +} + +.admin-card .highlight { + color: var(--accent-primary); + font-weight: 600; +} + +.help-text { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 15px; +} + +/* ============================================================ + GRILLES + ============================================================ */ + +.tracker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} + +.tracker-btn { + padding: 12px 15px; + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + text-align: left; +} + +.tracker-btn:hover { + border-color: var(--accent-primary); +} + +.tracker-btn.selected { + border-color: var(--accent-primary); + background: rgba(233, 69, 96, 0.1); +} + +.tracker-btn .tracker-name { + font-weight: 600; + display: block; + margin-bottom: 3px; +} + +.tracker-btn .tracker-source { + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* Config Grid */ +.config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.config-item { + background: var(--bg-card); + padding: 15px; + border-radius: var(--radius); +} + +.config-item label { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-primary); +} + +.config-item input { + width: 100%; + padding: 10px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 0.9rem; +} + +.config-item input:focus { + outline: none; + border-color: var(--accent-primary); +} + +/* Presets Grid */ +.presets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.preset-btn { + padding: 15px; + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.preset-btn:hover { + border-color: var(--accent-primary); + transform: translateY(-2px); +} + +.preset-btn small { + display: block; + color: var(--text-secondary); + font-size: 0.75rem; + margin-top: 5px; +} + +/* ============================================================ + TAGS CLOUD + ============================================================ */ + +.tags-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 15px; + min-height: 50px; +} + +.tag-item { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + background: var(--accent-secondary); + color: white; + border-radius: 20px; + font-size: 0.85rem; +} + +.tags-cloud.editable .tag-item { + padding-right: 8px; +} + +.tag-remove { + background: none; + border: none; + color: rgba(255,255,255,0.7); + cursor: pointer; + font-size: 1rem; + padding: 0 4px; + line-height: 1; +} + +.tag-remove:hover { + color: white; +} + +/* Categories Cloud */ +.categories-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.category-chip { + padding: 8px 15px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + font-size: 0.85rem; +} + +.category-chip:hover { + border-color: var(--accent-primary); + background: rgba(233, 69, 96, 0.1); +} + +.category-chip .cat-id { + color: var(--text-secondary); + font-size: 0.75rem; +} + +/* ============================================================ + FORMULAIRES + ============================================================ */ + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + color: var(--text-primary); + font-weight: 500; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 0.95rem; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent-primary); +} + +.form-group small { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.form-group small code { + background: var(--bg-primary); + padding: 2px 5px; + border-radius: 3px; +} + +.checkbox-inline { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-inline label { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 0; + cursor: pointer; +} + +.checkbox-inline input[type="checkbox"] { + width: auto; + accent-color: var(--accent-primary); +} + +.checkbox-inline small { + margin-top: 0; +} + +.add-form { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.add-form input { + flex: 1; + padding: 10px 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); +} + +/* ============================================================ + BOUTONS + ============================================================ */ + +.btn { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--accent-primary); + color: white; +} + +.btn-primary:hover { + background: #d63050; + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + border-color: var(--accent-primary); +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-icon { + padding: 8px 12px; + background: transparent; + border: none; + cursor: pointer; + font-size: 1.1rem; + transition: transform 0.1s; +} + +.btn-icon:hover { + transform: scale(1.2); +} + +.action-bar { + display: flex; + gap: 10px; + padding-top: 15px; + border-top: 1px solid var(--border-color); + margin-top: 15px; +} + +/* ============================================================ + LISTE DES FEEDS RSS + ============================================================ */ + +.feeds-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.feed-card { + display: flex; + align-items: center; + gap: 15px; + padding: 15px; + background: var(--bg-card); + border-radius: var(--radius); + border-left: 3px solid var(--accent-primary); +} + +.feed-card.disabled { + opacity: 0.5; + border-left-color: var(--text-secondary); +} + +.feed-info { + flex: 1; +} + +.feed-name { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 5px; +} + +.feed-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.feed-badge { + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; +} + +.feed-badge.movies { background: #e94560; color: white; } +.feed-badge.tv { background: #3b82f6; color: white; } +.feed-badge.anime { background: #f59e0b; color: white; } +.feed-badge.music { background: #10b981; color: white; } +.feed-badge.all { background: #8b5cf6; color: white; } +.feed-badge.flaresolverr { background: #6366f1; color: white; } +.feed-badge.cookies { background: #ec4899; color: white; } + +.feed-url { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 5px; + font-family: monospace; +} + +.feed-actions { + display: flex; + gap: 5px; +} + +/* ============================================================ + CONFIG SUMMARY + ============================================================ */ + +.config-summary { + display: grid; + gap: 10px; +} + +.summary-item { + display: flex; + align-items: center; + padding: 10px 15px; + background: var(--bg-card); + border-radius: var(--radius); +} + +.summary-tracker { + font-weight: 600; + min-width: 150px; + color: var(--accent-primary); +} + +.summary-cats { + display: flex; + gap: 10px; + flex-wrap: wrap; + flex: 1; +} + +.summary-cat { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.summary-cat span { + color: var(--text-primary); +} + +/* ============================================================ + TEST RESULT + ============================================================ */ + +.test-form { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.test-form input { + flex: 1; + padding: 10px 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); +} + +.test-result { + background: var(--bg-card); + padding: 15px; + border-radius: var(--radius); + margin-top: 15px; +} + +.result-row { + display: flex; + gap: 10px; + padding: 8px 0; +} + +.result-row .label { + min-width: 80px; + color: var(--text-secondary); +} + +.result-row .value { + color: var(--text-primary); + font-family: monospace; +} + +.result-row.success .value { + color: var(--success); + font-weight: 600; +} + +.test-success { + color: var(--success); +} + +.test-error { + color: var(--danger); +} + +/* ============================================================ + COLLAPSIBLE + ============================================================ */ + +.collapsible .collapsible-header { + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} + +.collapsible .collapse-icon { + transition: transform 0.2s; +} + +.collapsible.collapsed .collapsible-content { + display: none; +} + +.collapsible.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +/* Help Section */ +.help-section h4 { + color: var(--accent-primary); + margin: 15px 0 10px; +} + +.help-section ol { + padding-left: 20px; + color: var(--text-secondary); +} + +.help-section li { + margin-bottom: 5px; +} + +.help-table { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.help-row { + padding: 5px 10px; + background: var(--bg-primary); + border-radius: var(--radius); + font-size: 0.85rem; +} + +.help-row code { + color: var(--accent-primary); +} + +/* ============================================================ + TOAST + ============================================================ */ + +.toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 15px 25px; + border-radius: var(--radius); + color: white; + font-weight: 500; + z-index: 9999; + animation: slideIn 0.3s ease; +} + +.toast.success { background: var(--success); } +.toast.error { background: var(--danger); } +.toast.info { background: var(--accent-secondary); } + +@keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ============================================================ + EMPTY STATE + ============================================================ */ + +.empty-state { + text-align: center; + padding: 40px; + color: var(--text-secondary); +} + +.empty-state p { + margin: 5px 0; +} + +.loading { + color: var(--text-secondary); + font-style: italic; +} + +/* ============================================================ + RESPONSIVE + ============================================================ */ + +@media (max-width: 768px) { + .admin-tabs { + flex-wrap: wrap; + } + + .tab-btn { + flex: 1 1 30%; + padding: 10px; + } + + .tab-icon { + font-size: 1.2rem; + } + + .tab-label { + font-size: 0.75rem; + } + + .admin-card { + padding: 15px; + } + + .config-grid { + grid-template-columns: 1fr; + } + + .tracker-grid { + grid-template-columns: repeat(2, 1fr); + } + + .presets-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + grid-template-columns: 1fr; + } + + .action-bar { + flex-direction: column; + } + + .action-bar .btn { + width: 100%; + } + + .feed-card { + flex-direction: column; + align-items: flex-start; + } + + .feed-actions { + width: 100%; + justify-content: flex-end; + } +} + +/* ============================================================ + LEGACY SUPPORT (anciennes pages admin séparées) + ============================================================ */ + +.admin-info { + background: var(--bg-secondary); + padding: 20px; + border-radius: var(--radius); + margin-bottom: 20px; + border-left: 4px solid var(--accent-primary); +} + +.admin-info h3 { + margin-bottom: 10px; + color: var(--accent-primary); +} + +.admin-info p { + margin: 5px 0; + color: var(--text-secondary); +} + +.admin-section { + background: var(--bg-secondary); + padding: 25px; + border-radius: var(--radius); + margin-bottom: 20px; + box-shadow: var(--shadow); +} + +.admin-section h2 { + color: var(--accent-primary); + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.admin-nav { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 25px; +} + +.admin-nav a { + padding: 10px 20px; + background: var(--bg-card); + color: var(--text-primary); + text-decoration: none; + border-radius: var(--radius); + border: 1px solid var(--border-color); + transition: all 0.2s; +} + +.admin-nav a:hover { + border-color: var(--accent-primary); +} + +.admin-nav a.active { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.message-box { + position: fixed; + bottom: 20px; + right: 20px; + padding: 15px 25px; + border-radius: var(--radius); + color: white; + font-weight: 500; + z-index: 9999; + animation: slideIn 0.3s ease; +} + +.message-box.success { background: var(--success); } +.message-box.error { background: var(--danger); } +.message-box.info { background: var(--accent-secondary); } + + +/* ============================================================ + FILTERS EDITOR + ============================================================ */ + +.filters-editor { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 15px; +} + +.filter-editor-item { + background: var(--bg-card); + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border-color); +} + +.filter-editor-item.editing { + border-color: var(--accent-primary); +} + +.filter-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 15px; + cursor: pointer; + transition: background 0.2s; +} + +.filter-header:hover { + background: var(--bg-primary); +} + +.filter-icon { + font-size: 1.2rem; +} + +.filter-name { + flex: 1; + font-weight: 600; + color: var(--text-primary); +} + +.filter-count { + font-size: 0.8rem; + color: var(--text-secondary); + padding: 3px 8px; + background: var(--bg-primary); + border-radius: 12px; +} + +.filter-expand { + color: var(--text-secondary); + font-size: 0.8rem; +} + +.filter-values-preview { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 0 15px 12px; +} + +.value-chip { + padding: 3px 8px; + background: var(--bg-primary); + border-radius: 12px; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.value-more { + padding: 3px 8px; + font-size: 0.75rem; + color: var(--accent-primary); + font-weight: 600; +} + +.filter-edit-content { + padding: 15px; + background: var(--bg-primary); + border-top: 1px solid var(--border-color); +} + +.filter-meta-edit { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.filter-meta-edit input { + padding: 8px 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); +} + +.filter-name-input { + flex: 1; +} + +.filter-icon-input { + width: 60px; + text-align: center; +} + +.filter-values-textarea { + width: 100%; + padding: 10px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-family: monospace; + font-size: 0.9rem; + resize: vertical; +} + +.filter-edit-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.85rem; +} + +.btn-delete { + color: var(--danger); +} + +.btn-delete:hover { + transform: scale(1.2); +} + +/* Parsed results */ +.parsed-results { + margin-top: 10px; +} + +.parsed-item { + padding: 5px 0; + color: var(--text-primary); +} + +.parsed-item strong { + color: var(--accent-primary); +} + +/* ============================================================ + TORRENT CLIENT + ============================================================ */ + +.client-status { + padding: 15px; + background: var(--bg-primary); + border-radius: var(--radius); +} + +.status-badge { + display: inline-block; + padding: 5px 12px; + border-radius: 20px; + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 10px; +} + +.status-connected { + background: rgba(39, 174, 96, 0.2); + color: #27ae60; +} + +.status-disconnected { + background: rgba(231, 76, 60, 0.2); + color: #e74c3c; +} + +.status-disabled { + background: rgba(127, 140, 141, 0.2); + color: #7f8c8d; +} + +.plugins-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.plugin-item { + padding: 15px; + background: var(--bg-primary); + border-radius: var(--radius); + border: 1px solid var(--border-color); +} + +.plugin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.plugin-version { + font-size: 0.8rem; + color: var(--text-secondary); + background: var(--bg-card); + padding: 2px 8px; + border-radius: 10px; +} + +.plugin-description { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 5px 0; +} + +.plugin-author { + color: var(--text-secondary); + font-size: 0.8rem; +} +/* ============================================================ + CATÉGORIES PERSONNALISÉES CLIENT TORRENT + ============================================================ */ + +.custom-categories-list { + margin-bottom: 20px; +} + +.custom-category-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + background: var(--bg-primary); + border-radius: var(--radius); + margin-bottom: 10px; +} + +.custom-category-item .category-name { + flex: 1; + font-weight: 600; + color: var(--text-primary); +} + +.custom-category-item .category-path { + flex: 2; + color: var(--text-secondary); + font-family: monospace; + font-size: 0.9rem; + background: var(--bg-secondary); + padding: 5px 10px; + border-radius: 4px; +} + +.custom-category-item .category-actions { + display: flex; + gap: 5px; +} + +.custom-category-item .btn-edit, +.custom-category-item .btn-delete { + padding: 6px 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.custom-category-item .btn-edit { + background: var(--accent-secondary); + color: white; +} + +.custom-category-item .btn-delete { + background: var(--danger); + color: white; +} + +.custom-category-item .btn-edit:hover, +.custom-category-item .btn-delete:hover { + transform: scale(1.05); +} + +.add-category-form { + padding: 15px; + background: var(--bg-card); + border-radius: var(--radius); + border: 1px dashed var(--border-color); + margin-bottom: 15px; +} + +.add-category-form .form-row { + display: flex; + gap: 10px; + align-items: flex-end; +} + +.add-category-form .form-group { + flex: 1; +} + +.add-category-form .form-group-btn { + flex: 0 0 auto; +} + +.btn-success { + background: var(--success); + color: white; + border: none; + padding: 10px 15px; + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + transition: all 0.2s; +} + +.btn-success:hover { + background: #22b547; +} + +.no-categories { + text-align: center; + padding: 20px; + color: var(--text-secondary); + font-style: italic; +} + +/* Mobile */ +@media (max-width: 768px) { + .add-category-form .form-row { + flex-direction: column; + } + + .add-category-form .form-group-btn { + width: 100%; + } + + .add-category-form .form-group-btn button { + width: 100%; + } + + .custom-category-item { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .custom-category-item .category-path { + width: 100%; + word-break: break-all; + } + + .custom-category-item .category-actions { + width: 100%; + justify-content: flex-end; + } +} +/* ============================================================ + ONGLET MODULES + ============================================================ */ + +.modules-list { + display: flex; + flex-direction: column; + gap: 15px; + margin: 20px 0; +} + +.module-item { + display: flex; + align-items: flex-start; + gap: 15px; + padding: 20px; + background: var(--bg-primary); + border-radius: var(--radius); + border: 1px solid var(--border-color); + transition: all 0.3s ease; +} + +.module-item:hover { + border-color: var(--accent-primary); +} + +/* Toggle switch style */ +.module-toggle { + flex-shrink: 0; +} + +.module-toggle input[type="checkbox"] { + display: none; +} + +.module-toggle label { + display: block; + width: 50px; + height: 26px; + background: var(--bg-secondary); + border-radius: 13px; + position: relative; + cursor: pointer; + transition: all 0.3s ease; + border: 2px solid var(--border-color); +} + +.module-toggle label::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: var(--text-secondary); + border-radius: 50%; + transition: all 0.3s ease; +} + +.module-toggle input:checked + label { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.module-toggle input:checked + label::after { + left: 26px; + background: white; +} + +.module-info { + flex: 1; +} + +.module-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.module-icon { + font-size: 1.5rem; +} + +.module-name { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +.module-badge { + background: var(--accent-primary); + color: white; + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.module-description { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.5; +} + +.module-help { + display: flex; + flex-direction: column; + gap: 12px; +} + +.module-help p { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; + padding-left: 10px; + border-left: 3px solid var(--border-color); +} + +.module-help p strong { + color: var(--text-primary); +} + +/* Mobile */ +@media (max-width: 768px) { + .module-item { + flex-direction: column; + gap: 10px; + } + + .module-toggle { + align-self: flex-end; + order: -1; + } +} +/* ============================================================ + TRACKERS POUR DISCOVER + ============================================================ */ + +.discover-trackers-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 10px; + margin: 15px 0; +} + +.discover-tracker-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 15px; + background: var(--bg-primary); + border-radius: var(--radius); + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.discover-tracker-item:hover { + border-color: var(--accent-primary); +} + +.discover-tracker-item input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--accent-primary); +} + +.discover-tracker-item label { + flex: 1; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} + +.discover-tracker-item .tracker-name { + font-weight: 500; + color: var(--text-primary); +} + +.discover-tracker-item .tracker-source { + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--accent-secondary); + color: var(--text-secondary); +} + +.discover-tracker-item .tracker-source.jackett { + background: #2563eb20; + color: #3b82f6; +} + +.discover-tracker-item .tracker-source.prowlarr { + background: #7c3aed20; + color: #8b5cf6; +} + +/* Mobile */ +@media (max-width: 768px) { + .discover-trackers-list { + grid-template-columns: 1fr; + } +} diff --git a/app/static/css/cache-info.css b/app/static/css/cache-info.css new file mode 100644 index 0000000..a81c3fb --- /dev/null +++ b/app/static/css/cache-info.css @@ -0,0 +1,48 @@ +/* ============================================================ + CACHE INFO - Styles communs + ============================================================ */ + +.cache-info { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 15px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 0.85rem; + margin: 15px 0; + width: fit-content; +} + +.cache-badge { + color: var(--accent-primary); + font-weight: 600; +} + +.cache-timestamp { + color: var(--text-secondary); +} + +.btn-refresh { + background: transparent; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; +} + +.btn-refresh:hover { + background: var(--bg-primary); + transform: rotate(180deg); +} + +/* Mobile */ +@media (max-width: 768px) { + .cache-info { + width: 100%; + justify-content: center; + } +} diff --git a/app/static/css/discover.css b/app/static/css/discover.css new file mode 100644 index 0000000..c731760 --- /dev/null +++ b/app/static/css/discover.css @@ -0,0 +1,699 @@ +/* ============================================================ + LYCOSTORRENT - Page Découvrir + ============================================================ */ + +/* Onglets de catégories */ +.discover-tabs { + display: flex; + gap: 10px; + margin-bottom: 30px; + flex-wrap: wrap; + justify-content: center; +} + +.discover-tab { + padding: 12px 20px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.discover-tab:hover { + background: var(--bg-card); + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.discover-tab.active { + background: var(--accent-primary); + color: white; + border-color: var(--accent-primary); +} + +/* Grille de résultats */ +.discover-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 25px; + margin-bottom: 30px; +} + +/* Carte de film/série */ +.discover-card { + background: var(--bg-secondary); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: all 0.3s ease; + border: 1px solid var(--border-color); +} + +.discover-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + border-color: var(--accent-primary); +} + +.discover-card .poster-container { + position: relative; + aspect-ratio: 2/3; + overflow: hidden; +} + +.discover-card .poster { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.discover-card:hover .poster { + transform: scale(1.05); +} + +.discover-card .poster-placeholder { + width: 100%; + height: 100%; + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + color: var(--text-secondary); +} + +.discover-card .rating-badge { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.8); + color: #ffd700; + padding: 5px 10px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; +} + +.discover-card .card-info { + padding: 15px; +} + +.discover-card .card-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.discover-card .card-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.discover-card .card-year { + opacity: 0.8; +} + +.discover-card .card-type { + background: var(--accent-secondary); + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75rem; +} + +/* Badge nombre de torrents sur les cartes */ +.torrent-badge { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.8); + color: #4ade80; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; +} + + +/* Loader */ +.discover-loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px; + color: var(--text-secondary); + gap: 15px; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.spinner-small { + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* État vide */ +.discover-empty { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.discover-empty .empty-icon { + font-size: 4rem; + display: block; + margin-bottom: 15px; + opacity: 0.5; +} + +/* Modal détails */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; + overflow-y: auto; +} + +.detail-modal { + background: var(--bg-secondary); + border-radius: var(--radius); + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +.modal-close { + position: absolute; + top: 15px; + right: 15px; + background: var(--bg-card); + border: none; + color: var(--text-primary); + width: 35px; + height: 35px; + border-radius: 50%; + font-size: 1.2rem; + cursor: pointer; + z-index: 10; + transition: all 0.2s; +} + +.modal-close:hover { + background: var(--danger); + color: white; +} + +.detail-header { + display: flex; + gap: 25px; + padding: 25px; +} + +.detail-poster { + width: 200px; + border-radius: var(--radius); + flex-shrink: 0; +} + +.detail-info { + flex: 1; +} + +.detail-info h2 { + font-size: 1.8rem; + margin-bottom: 10px; + color: var(--text-primary); +} + +.detail-meta { + display: flex; + gap: 15px; + margin-bottom: 15px; + color: var(--text-secondary); +} + +.detail-rating { + color: #ffd700; + font-weight: 600; +} + +.detail-overview { + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 15px; +} + +.detail-genres { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.detail-genres span { + background: var(--accent-secondary); + color: var(--text-primary); + padding: 5px 12px; + border-radius: 15px; + font-size: 0.85rem; +} + +/* Section torrents */ +.detail-torrents { + padding: 25px; + border-top: 1px solid var(--border-color); +} + +.detail-torrents h3 { + margin-bottom: 15px; + color: var(--text-primary); +} + +.torrents-loading { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-secondary); + padding: 20px; +} + +.torrents-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.torrent-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px 15px; + background: var(--bg-primary); + border-radius: var(--radius); + transition: all 0.2s; +} + +.torrent-item:hover { + background: var(--bg-card); +} + +.torrent-info { + flex: 1; + min-width: 0; +} + +.torrent-name { + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 5px; +} + +.torrent-meta { + display: flex; + gap: 15px; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.torrent-meta .seeds { + color: var(--success); +} + +.torrent-meta .quality { + color: var(--accent-primary); +} + +.torrent-actions { + display: flex; + gap: 8px; +} + +.torrent-actions button { + padding: 8px 12px; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; +} + +.torrent-actions .btn-download { + background: var(--accent-primary); + color: white; +} + +.torrent-actions .btn-download:hover { + background: #d63850; +} + +.torrent-actions .btn-magnet { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.torrent-actions .btn-send { + background: var(--success); + color: white; +} + +.torrents-empty { + text-align: center; + padding: 30px; + color: var(--text-secondary); +} + +/* Footer crédit TMDb */ +.tmdb-credit { + margin-left: 15px; + font-size: 0.85rem; +} + +.tmdb-credit a { + color: var(--accent-primary); + text-decoration: none; +} + +.tmdb-credit a:hover { + text-decoration: underline; +} + +/* Responsive */ +@media (max-width: 768px) { + /* Navigation mobile */ + .main-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .main-nav a { + padding: 8px 15px; + font-size: 0.85rem; + flex: 1 1 auto; + text-align: center; + min-width: 80px; + max-width: 150px; + } + + .main-nav a.nav-logout { + flex: 0 0 auto; + min-width: 50px; + max-width: 50px; + padding: 8px 12px; + } + + .discover-tabs { + gap: 8px; + } + + .discover-tab { + padding: 10px 15px; + font-size: 0.85rem; + } + + .discover-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 15px; + } + + .modal-overlay { + padding: 10px; + } + + .detail-modal { + max-height: 95vh; + } + + .detail-header { + flex-direction: column; + align-items: center; + text-align: center; + padding: 15px; + gap: 15px; + } + + .detail-poster { + width: 120px; + } + + .detail-info h2 { + font-size: 1.3rem; + } + + .detail-meta { + justify-content: center; + flex-wrap: wrap; + gap: 10px; + } + + .detail-overview { + font-size: 0.9rem; + line-height: 1.5; + } + + .detail-genres { + justify-content: center; + } + + .detail-genres span { + padding: 4px 10px; + font-size: 0.8rem; + } + + /* Bande-annonce mobile */ + .detail-trailer { + padding: 0 15px 15px; + } + + .detail-trailer h3 { + font-size: 1rem; + } + + /* Torrents mobile */ + .detail-torrents { + padding: 15px; + } + + .detail-torrents h3 { + font-size: 1rem; + margin-bottom: 10px; + } + + .torrents-list { + gap: 8px; + } + + .torrent-item { + flex-direction: column; + align-items: stretch; + padding: 12px; + gap: 10px; + } + + .torrent-info { + width: 100%; + } + + .torrent-name { + font-size: 0.85rem; + white-space: normal; + word-break: break-word; + line-height: 1.4; + } + + .torrent-meta { + flex-wrap: wrap; + gap: 8px; + font-size: 0.8rem; + } + + .torrent-meta span { + background: var(--bg-secondary); + padding: 3px 8px; + border-radius: 4px; + } + + .torrent-actions { + width: 100%; + justify-content: space-between; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--border-color); + } + + .torrent-actions a, + .torrent-actions button { + flex: 1; + padding: 10px 8px; + text-align: center; + font-size: 1rem; + min-width: 0; + } +} + +/* Très petit écran */ +@media (max-width: 480px) { + /* Navigation */ + .main-nav { + gap: 6px; + } + + .main-nav a { + padding: 8px 10px; + font-size: 0.8rem; + min-width: 70px; + } + + .main-nav a.nav-logout { + min-width: 40px; + max-width: 40px; + padding: 8px; + } + + .discover-tabs { + gap: 6px; + } + + .discover-tab { + padding: 8px 12px; + font-size: 0.8rem; + flex: 1 1 auto; + text-align: center; + min-width: 0; + } + + .discover-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .discover-card .card-info { + padding: 10px; + } + + .discover-card .card-title { + font-size: 0.85rem; + } + + .discover-card .card-meta { + font-size: 0.75rem; + } + + .detail-poster { + width: 100px; + } + + .detail-info h2 { + font-size: 1.1rem; + } + + .torrent-actions { + flex-wrap: wrap; + } + + .torrent-actions a, + .torrent-actions button { + flex: 1 1 45%; + padding: 10px 6px; + font-size: 0.95rem; + } +} + +/* Hidden class */ +.hidden { + display: none !important; +} + +/* Bande-annonce YouTube */ +.detail-trailer { + padding: 0 25px 25px; +} + +.detail-trailer h3 { + margin-bottom: 15px; + color: var(--text-primary); +} + +.trailer-container { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* Ratio 16:9 */ + height: 0; + overflow: hidden; + border-radius: var(--radius); + background: var(--bg-primary); +} + +.trailer-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--radius); +} + +/* Lien vers le tracker */ +.torrent-item .torrent-link { + color: var(--accent-primary); + text-decoration: none; + font-size: 0.85rem; + margin-left: 10px; +} + +.torrent-item .torrent-link:hover { + text-decoration: underline; +} + +.torrent-actions .btn-link { + background: var(--bg-secondary); + color: var(--text-primary); + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.torrent-actions .btn-link:hover { + background: var(--accent-secondary); +} \ No newline at end of file diff --git a/app/static/css/latest.css b/app/static/css/latest.css new file mode 100644 index 0000000..b473955 --- /dev/null +++ b/app/static/css/latest.css @@ -0,0 +1,1678 @@ +/* ============================================================ + LYCOSTORRENT - Latest Page Styles + ============================================================ */ + +/* Navigation */ +.main-nav { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; +} + +.main-nav a { + padding: 10px 25px; + background: var(--bg-card); + color: var(--text-primary); + text-decoration: none; + border-radius: var(--radius); + transition: all 0.3s; + border: 1px solid var(--border-color); +} + +.main-nav a:hover { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.main-nav a.active { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* Settings Section */ +.latest-settings { + background: var(--bg-secondary); + padding: 25px; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 25px; +} + +/* ============================================================ + FILTRES PAR ANNÉE (PASTILLES) + ============================================================ */ + +.year-filters { + background: var(--bg-secondary); + padding: 15px 25px; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 25px; + display: flex; + align-items: center; + gap: 15px; + flex-wrap: wrap; +} + +.filter-label { + color: var(--text-secondary); + font-weight: 600; + font-size: 0.9rem; +} + +.year-pills { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.year-pill { + padding: 8px 16px; + background: var(--bg-card); + color: var(--text-primary); + border: 2px solid var(--border-color); + border-radius: 20px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.year-pill:hover { + border-color: var(--accent-primary); + background: var(--bg-primary); +} + +.year-pill.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; +} + +.filter-count { + color: var(--text-secondary); + font-size: 0.85rem; + margin-left: auto; +} + +/* Categories */ +.categories { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.category-btn { + padding: 10px 20px; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + font-size: 0.95rem; +} + +.category-btn:hover { + border-color: var(--accent-primary); +} + +.category-btn.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; +} + +/* Trackers Selector */ +.trackers-selector { + margin-bottom: 20px; +} + +.toggle-btn { + padding: 10px 20px; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.toggle-btn:hover { + border-color: var(--accent-primary); +} + +.trackers-panel { + margin-top: 15px; + padding: 15px; + background: var(--bg-card); + border-radius: var(--radius); +} + +.trackers-actions { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.trackers-actions button { + padding: 8px 15px; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; +} + +.trackers-actions button:hover { + border-color: var(--accent-primary); +} + +.trackers-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tracker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-primary); + border-radius: var(--radius); +} + +.tracker-item input { + accent-color: var(--accent-primary); +} + +.tracker-item label { + cursor: pointer; + font-size: 0.9rem; + flex: 1; +} + +/* Badges de source (Jackett/Prowlarr) - aussi pour latest */ +.tracker-item .source-badge { + font-size: 0.6rem; + font-weight: 700; + padding: 2px 5px; + border-radius: 4px; + text-transform: uppercase; +} + +/* Limit Selector */ +.limit-selector { + display: flex; + align-items: center; + gap: 15px; + flex-wrap: wrap; +} + +.limit-selector label { + color: var(--text-secondary); +} + +.limit-selector select { + padding: 10px 15px; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + font-size: 1rem; +} + +.btn-primary { + padding: 12px 25px; + background: var(--accent-primary); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: all 0.2s; +} + +.btn-primary:hover { + background: #d63050; + transform: translateY(-1px); +} + +/* Results Section */ +.latest-results { + background: var(--bg-secondary); + padding: 25px; + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.no-results { + text-align: center; + color: var(--text-secondary); + padding: 40px; + font-size: 1.1rem; +} + +.results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 2px solid var(--border-color); +} + +.results-header h2 { + color: var(--accent-primary); + font-size: 1.5rem; +} + +#resultsCount { + color: var(--text-secondary); + font-weight: 600; +} + +/* Results Grid */ +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 25px; +} + +/* Release Card */ +.release-card { + background: var(--bg-card); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); + transition: all 0.3s; +} + +.release-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); +} + +.card-poster { + position: relative; + width: 100%; + height: 400px; + overflow: hidden; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.card-poster img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.card-type { + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 5px 12px; + border-radius: 5px; + font-size: 0.8rem; + font-weight: 600; +} + +.card-rating { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.8); + color: #ffd700; + padding: 5px 10px; + border-radius: 5px; + font-weight: 600; + font-size: 0.85rem; +} + +.card-seeders { + position: absolute; + bottom: 10px; + right: 10px; + background: var(--success); + color: white; + padding: 5px 12px; + border-radius: 5px; + font-size: 0.8rem; + font-weight: 600; +} + +.card-variants { + position: absolute; + bottom: 10px; + left: 10px; + background: var(--warning); + color: black; + padding: 5px 12px; + border-radius: 5px; + font-size: 0.8rem; + font-weight: 600; +} + +.card-content { + padding: 15px; +} + +.card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.3; + height: 2.6em; +} + +.card-title-link { + text-decoration: none; + color: var(--text-primary); + transition: color 0.2s; +} + +.card-title-link:hover { + color: var(--accent-primary); +} + +.card-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-size: 0.85rem; +} + +.card-year { + color: var(--text-secondary); + font-weight: 600; +} + +.card-tracker { + background: var(--accent-secondary); + color: white; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.75rem; +} + +.card-tracker-link { + background: var(--accent-secondary); + color: white; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.75rem; + text-decoration: none; + transition: all 0.2s; +} + +.card-tracker-link:hover { + background: var(--accent-primary); + transform: scale(1.05); +} + +.btn-tracker { + padding: 10px 12px; + background: var(--accent-secondary); + color: white; + border: none; + border-radius: var(--radius); + text-decoration: none; + font-size: 1rem; + transition: all 0.2s; +} + +.btn-tracker:hover { + background: #0a2540; +} + +.card-overview { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 15px; + min-height: 3.9em; +} + +.card-actions { + display: flex; + gap: 8px; +} + +.btn-details { + flex: 1; + padding: 10px; + background: var(--accent-secondary); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} + +.btn-details:hover { + background: #0a2540; +} + +.btn-download-card { + padding: 10px 15px; + background: var(--success); + color: white; + border: none; + border-radius: var(--radius); + text-decoration: none; + font-size: 1rem; + transition: all 0.2s; +} + +.btn-download-card:hover { + background: #22b547; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + padding: 20px; +} + +.modal-content { + background: var(--bg-secondary); + border-radius: var(--radius); + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +.modal-close { + position: absolute; + top: 15px; + right: 20px; + font-size: 2rem; + color: white; + cursor: pointer; + z-index: 10; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.modal-header { + position: relative; + height: 350px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + overflow: hidden; +} + +.modal-backdrop { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.4; +} + +.modal-header-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 30px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.9)); + color: white; +} + +.modal-title { + font-size: 2rem; + margin-bottom: 10px; +} + +.modal-meta { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; + font-size: 1rem; +} + +.modal-rating { + color: #ffd700; +} + +.modal-body-content { + padding: 25px; +} + +.modal-section { + margin-bottom: 25px; +} + +.modal-section h3 { + color: var(--accent-primary); + margin-bottom: 15px; + font-size: 1.2rem; +} + +.modal-overview { + line-height: 1.7; + color: var(--text-secondary); +} + +.modal-trailer { + position: relative; + padding-bottom: 56.25%; + height: 0; + overflow: hidden; + border-radius: var(--radius); +} + +.modal-trailer iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} + +/* Music Modal */ +.music-modal-header { + display: flex; + align-items: center; + padding: 30px; + height: auto; + min-height: 250px; + background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); +} + +.modal-album-art { + width: 180px; + height: 180px; + border-radius: var(--radius); + object-fit: cover; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + margin-right: 30px; + background: #333; +} + +/* Fallback pour image non trouvée */ +.modal-album-art[src=""], +.modal-album-art:not([src]) { + background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%); + display: flex; + align-items: center; + justify-content: center; +} + +.music-modal-header-content { + flex: 1; + color: white; +} + +.modal-artist { + font-size: 1.3rem; + margin-top: 10px; + opacity: 0.9; +} + +.music-modal-meta { + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 15px; +} + +.music-modal-meta .no-data { + font-style: italic; + opacity: 0.7; + font-size: 0.9rem; +} + +/* Tags */ +.tags-cloud { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tag-item { + background: var(--accent-secondary); + color: white; + padding: 8px 16px; + border-radius: 20px; + font-size: 0.85rem; +} + +.external-link { + color: var(--accent-primary); + text-decoration: none; + font-weight: 600; +} + +.external-link:hover { + text-decoration: underline; +} + +/* Torrents Table */ +.torrents-list { + background: var(--bg-card); + padding: 15px; + border-radius: var(--radius); + overflow-x: auto; +} + +.torrents-table { + width: 100%; + border-collapse: collapse; + min-width: 600px; +} + +.torrents-table th { + text-align: left; + padding: 12px 10px; + background: var(--bg-primary); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; +} + +.torrents-table td { + padding: 12px 10px; + border-bottom: 1px solid var(--border-color); + font-size: 0.85rem; +} + +.torrents-table tr:hover { + background: var(--bg-primary); +} + +.torrents-table tr.best-torrent { + background: rgba(74, 222, 128, 0.1); +} + +.torrent-name-cell { + display: flex; + flex-direction: column; + gap: 5px; +} + +.torrent-title { + font-size: 0.85rem; + color: var(--text-primary); + word-break: break-word; +} + +.torrent-title-link { + font-size: 0.85rem; + color: var(--text-primary); + word-break: break-word; + text-decoration: none; + transition: color 0.2s; +} + +.torrent-title-link:hover { + color: var(--accent-primary); + text-decoration: underline; +} + +.tracker-badge-link { + background: var(--accent-secondary); + color: white; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.75rem; + text-decoration: none; + transition: all 0.2s; + display: inline-block; +} + +.tracker-badge-link:hover { + background: var(--accent-primary); +} + +.btn-link-small { + padding: 6px 10px; + background: var(--accent-secondary); + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + text-decoration: none; + transition: transform 0.1s; +} + +.btn-link-small:hover { + background: var(--accent-primary); + transform: scale(1.1); +} + +.torrent-tags { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.tag { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; +} + +.tag-quality { + background: #3b82f6; + color: white; +} + +.tag-language { + background: #8b5cf6; + color: white; +} + +.tag-best { + background: #ffd700; + color: #333; +} + +.tracker-badge { + background: var(--accent-secondary); + color: white; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.75rem; +} + +.seeders { + color: var(--success); + font-weight: 600; +} + +/* Styles boutons dans la table torrents (desktop) - COMME DISCOVER */ +.torrents-list-items { + display: flex; + flex-direction: column; + gap: 10px; +} + +.torrent-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px 15px; + background: var(--bg-primary); + border-radius: var(--radius); + transition: all 0.2s; +} + +.torrent-item:hover { + background: var(--bg-card); +} + +.torrent-item.best-torrent { + border-left: 3px solid var(--success); +} + +.torrent-info { + flex: 1; + min-width: 0; +} + +.torrent-name { + font-weight: 500; + color: var(--text-primary); + margin-bottom: 5px; + word-break: break-word; +} + +.torrent-name-link { + color: var(--accent-primary); + text-decoration: none; +} + +.torrent-name-link:hover { + text-decoration: underline; +} + +.torrent-meta { + display: flex; + gap: 15px; + font-size: 0.85rem; + color: var(--text-secondary); + flex-wrap: wrap; +} + +.torrent-meta .seeds { + color: var(--success); +} + +.torrent-meta .quality { + color: var(--accent-primary); +} + +.torrent-meta .language { + color: #8b5cf6; +} + +.torrent-meta .best { + color: #ffd700; +} + +.torrent-actions { + display: flex; + gap: 8px; +} + +.torrent-actions a, +.torrent-actions button { + padding: 8px 12px; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; + text-decoration: none; +} + +.torrent-actions .btn-link { + background: var(--accent-secondary); + color: white; +} + +.torrent-actions .btn-magnet { + background: var(--danger); + color: white; +} + +.torrent-actions .btn-download { + background: var(--success); + color: white; +} + +.torrent-actions .btn-send { + background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); + color: white; +} + +.torrent-actions a:hover, +.torrent-actions button:hover { + transform: scale(1.05); + opacity: 0.9; +} + +/* Message Box */ +.message-box { + position: fixed; + bottom: 20px; + right: 20px; + padding: 15px 25px; + border-radius: var(--radius); + color: white; + font-weight: 500; + z-index: 9999; + animation: slideIn 0.3s ease; +} + +.message-box.success { + background: var(--success); +} + +.message-box.error { + background: var(--danger); +} + +.message-box.info { + background: var(--accent-secondary); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Loader */ +.loader { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 9999; +} + +.loader p { + margin-top: 15px; + color: var(--text-primary); +} + +/* Responsive */ + +/* Tablette */ +@media (max-width: 1024px) { + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; + } + + .card-poster { + height: 300px; + } + + .modal-content { + max-width: 95%; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .header h1 { + font-size: 1.6rem; + } + + .subtitle { + font-size: 0.9rem; + } + + /* Navigation mobile - identique à search */ + .main-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .main-nav a { + padding: 8px 15px; + font-size: 0.85rem; + flex: 1 1 auto; + text-align: center; + min-width: 80px; + max-width: 150px; + } + + /* Logout à part sur sa propre ligne */ + .main-nav a.nav-logout { + flex: 0 0 auto; + min-width: 50px; + max-width: 50px; + padding: 8px 12px; + } + + /* Settings */ + .latest-settings { + padding: 15px; + } + + /* Filtres années - mobile */ + .year-filters { + padding: 12px 15px; + gap: 10px; + } + + .filter-label { + width: 100%; + margin-bottom: 5px; + } + + .year-pills { + gap: 6px; + width: 100%; + } + + .year-pill { + padding: 8px 12px; + font-size: 0.85rem; + flex: 1 1 auto; + text-align: center; + } + + .filter-count { + width: 100%; + text-align: center; + margin-left: 0; + } + + /* Categories - grille responsive */ + .categories { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 8px; + margin-bottom: 15px; + } + + .category-btn { + padding: 10px 8px; + font-size: 0.85rem; + text-align: center; + white-space: nowrap; + } + + /* Trackers - identique à search */ + .trackers-panel { + padding: 12px; + } + + .trackers-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .trackers-actions button { + flex: 1; + min-width: 100px; + padding: 10px 8px; + font-size: 0.85rem; + } + + .trackers-list { + max-height: 200px; + overflow-y: auto; + gap: 8px; + } + + .tracker-item { + padding: 8px 10px; + } + + .tracker-item label { + font-size: 0.85rem; + } + + /* Limit selector */ + .limit-selector { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .limit-selector select { + width: 100%; + } + + .limit-selector .btn-primary { + width: 100%; + } + + /* Results */ + .latest-results { + padding: 15px; + } + + .results-header { + flex-direction: column; + gap: 10px; + align-items: flex-start; + } + + .results-header h2 { + font-size: 1.2rem; + } + + /* Grid - 2 colonnes sur mobile */ + .results-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + /* Cards plus compactes */ + .release-card { + font-size: 0.9rem; + } + + .card-poster { + height: 200px; + } + + .card-type { + font-size: 0.7rem; + padding: 3px 8px; + top: 8px; + left: 8px; + } + + .card-rating { + font-size: 0.75rem; + padding: 3px 8px; + top: 8px; + right: 8px; + } + + .card-seeders, + .card-variants { + font-size: 0.7rem; + padding: 3px 8px; + bottom: 8px; + } + + .card-content { + padding: 10px; + } + + .card-title { + font-size: 0.85rem; + height: auto; + -webkit-line-clamp: 2; + margin-bottom: 5px; + } + + .card-meta { + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 5px; + margin-bottom: 8px; + } + + .card-year { + font-size: 0.75rem; + } + + .card-tracker { + font-size: 0.65rem; + padding: 2px 6px; + } + + .card-overview { + display: none; + } + + .card-actions { + display: flex; + flex-direction: row; + gap: 6px; + } + + .btn-details, + .btn-download-card, + .btn-tracker { + flex: 1; + padding: 8px 6px; + font-size: 0.8rem; + text-align: center; + } + + /* Modal - plein écran sur mobile */ + .modal { + padding: 0; + } + + .modal-content { + max-height: 100vh; + height: 100vh; + border-radius: 0; + overflow-y: auto; + } + + .modal-close { + top: 10px; + right: 15px; + font-size: 1.5rem; + width: 40px; + height: 40px; + } + + .modal-header { + height: 180px; + } + + .modal-header-content { + padding: 15px; + } + + .modal-title { + font-size: 1.2rem; + line-height: 1.3; + } + + .modal-meta { + font-size: 0.85rem; + gap: 8px; + flex-wrap: wrap; + } + + .modal-body-content { + padding: 15px; + } + + .modal-section { + margin-bottom: 20px; + } + + .modal-section h3 { + font-size: 1rem; + } + + .modal-overview { + font-size: 0.9rem; + } + + /* Music modal */ + .music-modal-header { + flex-direction: column; + text-align: center; + padding: 20px; + min-height: 180px; + } + + .modal-album-art { + width: 100px; + height: 100px; + margin-right: 0; + margin-bottom: 15px; + } + + .modal-artist { + font-size: 1rem; + } + + /* Torrents dans modal - EXACTEMENT COMME DISCOVER */ + .torrents-list { + padding: 10px; + } + + .torrents-list-items { + gap: 8px; + } + + .torrent-item { + flex-direction: column; + align-items: stretch; + padding: 12px; + gap: 10px; + } + + .torrent-info { + width: 100%; + } + + .torrent-name { + font-size: 0.85rem; + white-space: normal; + word-break: break-word; + line-height: 1.4; + } + + .torrent-meta { + flex-wrap: wrap; + gap: 8px; + font-size: 0.8rem; + } + + .torrent-meta span { + background: var(--bg-secondary); + padding: 3px 8px; + border-radius: 4px; + } + + .torrent-actions { + width: 100%; + justify-content: space-between; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--border-color); + } + + .torrent-actions a, + .torrent-actions button { + flex: 1; + padding: 10px 8px; + text-align: center; + font-size: 1rem; + min-width: 0; + } + + /* Tags */ + .tags-cloud { + gap: 6px; + } + + .tag-item { + padding: 6px 12px; + font-size: 0.8rem; + } +} + +/* Très petit écran */ +@media (max-width: 480px) { + .header h1 { + font-size: 1.4rem; + } + + /* Navigation sur petits écrans */ + .main-nav { + gap: 6px; + } + + .main-nav a { + padding: 8px 10px; + font-size: 0.8rem; + min-width: 70px; + } + + .main-nav a.nav-logout { + min-width: 40px; + max-width: 40px; + padding: 8px; + } + + /* Categories sur 2 colonnes */ + .categories { + grid-template-columns: repeat(2, 1fr); + } + + .category-btn { + padding: 10px 6px; + font-size: 0.8rem; + } + + /* Grille 1 colonne pour les cartes */ + .results-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .card-poster { + height: 250px; + } + + .card-overview { + display: block; + -webkit-line-clamp: 2; + min-height: auto; + font-size: 0.8rem; + margin-bottom: 10px; + } + + .card-actions { + flex-direction: row; + } + + .modal-title { + font-size: 1.1rem; + } + + /* Trackers actions en colonne */ + .trackers-actions { + flex-direction: column; + } + + .trackers-actions button { + width: 100%; + } + + /* Boutons torrents sur très petit écran - comme discover */ + .torrent-actions { + flex-wrap: wrap; + } + + .torrent-actions a, + .torrent-actions button { + flex: 1 1 45%; + padding: 10px 6px; + font-size: 0.95rem; + } +} + +/* RSS Source Badge */ +.source-badge.source-rss { + background: #10b981; + color: white; +} + +/* Bouton envoyer au client torrent */ +.btn-send-client-small { + background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); + color: white; + border: none; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.btn-send-client-small:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(39, 174, 96, 0.4); +} + +.btn-send-client-small:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* ============================================================ + MODAL OPTIONS TORRENT + ============================================================ */ + +.torrent-options-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + display: flex; + justify-content: center; + align-items: center; + z-index: 20000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.torrent-options-modal.visible { + opacity: 1; + visibility: visible; +} + +.torrent-options-content { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 25px; + width: 90%; + max-width: 400px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.torrent-options-content h3 { + margin: 0 0 20px 0; + color: var(--accent-primary); + font-size: 1.2rem; +} + +.torrent-option-group { + margin-bottom: 15px; +} + +.torrent-option-group label { + display: block; + margin-bottom: 5px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.torrent-option-group select, +.torrent-option-group input[type="text"] { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; +} + +.torrent-option-group select:focus, +.torrent-option-group input[type="text"]:focus { + outline: none; + border-color: var(--accent-primary); +} + +.torrent-option-group.checkbox-group { + display: flex; + align-items: center; + gap: 10px; +} + +.torrent-option-group.checkbox-group label { + margin-bottom: 0; + cursor: pointer; +} + +.torrent-option-group.checkbox-group input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-primary); + cursor: pointer; +} + +.torrent-options-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.torrent-options-buttons button { + flex: 1; + padding: 12px; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.torrent-options-buttons .btn-cancel { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.torrent-options-buttons .btn-cancel:hover { + background: var(--bg-card); +} + +.torrent-options-buttons .btn-confirm { + background: var(--success); + color: white; +} + +.torrent-options-buttons .btn-confirm:hover { + background: #22b547; +} + +/* Mobile */ +@media (max-width: 768px) { + .torrent-options-content { + width: 95%; + padding: 20px; + } + + .torrent-options-content h3 { + font-size: 1.1rem; + } +} + +/* ============================================================ + CACHE INFO + ============================================================ */ + +.results-meta { + display: flex; + align-items: center; + gap: 15px; + flex-wrap: wrap; +} + +.cache-info { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 0.85rem; +} + +.cache-badge { + color: var(--accent-primary); + font-weight: 600; +} + +.cache-timestamp { + color: var(--text-secondary); +} + +.btn-refresh { + background: transparent; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; +} + +.btn-refresh:hover { + background: var(--bg-primary); + transform: rotate(180deg); +} + +/* Mobile */ +@media (max-width: 768px) { + .results-meta { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .cache-info { + width: 100%; + justify-content: center; + } +} diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..953ec88 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,1168 @@ +/* ============================================================ + LYCOSTORRENT - Style de l'application de recherche + ============================================================ */ + +/* Variables par défaut (utilisées si aucun thème n'est défini) */ +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-card: #1f2940; + --text-primary: #eaeaea; + --text-secondary: #a0a0a0; + --accent-primary: #e94560; + --accent-secondary: #0f3460; + --success: #4ade80; + --warning: #fbbf24; + --danger: #ef4444; + --border-color: #2d3748; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + --radius: 8px; +} + +/* Reset */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +/* Container */ +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 30px; +} + +.header h1 { + font-size: 2.5rem; + color: var(--accent-primary); + margin-bottom: 5px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 1.1rem; +} + +/* Navigation */ +.main-nav { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; +} + +.main-nav a { + padding: 10px 25px; + background: var(--bg-card); + color: var(--text-primary); + text-decoration: none; + border-radius: var(--radius); + transition: all 0.3s; + border: 1px solid var(--border-color); +} + +.main-nav a:hover { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.main-nav a.active { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.main-nav a.nav-logout { + background: transparent; + border-color: var(--danger); + color: var(--danger); + padding: 10px 15px; +} + +.main-nav a.nav-logout:hover { + background: var(--danger); + color: white; +} + +/* ============================================================ + SEARCH SECTION + ============================================================ */ + +.search-section { + background: var(--bg-secondary); + padding: 25px; + border-radius: var(--radius); + margin-bottom: 25px; + box-shadow: var(--shadow); +} + +.search-bar { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +#search-input { + flex: 1; + padding: 14px 18px; + font-size: 1.1rem; + border: 2px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-primary); + color: var(--text-primary); + transition: border-color 0.2s; +} + +#search-input:focus { + outline: none; + border-color: var(--accent-primary); +} + +#search-input::placeholder { + color: var(--text-secondary); +} + +.btn-primary { + padding: 14px 28px; + font-size: 1rem; + font-weight: 600; + background: var(--accent-primary); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.btn-primary:hover { + background: #d63050; + transform: translateY(-1px); +} + +.btn-secondary { + padding: 8px 16px; + font-size: 0.9rem; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; +} + +.btn-secondary:hover { + background: var(--bg-card); +} + +/* Search options */ +.search-options { + display: flex; + gap: 30px; + flex-wrap: wrap; +} + +.option-group { + flex: 1; + min-width: 200px; +} + +.option-group label { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-secondary); +} + +#category-select { + width: 100%; + padding: 10px 14px; + font-size: 1rem; + border: 2px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; +} + +#category-select:focus { + outline: none; + border-color: var(--accent-primary); +} + +/* ============================================================ + TRACKERS SELECTOR (style Nouveautés) + ============================================================ */ + +.trackers-selector { + margin-top: 20px; +} + +.toggle-btn { + padding: 10px 20px; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + font-size: 0.95rem; +} + +.toggle-btn:hover { + border-color: var(--accent-primary); +} + +.trackers-panel { + margin-top: 15px; + padding: 15px; + background: var(--bg-card); + border-radius: var(--radius); +} + +.trackers-actions { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.trackers-actions button { + padding: 8px 15px; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} + +.trackers-actions button:hover { + border-color: var(--accent-primary); +} + +.trackers-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tracker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-primary); + border-radius: var(--radius); + border: 1px solid var(--border-color); + transition: border-color 0.2s; +} + +.tracker-item:hover { + border-color: var(--accent-primary); +} + +.tracker-item input { + accent-color: var(--accent-primary); +} + +.tracker-item label { + cursor: pointer; + font-size: 0.9rem; +} + +/* Legacy - garder pour compatibilité */ +.trackers-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tracker-checkbox { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 0.2s; +} + +.tracker-checkbox:hover { + border-color: var(--accent-primary); +} + +.tracker-checkbox input { + accent-color: var(--accent-primary); + width: 16px; + height: 16px; +} + +.tracker-name { + flex: 1; +} + +/* Badges de source (Jackett/Prowlarr) */ +.source-badge { + font-size: 0.65rem; + font-weight: 700; + padding: 2px 5px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.source-jackett { + background: #3b82f6; + color: white; +} + +.source-prowlarr { + background: #f59e0b; + color: #1a1a2e; +} + +.source-both { + background: linear-gradient(135deg, #3b82f6 50%, #f59e0b 50%); + color: white; + text-shadow: 0 0 2px rgba(0,0,0,0.5); +} + +/* ============================================================ + FILTERS SECTION + ============================================================ */ + +.filters-section { + background: var(--bg-secondary); + padding: 20px; + border-radius: var(--radius); + margin-bottom: 25px; + box-shadow: var(--shadow); +} + +.filters-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.filters-header h3 { + margin: 0; + color: var(--text-primary); +} + +.btn-toggle { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 5px 12px; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.btn-toggle:hover { + border-color: var(--accent-primary); +} + +.btn-toggle.collapsed { + transform: rotate(-90deg); +} + +.filters-content { + transition: all 0.3s ease; +} + +.filters-content.collapsed { + display: none; +} + +.filters-section h3 { + margin-bottom: 15px; + color: var(--text-primary); +} + +.results-count { + font-weight: normal; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.filters-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 15px; +} + +.filter-group { + background: var(--bg-card); + padding: 15px; + border-radius: var(--radius); + min-width: 180px; +} + +.filter-group h4 { + font-size: 0.95rem; + margin-bottom: 10px; + color: var(--accent-primary); +} + +.filter-values { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + font-size: 0.9rem; +} + +.filter-checkbox input { + accent-color: var(--accent-primary); + width: 14px; + height: 14px; +} + +.filter-label { + color: var(--text-primary); +} + +.filter-count { + color: var(--text-secondary); + font-size: 0.8rem; +} + +/* ============================================================ + RESULTS SECTION + ============================================================ */ + +.results-section { + background: var(--bg-secondary); + padding: 20px; + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.placeholder-text, +.no-results { + text-align: center; + color: var(--text-secondary); + padding: 40px; + font-size: 1.1rem; +} + +/* Results table */ +.results-table { + width: 100%; + border-collapse: collapse; +} + +.results-table th { + text-align: left; + padding: 12px 10px; + background: var(--bg-card); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + border-bottom: 2px solid var(--border-color); +} + +.results-table td { + padding: 12px 10px; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.results-table tr:hover { + background: var(--bg-card); +} + +/* Column widths */ +.col-name { width: 50%; } +.col-tracker { width: 12%; } +.col-size { width: 10%; } +.col-seeders { width: 8%; text-align: center; } +.col-date { width: 12%; } +.col-actions { width: 8%; text-align: center; } + +/* Torrent title and badges */ +.torrent-title { + font-weight: 500; + word-break: break-word; + margin-bottom: 6px; +} + +.torrent-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 0.75rem; + font-weight: 600; + border-radius: 4px; + text-transform: uppercase; +} + +.badge-quality { + background: #3b82f6; + color: white; +} + +.badge-source { + background: #8b5cf6; + color: white; +} + +.badge-codec { + background: #06b6d4; + color: white; +} + +.badge-language { + background: #22c55e; + color: white; +} + +.badge-hdr { + background: #f59e0b; + color: black; +} + +/* Seeders colors */ +.seeders-none { color: var(--danger); } +.seeders-low { color: var(--warning); } +.seeders-medium { color: var(--text-primary); } +.seeders-high { color: var(--success); font-weight: 600; } + +/* Action buttons */ +.btn-magnet, +.btn-download, +.btn-details { + display: inline-block; + padding: 6px; + font-size: 1.1rem; + text-decoration: none; + transition: transform 0.1s; +} + +.btn-magnet:hover, +.btn-download:hover, +.btn-details:hover { + transform: scale(1.2); +} + +/* ============================================================ + LOADING OVERLAY + ============================================================ */ + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid var(--border-color); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 15px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================================ + UTILITIES + ============================================================ */ + +.hidden { + display: none !important; +} + +.loading { + color: var(--text-secondary); + font-style: italic; +} + +/* ============================================================ + PAGINATION + ============================================================ */ + +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 0; + flex-wrap: wrap; + gap: 10px; +} + +.pagination-info { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.pagination-controls { + display: flex; + gap: 5px; + align-items: center; + flex-wrap: wrap; +} + +.pagination-btn { + padding: 8px 12px; + font-size: 0.9rem; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.pagination-btn:hover:not(:disabled) { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-btn.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + font-weight: 600; +} + +.pagination-ellipsis { + padding: 8px 5px; + color: var(--text-secondary); +} + +/* ============================================================ + SORTABLE HEADERS + ============================================================ */ + +.sortable { + cursor: pointer; + user-select: none; + transition: color 0.2s; +} + +.sortable:hover { + color: var(--accent-primary); +} + +.sort-icon { + margin-left: 5px; + opacity: 0.4; + font-size: 0.8rem; +} + +.sort-icon.active { + opacity: 1; + color: var(--accent-primary); +} + +/* ============================================================ + RESPONSIVE + ============================================================ */ + +/* Tablette */ +@media (max-width: 1024px) { + .container { + padding: 15px; + } + + .search-options { + gap: 20px; + } + + .filter-group { + min-width: 150px; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .header h1 { + font-size: 1.6rem; + } + + .subtitle { + font-size: 0.9rem; + } + + /* Navigation mobile */ + .main-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .main-nav a { + padding: 8px 15px; + font-size: 0.85rem; + flex: 1 1 auto; + text-align: center; + min-width: 80px; + max-width: 150px; + } + + /* Logout séparé */ + .main-nav a.nav-logout { + flex: 0 0 auto; + min-width: 50px; + max-width: 50px; + padding: 8px 12px; + } + + /* Search section */ + .search-section { + padding: 15px; + } + + .search-bar { + flex-direction: column; + } + + #search-input { + font-size: 1rem; + padding: 12px 15px; + } + + .btn-primary { + width: 100%; + padding: 12px; + } + + .search-options { + flex-direction: column; + gap: 15px; + } + + .option-group { + min-width: 100%; + } + + /* Trackers */ + .trackers-checkboxes { + gap: 8px; + } + + .tracker-checkbox { + padding: 6px 10px; + font-size: 0.85rem; + } + + /* Filters */ + .filters-section { + padding: 15px; + } + + .filters-container { + flex-direction: column; + gap: 10px; + } + + .filter-group { + width: 100%; + min-width: 100%; + padding: 12px; + } + + .filter-values { + max-height: 150px; + } + + /* Results - mode cards sur mobile */ + .results-section { + padding: 10px; + } + + .results-table { + display: none; + } + + .results-cards { + display: flex; + flex-direction: column; + gap: 12px; + } + + /* Card mobile pour les résultats */ + .result-card-mobile { + background: var(--bg-card); + padding: 12px; + border-radius: var(--radius); + border-left: 3px solid var(--accent-primary); + } + + .result-card-mobile .torrent-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 8px; + line-height: 1.3; + } + + .result-card-mobile .torrent-badges { + margin-bottom: 10px; + } + + .result-card-mobile .result-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 10px; + } + + .result-card-mobile .result-meta span { + display: flex; + align-items: center; + gap: 4px; + } + + .result-card-mobile .result-actions { + display: flex; + gap: 8px; + } + + .result-card-mobile .result-actions a { + flex: 1; + text-align: center; + padding: 10px; + border-radius: var(--radius); + text-decoration: none; + font-size: 1.1rem; + } + + .result-card-mobile .btn-magnet-mobile { + background: var(--accent-primary); + color: white; + } + + .result-card-mobile .btn-download-mobile { + background: var(--success); + color: white; + } + + .result-card-mobile .btn-details-mobile { + background: var(--accent-secondary); + color: white; + } + + /* Pagination */ + .pagination { + flex-direction: column; + gap: 15px; + } + + .pagination-controls { + justify-content: center; + } + + .pagination-btn { + padding: 10px 14px; + } +} + +/* Très petit écran */ +@media (max-width: 480px) { + .header h1 { + font-size: 1.4rem; + } + + .main-nav { + gap: 6px; + } + + .main-nav a { + padding: 8px 10px; + font-size: 0.8rem; + min-width: 70px; + } + + .main-nav a.nav-logout { + min-width: 40px; + max-width: 40px; + padding: 8px; + } + + .badge { + font-size: 0.65rem; + padding: 2px 6px; + } + + .source-badge { + font-size: 0.55rem; + padding: 1px 4px; + } +} + +/* RSS Source Badge */ +.source-badge.source-rss { + background: #10b981; + color: white; +} + +/* ============================================================ + FOOTER + ============================================================ */ + +.app-footer { + text-align: center; + padding: 20px; + margin-top: 30px; + color: var(--text-secondary); + font-size: 0.85rem; + border-top: 1px solid var(--border-color); +} + +.app-footer a { + color: var(--accent-primary); + text-decoration: none; +} + +.app-footer a:hover { + text-decoration: underline; +} + +/* ============================================================ + BOUTON ENVOYER AU CLIENT TORRENT + ============================================================ */ + +.btn-send-client, +.btn-send-client-mobile { + background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); + color: white; + border: none; + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; +} + +.btn-send-client:hover, +.btn-send-client-mobile:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(39, 174, 96, 0.4); +} + +.btn-send-client:disabled, +.btn-send-client-mobile:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* Toast notification */ +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + border-radius: 8px; + color: white; + font-weight: 500; + z-index: 10000; + transition: opacity 0.3s, transform 0.3s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.toast.hidden { + opacity: 0; + transform: translateX(-50%) translateY(20px); + pointer-events: none; +} + +.toast.success { + background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); +} + +.toast.error { + background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%); +} + +.toast.info { + background: linear-gradient(135deg, #2980b9 0%, #3498db 100%); +} + +.toast.warning { + background: linear-gradient(135deg, #d35400 0%, #e67e22 100%); +} + +/* ============================================================ + MODAL OPTIONS TORRENT + ============================================================ */ + +.torrent-options-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + display: flex; + justify-content: center; + align-items: center; + z-index: 20000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.torrent-options-modal.visible { + opacity: 1; + visibility: visible; +} + +.torrent-options-content { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 25px; + width: 90%; + max-width: 400px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.torrent-options-content h3 { + margin: 0 0 20px 0; + color: var(--accent-primary); + font-size: 1.2rem; +} + +.torrent-option-group { + margin-bottom: 15px; +} + +.torrent-option-group label { + display: block; + margin-bottom: 5px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.torrent-option-group select, +.torrent-option-group input[type="text"] { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; + box-sizing: border-box; +} + +.torrent-option-group select:focus, +.torrent-option-group input[type="text"]:focus { + outline: none; + border-color: var(--accent-primary); +} + +.torrent-option-group.checkbox-group { + display: flex; + align-items: center; + gap: 10px; +} + +.torrent-option-group.checkbox-group label { + margin-bottom: 0; + cursor: pointer; +} + +.torrent-option-group.checkbox-group input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-primary); + cursor: pointer; +} + +.torrent-options-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.torrent-options-buttons button { + flex: 1; + padding: 12px; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.torrent-options-buttons .btn-cancel { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.torrent-options-buttons .btn-cancel:hover { + background: var(--bg-card); +} + +.torrent-options-buttons .btn-confirm { + background: var(--success); + color: white; +} + +.torrent-options-buttons .btn-confirm:hover { + background: #22b547; +} \ No newline at end of file diff --git a/app/static/css/themes.css b/app/static/css/themes.css new file mode 100644 index 0000000..ea83b26 --- /dev/null +++ b/app/static/css/themes.css @@ -0,0 +1,168 @@ +/* ============================================================ + LYCOSTORRENT - Système de Thèmes + ============================================================ */ + +/* ============================================================ + THÈME SOMBRE (par défaut) + ============================================================ */ +[data-theme="dark"] { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-card: #1f2940; + --text-primary: #eaeaea; + --text-secondary: #a0a0a0; + --accent-primary: #e94560; + --accent-secondary: #0f3460; + --success: #4ade80; + --warning: #fbbf24; + --danger: #ef4444; + --border-color: #2d3748; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +/* ============================================================ + THÈME CLAIR + ============================================================ */ +[data-theme="light"] { + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --bg-card: #ffffff; + --text-primary: #1a1a2e; + --text-secondary: #666666; + --accent-primary: #e94560; + --accent-secondary: #e8e8e8; + --success: #22c55e; + --warning: #f59e0b; + --danger: #dc2626; + --border-color: #e0e0e0; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +/* ============================================================ + THÈME BLEU OCÉAN + ============================================================ */ +[data-theme="ocean"] { + --bg-primary: #0a192f; + --bg-secondary: #112240; + --bg-card: #1d3557; + --text-primary: #e6f1ff; + --text-secondary: #8892b0; + --accent-primary: #64ffda; + --accent-secondary: #233554; + --success: #64ffda; + --warning: #ffd700; + --danger: #ff6b6b; + --border-color: #233554; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.4); +} + +/* ============================================================ + THÈME VIOLET NUIT + ============================================================ */ +[data-theme="purple"] { + --bg-primary: #13111c; + --bg-secondary: #1e1a2e; + --bg-card: #2a2440; + --text-primary: #e8e6f0; + --text-secondary: #9d99b0; + --accent-primary: #a855f7; + --accent-secondary: #3b3256; + --success: #4ade80; + --warning: #fbbf24; + --danger: #f472b6; + --border-color: #3b3256; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.4); +} + +/* ============================================================ + THÈME VERT NATURE + ============================================================ */ +[data-theme="nature"] { + --bg-primary: #1a2f1a; + --bg-secondary: #0f2010; + --bg-card: #243524; + --text-primary: #e8f5e9; + --text-secondary: #a5d6a7; + --accent-primary: #4caf50; + --accent-secondary: #2e4a2e; + --success: #81c784; + --warning: #ffb74d; + --danger: #e57373; + --border-color: #2e4a2e; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.4); +} + +/* ============================================================ + THÈME ORANGE SUNSET + ============================================================ */ +[data-theme="sunset"] { + --bg-primary: #1f1510; + --bg-secondary: #2d1f15; + --bg-card: #3d2a1a; + --text-primary: #fff3e0; + --text-secondary: #bcaaa4; + --accent-primary: #ff7043; + --accent-secondary: #4a3228; + --success: #81c784; + --warning: #ffb74d; + --danger: #ef5350; + --border-color: #4a3228; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.4); +} + +/* ============================================================ + THÈME CYBERPUNK + ============================================================ */ +[data-theme="cyberpunk"] { + --bg-primary: #0d0d0d; + --bg-secondary: #1a1a1a; + --bg-card: #262626; + --text-primary: #00ff9f; + --text-secondary: #00cc7f; + --accent-primary: #ff00ff; + --accent-secondary: #330033; + --success: #00ff9f; + --warning: #ffff00; + --danger: #ff0066; + --border-color: #333333; + --shadow: 0 0 20px rgba(255, 0, 255, 0.3); +} + +/* ============================================================ + THÈME NORD + ============================================================ */ +[data-theme="nord"] { + --bg-primary: #2e3440; + --bg-secondary: #3b4252; + --bg-card: #434c5e; + --text-primary: #eceff4; + --text-secondary: #d8dee9; + --accent-primary: #88c0d0; + --accent-secondary: #4c566a; + --success: #a3be8c; + --warning: #ebcb8b; + --danger: #bf616a; + --border-color: #4c566a; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +/* ============================================================ + TRANSITIONS FLUIDES + ============================================================ */ +body, +.container, +.header, +.search-box, +.results-table, +.filter-bar, +.admin-card, +.modal-content, +.nav-link, +button, +input, +select { + transition: background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; +} \ No newline at end of file diff --git a/app/static/icons/icon-128x128.png b/app/static/icons/icon-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..8d4b171944c22e10cfee4040f94656e3ed9fe29b GIT binary patch literal 1214 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU}^PqaSW-L^LFm(+`9$>uE!m} zeY@t#$`{lqU2rzQ|L{{6fCKQ7JF z+h4Fvh|q8-Bogg&H$TvAzrSw&zxm7i%bTYq6q-L`cUmX1d+Nm{5>5=Ko8%|Pc3fd{ z5v%hI6THOP5b{OW=;Q>q2AE=vk?EzC;biqjO9M5Zlb_ZNNe4shw1py1Ll?NPNz9cL5AJ)s@@ef7(;L=exZ4RIJ=UX0sd|SJPMQ(@cfr~G`@0)-A!=D4ntT8Mv zboZqyDxJUEw3X#qx5JZ1+hy#l5;OK{@YIS6#QwSV*~L4db0vfAw<`Dik}K~MXELQR zeQ~J|G5mH;dM;C#ki%X+u}=BUi_gCwb7AO>wsTo@J9i>e0B3{IuYg+*8+S1*?)%sI z;#>A|1|Oycy;fVkMKCHebUKDIEaA8A>q)cUt#;trpH8Xm(Qmur*fSQXF}Uy7kNSJo z6c$+_ED!$3H@odOX6$kXqsLkf+t{^l%EiPkjAqbdNPhXY-jDHUqaZ`BltXsE=sTvA z`SzQ)*K_(Xbv)>?no?FFpyY5ZaLU%Z)efo+9)DhR+wC_`WKyW(Wd3+s>)HX;11;NG zuDbnmS;3$Yf6C?Oq%4+)oC3OzPakzNa41|-Yuv`6dt7Gk4RU3V?gh}V) zn?JfYjl3E5vpFz*nR504d;(q

5QZ*@4A@wa3Kfv19N&Z7btEh6*Q9o#%m@M z{2DAhj3WHQi#biMGpuBt(IIlMcy7_|+Ew=a5>5@O)0}!X8O)UkYA{{rVHmaXx`r}C mB>OK*hK=n^BE+EQ^6z)OTjhOTY5}lZV(@hJb6Mw<&;$UZ&h*j% literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-144x144.png b/app/static/icons/icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..2b8adb500fe70a25af90f89f7014c3b7b6b33e04 GIT binary patch literal 1344 zcmZ{ke>l?#9LH^Knw1#C!$oaMn$wZoi}ROT^+a~+$kIzM7md)hkpJojIBe|$cl=XpQR`>)sMdF9fADaLRcxPgIz zG1VVntX}><12bIRGvujO0|V$p7Nv%md;Bqi_1M?Xd zDZBkYUq1C%^* ziC2*ai5RvcHrd*)MPpey2`&^ptm!q3&YhW@Zu=bf+U`cZ_sjS^1l6#~Lr9P%(M>@Ylt6;zGNiXDilmAkbVrF|5SoB)eGJeS+#)M)B8hq9}$=+0^d`04UJ%#hzn`s-k39Ptii! z=5-B^_vibLE*L3}X)hi+(!7-5%7PnTCyq`f5QJm*?+f~$=Riwbg@hgg6G)iL61C*2 zmA7ro_-w5N_s1}2<0*JeIZ>{%lj8>CWJs-lD)`pp`!qrWtRBSCN0#ku?wQ7CS8p@b zv_YfqM5JG%?d`Zaf{X&oy0=yaCYvpZK7I14<*Oji8VS+(kdR9tRM8xW)IqT-TaMV| z@Gr&UdeEt_#eV(l)!r&gv^9U~c57l|g;;!-8=GnI(@IZ^+S1fR-lhq6?scj|>Sld( zU&sPu82*Bx`}_2+x}wAa4TLe_5Zb@pH-$Kw5#%u5fa0t(fwfT~B{6W}oWDqsU@|pS2Cuo zoVA~RiJs3I#^pctuzct5mKFRf!Mn{lso@xAqJyK>AGSu!7ksZ9eiPyBsu^B-fNDEH*(3Mo&V^t=Gv@Kl179_l5D9TY@y=R1{3qA zS*ZX4!7r`?RA%ylmV4RZfZh8p2&>kTo_qr2nyuf@dzwhNLOhQFRI**0YvmL=f>Xc7 zJpS&egCLC_A2sLkc8q^VS1kXfPb8#$urMlX zPCD@M<7>l;-iNCX=`axERIJwN*q!Bd>r5m_^q6R)03gYyayQa77H2{ zXsVgc;Y0&0&}Cm>1~)>&qIRs=3}?a zr#)=uc?k=GEablUYC(u$u>E4HE*MNq|5O#Ih#tl|-n|+~_ pyclDUY|E+$Ui(#R)K(|4FiNd+y-sYHag*{E zx6z_>ORDu|D=)`H1E)tn6Q+KstW0`5tK2m1M^AUkJkfJq;7A@N6$Kd z1O~kJ<7?%X#&DTKPVr*g_rlc*)kb;h@}#19advTWssOD0HZ(~GJ05`2#&N-RCZv6Q zU`?(rgp3fKg-dmzV4KVTy_!l)(lJ|FS?Ty=d_}E{dn}xMe_%dtu16O3T&6Ix6=jjr zc!&{W?yi7aelyK%2eXzi=Db~0GWRf)i4U$~ThZ1ZbezWAoaZjs4G21`YRBR&FitHa zE2J`@Z0$GXkOCRQ%}16--2Z+RFJb8@F`2GJ%R$(kOXIP58EQ@{I=#Yf(7M`GOl>m1 zqL#t8w9sF2`(^zUu{8{PsDww6P;L}b8l_H~e~nKXhnG#|-aBs3+Mii(BBq}Z8FXG` zAn|@hz|f@g1hZ$05JCJbn=d;kM0EA`WSmK>4Qh_JtCO|2HMTA~8f;An)a?yD{5#+{ z`^7uP8wAUedx9MS_lVdSG)R*mQRGnyd6Stzm+~v{W&K_tY zL|odC0A{wSX9c{YxzXp_vp=fxp5P<{83xbwnX4WYo|m+_##eZXqhrdNpDzbgw(d{s zA;_p5bmsN7WGQVc2Qs`}iLC(O-dDIcnuuiHzKm=C;qL``XiLkn!kUY4J@lg z5bS92KMqhtqZ%Ka?7Mr0fYSbjDiODhMGWV|!+tdWu*L_BCt#c9%MC!}n=hCb?i5>E z>jJ4&%`tWwv{MQ~mhT{adV^w!B`&@F($#xd<9RdM*Huddz%{RP=i$|KC$%E}yJ zM|Tandq(3K_T<*wvB{%GD^PS+qll$CfC%IUl5qJW>xyVoH3SJd1SgDU@9WOvrMP+b zx#umJy&HH>s0)->XHTpcSE5Hjy&0e8;G^cOf?8uzTEvT1lx>8=T?h+I)P9AXiE)-e zVW$;K$jJ}&LUWHn9AS682&gqx*1m7yA_}V#B=`7GrE68yfk=dq?5UZh^w@`&AG&cjZ&89Z;c(+JmZzgNW=x6u_j*AHq)$ literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-192x192.png b/app/static/icons/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..3b2772e673ef13fd4159976086ef12612f251e9d GIT binary patch literal 1887 zcmbW2=~vTd7RR52EI$H)gn|Of77n1nD%Do7vI&8(v#Wt!%4RAT)>DtY)b#IRw~#W|A$A_0 zH*HH;g<##@#Ev_B&EG8teJ0-t)yRJA+c*3f^8N$r}dMi1jdM_xP4hKUy1zz z7JB0U5dah=EA=U^WPp*jlJ)W-MyIgIB6&k6)qh9O7cX(g=Ir(Q7_3Ks_Z;0XW=0P@ zGjQ6^`(-9Ux_V_@U~XlJkB##zAvvh=#%6>XJF!ieTmz;mqEthyVJp3#`4vT3FAlVK zcia?hH>D6xF^3QviXDrv@Dffh1m!0eq!nM?IX$3a(*xOHhK`H-lRAp@l7ooJvHC|= zL=SpTPhF{K$9_R(40d;<#Y_~D>`pbXu+={pD}et2?FzB-aUC zAKEo|$CLt7$qWr6>g^pRN37e+@4-UrLc_yL=f>~rQDZYWHxrWGW!v$zazJIoO>+Uug*5(l}%!kd1 zH*Vp1|B4S1l_n94dBk%q3g^m;a4n!slIBl)Bgh*i=lqENCB9%#A~t`H;!Wv{Mh)7f ztAT$vn=^3Jt8)h3AJ4brl6?{Ne`Gh93r4!Ip!D(AAT0hyPyZ=D^k*W!RAB5`oo~Fq zpY|Ep@q6HRX(d1XxqJsQU`ISa^ekHp8Agac7M(2RMh+vu6B*vlMC+}+8(WKu!7(6E z_}?bN$_NcXd;$O#pv)t-VH@7<(+30r2GvrxQiXT0>$bSHAoL0 zrZE=zHJr{D;*t`%$de~i6Ut1Lm386;@j0Hp8OLYqEpPt*Hp{3&wrOYg_GH=L3!Sb9 zOV`V;wR!4D)ZEeZt-v6Ad?mjfnbhI3^8g;DXjRnmwLHhc;4J=!r*p4#C-((i+b9ge zV5SB4I*=txac4O0kfP-36DcyFFis)-)%slCpYS6yO!ZJffdYMw*j3M;NG*rfoD-C< z3+Am~s~3;tu9kQIdChmy%ly*78e%YfO)hI$-e%J_@(s!RvDDNb-FkRMnr>Y!v2kG^XyC==l0^5hG{$?99o+d1pe26uR$o*qM`Ms= zepXwS@ohRWszb7dpnus5nLDE3+iJD;2FR4rizLukqmpP^0#rGektloihnN8r&>8VU z*2#d!jWlLiFGUpMc;BtO_h=#`n@7Elf)nj|9=(4+s=i)*0InIgNDKw(UK!-e6!vaR zKZgF#*kQ7|M&gH+Lt2cIt-gUU3Tl~K9tT7Pdu8;cqPa5SYDUD9C5`333oTvD?mg~m z1k29Bkru|C0|Q)n>Ty~z7-aE$UP3l#x;<*D$&MiVk|ZiT%#@)+Ol&zqOvdvvp|(pb z-EeT30zX5Ee2I;#y4ZP?B#Jtb36#>+lg5XH7wzFq7ZXIdyQ0l)m>V7aaelYX6{+5%&tl1O zQ-R?DA|}aF8Nq%kyX2k|5Bt+8YV^hZB4ngpB@v91J5aFcbzINzCWFXA;IQLS9LIJm%L-yO{GF-P^I};*JXbYk>Ph+*n6965 z;&?ng8K$qxQPAS)S}?vQN0A=M74%B_Vh0K z^3z{fvBAK2oh&MIB4zRKKx?f{MP?HX`p08&QesVa6N>u^V zmeR#6vKZ~M1Q!a86#3tV7tk(qaKS(}gSK4BX1PI^SZ(5;q{CLo1Ua)7VeyArhege$ zq$(feF&WD|rp?Jxx|v1hqNhjhWNODtZ+CB~N%-QKw|N9{px-Wdg^#!Y!XgI487|PF zUDD}}`I?pho;~#bPx0{8!#lIk*-F4OmoUZrjt{2_$d{Z=wOK)orp-o)m`$FblMy~?4o`@2n_N2e14XCKEC%t&?k4z z0!w?3>+jCH)^}W90!N+$_v4HGJaah4ROW?H{4kvI$WFg-Wcb&)s$Ys1j;@E^SB=$l z^T*4^?rl)#Qy_MWcB6W0v;D;OZf(ijZdlxPMky+2YOs4p&a6OK-f0yHkx9SnU|Oz| z3gV60W7T&;ag`f}RZ+TB=~CqyPQaVf2IQdy6F-|&VD<=0C++(8drFWGHjmWhm`rDV z(@=4etp`X`3R~Ckew_`arvnh;qsf|^e)^C1(q9z$`aJ)BMGg+mJDv(m&M} zDjkjlA^=+;-~rX8{l`l9#V@Ce$CRfnz8i26tQR>lt+Yl!ldm6{R4?L9SaY)HFlRysR29z;axXrXE#0-%(6Y*6mfdr5>KIv`twA| z7AKfbAYXbiA9@ZPVr^^xF~+*f3K-XFU2DA;S_BUDCLc*MD14FJG^L2j1pn|y9`m1t zS%a0Y-hQI=tKTAFL9ecTNz4c^1=r?;Q-&cKvoYHmIT2KFRVVY`Rn`Fr3ma7gi z+g%si7R6D@o=g^}be?W0aVW2{ZIaLSm)6BTToVkmE;+8Z@zlFAS7)f0^aXm3$?UKt zEnTIL#7TZI|LkS&pT|gDC#F=Z&7b(9CA!INRyZHNv_mmyyX@cL@q*7rE!z>6SDm@| z#U97VxFEY~zEw&N_+d{Gn(kz)VO!l|*;={;Va46@5#36@{h^B)^J(n7_3G-vdZ+7tZZXoxf5XAOBrBkV$eGt3_d%;A{O4IrGm_c;|#zUAf@s0q@{ z;4V|ZN6D=E&roH0dYK71a?{+`tsa1HOlB>IF!g+3&Omtcj%ThgST)&q$z(84g!Y7P z29!O)K~#sGo%izyU~)6^-V3mFsPtqNkGZ_uT8z#Xuebge40Q8Ab(^~B&5zG5;f(U9 zxeb7>OU0wkso2i#a6a8N|Df4gz|2R*l>@-yGqLSxFNyebk7irjb2!!0)vV)q^ z{I?5fC8+dA8p6}T`H4m7>g$(Hi9Ih35W>MUzs1FGE1Ba1L0l)DmH*<9Urs8Y*lt0Iiq4ds>O>g zv|;+04(1zky8H?ml597Id(IY|4>MPpn>;qSAX(+neS-W#CZDmdi-k8k{+0D+)d^{= zq}lwG4qY>Cb-7V0MLPST^drY_2?VZCGBZ%RbueV1lj)Aq{iyV{yREHcDGko&T+fo0zfxY5+u@3HAqA}q0Wa9Z^ zl{Ro1<^N&QU$I!F^*nZ?#Os1Cf%f{|Zu9w>9<8oC|P7(`ivXtjhp{g(^K)M>r8^4h&*UPxxKw3=t`=+zhelCwL z7~!md@pT)Sv2R;AHxm&XZRIEl)R0ad3sEWPkt;VWDzLLillH0N*bO?Fovlh5g1P3k zc=YBf4ssd1d9kN9B!V{b?Xr~jj#JM%EGp0fmcCmzyZ^{0BqT?fiUMMO3oS=6y0d`Z zyC}}naopYAvw!RcT25TtPlJ@zZa6QG@ik4AuF3@tnWiyGBJSLWKF`{3+o@Yv_&| z4oOU@hvQMU)FCCF$|Wg-BPwZ5xeQ}wzMEd(bN+~De%YVT+H0@1KA-ul^?t8U%4Q!g zb=3u`0HD6k+hYp=N)1uKm8r*Q)XOOVZRK?yZrh@AKfS5DZ1PAa`8zjZL1j`Xg6X6x zb*3r>a)~)s2hOY1%&}co-F9VtNmGrz; zfOrf5(*n@+2H+a~ZwjUkjL(DN{t3f4{c**VyfRoIt0nfvjJ3 z#Dbe=;06PhK4}fL+Cym5;2(R%$LAZ9>oIu3k?48iLKNX1Np`77L8`Uj&OY(h3z~%s z5w4}OYa0!z)`UAe@e4|{3gI47c6~!bO0?kWK5@kYW9~|n*rFEa&&G=TKE85=8qV}O zUf$Lu2K-uKe5L6S*wEltYe}!w8bS|)dcEn0Y)#(c9)xFJZ|L$)&Rfg|wUE7uM{|`U zY}ugyOf2}+ZLi-<1HrBMIu}ZcQPKxufvl>)A%Ouu?*BPqucJE8$_F|x^v-KIUT{m7 z4NlF;*l`DSk)97ko$HpFr#-D3Yp3Opymw#lyP8{sEpOWyUPg?$? zu&<4gADU2@1(~2#nIH7*%2oG;N({41V+-iSAsPVPhrzO_V3N5415qKrY} zb+ocLsWWj%@hl$eRIuhDX2V9fUOWP2yR8#kBLPFMM&;(tu#+KlWk2|nmPkk z9$XDX^MPp4U=5+Wh8KHZNs|;0?k-zs6=TJV^#L9GDKk56d#^3=;_*7NDHoPL6%Xo_ zv5V5vi3}RysV5(orF=4lL%WQ(k$J&qpq?cNXZ2ikX~#aofG9zu@NUsrYR^aLsY*eeIKXZ;qOeJYM6KmD@ z?gP0WJ^fgPDoCrxl-SuuXHCR=*_u?cz2Q^zc_xGQv-=w!x~l*?-dJ zM)fnJY%m{bEK-!@{Lp6H?%CE-{vo>GLEcPs)u84}k4gd-1?BzrY@5iByU7R6t+xzy z?`66KSa`o~p)w}k!dcrtF8toP!v)8F8B=+Taov`9DKe6`B&;tu`AcLY(+BxLhnda0 z{(NxvA?Fpzc=13El|kiNVHejcnYTSz$sw|Psai7n3(3lbGsVJJ3*~pWRT*fvoBm4+ zJWJz7gF`ySyOTa{35i|d|KL!l3R(#D1=$us1*#vf-tr)0P{Q4hxxD+`DmMd!^Qy^L zk-XljqP$~xtSQO|d8Y*oJ^3~pX?64N%2YP`jt;|N>&8`epw&x|R$$pFK9ZFR=7bdt{_4G%z6XX5eqhimssGwcC&J zOE-eriP66Cmk-yM4sK-e^bsNMrO2%_z9+g5{)K*LmF z*Zm#w00bB1g_>AI{$X8Qw0gw)hrB5Dv?oi*``@r^^_Nj!>I|id=JQ-B2t{c`Ua9{t z`SMd|BJ1%RpDHVG0DLk0$SGNSZ{keAh~Rr4B%cjY4c5zBgmKhqn?Xe~-G8-wV)S=| zwKq-5k=v)##rogmjF)UOXuP+92J&7>f%A6X%{dU&+W28KpFtHyatXQjZ$x$CJ?c06 ze8e#!@{zga$P+KdEY5y!TfHK-juv*jAYzr{oX+*Lz)MWr(QlecxQ3C+7c=2-g>R4V zUw};J>02VByxRMh!2D^sU7f7kN_b&*7epGChU`i0Wow1cm`4Xf zx=gz2Z%hwxa6yNfUF6n(i)L^?hCFRTtn6&D`WD{*(*SbLTV15|(S3RTtgG3LjOdYn z`8sSE5AAv{P2jCKALnfvpWrhfDV9p(EI3lpQmX|L?PVJK zW8F4=`&as=4>P*oxx|0^wqdzG?9YcBlOyLu(ums01Lj<1sVw;0D|JZ%Bj%|LxkQKQ zdg+ewea>!2|8{bXIhOz8*0R&gyBBK|f(9Cq9!x{L_x9*QkZ3*;8tB@Z2nQb?W7quP zN*@1aFz63vrdObSt6+9%h1r)sJ3BYudek`9Cv$cBw(+fLTcPC7(Rs3&(=y)>nvJN<#>Z$4-*cIxl zh@di1Gso2OR(@|FN)$QH3C#I8_lOpp|I>nScAOGK(%?rEs)k2D=)%Z^11`>b3yx%+ zTI4LNXQITL6yj}=Yoih9tduTk(tLktJTm8Am~jdOb`Ce680UoKyy`eI9-U-MRh(u6 zi|&TV1SgbtOgq0sEwgL39?gG_6QFI^q3WJIVG)$6`jMeUBQ&+`Ua8h62WXdhB}LM% z-_MpQr48&K>XZy#=Sbowa{;?^eFn3jQk!I2@m_0-p^trsff= z@;-z=ZxeK@Epkc!@KN;&mAn59sTkpmUThz*84~6jW!I`^@h87b7U|RvOowh{+Ab=4 z{M(r$=o?2wloytBGLzlsbz-_Aa;Ixr6Ag4D9cd1gP1m`VEwZ4&mPOD1qKXz0nZS@* zgK4C}+ChtXW(C%%nes}=_wj+MpZeF4rr`=T8$p|D!QjqhR|*`f4rSmDCqK1RG;nr) z`da5pssRNZD1i z8q`81PY1rL_^{cqvCo$0lxXNMxPj1>utfTMc>H9SnlE!m{#R9E#r+uhW3nry<%@GX zO<@M+4dUqBmXX_;y;i6w<1=h-pERmC#(zrIj(qBx9M3F>JrMC_5ixJlfV*jpD8u;2 z*LTPIzCP)bDNg&q!GTY|=FP{H+wCgblhffj4R-95t&B&;vQBp-;1u5T^el5Vm}^^J zN5Aoe>oF=K)gGll-(So*C&nk9jzv1R56Bezec9Ka(c(+u)}%q zH-oT<80>DUK@+RcN;(mV!oj`m937;T>ZF!EHF!{=Pzddj2$cqJPH<9Bi!Kt|SsBg% z?m`xfn*ibO0mJUl{rH&HOJlU^_qbR?%JO1VPmGMkNTqFNFU`;|otc#kAg_2p5F3d7DIm#$sI-2w zAvdhFC2pEYsrzE&X;efjVN7WvM{dt^^#QQTrQ=0Ox?5K&jyXcEg|ZxLJjE$Ii;q}BK&AXVzFPK0MB|77p;asgbJNJ zjFIu%EK5DH)KEj`i{Oy;Xzmd@(%RCymS-x zeQ2=0FKpa%tG>&Flno1x<3J>y1q%i+uI~;kz^k6QI9!6Pr+tF46ynJ+|2X9n@qjj* zK(}%BX(Hvrr@zt&R<~N38`k-`L=zw}1uQfnsd!K0un_b(luuWgTR^atFmK z!?YSNYa!!zqp3jlEunLlp@rsm)wcUz1nKroXd%a5eIHc^hWAov*w^ipw*MDg$Zs

Hw5ByBZ_F?u@4vhR2b-Fevn|#VvSK#Unw6um!tWeNHwS;aCaB=!`Cp~; zT%XD51kIu*UD3jqua`tCC{T#}-iB5flG|B5tTrIxflu+;n!31i0Dkfa=cv^niU@2o4^A&2;e_ zVl0EPrzYT#=fi?4xjmm*MwuWoWI_AM_b$3f_OvHA=npHkguC4giFR^C5_4TIMx4)8 zCRdHzE3j1SQKes517c%3$d2B-T%PDvdKsVQ)<&<;ISUzlAp9^A65yI&y zmI`OpE$QGy<+0PxpIzxfopP#84nR4-4~xrY)V)tqeRK2hCYsi^suqix$e!mhK%>_d z#{ELXwSFOZz}d@Xxe#&oT#oh$zp=OyPK>38cYqH#gkJ+h+Rd0=|rI9<~)0QhaD5rpJ#P;Q928X5OL;6qT;5id)1oM zZEVQ$!P5@kt>gP(FWF%3j;HMzn4P>^4OcJUUUFsGp}YX*eQnmiEZA`M4_l&bvAVp{ zLakTTddmeUv^D0Qe4MDr#f!b5HG#UcNL1X*rt>7VY&htNZ_caZze&Y8(-bAWvluJ> zT~yoCNKIYM;NT{llT)Y2qEpJjYzRzd^5(nq4ZYS9z5E>DIj$joW=>02vrsjK6o-G9 zVx8yf_@&M!q`0;Kj1rhUOLuC@BE>yxLDLhDIA7=5Vnf*qr>vWK43yy?DWzUcytr@I-Y} We%d#<*GU4@Z=I))N7)))!v6tPWkl2f literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-72x72.png b/app/static/icons/icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd806b59b46796bcf1dac86e8c8840458900dde GIT binary patch literal 671 zcmV;Q0$}}#P)*a9+whpM1lyQg3u}I zabdVbEyyeOz3$5(39{%#y?al_qz7pwjWD$`FhR>-`Fy=J7h2k;_ahJU=Rq7%weR$n zlaj&upj_0l;FdIvASS~~kmTP0X_3@+$o|Bs%XEbq_w2@%nfGzNUffdVwggXGP-)6~ zNMbkXUTXw1wG)68Zatg27eheG)jt6l1XYE zzbHXll{C!q7jG=5n^@3DDN2c!aJ&TPZDUSmo$?SEg9?iS<1C^w38@JhYkawEaj;Be|UA=KQm%>fZ-MzCq zO}D1&B6mS^;;rW$u#1~jOF|Gq2qFkU1R;nZ1QCQFf z%T44!5QdA@-r))x0f-1lfS5^u2oOYoxK0MUmu<Ok9~7{ z`|wu?j9=&hoE-`PLjhnY01O3yp#U%x0EPm#8dYSJ5Re)8IcO130m#75L5Y9}s1*1)*&@ z_R*7I53>Md=|S$NVa%-K&jfN?HlZnw2BXevv={h}(mR+n`oLGXQfb9=)sOQhUIWC4}0HKnGq6W+0i5Qg{6tJ6m)A( zNIeZ5B^%bp8IcL;zXPgE*c+d>1inAuKfM#fs~qtS8itIVO3?O#5&{|{zC-46`8GA; zQUa#%ElLI9d&G!4yc`XN0>DrJ7zzMG0bnQq3 { + initTabs(); + loadAllData(); + setupEventListeners(); +}); + +function initTabs() { + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tabId = btn.dataset.tab; + switchTab(tabId); + }); + }); + + // Collapsibles + document.querySelectorAll('.collapsible').forEach(el => { + el.querySelector('.collapsible-header')?.addEventListener('click', () => { + el.classList.toggle('collapsed'); + }); + el.classList.add('collapsed'); // Fermé par défaut + }); +} + +function switchTab(tabId) { + // Boutons + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabId); + }); + + // Contenus + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.toggle('active', content.id === `tab-${tabId}`); + }); +} + +function loadAllData() { + loadModulesConfig(); // Modules en premier + loadTrackers(); + loadTags(); + loadRSSFeeds(); + loadLatestConfig(); + loadFiltersConfig(); + loadTorrentClientConfig(); + loadDiscoverTrackers(); // Trackers pour Discover +} + +function setupEventListeners() { + // === MODULES === + document.getElementById('saveModulesBtn')?.addEventListener('click', saveModulesConfig); + + // === CATÉGORIES === + document.getElementById('saveConfigBtn')?.addEventListener('click', saveConfig); + document.getElementById('resetConfigBtn')?.addEventListener('click', resetConfig); + + // === TAGS === + document.getElementById('addTagBtn')?.addEventListener('click', addTag); + document.getElementById('newTagInput')?.addEventListener('keypress', e => { + if (e.key === 'Enter') addTag(); + }); + document.getElementById('saveTagsBtn')?.addEventListener('click', saveTags); + document.getElementById('resetTagsBtn')?.addEventListener('click', resetTags); + document.getElementById('testParsingBtn')?.addEventListener('click', testParsing); + + // Présets + document.querySelectorAll('.preset-btn').forEach(btn => { + btn.addEventListener('click', () => addPreset(btn.dataset.preset)); + }); + + // === FILTRES === + document.getElementById('saveFiltersBtn')?.addEventListener('click', saveFilters); + document.getElementById('resetFiltersBtn')?.addEventListener('click', resetFilters); + document.getElementById('addFilterBtn')?.addEventListener('click', addNewFilter); + document.getElementById('testFilterBtn')?.addEventListener('click', testFilters); + + // === RSS === + document.getElementById('add-feed-form')?.addEventListener('submit', addRSSFeed); + document.getElementById('test-feed-btn')?.addEventListener('click', testRSSFeed); + + // === CLIENT TORRENT === + document.getElementById('testTorrentClientBtn')?.addEventListener('click', testTorrentClient); + document.getElementById('saveTorrentClientBtn')?.addEventListener('click', saveTorrentClient); +} + +// ============================================================ +// ONGLET CATÉGORIES +// ============================================================ + +async function loadTrackers() { + try { + const response = await fetch('/api/trackers'); + const data = await response.json(); + + if (data.success) { + trackers = data.trackers.filter(t => !t.id.startsWith('rss:')); + renderTrackers(); + } + } catch (error) { + console.error('Erreur chargement trackers:', error); + document.getElementById('trackerSelector').innerHTML = '

Erreur de chargement

'; + } +} + +function renderTrackers() { + const container = document.getElementById('trackerSelector'); + if (!container) return; + + if (trackers.length === 0) { + container.innerHTML = '

Aucun tracker configuré

'; + return; + } + + container.innerHTML = trackers.map(tracker => ` + + `).join(''); + + container.querySelectorAll('.tracker-btn').forEach(btn => { + btn.addEventListener('click', () => selectTracker(btn.dataset.id)); + }); +} + +async function selectTracker(trackerId) { + selectedTracker = trackers.find(t => t.id === trackerId); + if (!selectedTracker) return; + + // Highlight + document.querySelectorAll('.tracker-btn').forEach(btn => { + btn.classList.toggle('selected', btn.dataset.id === trackerId); + }); + + // Afficher sections + document.getElementById('categoriesSection')?.classList.remove('hidden'); + document.getElementById('configSection')?.classList.remove('hidden'); + + // Nom du tracker + document.querySelectorAll('#selectedTrackerName, #configTrackerName').forEach(el => { + if (el) el.textContent = selectedTracker.name; + }); + + // Charger les catégories + await loadTrackerCategories(trackerId); + loadTrackerConfig(trackerId); +} + +async function loadTrackerCategories(trackerId) { + const container = document.getElementById('availableCategories'); + if (!container) return; + + container.innerHTML = '

Chargement...

'; + + try { + const response = await fetch(`/api/admin/tracker-categories?tracker=${encodeURIComponent(trackerId)}`); + const data = await response.json(); + + if (data.success && data.categories?.length > 0) { + container.innerHTML = data.categories.map(cat => ` + + `).join(''); + + container.querySelectorAll('.category-chip').forEach(chip => { + chip.addEventListener('click', () => addCategoryToConfig(chip.dataset.id)); + }); + } else { + container.innerHTML = '

Aucune catégorie disponible

'; + } + } catch (error) { + container.innerHTML = '

Erreur de chargement

'; + } +} + +function loadTrackerConfig(trackerId) { + const config = latestConfig[trackerId] || {}; + + document.getElementById('config-movies').value = config.movies || ''; + document.getElementById('config-tv').value = config.tv || ''; + document.getElementById('config-anime').value = config.anime || ''; + document.getElementById('config-music').value = config.music || ''; +} + +function addCategoryToConfig(catId) { + // Ajouter à tous les champs qui sont focusés ou au premier + const inputs = ['config-movies', 'config-tv', 'config-anime', 'config-music']; + const focused = document.activeElement; + + let targetInput; + if (inputs.includes(focused?.id)) { + targetInput = focused; + } else { + targetInput = document.getElementById('config-movies'); + } + + if (targetInput) { + const current = targetInput.value.split(',').map(s => s.trim()).filter(Boolean); + if (!current.includes(catId)) { + current.push(catId); + targetInput.value = current.join(','); + } + } +} + +async function saveConfig() { + if (!selectedTracker) { + showToast('Sélectionnez un tracker', 'error'); + return; + } + + const config = { + tracker: selectedTracker.id, + categories: { + movies: document.getElementById('config-movies').value, + tv: document.getElementById('config-tv').value, + anime: document.getElementById('config-anime').value, + music: document.getElementById('config-music').value + } + }; + + try { + const response = await fetch('/api/admin/latest-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: config }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Configuration sauvegardée', 'success'); + loadLatestConfig(); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +function resetConfig() { + document.getElementById('config-movies').value = ''; + document.getElementById('config-tv').value = ''; + document.getElementById('config-anime').value = ''; + document.getElementById('config-music').value = ''; +} + +async function loadLatestConfig() { + try { + const response = await fetch('/api/admin/latest-config'); + const data = await response.json(); + + if (data.success) { + latestConfig = data.config || {}; + renderConfigSummary(); + } + } catch (error) { + console.error('Erreur chargement config:', error); + } +} + +function renderConfigSummary() { + const container = document.getElementById('configSummary'); + if (!container) return; + + const entries = Object.entries(latestConfig); + + if (entries.length === 0) { + container.innerHTML = '

Aucune configuration. Les catégories par défaut seront utilisées.

'; + return; + } + + container.innerHTML = entries.map(([trackerId, cats]) => ` +
+ ${escapeHtml(trackerId)} +
+ ${cats.movies ? `🎥 ${cats.movies}` : ''} + ${cats.tv ? `📺 ${cats.tv}` : ''} + ${cats.anime ? `🎌 ${cats.anime}` : ''} + ${cats.music ? `🎵 ${cats.music}` : ''} +
+
+ `).join(''); +} + +// ============================================================ +// ONGLET TAGS +// ============================================================ + +async function loadTags() { + try { + const response = await fetch('/api/admin/parsing-tags'); + const data = await response.json(); + + if (data.success) { + currentTags = data.tags || []; + renderTags(); + } + } catch (error) { + console.error('Erreur chargement tags:', error); + } +} + +function renderTags() { + const container = document.getElementById('tagsList'); + if (!container) return; + + if (currentTags.length === 0) { + container.innerHTML = '

Aucun tag configuré

'; + return; + } + + const sorted = [...currentTags].sort((a, b) => a.localeCompare(b)); + + container.innerHTML = sorted.map(tag => ` + + ${escapeHtml(tag)} + + + `).join(''); + + container.querySelectorAll('.tag-remove').forEach(btn => { + btn.addEventListener('click', () => removeTag(btn.dataset.tag)); + }); +} + +function addTag() { + const input = document.getElementById('newTagInput'); + const tag = input.value.trim().toUpperCase(); + + if (!tag) return; + + if (currentTags.includes(tag)) { + showToast('Ce tag existe déjà', 'error'); + return; + } + + currentTags.push(tag); + input.value = ''; + renderTags(); +} + +function removeTag(tag) { + currentTags = currentTags.filter(t => t !== tag); + renderTags(); +} + +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) { + showToast('Tags sauvegardés', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +async function resetTags() { + if (!confirm('Réinitialiser aux tags par défaut ?')) return; + + try { + const response = await fetch('/api/admin/parsing-tags/reset', { method: 'POST' }); + const data = await response.json(); + + if (data.success) { + currentTags = data.tags || []; + renderTags(); + showToast('Tags réinitialisés', 'success'); + } + } catch (error) { + showToast('Erreur', 'error'); + } +} + +function addPreset(presetName) { + const presets = { + langues: ['FRENCH', 'MULTI', 'TRUEFRENCH', 'VFF', 'VFQ', 'VOSTFR', 'SUBFRENCH', 'FASTSUB'], + resolutions: ['2160P', '1080P', '720P', '4K', 'UHD', 'HDTV', 'PDTV'], + sources: ['BLURAY', 'BDRIP', 'BRRIP', 'DVDRIP', 'WEBRIP', 'WEB-DL', 'HDTV', 'REMUX'], + codecs: ['X264', 'X265', 'H264', 'H265', 'HEVC', 'AVC', 'AV1', 'XVID', 'DIVX'], + audio: ['DTS', 'AC3', 'AAC', 'FLAC', 'TRUEHD', 'ATMOS', 'EAC3'] + }; + + const newTags = presets[presetName] || []; + let added = 0; + + newTags.forEach(tag => { + if (!currentTags.includes(tag)) { + currentTags.push(tag); + added++; + } + }); + + renderTags(); + showToast(`${added} tags ajoutés`, 'success'); +} + +async function testParsing() { + const input = document.getElementById('testTitleInput'); + const title = input.value.trim(); + + if (!title) { + showToast('Entrez un titre', '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'); + } + } catch (error) { + showToast('Erreur', 'error'); + } +} + +// ============================================================ +// ONGLET RSS +// ============================================================ + +async function loadRSSFeeds() { + try { + const response = await fetch('/api/admin/rss'); + const data = await response.json(); + + if (data.success) { + rssFeeds = data.feeds || []; + renderRSSFeeds(); + } + } catch (error) { + console.error('Erreur chargement RSS:', error); + } +} + +function renderRSSFeeds() { + const container = document.getElementById('feeds-list'); + if (!container) return; + + if (rssFeeds.length === 0) { + container.innerHTML = '

Aucun flux RSS configuré

'; + return; + } + + container.innerHTML = rssFeeds.map(feed => ` +
+
+
${escapeHtml(feed.name)}
+
+ ${getCategoryLabel(feed.category)} + ${feed.use_flaresolverr ? '🛡️' : ''} + ${feed.has_cookies ? '🍪' : ''} +
+
${maskUrl(feed.url)}
+
+
+ + +
+
+ `).join(''); +} + +async function addRSSFeed(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) { + showToast('Remplissez tous les champs obligatoires', 'error'); + 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) { + document.getElementById('add-feed-form').reset(); + document.getElementById('test-result').classList.add('hidden'); + loadRSSFeeds(); + showToast('Flux RSS ajouté', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +async function testRSSFeed() { + 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) { + showToast('Entrez une URL', 'error'); + return; + } + + const resultDiv = document.getElementById('test-result'); + resultDiv.classList.remove('hidden'); + resultDiv.innerHTML = '

🔄 Test en cours...

'; + + 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 = ` +
+ ✅ Succès ! ${data.count} résultats trouvés +
+ `; + } else { + resultDiv.innerHTML = ` +
+ ❌ Échec - Vérifiez l'URL et les cookies +
+ `; + } + } catch (error) { + resultDiv.innerHTML = '
❌ Erreur de connexion
'; + } +} + +async function toggleRSSFeed(feedId) { + try { + const response = await fetch(`/api/admin/rss/${feedId}/toggle`, { method: 'POST' }); + const data = await response.json(); + + if (data.success) { + loadRSSFeeds(); + } + } catch (error) { + showToast('Erreur', 'error'); + } +} + +async function deleteRSSFeed(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) { + loadRSSFeeds(); + showToast('Flux supprimé', 'success'); + } + } catch (error) { + showToast('Erreur', 'error'); + } +} + +// ============================================================ +// ONGLET FILTRES +// ============================================================ + +async function loadFiltersConfig() { + try { + const response = await fetch('/api/admin/filters'); + const data = await response.json(); + + if (data.success) { + filtersConfig = data.filters || {}; + renderFilters(); + } + } catch (error) { + console.error('Erreur chargement filtres:', error); + } +} + +function renderFilters() { + const container = document.getElementById('filtersList'); + if (!container) return; + + const filterKeys = Object.keys(filtersConfig); + + if (filterKeys.length === 0) { + container.innerHTML = '

Aucun filtre configuré

'; + return; + } + + container.innerHTML = filterKeys.map(key => { + const filter = filtersConfig[key]; + const values = filter.values || []; + const isEditing = editingFilter === key; + + return ` +
+
+ ${filter.icon || '🏷️'} + ${escapeHtml(filter.name || key)} + ${values.length} valeurs + ${isEditing ? '▼' : '▶'} +
+ ${isEditing ? ` +
+
+ + + +
+
+ +
+
+ + +
+
+ ` : ` +
+ ${values.slice(0, 10).map(v => `${escapeHtml(v)}`).join('')} + ${values.length > 10 ? `+${values.length - 10}` : ''} +
+ `} +
+ `; + }).join(''); +} + +function toggleFilterEdit(key) { + if (editingFilter === key) { + editingFilter = null; + } else { + editingFilter = key; + } + renderFilters(); +} + +function saveFilterEdit(key) { + const item = document.querySelector(`.filter-editor-item[data-key="${key}"]`); + if (!item) return; + + const nameInput = item.querySelector('.filter-name-input'); + const iconInput = item.querySelector('.filter-icon-input'); + const valuesTextarea = item.querySelector('.filter-values-textarea'); + + const name = nameInput?.value.trim() || key; + const icon = iconInput?.value.trim() || '🏷️'; + const values = valuesTextarea?.value.split('\n').map(v => v.trim()).filter(Boolean) || []; + + filtersConfig[key] = { + name, + icon, + values + }; + + editingFilter = null; + renderFilters(); + showToast('Modifications appliquées (non sauvegardées)', 'info'); +} + +function cancelFilterEdit() { + editingFilter = null; + renderFilters(); +} + +function deleteFilter(key) { + if (!confirm(`Supprimer le filtre "${filtersConfig[key]?.name || key}" ?`)) return; + + delete filtersConfig[key]; + editingFilter = null; + renderFilters(); + showToast('Filtre supprimé (non sauvegardé)', 'info'); +} + +function addNewFilter() { + // Créer une modale simple + const name = prompt('Nom du filtre (ex: Genre Jeu, Format Vidéo):'); + if (!name) return; + + // Générer automatiquement le nom technique + const key = name.toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // Enlever accents + .replace(/[^a-z0-9]+/g, '_') // Remplacer caractères spéciaux par _ + .replace(/^_|_$/g, ''); // Enlever _ au début/fin + + if (!key) { + showToast('Nom invalide', 'error'); + return; + } + + if (filtersConfig[key]) { + showToast('Un filtre similaire existe déjà', 'error'); + return; + } + + const icon = prompt('Icône emoji (ex: 🎮, 📚, 🎵):', '🏷️') || '🏷️'; + + filtersConfig[key] = { + name: name, + icon: icon, + values: [] + }; + + editingFilter = key; + renderFilters(); + showToast(`Filtre "${name}" créé. Ajoutez des valeurs puis sauvegardez.`, 'success'); +} + +async function saveFilters() { + try { + const response = await fetch('/api/admin/filters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filters: filtersConfig }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Filtres sauvegardés', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +async function resetFilters() { + if (!confirm('Réinitialiser aux filtres par défaut ?')) return; + + try { + const response = await fetch('/api/admin/filters/reset', { method: 'POST' }); + const data = await response.json(); + + if (data.success) { + filtersConfig = data.filters || {}; + editingFilter = null; + renderFilters(); + showToast('Filtres réinitialisés', 'success'); + } + } catch (error) { + showToast('Erreur', 'error'); + } +} + +async function testFilters() { + const input = document.getElementById('testFilterInput'); + const title = input?.value.trim(); + + if (!title) { + showToast('Entrez un titre', 'error'); + return; + } + + try { + const response = await fetch('/api/admin/filters/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }) + }); + + const data = await response.json(); + const resultDiv = document.getElementById('filterTestResult'); + + if (data.success && resultDiv) { + const parsed = data.parsed || {}; + const entries = Object.entries(parsed).filter(([k, v]) => v && (Array.isArray(v) ? v.length > 0 : true)); + + if (entries.length === 0) { + resultDiv.innerHTML = '

❌ Aucun filtre détecté

'; + } else { + resultDiv.innerHTML = ` +

✅ Filtres détectés :

+
+ ${entries.map(([key, value]) => { + const filter = filtersConfig[key]; + const icon = filter?.icon || '🏷️'; + const name = filter?.name || key; + const values = Array.isArray(value) ? value.join(', ') : value; + return `
${icon} ${escapeHtml(name)}: ${escapeHtml(values)}
`; + }).join('')} +
+ `; + } + resultDiv.classList.remove('hidden'); + } + } catch (error) { + showToast('Erreur', 'error'); + } +} + +// ============================================================ +// UTILITAIRES +// ============================================================ + +function getCategoryLabel(cat) { + const labels = { + movies: '🎬 Films', + tv: '📺 Séries', + anime: '🎌 Anime', + music: '🎵 Musique', + all: '📦 Toutes' + }; + return labels[cat] || cat; +} + +function maskUrl(url) { + return url.replace(/passkey=[^&]+/gi, 'passkey=***') + .replace(/apikey=[^&]+/gi, 'apikey=***'); +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + if (!toast) return; + + toast.textContent = message; + toast.className = `toast ${type}`; + toast.classList.remove('hidden'); + + setTimeout(() => toast.classList.add('hidden'), 3000); +} + +// ============================================================ +// ONGLET CLIENT TORRENT +// ============================================================ + +async function loadTorrentClientConfig() { + try { + // Charger les plugins disponibles + const pluginsResponse = await fetch('/api/admin/torrent-client/plugins'); + const pluginsData = await pluginsResponse.json(); + + if (pluginsData.success) { + renderPluginsList(pluginsData.plugins); + populatePluginSelect(pluginsData.plugins); + } + + // Charger la config actuelle + const configResponse = await fetch('/api/admin/torrent-client/config'); + const configData = await configResponse.json(); + + if (configData.success) { + fillTorrentClientForm(configData.config, configData.connected); + updateTorrentClientStatus(configData.config, configData.connected); + } + + // Charger les catégories personnalisées + await loadCustomCategories(); + + // Event listeners pour les catégories + document.getElementById('addCategoryBtn')?.addEventListener('click', addCategory); + document.getElementById('saveCategoriesBtn')?.addEventListener('click', saveCustomCategories); + document.getElementById('syncCategoriesBtn')?.addEventListener('click', syncCategoriesWithClient); + + } catch (error) { + console.error('Erreur chargement config client torrent:', error); + } +} + +function renderPluginsList(plugins) { + const container = document.getElementById('pluginsList'); + if (!container) return; + + if (plugins.length === 0) { + container.innerHTML = '

Aucun plugin installé

'; + return; + } + + container.innerHTML = plugins.map(p => ` +
+
+ ${escapeHtml(p.name)} + v${escapeHtml(p.version)} +
+

${escapeHtml(p.description)}

+ Par ${escapeHtml(p.author)} +
+ `).join(''); +} + +function populatePluginSelect(plugins) { + const select = document.getElementById('tcPlugin'); + if (!select) return; + + select.innerHTML = ''; + + plugins.forEach(p => { + const option = document.createElement('option'); + option.value = p.id; + option.textContent = p.name; + select.appendChild(option); + }); +} + +function fillTorrentClientForm(config, connected) { + if (!config) return; + + document.getElementById('tcEnabled').checked = config.enabled || false; + document.getElementById('tcPlugin').value = config.plugin || ''; + document.getElementById('tcHost').value = config.host || ''; + document.getElementById('tcPort').value = config.port || ''; + document.getElementById('tcPath').value = config.path || ''; + document.getElementById('tcUsername').value = config.username || ''; + // Ne pas remplir le mot de passe (sécurité) + document.getElementById('tcSSL').checked = config.use_ssl || false; +} + +function updateTorrentClientStatus(config, connected) { + const container = document.getElementById('torrentClientStatus'); + if (!container) return; + + if (!config || !config.enabled) { + container.innerHTML = ` + ⚫ Désactivé +

Aucun client torrent configuré

+ `; + return; + } + + // Construire l'URL affichée + let urlDisplay = config.host; + if (config.port) urlDisplay += `:${config.port}`; + if (config.path) urlDisplay += config.path; + + if (connected) { + container.innerHTML = ` + 🟢 Connecté +

${escapeHtml(config.plugin)} sur ${escapeHtml(urlDisplay)}

+ `; + } else { + container.innerHTML = ` + 🔴 Déconnecté +

${escapeHtml(config.plugin)} - Vérifiez les paramètres

+ `; + } +} + +function getTorrentClientFormData() { + return { + enabled: document.getElementById('tcEnabled').checked, + plugin: document.getElementById('tcPlugin').value, + host: document.getElementById('tcHost').value, + port: document.getElementById('tcPort').value, + path: document.getElementById('tcPath').value, + username: document.getElementById('tcUsername').value, + password: document.getElementById('tcPassword').value, + use_ssl: document.getElementById('tcSSL').checked + }; +} + +async function testTorrentClient() { + const data = getTorrentClientFormData(); + + if (!data.plugin) { + showToast('Sélectionnez un client', 'error'); + return; + } + + if (!data.host) { + showToast('Entrez l\'adresse du serveur', 'error'); + return; + } + + const resultDiv = document.getElementById('tcTestResult'); + resultDiv.innerHTML = '

Test en cours...

'; + resultDiv.classList.remove('hidden'); + + try { + const response = await fetch('/api/admin/torrent-client/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + resultDiv.innerHTML = ` +

✅ Connexion réussie !

+ ${result.version ? `

Version: ${escapeHtml(result.version)}

` : ''} + `; + showToast('Connexion réussie', 'success'); + } else { + resultDiv.innerHTML = `

❌ ${escapeHtml(result.error || result.message)}

`; + showToast('Échec de la connexion', 'error'); + } + + } catch (error) { + resultDiv.innerHTML = `

❌ Erreur: ${escapeHtml(error.message)}

`; + showToast('Erreur de connexion', 'error'); + } +} + +async function saveTorrentClient() { + const data = getTorrentClientFormData(); + + try { + const response = await fetch('/api/admin/torrent-client/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + showToast(result.message || 'Configuration sauvegardée', 'success'); + if (result.warning) { + showToast(result.warning, 'warning'); + } + loadTorrentClientConfig(); + } else { + showToast(result.error || 'Erreur', 'error'); + } + + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +// ============================================================ +// CATÉGORIES PERSONNALISÉES +// ============================================================ + +let customCategories = {}; + +async function loadCustomCategories() { + try { + const response = await fetch('/api/admin/torrent-client/categories'); + const data = await response.json(); + + if (data.success) { + customCategories = data.categories || {}; + renderCustomCategories(); + } + } catch (error) { + console.error('Erreur chargement catégories:', error); + } +} + +function renderCustomCategories() { + const container = document.getElementById('customCategoriesList'); + if (!container) return; + + if (Object.keys(customCategories).length === 0) { + container.innerHTML = '

Aucune catégorie configurée

'; + return; + } + + container.innerHTML = Object.entries(customCategories).map(([name, path]) => ` +
+ 📁 ${escapeHtml(name)} + ${escapeHtml(path) || '(pas de chemin)'} +
+ + +
+
+ `).join(''); +} + +function addCategory() { + const nameInput = document.getElementById('newCategoryName'); + const pathInput = document.getElementById('newCategoryPath'); + + const name = nameInput.value.trim(); + const path = pathInput.value.trim(); + + if (!name) { + showToast('Nom de catégorie requis', 'error'); + return; + } + + customCategories[name] = path; + renderCustomCategories(); + + // Reset form + nameInput.value = ''; + pathInput.value = ''; + + showToast(`Catégorie "${name}" ajoutée`, 'success'); +} + +function editCategory(name) { + const newName = prompt('Nouveau nom:', name); + if (!newName || newName === name) return; + + const newPath = prompt('Chemin:', customCategories[name] || ''); + + // Supprimer l'ancienne et ajouter la nouvelle + const oldPath = customCategories[name]; + delete customCategories[name]; + customCategories[newName] = newPath !== null ? newPath : oldPath; + + renderCustomCategories(); + showToast('Catégorie modifiée', 'success'); +} + +function deleteCategory(name) { + if (!confirm(`Supprimer la catégorie "${name}" ?`)) return; + + delete customCategories[name]; + renderCustomCategories(); + showToast('Catégorie supprimée', 'success'); +} + +async function saveCustomCategories() { + try { + const response = await fetch('/api/admin/torrent-client/categories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ categories: customCategories }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Catégories sauvegardées', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +async function syncCategoriesWithClient() { + try { + const response = await fetch('/api/admin/torrent-client/sync-categories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message || 'Catégories synchronisées', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +// Exposer les fonctions pour les onclick inline +window.toggleRSSFeed = toggleRSSFeed; +window.deleteRSSFeed = deleteRSSFeed; +window.toggleFilterEdit = toggleFilterEdit; +window.saveFilterEdit = saveFilterEdit; +window.cancelFilterEdit = cancelFilterEdit; +window.deleteFilter = deleteFilter; +window.editCategory = editCategory; +window.deleteCategory = deleteCategory; + +// ============================================================ +// GESTION DES THÈMES +// ============================================================ + +function initThemes() { + // Charger le thème sauvegardé + const savedTheme = localStorage.getItem('lycostorrent-theme') || 'dark'; + applyTheme(savedTheme); + + // Marquer le thème actif + document.querySelectorAll('.theme-card').forEach(card => { + card.classList.toggle('active', card.dataset.theme === savedTheme); + + // Ajouter l'événement click + card.addEventListener('click', () => { + const theme = card.dataset.theme; + applyTheme(theme); + + // Mettre à jour l'UI + document.querySelectorAll('.theme-card').forEach(c => c.classList.remove('active')); + card.classList.add('active'); + + // Sauvegarder + localStorage.setItem('lycostorrent-theme', theme); + showToast(`Thème "${getThemeName(theme)}" appliqué`, 'success'); + }); + }); +} + +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); +} + +function getThemeName(theme) { + const names = { + 'dark': 'Sombre', + 'light': 'Clair', + 'ocean': 'Océan', + 'purple': 'Violet', + 'nature': 'Nature', + 'sunset': 'Sunset', + 'cyberpunk': 'Cyberpunk', + 'nord': 'Nord' + }; + return names[theme] || theme; +} + +// Initialiser les thèmes au chargement de la page +document.addEventListener('DOMContentLoaded', () => { + initThemes(); +}); + +// ============================================================ +// GESTION DES MODULES +// ============================================================ + +async function loadModulesConfig() { + try { + const response = await fetch('/api/admin/modules'); + const data = await response.json(); + + if (data.success && data.modules) { + document.getElementById('module-search').checked = data.modules.search !== false; + document.getElementById('module-latest').checked = data.modules.latest !== false; + document.getElementById('module-discover').checked = data.modules.discover === true; + } + } catch (error) { + console.error('Erreur chargement modules:', error); + } +} + +async function saveModulesConfig() { + try { + const modules = { + search: document.getElementById('module-search').checked, + latest: document.getElementById('module-latest').checked, + discover: document.getElementById('module-discover').checked + }; + + const response = await fetch('/api/admin/modules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modules }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Modules sauvegardés ! Rechargez la page pour voir les changements.', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +// ============================================================ +// TRACKERS POUR DISCOVER +// ============================================================ + +let allTrackers = []; +let selectedDiscoverTrackers = []; + +async function loadDiscoverTrackers() { + const container = document.getElementById('discoverTrackersList'); + if (!container) return; + + try { + // Charger tous les trackers + const trackersResponse = await fetch('/api/trackers'); + const trackersData = await trackersResponse.json(); + + if (trackersData.success) { + allTrackers = trackersData.trackers || []; + } + + // Charger la config des trackers sélectionnés + const configResponse = await fetch('/api/admin/discover-trackers'); + const configData = await configResponse.json(); + + if (configData.success && configData.trackers) { + selectedDiscoverTrackers = configData.trackers; + } else { + // Par défaut, tous les trackers sont sélectionnés + selectedDiscoverTrackers = allTrackers.map(t => t.id); + } + + renderDiscoverTrackers(); + + } catch (error) { + container.innerHTML = '

Erreur de chargement des trackers

'; + } +} + +function renderDiscoverTrackers() { + const container = document.getElementById('discoverTrackersList'); + if (!container || !allTrackers.length) { + container.innerHTML = '

Aucun tracker disponible

'; + return; + } + + container.innerHTML = allTrackers.map(tracker => { + const isChecked = selectedDiscoverTrackers.includes(tracker.id); + const source = tracker.id.startsWith('jackett:') ? 'jackett' : + tracker.id.startsWith('prowlarr:') ? 'prowlarr' : ''; + const sourceLabel = source.charAt(0).toUpperCase() + source.slice(1); + + return ` +
+ + +
+ `; + }).join(''); +} + +function toggleDiscoverTracker(trackerId) { + const index = selectedDiscoverTrackers.indexOf(trackerId); + if (index > -1) { + selectedDiscoverTrackers.splice(index, 1); + } else { + selectedDiscoverTrackers.push(trackerId); + } +} + +function selectAllDiscoverTrackers() { + selectedDiscoverTrackers = allTrackers.map(t => t.id); + renderDiscoverTrackers(); +} + +function selectNoneDiscoverTrackers() { + selectedDiscoverTrackers = []; + renderDiscoverTrackers(); +} + +async function saveDiscoverTrackers() { + try { + const response = await fetch('/api/admin/discover-trackers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ trackers: selectedDiscoverTrackers }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Trackers Discover sauvegardés !', 'success'); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +// Event listeners pour les boutons +document.getElementById('selectAllDiscoverTrackers')?.addEventListener('click', selectAllDiscoverTrackers); +document.getElementById('selectNoneDiscoverTrackers')?.addEventListener('click', selectNoneDiscoverTrackers); +document.getElementById('saveDiscoverTrackers')?.addEventListener('click', saveDiscoverTrackers); + +// Exposer les fonctions +window.toggleDiscoverTracker = toggleDiscoverTracker; + +// ============================================================ +// GESTION DU CACHE +// ============================================================ + +let cacheConfig = {}; + +async function loadCacheStatus() { + try { + const response = await fetch('/api/cache/status'); + const data = await response.json(); + + if (data.success) { + updateCacheStatusDisplay(data); + } + } catch (error) { + console.error('Erreur chargement statut cache:', error); + } +} + +function updateCacheStatusDisplay(status) { + const badge = document.getElementById('cacheStatusBadge'); + const lastRefresh = document.getElementById('cacheLastRefresh'); + const nextRefresh = document.getElementById('cacheNextRefresh'); + const sizeDisplay = document.getElementById('cacheSizeDisplay'); + + if (!badge) return; + + // Badge statut + if (!status.enabled) { + badge.textContent = '⚫ Désactivé'; + badge.className = 'status-badge status-disabled'; + } else if (status.is_refreshing) { + badge.textContent = '🔄 Refresh en cours...'; + badge.className = 'status-badge status-refreshing'; + } else if (status.status === 'success') { + badge.textContent = '🟢 Actif'; + badge.className = 'status-badge status-success'; + } else if (status.status === 'error') { + badge.textContent = '🔴 Erreur'; + badge.className = 'status-badge status-error'; + } else { + badge.textContent = '⚪ Jamais exécuté'; + badge.className = 'status-badge status-never'; + } + + // Dernier refresh + if (status.last_refresh) { + const date = new Date(status.last_refresh); + lastRefresh.textContent = `${date.toLocaleDateString('fr-FR')} à ${date.toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} (${status.last_refresh_ago || ''})`; + } else { + lastRefresh.textContent = 'Jamais'; + } + + // Prochain refresh + if (status.next_refresh && status.enabled) { + const date = new Date(status.next_refresh); + nextRefresh.textContent = date.toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'}); + } else { + nextRefresh.textContent = '-'; + } + + // Taille + if (status.cache_size_mb > 0) { + sizeDisplay.textContent = `${status.cache_size_mb} Mo`; + } else { + sizeDisplay.textContent = 'Vide'; + } +} + +async function loadCacheConfig() { + try { + const response = await fetch('/api/cache/config'); + const data = await response.json(); + + if (data.success && data.config) { + cacheConfig = data.config; + fillCacheForm(data.config); + } + } catch (error) { + console.error('Erreur chargement config cache:', error); + } +} + +function fillCacheForm(config) { + document.getElementById('cacheEnabled').checked = config.enabled || false; + document.getElementById('cacheInterval').value = config.interval_minutes || 60; + + // Latest + const latest = config.latest || {}; + document.getElementById('cacheLatestEnabled').checked = latest.enabled !== false; + document.getElementById('cacheLatestMovies').checked = (latest.categories || []).includes('movies'); + document.getElementById('cacheLatestTv').checked = (latest.categories || []).includes('tv'); + document.getElementById('cacheLatestAnime').checked = (latest.categories || []).includes('anime'); + document.getElementById('cacheLatestMusic').checked = (latest.categories || []).includes('music'); + document.getElementById('cacheLatestLimit').value = latest.limit || 50; + + // Discover + const discover = config.discover || {}; + document.getElementById('cacheDiscoverEnabled').checked = discover.enabled !== false; + document.getElementById('cacheDiscoverLimit').value = discover.limit || 30; + + // Charger les trackers + loadCacheTrackers(latest.trackers || []); +} + +async function loadCacheTrackers(selectedTrackers) { + try { + const response = await fetch('/api/trackers'); + const data = await response.json(); + + if (data.success && data.trackers) { + const container = document.getElementById('cacheTrackersList'); + container.innerHTML = ''; + + data.trackers.forEach(tracker => { + const label = document.createElement('label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = tracker.id; + checkbox.checked = selectedTrackers.length === 0 || selectedTrackers.includes(tracker.id); + checkbox.className = 'cache-tracker-checkbox'; + + label.appendChild(checkbox); + label.appendChild(document.createTextNode(' ' + tracker.name)); + container.appendChild(label); + }); + } + } catch (error) { + console.error('Erreur chargement trackers cache:', error); + } +} + +async function saveCacheConfig() { + try { + // Récupérer les catégories cochées + const categories = []; + if (document.getElementById('cacheLatestMovies').checked) categories.push('movies'); + if (document.getElementById('cacheLatestTv').checked) categories.push('tv'); + if (document.getElementById('cacheLatestAnime').checked) categories.push('anime'); + if (document.getElementById('cacheLatestMusic').checked) categories.push('music'); + + // Récupérer les trackers cochés + const trackers = []; + document.querySelectorAll('.cache-tracker-checkbox:checked').forEach(cb => { + trackers.push(cb.value); + }); + + const config = { + enabled: document.getElementById('cacheEnabled').checked, + interval_minutes: parseInt(document.getElementById('cacheInterval').value), + latest_enabled: document.getElementById('cacheLatestEnabled').checked, + latest_categories: categories, + latest_trackers: trackers, + latest_limit: parseInt(document.getElementById('cacheLatestLimit').value), + discover_enabled: document.getElementById('cacheDiscoverEnabled').checked, + discover_limit: parseInt(document.getElementById('cacheDiscoverLimit').value) + }; + + const response = await fetch('/api/cache/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Configuration du cache sauvegardée !', 'success'); + loadCacheStatus(); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +async function refreshCache() { + const btn = document.getElementById('refreshCacheBtn'); + const originalText = btn.textContent; + btn.textContent = '⏳ Refresh en cours...'; + btn.disabled = true; + + try { + const response = await fetch('/api/cache/refresh', { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + showToast('Refresh du cache lancé !', 'success'); + // Mettre à jour le statut périodiquement + setTimeout(loadCacheStatus, 2000); + setTimeout(loadCacheStatus, 5000); + setTimeout(loadCacheStatus, 10000); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } finally { + btn.textContent = originalText; + btn.disabled = false; + } +} + +async function clearCache() { + if (!confirm('Voulez-vous vraiment vider le cache ?')) return; + + try { + const response = await fetch('/api/cache/clear', { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + showToast('Cache vidé !', 'success'); + loadCacheStatus(); + } else { + showToast(data.error || 'Erreur', 'error'); + } + } catch (error) { + showToast('Erreur de connexion', 'error'); + } +} + +// Event listeners Cache +document.getElementById('refreshCacheBtn')?.addEventListener('click', refreshCache); +document.getElementById('clearCacheBtn')?.addEventListener('click', clearCache); +document.getElementById('saveCacheConfigBtn')?.addEventListener('click', saveCacheConfig); + +// Charger le cache au chargement de la page +document.addEventListener('DOMContentLoaded', function() { + // Charger le statut et la config du cache si l'onglet existe + if (document.getElementById('tab-cache')) { + loadCacheStatus(); + loadCacheConfig(); + + // Rafraîchir le statut toutes les 30 secondes + setInterval(loadCacheStatus, 30000); + } +}); diff --git a/app/static/js/admin_latest.js b/app/static/js/admin_latest.js new file mode 100644 index 0000000..1742c91 --- /dev/null +++ b/app/static/js/admin_latest.js @@ -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 = '

Chargement des catégories...

'; + + 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 = '

Impossible de charger les catégories

'; + } + } catch (error) { + console.error('Erreur:', error); + document.getElementById('availableCategories').innerHTML = '

Erreur de connexion

'; + } +} + +// ============================================================ +// AFFICHAGE +// ============================================================ + +function displayTrackerSelector(trackers) { + const container = document.getElementById('trackerSelector'); + + if (trackers.length === 0) { + container.innerHTML = '

Aucun tracker configuré dans Jackett

'; + return; + } + + container.innerHTML = trackers.map(tracker => ` + + `).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 = '

Aucune catégorie trouvée pour ce tracker

'; + 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 = '
'; + + for (const [prefix, cats] of Object.entries(grouped).sort((a, b) => a[0] - b[0])) { + html += ` +
+

${prefixNames[prefix] || `Catégorie ${prefix}`}

+
+ ${cats.map(cat => ` +
+ ${cat.id} + ${escapeHtml(cat.name)} + +
+ `).join('')} +
+
+ `; + } + + html += '
'; + 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 = ` +
Ajout rapide:
+ ${relevantCats.slice(0, 6).map(cat => ` + + `).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 = '

Aucune configuration sauvegardée. Les catégories par défaut seront utilisées.

'; + return; + } + + let html = ''; + + for (const [trackerId, trackerConfig] of Object.entries(config)) { + const tracker = allTrackers.find(t => t.id === trackerId); + const trackerName = tracker ? tracker.name : trackerId; + + html += ` + + + + + + + + `; + } + + html += '
TrackerFilmsSériesAnimeMusique
${escapeHtml(trackerName)}${trackerConfig.movies || '-'}${trackerConfig.tv || '-'}${trackerConfig.anime || '-'}${trackerConfig.music || '-'}
'; + 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; +} \ No newline at end of file diff --git a/app/static/js/admin_parsing.js b/app/static/js/admin_parsing.js new file mode 100644 index 0000000..7028f40 --- /dev/null +++ b/app/static/js/admin_parsing.js @@ -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 = '

Aucun tag configuré

'; + return; + } + + // Trier alphabétiquement + const sortedTags = [...currentTags].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + container.innerHTML = sortedTags.map(tag => ` + + ${escapeHtml(tag)} + + + `).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; +} \ No newline at end of file diff --git a/app/static/js/admin_rss.js b/app/static/js/admin_rss.js new file mode 100644 index 0000000..af3a758 --- /dev/null +++ b/app/static/js/admin_rss.js @@ -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 = ` +
+

🔗 Aucun flux RSS configuré

+

Ajoutez votre premier flux ci-dessus pour commencer

+
+ `; + return; + } + + container.innerHTML = feeds.map(feed => ` +
+
+
+

${escapeHtml(feed.name)}

+ ${getCategoryLabel(feed.category)} + ${feed.use_flaresolverr ? '🛡️ Flaresolverr' : ''} + ${feed.has_cookies ? '🍪 Cookies' : ''} +
+
+ + + +
+
+
+ ${maskUrl(feed.url)} +
+ ${feed.passkey ? '
🔑 Passkey configuré
' : ''} +
+ `).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 = '

🔄 Test en cours...

'; + + 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 = ` +
+

✅ Test réussi ! ${data.count} résultats trouvés

+
+ ${data.sample.map(item => ` +
+ ${escapeHtml(item.Title)} + ${item.SizeFormatted} • ${item.Seeders} seeders +
+ `).join('')} +
+
+ `; + } else { + resultDiv.innerHTML = ` +
+

❌ Aucun résultat trouvé

+

Vérifiez l'URL et les cookies. Si erreur 403, activez Flaresolverr et ajoutez vos cookies.

+
+ `; + } + } catch (error) { + console.error('Erreur:', error); + resultDiv.innerHTML = ` +
+

❌ Erreur lors du test

+

${error.message}

+
+ `; + } +} + +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); +} \ No newline at end of file diff --git a/app/static/js/discover.js b/app/static/js/discover.js new file mode 100644 index 0000000..337a473 --- /dev/null +++ b/app/static/js/discover.js @@ -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 + ? `🧲 ${torrentCount}` + : ''; + + return ` +
+
+ ${posterUrl + ? `${escapeHtml(title)}` + : `
${type}
` + } + ⭐ ${rating} + ${torrentBadge} +
+
+
${escapeHtml(title)}
+
+ ${year || 'N/A'} + ${type} +
+
+
+ `; + }).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 => `${g.name}`).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 => `${g.name}`).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 ` +
+
+
${escapeHtml(torrent.Title)}
+
+ 📡 ${escapeHtml(tracker)} + 💾 ${size} + 🌱 ${seeds} + ${quality ? `${escapeHtml(quality)}` : ''} +
+
+
+ ${detailsUrl ? `🔗` : ''} + ${magnetUrl ? `🧲` : ''} + ${downloadUrl ? `⬇️` : ''} + ${torrentClientEnabled && torrentUrl ? + `` + : ''} +
+
+ `; + }).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 = ` +
+

📥 Options de téléchargement

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + 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 = ''; + + let categoriesWithPaths = {}; + + try { + const response = await fetch('/api/torrent-client/categories'); + const data = await response.json(); + + categorySelect.innerHTML = ''; + if (data.success && data.categories) { + data.categories.forEach(cat => { + categorySelect.innerHTML += ``; + }); + categoriesWithPaths = data.custom_categories || {}; + } + } catch (error) { + categorySelect.innerHTML = ''; + } + + 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); +} diff --git a/app/static/js/latest.js b/app/static/js/latest.js new file mode 100644 index 0000000..e7a44c2 --- /dev/null +++ b/app/static/js/latest.js @@ -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 = '

Aucun tracker configuré

'; + 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 = 'RSS'; + } else if (tracker.sources.includes('jackett') && tracker.sources.includes('prowlarr')) { + sourceBadge = 'J+P'; + } else if (tracker.sources.includes('jackett')) { + sourceBadge = 'J'; + } else if (tracker.sources.includes('prowlarr')) { + sourceBadge = 'P'; + } + } else if (tracker.source) { + if (tracker.source === 'jackett') { + sourceBadge = 'J'; + } else if (tracker.source === 'prowlarr') { + sourceBadge = 'P'; + } + } + + 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 = '

Aucun résultat pour les années sélectionnées

'; + 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 = ` + Artiste: ${escapeHtml(music.artist)}
+ Album: ${escapeHtml(music.album)}
+ ${music.tags?.length ? `Genres: ${escapeHtml(music.tags.join(', '))}` : ''} + `; + } else if (isAnime) { + contentType = '🎌'; + } else if (tmdb.type === 'tv') { + contentType = '📺'; + } + + card.innerHTML = ` +
+ ${escapeHtml(title)} +
${contentType} ${isMusic ? 'Musique' : (isAnime ? 'Anime' : (tmdb.type === 'tv' ? 'Série' : 'Film'))}
+ ${!isMusic && tmdb.vote_average ? `
⭐ ${tmdb.vote_average.toFixed(1)}
` : ''} + ${isMusic && music.listeners ? `
👥 ${formatNumber(music.listeners)}
` : ''} + ${variantsCount > 1 ? `
📦 ${variantsCount} versions
` : ''} +
🌱 ${mainTorrent.Seeders || 0}
+
+
+
${escapeHtml(title)}
+ +
${overview}
+
+ + ${torrentUrl ? `🔗` : ''} + ${mainTorrent.MagnetUri ? `🧲` : ''} +
+
+ `; + + 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 = ` + + + `; +} + +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 = ` + + + `; +} + +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 = `
`; + + torrents.forEach((torrent, index) => { + const quality = extractQuality(torrent.Title); + const language = extractLanguage(torrent.Title); + const torrentUrl = torrent.Details || torrent.Guid || ''; + + html += ` +
+
+
+ ${torrentUrl + ? `${escapeHtml(torrent.Title)}` + : escapeHtml(torrent.Title) + } +
+
+ 📡 ${escapeHtml(torrent.Tracker)} + 💾 ${torrent.SizeFormatted || 'N/A'} + 🌱 ${torrent.Seeders || 0} + ${quality ? `${quality}` : ''} + ${language ? `${language}` : ''} + ${index === 0 ? '👑 Meilleur' : ''} +
+
+
+ ${torrentUrl ? `🔗` : ''} + ${torrent.MagnetUri ? `🧲` : ''} + ${torrent.Link ? `⬇️` : ''} + ${torrentClientEnabled && (torrent.MagnetUri || (torrentClientSupportsTorrentFiles && torrent.Link)) ? `` : ''} +
+
+ `; + }); + + html += `
`; + 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 = ` +
+

📥 Options de téléchargement

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + document.body.appendChild(modal); + + // Fermer en cliquant à l'extérieur + modal.addEventListener('click', (e) => { + if (e.target === modal) closeTorrentOptionsModal(); + }); + } + + // Charger les catégories + const categorySelect = document.getElementById('torrentCategory'); + const savePathInput = document.getElementById('torrentSavePath'); + categorySelect.innerHTML = ''; + + let categoriesWithPaths = {}; + + try { + const response = await fetch('/api/torrent-client/categories'); + const data = await response.json(); + + categorySelect.innerHTML = ''; + if (data.success && data.categories) { + data.categories.forEach(cat => { + categorySelect.innerHTML += ``; + }); + // Stocker les chemins personnalisés + categoriesWithPaths = data.custom_categories || {}; + } + } catch (error) { + categorySelect.innerHTML = ''; + } + + // Auto-remplir le chemin quand on sélectionne une catégorie + categorySelect.onchange = () => { + const selectedCat = categorySelect.value; + if (selectedCat && categoriesWithPaths[selectedCat]) { + savePathInput.value = categoriesWithPaths[selectedCat]; + } else { + savePathInput.value = ''; + } + }; + + // Reset les champs + savePathInput.value = ''; + document.getElementById('torrentPaused').checked = false; + + // Configurer le bouton de confirmation + const confirmBtn = document.getElementById('confirmTorrentAdd'); + confirmBtn.onclick = async () => { + const category = document.getElementById('torrentCategory').value; + const savePath = document.getElementById('torrentSavePath').value.trim(); + const paused = document.getElementById('torrentPaused').checked; + + closeTorrentOptionsModal(); + await doSendToTorrentClient(url, button, category, savePath, paused); + }; + + // Afficher le modal + modal.classList.add('visible'); +} + +function closeTorrentOptionsModal() { + const modal = document.getElementById('torrentOptionsModal'); + if (modal) { + modal.classList.remove('visible'); + } +} + +async function doSendToTorrentClient(url, button, category, savePath, paused) { + const originalText = button.textContent; + button.textContent = '⏳'; + button.disabled = true; + + try { + const body = { url: url }; + if (category) body.category = category; + if (savePath) body.save_path = savePath; + if (paused) body.paused = paused; + + const response = await fetch('/api/torrent-client/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + + if (data.success) { + button.textContent = '✅'; + 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(); diff --git a/app/static/js/nav.js b/app/static/js/nav.js new file mode 100644 index 0000000..a5ecead --- /dev/null +++ b/app/static/js/nav.js @@ -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 += `🔍 Recherche`; + } + + // Module Nouveautés + if (modules.latest !== false) { + const isActive = currentPath === '/latest' ? 'active' : ''; + navHTML += `🎬 Nouveautés`; + } + + // Module Découvrir + if (modules.discover === true) { + const isActive = currentPath === '/discover' ? 'active' : ''; + navHTML += `🌟 Découvrir`; + } + + // Admin toujours visible + const isAdminActive = currentPath === '/admin' ? 'active' : ''; + navHTML += `⚙️ Admin`; + + // Déconnexion + navHTML += `🚪`; + + nav.innerHTML = navHTML; + + } catch (error) { + // Fallback si erreur + nav.innerHTML = ` + 🔍 Recherche + 🎬 Nouveautés + ⚙️ Admin + 🚪 + `; + } +})(); \ No newline at end of file diff --git a/app/static/js/search.js b/app/static/js/search.js new file mode 100644 index 0000000..faf010f --- /dev/null +++ b/app/static/js/search.js @@ -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 = '

Aucun tracker configuré

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

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

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

Aucun résultat trouvé

'; + } else { + container.innerHTML = '

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

'; + } + return; + } + + const pageResults = getPageResults(); + const isMobile = window.innerWidth <= 768; + + if (isMobile) { + // Mode cards pour mobile + container.innerHTML = ` + ${renderPagination()} +
+ ${pageResults.map(torrent => renderTorrentCard(torrent)).join('')} +
+ ${renderPagination()} + `; + } else { + // Mode table pour desktop + container.innerHTML = ` + ${renderPagination()} + + + + + + + + + + + + + ${pageResults.map(torrent => renderTorrentRow(torrent)).join('')} + +
+ Nom ${getSortIcon('Title')} + + Tracker ${getSortIcon('Tracker')} + + Taille ${getSortIcon('Size')} + + Seeders ${getSortIcon('Seeders')} + + Date ${getSortIcon('PublishDate')} + Actions
+ ${renderPagination()} + `; + } +} + +function renderTorrentCard(torrent) { + const parsed = torrent.parsed || {}; + + const badges = []; + if (parsed.quality?.length) { + badges.push(...parsed.quality.map(q => `${escapeHtml(q)}`)); + } + if (parsed.source?.length) { + badges.push(...parsed.source.map(s => `${escapeHtml(s)}`)); + } + if (parsed.language?.length) { + badges.push(...parsed.language.map(l => `${escapeHtml(l)}`)); + } + + const seedersClass = getSeedersClass(torrent.Seeders); + + // Sanitize URLs + const magnetUrl = sanitizeUrl(torrent.MagnetUri); + const downloadUrl = sanitizeUrl(torrent.Link); + const detailsUrl = sanitizeUrl(torrent.Details); + + return ` +
+
${escapeHtml(torrent.Title)}
+
${badges.join('')}
+
+ 📁 ${escapeHtml(torrent.SizeFormatted || 'N/A')} + 🌱 ${parseInt(torrent.Seeders) || 0} + 🏷️ ${escapeHtml(torrent.Tracker)} +
+
+ ${magnetUrl ? `🧲` : ''} + ${downloadUrl ? `⬇️` : ''} + ${detailsUrl ? `🔗` : ''} + ${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `` : ''} +
+
+ `; +} + +function getSortIcon(field) { + if (currentSort.field !== field) { + return ''; + } + return currentSort.order === 'asc' + ? '' + : ''; +} + +function renderTorrentRow(torrent) { + const parsed = torrent.parsed || {}; + + const badges = []; + + if (parsed.quality?.length) { + badges.push(...parsed.quality.map(q => `${escapeHtml(q)}`)); + } + if (parsed.source?.length) { + badges.push(...parsed.source.map(s => `${escapeHtml(s)}`)); + } + if (parsed.video_codec?.length) { + badges.push(...parsed.video_codec.map(c => `${escapeHtml(c)}`)); + } + if (parsed.language?.length) { + badges.push(...parsed.language.map(l => `${escapeHtml(l)}`)); + } + if (parsed.hdr?.length) { + badges.push(...parsed.hdr.map(h => `${escapeHtml(h)}`)); + } + + const seedersClass = getSeedersClass(torrent.Seeders); + + // Sanitize URLs + const magnetUrl = sanitizeUrl(torrent.MagnetUri); + const downloadUrl = sanitizeUrl(torrent.Link); + const detailsUrl = sanitizeUrl(torrent.Details); + + return ` +
+
${escapeHtml(torrent.Title)}
+
${badges.join('')}
+
${escapeHtml(torrent.Tracker)}${escapeHtml(torrent.SizeFormatted || 'N/A')}${parseInt(torrent.Seeders) || 0}${escapeHtml(torrent.PublishDate || 'N/A')} + ${magnetUrl ? `🧲` : ''} + ${downloadUrl ? `⬇️` : ''} + ${detailsUrl ? `🔗` : ''} + ${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `` : ''} +
+ + + + +
Filmscat=2145
Sériescat=2184
Animecat=2179
Musiquecat=2139
+ + + + + + + \ No newline at end of file diff --git a/app/templates/discover.html b/app/templates/discover.html new file mode 100644 index 0000000..85c0051 --- /dev/null +++ b/app/templates/discover.html @@ -0,0 +1,129 @@ + + + + + + Lycostorrent - Découvrir + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

🌟 Lycostorrent

+

Découvrez les nouveautés cinéma & TV

+ +
+ + +
+ + +
+ + + + + +
+ +
+ + + + + + + + + + + +
+ Lycostorrent v1.0.0 + Données fournies par TMDb +
+
+ + + + + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..8af62db --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,133 @@ + + + + + + Lycostorrent - Recherche + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

🔍 Lycostorrent

+

Recherche de torrents

+ +
+ + +
+ + + +
+
+ + +
+
+ + +
+ + +
+
+ + + + + +
+
+

Effectuez une recherche pour voir les résultats

+
+
+ + + + + +
+ Lycostorrent v1.1.0 +
+
+ + + + + + \ No newline at end of file diff --git a/app/templates/latest.html b/app/templates/latest.html new file mode 100644 index 0000000..16c2565 --- /dev/null +++ b/app/templates/latest.html @@ -0,0 +1,145 @@ + + + + + + Lycostorrent - Nouveautés + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

🎬 Lycostorrent

+

Dernières sorties Films, Séries & Musique

+ +
+ + +
+
+ + + + +
+ +
+ + +
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + +
+ Lycostorrent v1.0.0 +
+
+ + + + + + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..a5265db --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,232 @@ + + + + + + Lycostorrent - Connexion + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

🔐 Lycostorrent

+

Connexion requise

+
+ + + + + +
+ Lycostorrent +
+
+ + {% if locked_message %} + + {% endif %} + + + + diff --git a/app/tmdb_api.py b/app/tmdb_api.py new file mode 100644 index 0000000..79f20bd --- /dev/null +++ b/app/tmdb_api.py @@ -0,0 +1,371 @@ +import requests +import logging +import re +import json +import os + +logger = logging.getLogger(__name__) + +# Chemin vers le fichier de configuration des tags +PARSING_TAGS_PATH = '/app/config/parsing_tags.json' + +# Tags par défaut (exportable pour reset) +DEFAULT_PARSING_TAGS = [ + # Langues (non-ambigus) + "MULTi", "MULTI", "VOSTFR", "VOST", "VFF", "VFQ", "VFI", + "FRENCH", "TRUEFRENCH", "SUBFRENCH", + # Résolutions + "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/Video + "HDR", "HDR10", "DV", "DOLBY", "ATMOS", "DTS", "AC3", "AAC", "FLAC", "TrueHD", + # Autres (non-ambigus) + "PROPER", "REPACK" +] + +def _load_parsing_tags(): + """Charge les tags de parsing depuis le fichier JSON""" + try: + if os.path.exists(PARSING_TAGS_PATH): + with open(PARSING_TAGS_PATH, 'r', encoding='utf-8') as f: + config = json.load(f) + return config.get('technical_tags', DEFAULT_PARSING_TAGS) + except Exception as e: + logger.warning(f"Impossible de charger parsing_tags.json: {e}") + + return DEFAULT_PARSING_TAGS.copy() + + +def _save_parsing_tags(tags): + """Sauvegarde les tags de parsing dans le fichier JSON""" + try: + os.makedirs(os.path.dirname(PARSING_TAGS_PATH), exist_ok=True) + with open(PARSING_TAGS_PATH, 'w', encoding='utf-8') as f: + json.dump({'technical_tags': tags}, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"Erreur sauvegarde parsing_tags.json: {e}") + return False + + +class TMDbAPI: + """Classe pour interagir avec l'API TMDb (The Movie Database)""" + + def __init__(self, api_key=None): + self.api_key = api_key + self.base_url = "https://api.themoviedb.org/3" + self.image_base_url = "https://image.tmdb.org/t/p/w500" + self.session = requests.Session() + + def search_movie(self, title, year=None): + """Recherche un film sur TMDb""" + try: + clean_title = self._clean_title(title) + + params = { + 'api_key': self.api_key, + 'query': clean_title, + 'language': 'fr-FR' + } + + if year: + params['year'] = year + + response = self.session.get( + f"{self.base_url}/search/movie", + params=params, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('results'): + return self._format_movie(data['results'][0]) + + # Réessai sans année + if year: + params.pop('year') + response = self.session.get( + f"{self.base_url}/search/movie", + params=params, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('results'): + return self._format_movie(data['results'][0]) + + return None + + except Exception as e: + logger.error(f"Erreur recherche film TMDb: {e}") + return None + + def search_tv(self, title, year=None): + """Recherche une série sur TMDb""" + try: + clean_title = self._clean_title(title) + + params = { + 'api_key': self.api_key, + 'query': clean_title, + 'language': 'fr-FR' + } + + if year: + params['first_air_date_year'] = year + + response = self.session.get( + f"{self.base_url}/search/tv", + params=params, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('results'): + return self._format_tv(data['results'][0]) + + # Réessai sans année + if year: + params.pop('first_air_date_year') + response = self.session.get( + f"{self.base_url}/search/tv", + params=params, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('results'): + return self._format_tv(data['results'][0]) + + return None + + except Exception as e: + logger.error(f"Erreur recherche série TMDb: {e}") + return None + + def get_movie_videos(self, movie_id): + """Récupère la bande-annonce d'un film""" + try: + # Essai en français d'abord + for lang in ['fr-FR', 'en-US']: + response = self.session.get( + f"{self.base_url}/movie/{movie_id}/videos", + params={'api_key': self.api_key, 'language': lang}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + for video in data.get('results', []): + if video.get('type') == 'Trailer' and video.get('site') == 'YouTube': + return f"https://www.youtube.com/watch?v={video['key']}" + + return None + + except Exception as e: + logger.error(f"Erreur récupération vidéos: {e}") + return None + + def get_tv_videos(self, tv_id): + """Récupère la bande-annonce d'une série""" + try: + for lang in ['fr-FR', 'en-US']: + response = self.session.get( + f"{self.base_url}/tv/{tv_id}/videos", + params={'api_key': self.api_key, 'language': lang}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + for video in data.get('results', []): + if video.get('type') == 'Trailer' and video.get('site') == 'YouTube': + return f"https://www.youtube.com/watch?v={video['key']}" + + return None + + except Exception as e: + logger.error(f"Erreur récupération vidéos série: {e}") + return None + + def _format_movie(self, movie): + """Formate les données d'un film""" + poster_path = movie.get('poster_path') + backdrop_path = movie.get('backdrop_path') + + return { + 'tmdb_id': movie.get('id'), + 'title': movie.get('title'), + 'original_title': movie.get('original_title'), + 'overview': movie.get('overview') or 'Synopsis non disponible', + 'release_date': movie.get('release_date'), + 'year': movie.get('release_date', '')[:4] if movie.get('release_date') else None, + 'poster_url': f"{self.image_base_url}{poster_path}" if poster_path else None, + 'backdrop_url': f"{self.image_base_url}{backdrop_path}" if backdrop_path else None, + 'vote_average': movie.get('vote_average'), + 'vote_count': movie.get('vote_count'), + 'popularity': movie.get('popularity'), + 'type': 'movie' + } + + def _format_tv(self, tv): + """Formate les données d'une série""" + poster_path = tv.get('poster_path') + backdrop_path = tv.get('backdrop_path') + + return { + 'tmdb_id': tv.get('id'), + 'title': tv.get('name'), + 'original_title': tv.get('original_name'), + 'overview': tv.get('overview') or 'Synopsis non disponible', + 'first_air_date': tv.get('first_air_date'), + 'year': tv.get('first_air_date', '')[:4] if tv.get('first_air_date') else None, + 'poster_url': f"{self.image_base_url}{poster_path}" if poster_path else None, + 'backdrop_url': f"{self.image_base_url}{backdrop_path}" if backdrop_path else None, + 'vote_average': tv.get('vote_average'), + 'vote_count': tv.get('vote_count'), + 'popularity': tv.get('popularity'), + 'type': 'tv' + } + + def _clean_title(self, title): + """Nettoie le titre pour la recherche TMDb - Version améliorée""" + original = title + + # Charger les tags depuis le fichier de configuration + technical_tags = _load_parsing_tags() + + # ============================================================ + # ÉTAPE 1: Pré-nettoyage + # ============================================================ + + # Supprimer les tags entre crochets au début et à la fin + # [Team Arcedo] Title... ou Title...-[Shinrei] + title = re.sub(r'^\s*\[[^\]]*\]\s*', '', title) # Début + title = re.sub(r'\s*-?\[[^\]]*\]\s*$', '', title) # Fin + title = re.sub(r'\s*\[[^\]]*\]\s*', ' ', title) # Milieu (remplacer par espace) + + # Remplacer points et underscores par espaces + title = title.replace('.', ' ').replace('_', ' ') + + # ============================================================ + # ÉTAPE 2: Gestion des alias (AKA) + # ============================================================ + + # "Napoleon vu par Abel Gance AKA Napoleon 1927" → garder avant AKA + if ' AKA ' in title.upper(): + parts = re.split(r'\s+AKA\s+', title, flags=re.IGNORECASE) + title = parts[0].strip() + + # ============================================================ + # ÉTAPE 3: Trouver le point de coupure + # ============================================================ + + # Priorité 1: Année (19XX ou 20XX) + year_match = re.search(r'\b(19\d{2}|20\d{2})\b', title) + + # Priorité 2: Format série S01E01, S01EP01, S01, E1154, EP01 + serie_match = re.search(r'\b[Ss](\d{1,2})(?:[Ee][Pp]?(\d{1,4}))?\b|\b[Ee][Pp]?(\d{1,4})\b', title) + + # Priorité 3: Tags techniques depuis la config + # Construire le pattern regex à partir des tags + escaped_tags = [re.escape(tag) for tag in technical_tags] + tech_pattern = r'\b(' + '|'.join(escaped_tags) + r')\b' + tech_match = re.search(tech_pattern, title, re.IGNORECASE) + + # Déterminer le point de coupure (le plus tôt dans la chaîne) + cut_positions = [] + + if year_match: + cut_positions.append(year_match.start()) + if serie_match: + cut_positions.append(serie_match.start()) + if tech_match: + cut_positions.append(tech_match.start()) + + if cut_positions: + cut_pos = min(cut_positions) + title = title[:cut_pos].strip() + + # ============================================================ + # ÉTAPE 4: Nettoyage final + # ============================================================ + + # Supprimer DC (Director's Cut) en fin de titre + title = re.sub(r'\s+DC\s*$', '', title, flags=re.IGNORECASE) + + # Supprimer les tirets de fin (souvent avant le groupe de release) + title = re.sub(r'\s*-\s*$', '', title) + + # Supprimer les espaces multiples + title = re.sub(r'\s+', ' ', title).strip() + + # Supprimer les mots orphelins courants en fin + title = re.sub(r'\s+(The|A|An|Le|La|Les|Un|Une|Des)$', '', title, flags=re.IGNORECASE) + + # ============================================================ + # ÉTAPE 5: Fallback si titre trop court + # ============================================================ + + if len(title) < 2: + # Reprendre l'original et faire extraction basique + title = original + title = re.sub(r'^\s*\[[^\]]*\]\s*', '', title) + title = title.replace('.', ' ').replace('_', ' ') + # Prendre les premiers mots avant un pattern technique + m = re.match(r'^([\w\s]+?)(?:\s+(?:S\d|E\d|\d{4}|iNTEGRALE|MULTi|VOSTFR|1080p|720p))', title, re.IGNORECASE) + if m: + title = m.group(1).strip() + else: + # Prendre les 3-4 premiers mots + words = title.split()[:4] + title = ' '.join(words) + + logger.debug(f"Titre nettoyé: '{title}' (depuis: '{original[:80]}')") + return title + + def enrich_torrent(self, torrent_title, category=None): + """Enrichit un torrent avec les données TMDb""" + try: + # Détecter le type si non spécifié + if not category: + patterns = [r'S\d{2}E\d{2}', r'S\d{2}\s', r'saison\s*\d+', r'season\s*\d+'] + for p in patterns: + if re.search(p, torrent_title, re.IGNORECASE): + category = 'tv' + break + if not category: + category = 'movie' + + # Extraire l'année + m = re.search(r'\b(19\d{2}|20\d{2})\b', torrent_title) + year = int(m.group(0)) if m else None + + clean = self._clean_title(torrent_title) + logger.info(f"🎬 Recherche TMDb: '{clean}' (type: {category}, année: {year})") + + if category == 'movie': + data = self.search_movie(clean, year) + if data: + data['trailer_url'] = self.get_movie_videos(data['tmdb_id']) + logger.info(f"✅ Film trouvé: {data['title']}") + return data + else: + data = self.search_tv(clean, year) + if data: + data['trailer_url'] = self.get_tv_videos(data['tmdb_id']) + logger.info(f"✅ Série trouvée: {data['title']}") + return data + + return None + + except Exception as e: + logger.error(f"Erreur enrichissement TMDb: {e}") + return None \ No newline at end of file diff --git a/app/torrent_parser.py b/app/torrent_parser.py new file mode 100644 index 0000000..76b2ffa --- /dev/null +++ b/app/torrent_parser.py @@ -0,0 +1,268 @@ +import re +import logging +import json +import os + +logger = logging.getLogger(__name__) + +# Chemin vers le fichier de configuration des filtres +FILTERS_CONFIG_PATH = '/app/config/filters_config.json' + +# Configuration par défaut (fallback) +DEFAULT_FILTERS = { + "quality": { + "name": "Qualité", + "icon": "📺", + "values": ["2160p", "1080p", "720p", "480p", "360p", "4K", "UHD"] + }, + "source": { + "name": "Source", + "icon": "📀", + "values": ["BluRay", "Blu-Ray", "WEB-DL", "WEBRip", "HDTV", "DVDRip", "Remux"] + }, + "video_codec": { + "name": "Codec Vidéo", + "icon": "🎬", + "values": ["x265", "x264", "H265", "H264", "HEVC", "AVC", "AV1"] + }, + "audio": { + "name": "Audio", + "icon": "🔊", + "values": ["DTS-HD MA", "DTS", "Atmos", "TrueHD", "AAC", "AC3", "FLAC", "MP3"] + }, + "language": { + "name": "Langue", + "icon": "🗣️", + "values": ["FRENCH", "TRUEFRENCH", "VFF", "VOSTFR", "MULTI", "ENGLISH"] + }, + "hdr": { + "name": "HDR", + "icon": "✨", + "values": ["HDR10+", "HDR10", "HDR", "DV", "Dolby Vision"] + }, + "audio_format": { + "name": "Format Audio", + "icon": "🎵", + "values": ["FLAC", "MP3", "AAC", "320", "V0", "24bit", "16bit", "Lossless"] + }, + "music_type": { + "name": "Type Musique", + "icon": "💿", + "values": ["Album", "Single", "EP", "Live", "Concert", "Discography", "Soundtrack"] + }, + "music_source": { + "name": "Source Musique", + "icon": "📻", + "values": ["CD", "Vinyl", "WEB", "SACD"] + }, + "platform": { + "name": "Plateforme", + "icon": "🎮", + "values": ["PC", "Windows", "Linux", "Mac", "MacOS", "Android", "iOS", "PS5", "PS4", "Xbox", "Switch", "Steam", "GOG"] + }, + "software_type": { + "name": "Type Logiciel", + "icon": "💻", + "values": ["Portable", "Repack", "ISO", "Setup", "Crack", "Keygen", "Patch", "x64", "x86"] + }, + "ebook_format": { + "name": "Format Ebook", + "icon": "📚", + "values": ["EPUB", "PDF", "MOBI", "AZW3", "CBR", "CBZ", "DJVU"] + }, + "ebook_type": { + "name": "Type Ebook", + "icon": "📖", + "values": ["Roman", "BD", "Comics", "Manga", "Magazine", "Guide", "Audiobook"] + }, + "game_type": { + "name": "Type Jeu", + "icon": "🕹️", + "values": ["RPG", "FPS", "Action", "Adventure", "Strategy", "Simulation", "Sport", "Racing"] + } +} + + +def load_filters_config(): + """Charge la configuration des filtres depuis le fichier JSON""" + try: + if os.path.exists(FILTERS_CONFIG_PATH): + with open(FILTERS_CONFIG_PATH, 'r', encoding='utf-8') as f: + config = json.load(f) + return config.get('filters', DEFAULT_FILTERS) + except Exception as e: + logger.warning(f"Impossible de charger filters_config.json: {e}") + + return DEFAULT_FILTERS + + +def save_filters_config(filters): + """Sauvegarde la configuration des filtres dans le fichier JSON""" + try: + os.makedirs(os.path.dirname(FILTERS_CONFIG_PATH), exist_ok=True) + with open(FILTERS_CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump({'filters': filters}, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"Erreur sauvegarde filters_config.json: {e}") + return False + + +def get_default_filters(): + """Retourne les filtres par défaut""" + return DEFAULT_FILTERS.copy() + + +class TorrentParser: + """Parser pour extraire les métadonnées des titres de torrents""" + + def __init__(self): + self._config_mtime = 0 # Date de modification du fichier config + self.reload_config() + + # Patterns fixes (non configurables) + self.fixed_patterns = { + 'release_group': r'-([A-Za-z0-9]+)(?:\s*\(|$|\s*$)', + 'year': r'\b(19\d{2}|20\d{2})\b', + 'season': r'[Ss](\d{1,2})(?:[Ee]\d{1,2})?', + 'episode': r'[Ss]\d{1,2}[Ee](\d{1,2})', + 'bit_depth': r'\b(10[\s-]?bit|8[\s-]?bit)\b', + 'edition': r'\b(EXTENDED|REMASTERED|DIRECTOR\'?S?\.?CUT|UNCUT|UNRATED|THEATRICAL|DELUXE|SPECIAL\.?EDITION)\b', + 'repack': r'\b(REPACK|PROPER|RERIP|REAL)\b', + } + + def _check_config_update(self): + """Vérifie si le fichier config a été modifié et recharge si nécessaire""" + try: + if os.path.exists(FILTERS_CONFIG_PATH): + mtime = os.path.getmtime(FILTERS_CONFIG_PATH) + if mtime > self._config_mtime: + logger.info("🔄 Config des filtres modifiée, rechargement...") + self.reload_config() + self._config_mtime = mtime + except Exception as e: + logger.warning(f"Erreur vérification config: {e}") + + def reload_config(self): + """Recharge la configuration des filtres""" + self.filters_config = load_filters_config() + self._build_patterns() + try: + if os.path.exists(FILTERS_CONFIG_PATH): + self._config_mtime = os.path.getmtime(FILTERS_CONFIG_PATH) + except: + pass + + def _build_patterns(self): + """Construit les patterns regex à partir de la config""" + self.patterns = {} + + for filter_key, filter_data in self.filters_config.items(): + values = filter_data.get('values', []) + if values: + # Échapper les caractères spéciaux et créer le pattern + escaped_values = [re.escape(v) for v in values] + # Trier par longueur décroissante pour matcher les plus longs d'abord + escaped_values.sort(key=len, reverse=True) + pattern = r'\b(' + '|'.join(escaped_values) + r')\b' + self.patterns[filter_key] = pattern + + def parse(self, title): + """Parse un titre de torrent et retourne les métadonnées extraites""" + # Vérifier si la config a changé + self._check_config_update() + + if not title: + return {} + + parsed = {} + + # Extraire avec les patterns dynamiques (filtres configurables) + for key, pattern in self.patterns.items(): + try: + matches = re.findall(pattern, title, re.IGNORECASE) + if matches: + # Normaliser et dédupliquer + normalized = list(set([self._normalize(m, key) for m in matches])) + parsed[key] = normalized + except re.error as e: + logger.warning(f"Regex error for {key}: {e}") + + # Extraire avec les patterns fixes + for key, pattern in self.fixed_patterns.items(): + try: + matches = re.findall(pattern, title, re.IGNORECASE) + if matches: + if key in ['year', 'season', 'episode', 'release_group']: + parsed[key] = matches[0] if matches else None + else: + parsed[key] = list(set(matches)) + except re.error as e: + logger.warning(f"Regex error for {key}: {e}") + + return parsed + + def _normalize(self, value, key): + """Normalise une valeur (met en forme standard)""" + if not value: + return value + + # Chercher la valeur exacte dans la config (case-insensitive) + if key in self.filters_config: + config_values = self.filters_config[key].get('values', []) + for config_val in config_values: + if config_val.lower() == value.lower(): + return config_val + + return value + + def enrich_torrent(self, torrent): + """Ajoute les métadonnées parsées à un torrent""" + title = torrent.get('Title', '') + torrent['parsed'] = self.parse(title) + return torrent + + def get_filters_info(self): + """Retourne les infos des filtres (pour le frontend)""" + self._check_config_update() + return self.filters_config + + +# Instance globale (optionnel, pour réutilisation) +_parser_instance = None + +def get_parser(): + """Retourne l'instance du parser (singleton)""" + global _parser_instance + if _parser_instance is None: + _parser_instance = TorrentParser() + return _parser_instance + +def reload_parser(): + """Force le rechargement de la config du parser""" + global _parser_instance + if _parser_instance: + _parser_instance.reload_config() + + +# Test du parser +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + parser = TorrentParser() + + test_titles = [ + 'Avatar.2009.2160p.BluRay.x265.10bit.HDR.DTS-HD.MA-GROUP', + 'Gojira.-.Fortitude.2021.FLAC.24bit.WEB.Album-GROUP', + 'The.Office.S01E01.FRENCH.1080p.WEB-DL.x264-TEAM', + 'Pink.Floyd.-.Discography.1967-2014.FLAC.Lossless-BAND', + 'Metallica.-.Live.in.Paris.2024.MP3.320.Concert-METAL', + 'The.Last.of.Us.Part.I.v1.1.2-FitGirl.Repack', + ] + + for title in test_titles: + print(f"\n{'='*60}") + print(f"📺 {title}") + parsed = parser.parse(title) + for key, value in parsed.items(): + if value: + print(f" {key}: {value}") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6932bf7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + lycostorrent: + build: . + container_name: lycostorrent + restart: unless-stopped + ports: + - 5099:5097 + volumes: + - /DATA/appdata/lycostorrent/config:/app/config + - /DATA/appdata/lycostorrent/logs:/app/logs + environment: + # Jackett (optionnel si Prowlarr configuré) + - JACKETT_URL=http://192.168.1.235:9117 + - JACKETT_API_KEY=arcjccqmi11twplskatbfiqmodvo4rts + # Prowlarr (optionnel si Jackett configuré) + - PROWLARR_URL=http://192.168.1.235:9696 + - PROWLARR_API_KEY=3549f5418f8c4a158458ae48deb8c376 + - TMDB_API_KEY=2c83d2d4e409057a83e185a128348c67 + - LASTFM_API_KEY=e73f026a448b8fc18bb15bb67cf38d0d + - FLARESOLVERR_URL=http://192.168.1.235:8191 + - FLASK_ENV=production + - LOG_LEVEL=INFO + # Login + - AUTH_USERNAME=admin + - AUTH_PASSWORD=admin + - RATE_LIMIT_MAX_REQUESTS=90 # Max requêtes/minute + - SESSION_COOKIE_SECURE=false # true si HTTPS + networks: + - lycostorrent-network +networks: + lycostorrent-network: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22b7241 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.0.0 +requests==2.31.0 +python-dateutil==2.8.2 +APScheduler>=3.10.0 \ No newline at end of file