Initial commit

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

946
CHANGELOG.md Normal file
View File

@@ -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 `<table>` à `<div>` (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
<div class="torrent-item">
<div class="torrent-info">
<div class="torrent-name">...</div>
<div class="torrent-meta">...</div>
</div>
<div class="torrent-actions">
<a class="btn-link">🔗</a>
<a class="btn-magnet">🧲</a>
...
</div>
</div>
```
---
## [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 `<td>`
- **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/<category>` - Liste TMDb par catégorie
- `GET /api/discover/detail/<type>/<id>` - 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é

23
Dockerfile Normal file
View File

@@ -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"]

198
README.md Normal file
View File

@@ -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)**

1
app/VERSION Normal file
View File

@@ -0,0 +1 @@
2.0.0

435
app/cache_manager.py Normal file
View File

@@ -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

57
app/config.py Normal file
View File

@@ -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)

View File

@@ -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"]
}
}
}

269
app/indexer_manager.py Normal file
View File

@@ -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
}
}

202
app/jackett_api.py Normal file
View File

@@ -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"

212
app/lastfm_api.py Normal file
View File

@@ -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

2945
app/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)
```

View File

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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

265
app/prowlarr_api.py Normal file
View File

@@ -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 []

559
app/rss_source.py Normal file
View File

@@ -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('<?xml') or '<rss' in html_content[:500]:
logger.info("📄 Contenu XML détecté")
return html_content.encode('utf-8')
# Si ce n'est pas du XML, c'est probablement une page de login
if 'login' in html_content.lower() or 'connexion' in html_content.lower():
logger.warning("⚠️ Page de connexion détectée - vérifiez vos cookies")
else:
logger.warning(f"⚠️ Le contenu ne semble pas être du XML RSS")
logger.debug(f"📄 Début du contenu: {html_content[:500]}")
# Retourner quand même pour essayer de parser
return html_content.encode('utf-8')
else:
logger.error(f"❌ Flaresolverr error: {data.get('message', 'Unknown error')}")
return None
except requests.exceptions.Timeout:
logger.error("⏱️ Flaresolverr timeout")
return None
except Exception as e:
logger.error(f"❌ Flaresolverr error: {e}")
return None
def _extract_domain(self, url):
"""Extrait le domaine d'une URL"""
try:
parsed = urlparse(url)
# Retourner le domaine avec le point devant pour matcher les sous-domaines
domain = parsed.netloc
if domain.startswith('www.'):
domain = domain[4:]
return f".{domain}"
except:
return ".yggtorrent.org"
def _parse_rss(self, content, source_name):
"""Parse le contenu XML RSS et retourne une liste de résultats"""
results = []
try:
root = ET.fromstring(content)
# Trouver les items (RSS 2.0 ou Atom)
items = root.findall('.//item')
if not items:
# Essayer le format Atom
ns = {'atom': 'http://www.w3.org/2005/Atom'}
items = root.findall('.//atom:entry', ns)
for item in items:
result = self._parse_item(item, source_name)
if result:
results.append(result)
except ET.ParseError as e:
logger.error(f"❌ Erreur parsing XML: {e}")
return results
def _parse_item(self, item, source_name):
"""Parse un item RSS individuel"""
try:
# Namespaces courants
ns = {
'torrent': 'http://xmlns.ezrss.it/0.1/',
'atom': 'http://www.w3.org/2005/Atom',
'newznab': 'http://www.newznab.com/DTD/2010/feeds/attributes/'
}
# Titre
title = self._get_text(item, 'title') or ''
if not title:
return None
# Lien torrent/magnet
link = self._get_text(item, 'link') or ''
enclosure = item.find('enclosure')
if enclosure is not None:
link = enclosure.get('url', link)
# Magnet URI
magnet = ''
magnet_el = item.find('.//torrent:magnetURI', ns)
if magnet_el is not None and magnet_el.text:
magnet = magnet_el.text
elif link.startswith('magnet:'):
magnet = link
link = ''
# Taille
size = 0
size_el = item.find('.//torrent:contentLength', ns)
if size_el is not None and size_el.text:
try:
size = int(size_el.text)
except ValueError:
pass
# Essayer avec enclosure
if size == 0 and enclosure is not None:
try:
size = int(enclosure.get('length', 0))
except ValueError:
pass
# Essayer avec newznab:attr
if size == 0:
for attr in item.findall('.//newznab:attr', ns):
if attr.get('name') == 'size':
try:
size = int(attr.get('value', 0))
except ValueError:
pass
break
# Seeders/Leechers (si disponibles)
seeders = 0
leechers = 0
seeders_el = item.find('.//torrent:seeds', ns)
if seeders_el is not None and seeders_el.text:
try:
seeders = int(seeders_el.text)
except ValueError:
pass
leechers_el = item.find('.//torrent:peers', ns)
if leechers_el is not None and leechers_el.text:
try:
leechers = int(leechers_el.text)
except ValueError:
pass
# Date de publication
pub_date = self._get_text(item, 'pubDate') or ''
pub_date_formatted = self._format_date(pub_date)
pub_date_iso = self._parse_date_to_iso(pub_date)
# Lien détails
details = self._get_text(item, 'guid') or self._get_text(item, 'link') or ''
if details.startswith('magnet:'):
details = ''
# Catégorie
category = self._get_text(item, 'category') or ''
return {
'Title': title,
'Link': link if not link.startswith('magnet:') else '',
'MagnetUri': magnet,
'Size': size,
'SizeFormatted': self._format_size(size),
'Seeders': seeders,
'Peers': leechers,
'PublishDate': pub_date_formatted,
'PublishDateRaw': pub_date_iso or pub_date,
'Tracker': source_name,
'Details': details,
'Category': category,
'Source': 'rss'
}
except Exception as e:
logger.warning(f"⚠️ Erreur parsing item RSS: {e}")
return None
def _get_text(self, element, tag):
"""Récupère le texte d'un sous-élément"""
el = element.find(tag)
if el is not None and el.text:
return el.text.strip()
return None
def _format_date(self, date_str):
"""Formate une date RSS en format lisible"""
if not date_str:
return ''
try:
# Format RSS standard: "Tue, 24 Dec 2025 10:30:00 +0000"
for fmt in [
'%a, %d %b %Y %H:%M:%S %z',
'%a, %d %b %Y %H:%M:%S %Z',
'%Y-%m-%dT%H:%M:%S%z',
'%Y-%m-%dT%H:%M:%SZ',
'%Y-%m-%d %H:%M:%S',
]:
try:
dt = datetime.strptime(date_str.strip(), fmt)
return dt.strftime('%d/%m/%Y %H:%M')
except ValueError:
continue
# Si aucun format ne marche, retourner tel quel
return date_str[:16] if len(date_str) > 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]

375
app/security.py Normal file
View File

@@ -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)

1424
app/static/css/admin.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

699
app/static/css/discover.css Normal file
View File

@@ -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);
}

1678
app/static/css/latest.css Normal file

File diff suppressed because it is too large Load Diff

1168
app/static/css/style.css Normal file

File diff suppressed because it is too large Load Diff

168
app/static/css/themes.css Normal file
View File

@@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,985 @@
/**
* Lycostorrent - Search & Filter
* Filtrage, tri et pagination 100% côté client
*/
// État global
let allResults = []; // Tous les résultats de la recherche
let filteredResults = []; // Résultats après filtrage
let activeFilters = {}; // Filtres actifs { quality: ['1080p'], language: ['FRENCH', 'MULTI'], ... }
let availableFilters = {}; // Filtres disponibles extraits des résultats
// Pagination
let currentPage = 1;
const RESULTS_PER_PAGE = 50;
// Tri
let currentSort = { field: 'Seeders', order: 'desc' };
// Configuration des filtres (chargée dynamiquement)
let FILTER_CONFIG = {
// Fallback si l'API ne répond pas
Tracker: { name: 'Tracker', icon: '🌐', order: 999, fromRoot: true },
};
// ============================================================
// INITIALISATION
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
loadFiltersConfig(); // Charger les filtres depuis l'API
loadTrackers();
setupEventListeners();
// Re-render lors du changement de taille de fenêtre
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (filteredResults.length > 0) {
renderResults();
}
}, 250);
});
});
function setupEventListeners() {
// Recherche au clic ou Entrée
document.getElementById('search-btn').addEventListener('click', performSearch);
document.getElementById('search-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
// Effacer les filtres
document.getElementById('clear-filters').addEventListener('click', clearAllFilters);
// Toggle panel trackers
document.getElementById('toggleTrackers').addEventListener('click', () => {
const panel = document.getElementById('trackersPanel');
panel.classList.toggle('hidden');
});
// Tout sélectionner / désélectionner
document.getElementById('selectAllTrackers').addEventListener('click', () => {
document.querySelectorAll('#trackers-list input[type="checkbox"]').forEach(cb => cb.checked = true);
});
document.getElementById('deselectAllTrackers').addEventListener('click', () => {
document.querySelectorAll('#trackers-list input[type="checkbox"]').forEach(cb => cb.checked = false);
});
// Toggle filtres
document.getElementById('toggle-filters')?.addEventListener('click', () => {
const btn = document.getElementById('toggle-filters');
const content = document.getElementById('filters-content');
btn.classList.toggle('collapsed');
content.classList.toggle('collapsed');
btn.textContent = content.classList.contains('collapsed') ? '▶' : '▼';
});
}
// ============================================================
// CHARGEMENT DE LA CONFIG DES FILTRES
// ============================================================
async function loadFiltersConfig() {
try {
const response = await fetch('/api/filters');
const data = await response.json();
if (data.success && data.filters) {
// Construire FILTER_CONFIG depuis l'API
FILTER_CONFIG = {};
let order = 1;
for (const [key, filter] of Object.entries(data.filters)) {
FILTER_CONFIG[key] = {
name: filter.name || key,
icon: filter.icon || '🏷️',
order: order++
};
}
// Toujours ajouter Tracker à la fin
FILTER_CONFIG['Tracker'] = { name: 'Tracker', icon: '🌐', order: 999, fromRoot: true };
console.log('✅ Filtres chargés:', Object.keys(FILTER_CONFIG).length);
}
} catch (error) {
console.error('Erreur chargement config filtres:', error);
// Garder le fallback par défaut
}
}
// ============================================================
// CHARGEMENT DES TRACKERS
// ============================================================
async function loadTrackers() {
try {
const response = await fetch('/api/trackers');
const data = await response.json();
if (data.success && data.trackers) {
renderTrackers(data.trackers);
} else {
showError('Impossible de charger les trackers');
}
} catch (error) {
console.error('Erreur chargement trackers:', error);
showError('Erreur de connexion au serveur');
}
}
function renderTrackers(trackers) {
const container = document.getElementById('trackers-list');
if (trackers.length === 0) {
container.innerHTML = '<p class="no-trackers">Aucun tracker configuré</p>';
return;
}
container.innerHTML = trackers.map(tracker => {
// Créer le badge de source
let sourceBadge = '';
if (tracker.sources && tracker.sources.length > 0) {
if (tracker.sources.includes('jackett') && tracker.sources.includes('prowlarr')) {
sourceBadge = '<span class="source-badge source-both" title="Jackett + Prowlarr">J+P</span>';
} else if (tracker.sources.includes('jackett')) {
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
} else if (tracker.sources.includes('prowlarr')) {
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
} else if (tracker.sources.includes('rss')) {
sourceBadge = '<span class="source-badge source-rss" title="RSS">RSS</span>';
}
} else {
if (tracker.source === 'jackett') {
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
} else if (tracker.source === 'prowlarr') {
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
}
}
return `
<div class="tracker-item">
<input type="checkbox" id="tracker-${escapeHtml(tracker.id)}" value="${escapeHtml(tracker.id)}" checked>
<label for="tracker-${escapeHtml(tracker.id)}">${escapeHtml(tracker.name)}</label>
${sourceBadge}
</div>
`;
}).join('');
}
function getSelectedTrackers() {
const checkboxes = document.querySelectorAll('#trackers-list input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
// ============================================================
// RECHERCHE
// ============================================================
async function performSearch() {
const query = document.getElementById('search-input').value.trim();
const category = document.getElementById('category-select').value;
const trackers = getSelectedTrackers();
// Validation
if (!query) {
showError('Veuillez entrer une recherche');
return;
}
if (trackers.length === 0) {
showError('Veuillez sélectionner au moins un tracker');
return;
}
// Afficher le loading
showLoading(true);
// Reset
activeFilters = {};
availableFilters = {};
currentPage = 1;
currentSort = { field: 'Seeders', order: 'desc' };
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, category, trackers })
});
const data = await response.json();
if (data.success) {
allResults = data.results;
filteredResults = [...allResults];
// Trier par seeders par défaut
sortResults();
// Extraire les filtres disponibles depuis les résultats
extractAvailableFilters();
// Afficher les filtres et les résultats
renderFilters();
renderResults();
// Afficher la section des filtres
document.getElementById('filters-section').classList.remove('hidden');
} else {
showError(data.error || 'Erreur lors de la recherche');
}
} catch (error) {
console.error('Erreur recherche:', error);
showError('Erreur de connexion au serveur');
} finally {
showLoading(false);
}
}
// ============================================================
// TRI
// ============================================================
function sortResults() {
const { field, order } = currentSort;
filteredResults.sort((a, b) => {
let valA, valB;
switch (field) {
case 'Title':
valA = (a.Title || '').toLowerCase();
valB = (b.Title || '').toLowerCase();
break;
case 'Tracker':
valA = (a.Tracker || '').toLowerCase();
valB = (b.Tracker || '').toLowerCase();
break;
case 'Size':
valA = a.Size || 0;
valB = b.Size || 0;
break;
case 'Seeders':
valA = a.Seeders || 0;
valB = b.Seeders || 0;
break;
case 'PublishDate':
valA = a.PublishDateRaw || '';
valB = b.PublishDateRaw || '';
break;
default:
valA = a[field] || 0;
valB = b[field] || 0;
}
if (valA < valB) return order === 'asc' ? -1 : 1;
if (valA > valB) return order === 'asc' ? 1 : -1;
return 0;
});
}
function onSortChange(field) {
if (currentSort.field === field) {
// Inverser l'ordre si on clique sur la même colonne
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
} else {
currentSort.field = field;
// Ordre par défaut selon le champ
currentSort.order = (field === 'Title' || field === 'Tracker') ? 'asc' : 'desc';
}
sortResults();
renderResults();
}
// ============================================================
// EXTRACTION DES FILTRES DISPONIBLES
// ============================================================
function extractAvailableFilters() {
availableFilters = {};
for (const torrent of allResults) {
const parsed = torrent.parsed || {};
for (const [key, config] of Object.entries(FILTER_CONFIG)) {
if (!availableFilters[key]) {
availableFilters[key] = {};
}
let values;
if (config.fromRoot) {
// Valeur directement sur le torrent (ex: Tracker)
values = torrent[key] ? [torrent[key]] : [];
} else {
// Valeur dans parsed
values = parsed[key] || [];
}
const valueArray = Array.isArray(values) ? values : [values];
for (const value of valueArray) {
if (value) {
availableFilters[key][value] = (availableFilters[key][value] || 0) + 1;
}
}
}
}
console.log('Filtres disponibles:', availableFilters);
}
// ============================================================
// RENDU DES FILTRES
// ============================================================
function renderFilters() {
const container = document.getElementById('filters-container');
container.innerHTML = '';
// Trier les filtres par ordre défini
const sortedFilters = Object.keys(availableFilters)
.filter(key => Object.keys(availableFilters[key]).length > 0)
.sort((a, b) => (FILTER_CONFIG[a]?.order || 99) - (FILTER_CONFIG[b]?.order || 99));
for (const filterKey of sortedFilters) {
const filterConfig = FILTER_CONFIG[filterKey];
const values = availableFilters[filterKey];
// Trier les valeurs par nombre d'occurrences
const sortedValues = Object.entries(values)
.sort((a, b) => b[1] - a[1]);
const filterHTML = `
<div class="filter-group" data-filter="${filterKey}">
<h4>${filterConfig.icon} ${filterConfig.name}</h4>
<div class="filter-values">
${sortedValues.map(([value, count]) => `
<label class="filter-checkbox">
<input
type="checkbox"
data-filter="${filterKey}"
data-value="${escapeHtml(value)}"
${isFilterActive(filterKey, value) ? 'checked' : ''}
>
<span class="filter-label">${escapeHtml(value)}</span>
<span class="filter-count">(${count})</span>
</label>
`).join('')}
</div>
</div>
`;
container.innerHTML += filterHTML;
}
// Ajouter les event listeners sur les checkboxes
container.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', onFilterChange);
});
updateResultsCount();
}
function isFilterActive(filterKey, value) {
return activeFilters[filterKey]?.includes(value) || false;
}
// ============================================================
// GESTION DES FILTRES
// ============================================================
function onFilterChange(event) {
const checkbox = event.target;
const filterKey = checkbox.dataset.filter;
const value = checkbox.dataset.value;
if (!activeFilters[filterKey]) {
activeFilters[filterKey] = [];
}
if (checkbox.checked) {
if (!activeFilters[filterKey].includes(value)) {
activeFilters[filterKey].push(value);
}
} else {
activeFilters[filterKey] = activeFilters[filterKey].filter(v => v !== value);
if (activeFilters[filterKey].length === 0) {
delete activeFilters[filterKey];
}
}
console.log('Filtres actifs:', activeFilters);
// Appliquer les filtres
applyFilters();
}
function applyFilters() {
// Reset pagination
currentPage = 1;
if (Object.keys(activeFilters).length === 0) {
filteredResults = [...allResults];
} else {
filteredResults = allResults.filter(torrent => {
const parsed = torrent.parsed || {};
for (const [filterKey, selectedValues] of Object.entries(activeFilters)) {
if (selectedValues.length === 0) continue;
let torrentValues;
if (FILTER_CONFIG[filterKey]?.fromRoot) {
torrentValues = torrent[filterKey] ? [torrent[filterKey]] : [];
} else {
torrentValues = parsed[filterKey] || [];
}
const torrentValuesArray = Array.isArray(torrentValues) ? torrentValues : [torrentValues];
const hasMatch = selectedValues.some(val => torrentValuesArray.includes(val));
if (!hasMatch) {
return false;
}
}
return true;
});
}
// Réappliquer le tri
sortResults();
renderResults();
updateResultsCount();
}
function clearAllFilters() {
activeFilters = {};
currentPage = 1;
document.querySelectorAll('#filters-container input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
filteredResults = [...allResults];
sortResults();
renderResults();
updateResultsCount();
}
function updateResultsCount() {
const countEl = document.getElementById('results-count');
const total = allResults.length;
const filtered = filteredResults.length;
if (total === filtered) {
countEl.textContent = `(${total} résultats)`;
} else {
countEl.textContent = `(${filtered} / ${total} résultats)`;
}
}
// ============================================================
// PAGINATION
// ============================================================
function getTotalPages() {
return Math.ceil(filteredResults.length / RESULTS_PER_PAGE);
}
function getPageResults() {
const start = (currentPage - 1) * RESULTS_PER_PAGE;
const end = start + RESULTS_PER_PAGE;
return filteredResults.slice(start, end);
}
function goToPage(page) {
const totalPages = getTotalPages();
if (page < 1 || page > totalPages) return;
currentPage = page;
renderResults();
// Scroll vers le haut des résultats
document.getElementById('results-section').scrollIntoView({ behavior: 'smooth' });
}
function renderPagination() {
const totalPages = getTotalPages();
if (totalPages <= 1) return '';
const pages = [];
const maxVisiblePages = 7;
// Toujours afficher la première page
pages.push(1);
if (totalPages <= maxVisiblePages) {
for (let i = 2; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Logique pour afficher les pages autour de la page courante
let start = Math.max(2, currentPage - 2);
let end = Math.min(totalPages - 1, currentPage + 2);
if (currentPage <= 3) {
end = 5;
}
if (currentPage >= totalPages - 2) {
start = totalPages - 4;
}
if (start > 2) {
pages.push('...');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages - 1) {
pages.push('...');
}
pages.push(totalPages);
}
const startResult = (currentPage - 1) * RESULTS_PER_PAGE + 1;
const endResult = Math.min(currentPage * RESULTS_PER_PAGE, filteredResults.length);
return `
<div class="pagination">
<div class="pagination-info">
Résultats ${startResult} - ${endResult} sur ${filteredResults.length}
</div>
<div class="pagination-controls">
<button class="pagination-btn" onclick="goToPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
← Précédent
</button>
${pages.map(page => {
if (page === '...') {
return '<span class="pagination-ellipsis">...</span>';
}
return `<button class="pagination-btn ${page === currentPage ? 'active' : ''}" onclick="goToPage(${page})">${page}</button>`;
}).join('')}
<button class="pagination-btn" onclick="goToPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>
Suivant
</button>
</div>
</div>
`;
}
// ============================================================
// RENDU DES RÉSULTATS
// ============================================================
function renderResults() {
const container = document.getElementById('results-container');
if (filteredResults.length === 0) {
if (allResults.length === 0) {
container.innerHTML = '<p class="no-results">Aucun résultat trouvé</p>';
} else {
container.innerHTML = '<p class="no-results">Aucun résultat ne correspond aux filtres sélectionnés</p>';
}
return;
}
const pageResults = getPageResults();
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// Mode cards pour mobile
container.innerHTML = `
${renderPagination()}
<div class="results-cards">
${pageResults.map(torrent => renderTorrentCard(torrent)).join('')}
</div>
${renderPagination()}
`;
} else {
// Mode table pour desktop
container.innerHTML = `
${renderPagination()}
<table class="results-table">
<thead>
<tr>
<th class="col-name sortable" onclick="onSortChange('Title')">
Nom ${getSortIcon('Title')}
</th>
<th class="col-tracker sortable" onclick="onSortChange('Tracker')">
Tracker ${getSortIcon('Tracker')}
</th>
<th class="col-size sortable" onclick="onSortChange('Size')">
Taille ${getSortIcon('Size')}
</th>
<th class="col-seeders sortable" onclick="onSortChange('Seeders')">
Seeders ${getSortIcon('Seeders')}
</th>
<th class="col-date sortable" onclick="onSortChange('PublishDate')">
Date ${getSortIcon('PublishDate')}
</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
${pageResults.map(torrent => renderTorrentRow(torrent)).join('')}
</tbody>
</table>
${renderPagination()}
`;
}
}
function renderTorrentCard(torrent) {
const parsed = torrent.parsed || {};
const badges = [];
if (parsed.quality?.length) {
badges.push(...parsed.quality.map(q => `<span class="badge badge-quality">${escapeHtml(q)}</span>`));
}
if (parsed.source?.length) {
badges.push(...parsed.source.map(s => `<span class="badge badge-source">${escapeHtml(s)}</span>`));
}
if (parsed.language?.length) {
badges.push(...parsed.language.map(l => `<span class="badge badge-language">${escapeHtml(l)}</span>`));
}
const seedersClass = getSeedersClass(torrent.Seeders);
// Sanitize URLs
const magnetUrl = sanitizeUrl(torrent.MagnetUri);
const downloadUrl = sanitizeUrl(torrent.Link);
const detailsUrl = sanitizeUrl(torrent.Details);
return `
<div class="result-card-mobile">
<div class="torrent-title">${escapeHtml(torrent.Title)}</div>
<div class="torrent-badges">${badges.join('')}</div>
<div class="result-meta">
<span>📁 ${escapeHtml(torrent.SizeFormatted || 'N/A')}</span>
<span class="${seedersClass}">🌱 ${parseInt(torrent.Seeders) || 0}</span>
<span>🏷️ ${escapeHtml(torrent.Tracker)}</span>
</div>
<div class="result-actions">
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet-mobile" title="Magnet">🧲</a>` : ''}
${downloadUrl ? `<a href="${downloadUrl}" class="btn-download-mobile" title="Télécharger" target="_blank">⬇️</a>` : ''}
${detailsUrl ? `<a href="${detailsUrl}" class="btn-details-mobile" title="Détails" target="_blank">🔗</a>` : ''}
${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `<button class="btn-send-client-mobile" title="Envoyer au client" onclick="sendToTorrentClient('${magnetUrl || downloadUrl}', this)">📥</button>` : ''}
</div>
</div>
`;
}
function getSortIcon(field) {
if (currentSort.field !== field) {
return '<span class="sort-icon">⇅</span>';
}
return currentSort.order === 'asc'
? '<span class="sort-icon active">↑</span>'
: '<span class="sort-icon active">↓</span>';
}
function renderTorrentRow(torrent) {
const parsed = torrent.parsed || {};
const badges = [];
if (parsed.quality?.length) {
badges.push(...parsed.quality.map(q => `<span class="badge badge-quality">${escapeHtml(q)}</span>`));
}
if (parsed.source?.length) {
badges.push(...parsed.source.map(s => `<span class="badge badge-source">${escapeHtml(s)}</span>`));
}
if (parsed.video_codec?.length) {
badges.push(...parsed.video_codec.map(c => `<span class="badge badge-codec">${escapeHtml(c)}</span>`));
}
if (parsed.language?.length) {
badges.push(...parsed.language.map(l => `<span class="badge badge-language">${escapeHtml(l)}</span>`));
}
if (parsed.hdr?.length) {
badges.push(...parsed.hdr.map(h => `<span class="badge badge-hdr">${escapeHtml(h)}</span>`));
}
const seedersClass = getSeedersClass(torrent.Seeders);
// Sanitize URLs
const magnetUrl = sanitizeUrl(torrent.MagnetUri);
const downloadUrl = sanitizeUrl(torrent.Link);
const detailsUrl = sanitizeUrl(torrent.Details);
return `
<tr>
<td class="col-name">
<div class="torrent-title">${escapeHtml(torrent.Title)}</div>
<div class="torrent-badges">${badges.join('')}</div>
</td>
<td class="col-tracker">${escapeHtml(torrent.Tracker)}</td>
<td class="col-size">${escapeHtml(torrent.SizeFormatted || 'N/A')}</td>
<td class="col-seeders ${seedersClass}">${parseInt(torrent.Seeders) || 0}</td>
<td class="col-date">${escapeHtml(torrent.PublishDate || 'N/A')}</td>
<td class="col-actions">
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
${downloadUrl ? `<a href="${downloadUrl}" class="btn-download" title="Télécharger" target="_blank">⬇️</a>` : ''}
${detailsUrl ? `<a href="${detailsUrl}" class="btn-details" title="Détails" target="_blank">🔗</a>` : ''}
${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `<button class="btn-send-client" title="Envoyer au client" onclick="sendToTorrentClient('${magnetUrl || downloadUrl}', this)">📥</button>` : ''}
</td>
</tr>
`;
}
function getSeedersClass(seeders) {
if (!seeders || seeders === 0) return 'seeders-none';
if (seeders < 5) return 'seeders-low';
if (seeders < 20) return 'seeders-medium';
return 'seeders-high';
}
// ============================================================
// UTILITAIRES
// ============================================================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function sanitizeUrl(url) {
if (!url) return '';
// Autoriser uniquement http, https, et magnet
const allowedProtocols = ['http:', 'https:', 'magnet:'];
try {
// Pour les URLs magnet, vérifier le préfixe
if (url.startsWith('magnet:')) {
return url;
}
const parsed = new URL(url);
if (!allowedProtocols.includes(parsed.protocol)) {
console.warn('URL avec protocole non autorisé:', parsed.protocol);
return '';
}
return url;
} catch (e) {
// Si ce n'est pas une URL valide, retourner vide
console.warn('URL invalide:', url);
return '';
}
}
function showLoading(show) {
const overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
function showError(message) {
alert(message);
}
// ============================================================
// CLIENT TORRENT
// ============================================================
let torrentClientEnabled = false;
let torrentClientSupportsTorrentFiles = false;
async function checkTorrentClient() {
try {
const response = await fetch('/api/torrent-client/status');
const data = await response.json();
torrentClientEnabled = data.success && data.enabled && data.connected;
// Par défaut true si non spécifié (qBittorrent supporte les .torrent)
torrentClientSupportsTorrentFiles = data.supportsTorrentFiles !== false;
console.log('🔌 Client torrent:', torrentClientEnabled ? 'connecté' : 'non connecté',
'| Supporte .torrent:', torrentClientSupportsTorrentFiles);
} catch (error) {
torrentClientEnabled = false;
torrentClientSupportsTorrentFiles = false;
console.log('🔌 Client torrent: erreur de connexion');
}
}
async function sendToTorrentClient(url, button) {
if (!url) {
showToast('Aucun lien disponible', 'error');
return;
}
// Afficher le modal de sélection
showTorrentOptionsModal(url, button);
}
async function showTorrentOptionsModal(url, button) {
// Créer le modal s'il n'existe pas
let modal = document.getElementById('torrentOptionsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'torrentOptionsModal';
modal.className = 'torrent-options-modal';
modal.innerHTML = `
<div class="torrent-options-content">
<h3>📥 Options de téléchargement</h3>
<div class="torrent-option-group">
<label for="torrentCategory">Catégorie</label>
<select id="torrentCategory">
<option value="">-- Aucune --</option>
</select>
</div>
<div class="torrent-option-group">
<label for="torrentSavePath">Dossier (optionnel)</label>
<input type="text" id="torrentSavePath" placeholder="/chemin/vers/dossier">
</div>
<div class="torrent-option-group checkbox-group">
<input type="checkbox" id="torrentPaused">
<label for="torrentPaused">Démarrer en pause</label>
</div>
<div class="torrent-options-buttons">
<button class="btn-cancel" onclick="closeTorrentOptionsModal()">Annuler</button>
<button class="btn-confirm" id="confirmTorrentAdd">Envoyer</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Fermer en cliquant à l'extérieur
modal.addEventListener('click', (e) => {
if (e.target === modal) closeTorrentOptionsModal();
});
}
// Charger les catégories
const categorySelect = document.getElementById('torrentCategory');
const savePathInput = document.getElementById('torrentSavePath');
categorySelect.innerHTML = '<option value="">-- Chargement... --</option>';
let categoriesWithPaths = {};
try {
const response = await fetch('/api/torrent-client/categories');
const data = await response.json();
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
if (data.success && data.categories) {
data.categories.forEach(cat => {
categorySelect.innerHTML += `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`;
});
// Stocker les chemins personnalisés
categoriesWithPaths = data.custom_categories || {};
}
} catch (error) {
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
}
// Auto-remplir le chemin quand on sélectionne une catégorie
categorySelect.onchange = () => {
const selectedCat = categorySelect.value;
if (selectedCat && categoriesWithPaths[selectedCat]) {
savePathInput.value = categoriesWithPaths[selectedCat];
} else {
savePathInput.value = '';
}
};
// Reset les champs
savePathInput.value = '';
document.getElementById('torrentPaused').checked = false;
// Configurer le bouton de confirmation
const confirmBtn = document.getElementById('confirmTorrentAdd');
confirmBtn.onclick = async () => {
const category = document.getElementById('torrentCategory').value;
const savePath = document.getElementById('torrentSavePath').value.trim();
const paused = document.getElementById('torrentPaused').checked;
closeTorrentOptionsModal();
await doSendToTorrentClient(url, button, category, savePath, paused);
};
// Afficher le modal
modal.classList.add('visible');
}
function closeTorrentOptionsModal() {
const modal = document.getElementById('torrentOptionsModal');
if (modal) {
modal.classList.remove('visible');
}
}
async function doSendToTorrentClient(url, button, category, savePath, paused) {
const originalText = button.textContent;
button.textContent = '⏳';
button.disabled = true;
try {
const body = { url: url };
if (category) body.category = category;
if (savePath) body.save_path = savePath;
if (paused) body.paused = paused;
const response = await fetch('/api/torrent-client/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await response.json();
if (data.success) {
button.textContent = '✅';
showToast('Torrent envoyé !', 'success');
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
} else {
button.textContent = '❌';
showToast(data.error || 'Erreur', 'error');
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
} catch (error) {
button.textContent = '❌';
showToast('Erreur de connexion', 'error');
setTimeout(() => {
button.textContent = '📥';
button.disabled = false;
}, 2000);
}
}
function showToast(message, type = 'info') {
// Créer le toast s'il n'existe pas
let toast = document.getElementById('toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast';
toast.className = 'toast hidden';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = `toast ${type}`;
setTimeout(() => toast.classList.add('hidden'), 3000);
}
// Vérifier le client torrent au chargement
checkTorrentClient();

View File

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

77
app/static/manifest.json Normal file
View File

@@ -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" }]
}
]
}

143
app/static/sw.js Normal file
View File

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

880
app/templates/admin.html Normal file
View File

@@ -0,0 +1,880 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Administration</title>
<!-- Chargement du thème (en premier pour éviter le flash) -->
<script src="/static/js/theme-loader.js"></script>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#e63946">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Lycostorrent">
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/themes.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>⚙️ Administration</h1>
<p class="subtitle">Configuration de Lycostorrent</p>
<nav class="main-nav" id="mainNav">
<!-- Navigation générée dynamiquement -->
</nav>
</header>
<!-- Onglets -->
<div class="admin-tabs">
<button class="tab-btn active" data-tab="modules">
<span class="tab-icon">🧩</span>
<span class="tab-label">Modules</span>
</button>
<button class="tab-btn" data-tab="categories">
<span class="tab-icon">📂</span>
<span class="tab-label">Catégories</span>
</button>
<button class="tab-btn" data-tab="tags">
<span class="tab-icon">🏷️</span>
<span class="tab-label">Tags</span>
</button>
<button class="tab-btn" data-tab="filters">
<span class="tab-icon">🎛️</span>
<span class="tab-label">Filtres</span>
</button>
<button class="tab-btn" data-tab="rss">
<span class="tab-icon">📡</span>
<span class="tab-label">Flux RSS</span>
</button>
<button class="tab-btn" data-tab="cache">
<span class="tab-icon">🔄</span>
<span class="tab-label">Cache</span>
</button>
<button class="tab-btn" data-tab="torrent-client">
<span class="tab-icon">⬇️</span>
<span class="tab-label">Client Torrent</span>
</button>
<button class="tab-btn" data-tab="appearance">
<span class="tab-icon">🎨</span>
<span class="tab-label">Apparence</span>
</button>
</div>
<!-- Contenu des onglets -->
<div class="admin-content">
<!-- ═══════════════════════════════════════════════════════════
ONGLET MODULES
═══════════════════════════════════════════════════════════ -->
<div id="tab-modules" class="tab-content active">
<div class="tab-header">
<h2>🧩 Modules</h2>
<p>Activez ou désactivez les fonctionnalités de Lycostorrent.</p>
</div>
<div class="admin-card">
<h3>📱 Fonctionnalités disponibles</h3>
<p class="help-text">Cochez les modules que vous souhaitez activer. La navigation s'adaptera automatiquement.</p>
<div class="modules-list">
<div class="module-item">
<div class="module-toggle">
<input type="checkbox" id="module-search" checked>
<label for="module-search"></label>
</div>
<div class="module-info">
<div class="module-header">
<span class="module-icon">🔍</span>
<span class="module-name">Recherche</span>
</div>
<p class="module-description">Recherche de torrents sur vos trackers configurés (Jackett/Prowlarr).</p>
</div>
</div>
<div class="module-item">
<div class="module-toggle">
<input type="checkbox" id="module-latest" checked>
<label for="module-latest"></label>
</div>
<div class="module-info">
<div class="module-header">
<span class="module-icon">🎬</span>
<span class="module-name">Nouveautés</span>
</div>
<p class="module-description">Dernières sorties depuis vos trackers (Films, Séries, Anime, Musique).</p>
</div>
</div>
<div class="module-item">
<div class="module-toggle">
<input type="checkbox" id="module-discover">
<label for="module-discover"></label>
</div>
<div class="module-info">
<div class="module-header">
<span class="module-icon">🌟</span>
<span class="module-name">Découvrir</span>
<span class="module-badge">Nouveau</span>
</div>
<p class="module-description">Explorez les nouveautés cinéma et TV depuis TMDb, puis trouvez les torrents disponibles.</p>
</div>
</div>
</div>
<div class="action-bar">
<button id="saveModulesBtn" class="btn btn-primary">💾 Sauvegarder</button>
</div>
</div>
<!-- Configuration des trackers pour Discover -->
<div class="admin-card" id="discoverTrackersCard">
<h3>🌟 Trackers pour Découvrir</h3>
<p class="help-text">Sélectionnez les trackers à utiliser pour la recherche de torrents dans la page Découvrir.</p>
<div class="discover-trackers-list" id="discoverTrackersList">
<p class="loading">Chargement des trackers...</p>
</div>
<div class="action-bar">
<button id="selectAllDiscoverTrackers" class="btn btn-secondary">✅ Tout sélectionner</button>
<button id="selectNoneDiscoverTrackers" class="btn btn-secondary">❌ Tout désélectionner</button>
<button id="saveDiscoverTrackers" class="btn btn-primary">💾 Sauvegarder</button>
</div>
</div>
<div class="admin-card">
<h3> À propos des modules</h3>
<div class="module-help">
<p><strong>🔍 Recherche</strong> : Page d'accueil par défaut. Permet de rechercher des torrents par mots-clés sur tous vos trackers.</p>
<p><strong>🎬 Nouveautés</strong> : Affiche les derniers torrents publiés sur vos trackers, enrichis avec les métadonnées TMDb/Last.fm.</p>
<p><strong>🌟 Découvrir</strong> : Inverse la logique - part des nouveautés TMDb (films/séries récents) et recherche les torrents disponibles.</p>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET CATÉGORIES
═══════════════════════════════════════════════════════════ -->
<div id="tab-categories" class="tab-content">
<div class="tab-header">
<h2>📂 Configuration des Catégories</h2>
<p>Associez les catégories Jackett/Prowlarr à chaque type de contenu pour chaque tracker.</p>
</div>
<!-- Sélection du tracker -->
<div class="admin-card">
<h3>1. Sélectionner un tracker</h3>
<div id="trackerSelector" class="tracker-grid">
<p class="loading">Chargement des trackers...</p>
</div>
</div>
<!-- Catégories disponibles -->
<div id="categoriesSection" class="admin-card hidden">
<h3>2. Catégories disponibles sur <span id="selectedTrackerName" class="highlight"></span></h3>
<div id="availableCategories" class="categories-cloud">
<p class="loading">Chargement...</p>
</div>
</div>
<!-- Configuration -->
<div id="configSection" class="admin-card hidden">
<h3>3. Associer les catégories</h3>
<p class="help-text">Cliquez sur une catégorie ci-dessus pour l'ajouter, ou entrez les IDs manuellement.</p>
<div class="config-grid">
<div class="config-item">
<label>🎥 Films</label>
<input type="text" id="config-movies" placeholder="Ex: 2000,2010">
<div class="quick-add" data-target="movies"></div>
</div>
<div class="config-item">
<label>📺 Séries</label>
<input type="text" id="config-tv" placeholder="Ex: 5000,5010">
<div class="quick-add" data-target="tv"></div>
</div>
<div class="config-item">
<label>🎌 Anime</label>
<input type="text" id="config-anime" placeholder="Ex: 5070">
<div class="quick-add" data-target="anime"></div>
</div>
<div class="config-item">
<label>🎵 Musique</label>
<input type="text" id="config-music" placeholder="Ex: 3000">
<div class="quick-add" data-target="music"></div>
</div>
</div>
<div class="action-bar">
<button id="saveConfigBtn" class="btn btn-primary">💾 Sauvegarder</button>
<button id="resetConfigBtn" class="btn btn-secondary">🔄 Réinitialiser</button>
</div>
</div>
<!-- Résumé -->
<div class="admin-card">
<h3>📋 Configuration actuelle</h3>
<div id="configSummary" class="config-summary">
<p class="loading">Chargement...</p>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET TAGS
═══════════════════════════════════════════════════════════ -->
<div id="tab-tags" class="tab-content">
<div class="tab-header">
<h2>🏷️ Tags de Parsing</h2>
<p>Ces tags sont utilisés pour nettoyer les titres avant la recherche TMDb/Last.fm.</p>
</div>
<!-- Tags actuels -->
<div class="admin-card">
<h3>Tags de coupure</h3>
<p class="help-text">Le titre sera coupé au premier tag rencontré. Ex: <code>Avatar.2009.MULTi.1080p</code><code>Avatar 2009</code></p>
<div id="tagsList" class="tags-cloud editable">
<p class="loading">Chargement...</p>
</div>
<div class="add-form">
<input type="text" id="newTagInput" placeholder="Nouveau tag..." maxlength="30">
<button id="addTagBtn" class="btn btn-primary"> Ajouter</button>
</div>
<div class="action-bar">
<button id="saveTagsBtn" class="btn btn-primary">💾 Sauvegarder</button>
<button id="resetTagsBtn" class="btn btn-secondary">🔄 Réinitialiser</button>
</div>
</div>
<!-- Présets -->
<div class="admin-card">
<h3>📦 Ajouter des présets</h3>
<div class="presets-grid">
<button class="preset-btn" data-preset="langues">
🗣️ Langues
<small>VFF, VFQ, VOSTFR...</small>
</button>
<button class="preset-btn" data-preset="resolutions">
📺 Résolutions
<small>1080p, 720p, 4K...</small>
</button>
<button class="preset-btn" data-preset="sources">
📀 Sources
<small>BluRay, WEB, HDTV...</small>
</button>
<button class="preset-btn" data-preset="codecs">
🎬 Codecs
<small>x264, x265, HEVC...</small>
</button>
<button class="preset-btn" data-preset="audio">
🔊 Audio
<small>DTS, AC3, FLAC...</small>
</button>
</div>
</div>
<!-- Test -->
<div class="admin-card">
<h3>🧪 Tester le parsing</h3>
<div class="test-form">
<input type="text" id="testTitleInput" placeholder="Ex: Avatar.2009.MULTi.1080p.BluRay.x264">
<button id="testParsingBtn" class="btn btn-primary">Tester</button>
</div>
<div id="testResult" class="test-result hidden">
<div class="result-row">
<span class="label">Original:</span>
<span id="testOriginal" class="value"></span>
</div>
<div class="result-row success">
<span class="label">Nettoyé:</span>
<span id="testCleaned" class="value"></span>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET FILTRES
═══════════════════════════════════════════════════════════ -->
<div id="tab-filters" class="tab-content">
<div class="tab-header">
<h2>🎛️ Filtres de Recherche</h2>
<p>Configurez les mots-clés détectés dans les titres de torrents pour créer les filtres.</p>
</div>
<!-- Liste des filtres -->
<div class="admin-card">
<h3>Filtres configurés</h3>
<p class="help-text">Cliquez sur un filtre pour modifier ses valeurs. Les valeurs sont détectées dans les titres de torrents.</p>
<div id="filtersList" class="filters-editor">
<p class="loading">Chargement...</p>
</div>
<div class="action-bar">
<button id="saveFiltersBtn" class="btn btn-primary">💾 Sauvegarder</button>
<button id="resetFiltersBtn" class="btn btn-secondary">🔄 Réinitialiser</button>
<button id="addFilterBtn" class="btn btn-secondary"> Nouveau filtre</button>
</div>
</div>
<!-- Test -->
<div class="admin-card">
<h3>🧪 Tester la détection</h3>
<p class="help-text">Entrez un titre de torrent pour voir les filtres détectés.</p>
<div class="test-form">
<input type="text" id="testFilterInput" placeholder="Ex: Gojira.-.Fortitude.2021.FLAC.24bit.WEB.Album">
<button id="testFilterBtn" class="btn btn-primary">Tester</button>
</div>
<div id="filterTestResult" class="test-result hidden"></div>
</div>
<!-- Aide -->
<div class="admin-card collapsible collapsed">
<h3 class="collapsible-header">
❓ Aide - Comment ça marche
<span class="collapse-icon"></span>
</h3>
<div class="collapsible-content">
<div class="help-section">
<h4>Principe</h4>
<p>Le parser analyse les titres de torrents et cherche les mots-clés configurés. Chaque mot trouvé crée une option de filtre.</p>
<h4>Exemple</h4>
<p><code>Gojira.-.Fortitude.2021.FLAC.24bit.WEB.Album</code></p>
<p>→ Format Audio: <strong>FLAC, 24bit</strong> | Type: <strong>Album</strong> | Source: <strong>WEB</strong></p>
<h4>Conseils</h4>
<ul>
<li>Les mots-clés sont insensibles à la casse (FLAC = flac = Flac)</li>
<li>Évitez les mots trop courts ou trop communs</li>
<li>Testez vos modifications avant de sauvegarder</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET RSS
═══════════════════════════════════════════════════════════ -->
<div id="tab-rss" class="tab-content">
<div class="tab-header">
<h2>📡 Flux RSS</h2>
<p>Ajoutez des flux RSS pour les trackers non disponibles dans Jackett/Prowlarr.</p>
</div>
<!-- Formulaire d'ajout -->
<div class="admin-card">
<h3> Ajouter un flux</h3>
<form id="add-feed-form" class="rss-form">
<div class="form-row">
<div class="form-group">
<label for="feed-name">Nom *</label>
<input type="text" id="feed-name" placeholder="Ex: YGG Films" required>
</div>
<div class="form-group">
<label for="feed-category">Catégorie *</label>
<select id="feed-category" required>
<option value="">-- Choisir --</option>
<option value="movies">🎬 Films</option>
<option value="tv">📺 Séries</option>
<option value="anime">🎌 Anime</option>
<option value="music">🎵 Musique</option>
<option value="all">📦 Toutes</option>
</select>
</div>
</div>
<div class="form-group">
<label for="feed-url">URL du flux RSS *</label>
<input type="url" id="feed-url" placeholder="https://tracker.xxx/rss?passkey={passkey}" required>
<small>Utilisez <code>{passkey}</code> comme placeholder</small>
</div>
<div class="form-group">
<label for="feed-passkey">Passkey</label>
<input type="text" id="feed-passkey" placeholder="Votre passkey privé">
</div>
<div class="form-row">
<div class="form-group checkbox-inline">
<label>
<input type="checkbox" id="feed-flaresolverr">
<span>🛡️ Flaresolverr</span>
</label>
<small>Anti-Cloudflare</small>
</div>
</div>
<div class="form-group">
<label for="feed-cookies">Cookies de session</label>
<textarea id="feed-cookies" rows="2" placeholder="ygg_=abc123; cf_clearance=xyz789"></textarea>
<small>Récupérez-les depuis DevTools (F12) → Application → Cookies</small>
</div>
<div class="action-bar">
<button type="button" id="test-feed-btn" class="btn btn-secondary">🧪 Tester</button>
<button type="submit" class="btn btn-primary"> Ajouter</button>
</div>
</form>
<div id="test-result" class="test-result hidden"></div>
</div>
<!-- Liste des flux -->
<div class="admin-card">
<h3>📋 Flux configurés</h3>
<div id="feeds-list" class="feeds-list">
<p class="loading">Chargement...</p>
</div>
</div>
<!-- Aide -->
<div class="admin-card collapsible">
<h3 class="collapsible-header">
❓ Aide - Comment configurer un flux RSS
<span class="collapse-icon"></span>
</h3>
<div class="collapsible-content">
<div class="help-section">
<h4>YGGTorrent</h4>
<ol>
<li>Connectez-vous à YGG</li>
<li>Profil → "Mon RSS"</li>
<li>Copiez l'URL avec votre passkey</li>
</ol>
<h4>Catégories YGG</h4>
<div class="help-table">
<span class="help-row"><code>id=2145</code> Films</span>
<span class="help-row"><code>id=2184</code> Séries</span>
<span class="help-row"><code>id=2179</code> Anime</span>
<span class="help-row"><code>id=2139</code> Musique</span>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET CACHE
═══════════════════════════════════════════════════════════ -->
<div id="tab-cache" class="tab-content">
<div class="tab-header">
<h2>🔄 Cache des données</h2>
<p>Pré-chargez les données Latest et Discover pour un affichage instantané.</p>
</div>
<!-- Statut du cache -->
<div class="admin-card">
<h3>📊 Statut du cache</h3>
<div id="cacheStatus" class="cache-status">
<div class="status-row">
<span class="status-label">État :</span>
<span id="cacheStatusBadge" class="status-badge">Chargement...</span>
</div>
<div class="status-row">
<span class="status-label">Dernier refresh :</span>
<span id="cacheLastRefresh">-</span>
</div>
<div class="status-row">
<span class="status-label">Prochain refresh :</span>
<span id="cacheNextRefresh">-</span>
</div>
<div class="status-row">
<span class="status-label">Taille du cache :</span>
<span id="cacheSizeDisplay">-</span>
</div>
</div>
<div class="action-bar" style="margin-top: 15px;">
<button id="refreshCacheBtn" class="btn btn-primary">🔄 Forcer le refresh maintenant</button>
<button id="clearCacheBtn" class="btn btn-secondary">🗑️ Vider le cache</button>
</div>
</div>
<!-- Configuration -->
<div class="admin-card">
<h3>⚙️ Configuration</h3>
<div class="form-row">
<div class="form-group">
<label for="cacheEnabled">
<input type="checkbox" id="cacheEnabled">
Activer le cache automatique
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="cacheInterval">Intervalle de refresh</label>
<select id="cacheInterval">
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60" selected>1 heure</option>
<option value="120">2 heures</option>
<option value="240">4 heures</option>
<option value="360">6 heures</option>
</select>
</div>
</div>
</div>
<!-- Configuration Latest -->
<div class="admin-card">
<h3>📥 Cache Latest (Nouveautés)</h3>
<div class="form-row">
<div class="form-group">
<label for="cacheLatestEnabled">
<input type="checkbox" id="cacheLatestEnabled" checked>
Activer le cache Latest
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Catégories à cacher :</label>
<div class="checkbox-group">
<label><input type="checkbox" id="cacheLatestMovies" checked> 🎥 Films</label>
<label><input type="checkbox" id="cacheLatestTv" checked> 📺 Séries</label>
<label><input type="checkbox" id="cacheLatestAnime"> 🎌 Anime</label>
<label><input type="checkbox" id="cacheLatestMusic"> 🎵 Musique</label>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="cacheLatestLimit">Nombre de résultats par catégorie</label>
<select id="cacheLatestLimit">
<option value="30">30</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Trackers à utiliser :</label>
<p class="help-text">Laissez vide pour utiliser tous les trackers actifs</p>
<div id="cacheTrackersList" class="checkbox-group trackers-checkboxes">
<!-- Rempli dynamiquement -->
</div>
</div>
</div>
</div>
<!-- Configuration Discover -->
<div class="admin-card">
<h3>🎬 Cache Discover</h3>
<div class="form-row">
<div class="form-group">
<label for="cacheDiscoverEnabled">
<input type="checkbox" id="cacheDiscoverEnabled" checked>
Activer le cache Discover
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="cacheDiscoverLimit">Nombre de films/séries à cacher</label>
<select id="cacheDiscoverLimit">
<option value="20">20</option>
<option value="30" selected>30</option>
<option value="50">50</option>
</select>
</div>
</div>
</div>
<!-- Sauvegarder -->
<div class="admin-card">
<div class="action-bar">
<button id="saveCacheConfigBtn" class="btn btn-primary">💾 Sauvegarder la configuration</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET CLIENT TORRENT
═══════════════════════════════════════════════════════════ -->
<div id="tab-torrent-client" class="tab-content">
<div class="tab-header">
<h2>⬇️ Client Torrent</h2>
<p>Configurez votre client torrent pour envoyer directement les téléchargements.</p>
</div>
<!-- Statut -->
<div class="admin-card">
<h3>📊 Statut</h3>
<div id="torrentClientStatus" class="client-status">
<span class="status-loading">Chargement...</span>
</div>
</div>
<!-- Configuration -->
<div class="admin-card">
<h3>⚙️ Configuration</h3>
<div class="form-row">
<div class="form-group">
<label for="tcEnabled">
<input type="checkbox" id="tcEnabled">
Activer le client torrent
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="tcPlugin">Client</label>
<select id="tcPlugin">
<option value="">-- Sélectionner --</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="tcHost">Hôte</label>
<input type="text" id="tcHost" placeholder="geco.useed.me ou 192.168.1.x">
<small class="form-help">Domaine ou IP (sans http://)</small>
</div>
<div class="form-group">
<label for="tcPort">Port</label>
<input type="number" id="tcPort" placeholder="8080 ou vide">
<small class="form-help">Laisser vide si port par défaut</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="tcPath">Chemin (optionnel)</label>
<input type="text" id="tcPath" placeholder="/qbittorrent">
<small class="form-help">Si derrière un reverse proxy</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="tcUsername">Utilisateur</label>
<input type="text" id="tcUsername" placeholder="admin">
</div>
<div class="form-group">
<label for="tcPassword">Mot de passe</label>
<input type="password" id="tcPassword" placeholder="••••••••">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="tcSSL">
<input type="checkbox" id="tcSSL">
Utiliser SSL (HTTPS)
</label>
</div>
</div>
<div class="action-bar">
<button id="testTorrentClientBtn" class="btn btn-secondary">🔌 Tester la connexion</button>
<button id="saveTorrentClientBtn" class="btn btn-primary">💾 Sauvegarder</button>
</div>
<div id="tcTestResult" class="test-result hidden"></div>
</div>
<!-- Catégories personnalisées -->
<div class="admin-card">
<h3>📁 Catégories & Dossiers</h3>
<p class="help-text">Définissez vos catégories avec leur dossier de destination par défaut.</p>
<div id="customCategoriesList" class="custom-categories-list">
<!-- Rempli dynamiquement -->
</div>
<div class="add-category-form">
<div class="form-row">
<div class="form-group">
<label for="newCategoryName">Nom de la catégorie</label>
<input type="text" id="newCategoryName" placeholder="Films, Séries, Musique...">
</div>
<div class="form-group">
<label for="newCategoryPath">Chemin de destination</label>
<input type="text" id="newCategoryPath" placeholder="/downloads/films">
</div>
<div class="form-group form-group-btn">
<button id="addCategoryBtn" class="btn btn-success"> Ajouter</button>
</div>
</div>
</div>
<div class="action-bar">
<button id="syncCategoriesBtn" class="btn btn-secondary">🔄 Synchroniser avec le client</button>
<button id="saveCategoriesBtn" class="btn btn-primary">💾 Sauvegarder</button>
</div>
</div>
<!-- Plugins disponibles -->
<div class="admin-card collapsible collapsed">
<h3 class="collapsible-header">
🔌 Plugins disponibles
<span class="collapse-icon"></span>
</h3>
<div class="collapsible-content">
<div id="pluginsList" class="plugins-list">
<p class="loading">Chargement...</p>
</div>
<p class="help-text">
Pour ajouter un nouveau client, créez un plugin dans <code>plugins/torrent_clients/</code>
</p>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ONGLET APPARENCE
═══════════════════════════════════════════════════════════ -->
<div id="tab-appearance" class="tab-content">
<div class="tab-header">
<h2>🎨 Apparence</h2>
<p>Personnalisez l'apparence de Lycostorrent.</p>
</div>
<!-- Sélection du thème -->
<div class="admin-card">
<h3>🎭 Thème</h3>
<p class="help-text">Choisissez un thème pour l'interface.</p>
<div class="theme-grid">
<div class="theme-card" data-theme="dark">
<div class="theme-preview theme-preview-dark">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">🌙 Sombre</span>
</div>
<div class="theme-card" data-theme="light">
<div class="theme-preview theme-preview-light">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">☀️ Clair</span>
</div>
<div class="theme-card" data-theme="ocean">
<div class="theme-preview theme-preview-ocean">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">🌊 Océan</span>
</div>
<div class="theme-card" data-theme="purple">
<div class="theme-preview theme-preview-purple">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">💜 Violet</span>
</div>
<div class="theme-card" data-theme="nature">
<div class="theme-preview theme-preview-nature">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">🌿 Nature</span>
</div>
<div class="theme-card" data-theme="sunset">
<div class="theme-preview theme-preview-sunset">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">🌅 Sunset</span>
</div>
<div class="theme-card" data-theme="cyberpunk">
<div class="theme-preview theme-preview-cyberpunk">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">🤖 Cyberpunk</span>
</div>
<div class="theme-card" data-theme="nord">
<div class="theme-preview theme-preview-nord">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
<div class="preview-card"></div>
</div>
</div>
<span class="theme-name">❄️ Nord</span>
</div>
</div>
</div>
</div>
</div>
<!-- Message toast -->
<div id="toast" class="toast hidden"></div>
<!-- Footer -->
<footer class="app-footer">
<span>Lycostorrent v<span id="app-version">1.0.0</span></span>
</footer>
</div>
<script src="/static/js/admin.js"></script>
<script src="/static/js/nav.js"></script>
<script>
fetch('/api/version').then(r => r.json()).then(data => {
if (data.version) document.getElementById('app-version').textContent = data.version;
}).catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Admin Nouveautés</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>⚙️ Administration</h1>
<p class="subtitle">Configuration des catégories pour les Nouveautés</p>
<nav class="main-nav">
<a href="/">🔍 Recherche</a>
<a href="/latest">🎬 Nouveautés</a>
<a href="/admin/latest" class="active">⚙️ Catégories</a>
<a href="/admin/parsing">🏷️ Tags</a>
</nav>
</header>
<!-- Instructions -->
<div class="admin-info">
<h3> Comment ça marche</h3>
<p>Configurez les catégories Jackett à utiliser pour chaque type de contenu et chaque tracker.</p>
<p>Les catégories sont les IDs numériques de Jackett (ex: 2000 = Films, 5000 = Séries).</p>
</div>
<!-- Sélection du tracker -->
<div class="admin-section">
<h2>1. Sélectionner un tracker</h2>
<div id="trackerSelector" class="tracker-selector">
<p class="loading">Chargement des trackers...</p>
</div>
</div>
<!-- Catégories du tracker sélectionné -->
<div id="categoriesSection" class="admin-section hidden">
<h2>2. Catégories disponibles sur <span id="selectedTrackerName"></span></h2>
<div id="availableCategories" class="available-categories">
<p class="loading">Chargement des catégories...</p>
</div>
</div>
<!-- Configuration des catégories -->
<div id="configSection" class="admin-section hidden">
<h2>3. Configuration pour <span id="configTrackerName"></span></h2>
<div class="config-grid">
<!-- Films -->
<div class="config-card">
<h3>🎥 Films</h3>
<p class="config-description">Catégories pour les films</p>
<input type="text" id="config-movies" placeholder="Ex: 2000,2010,2020">
<div class="quick-add" data-target="movies"></div>
</div>
<!-- Séries -->
<div class="config-card">
<h3>📺 Séries</h3>
<p class="config-description">Catégories pour les séries TV</p>
<input type="text" id="config-tv" placeholder="Ex: 5000,5010,5020">
<div class="quick-add" data-target="tv"></div>
</div>
<!-- Anime -->
<div class="config-card">
<h3>🎌 Anime</h3>
<p class="config-description">Catégories pour les animes</p>
<input type="text" id="config-anime" placeholder="Ex: 5070,5080">
<div class="quick-add" data-target="anime"></div>
</div>
<!-- Musique -->
<div class="config-card">
<h3>🎵 Musique</h3>
<p class="config-description">Catégories pour la musique</p>
<input type="text" id="config-music" placeholder="Ex: 3000,3010">
<div class="quick-add" data-target="music"></div>
</div>
</div>
<div class="config-actions">
<button id="saveConfigBtn" class="btn-primary">💾 Sauvegarder</button>
<button id="resetConfigBtn" class="btn-secondary">🔄 Réinitialiser</button>
</div>
</div>
<!-- Résumé de la configuration -->
<div id="summarySection" class="admin-section">
<h2>📋 Résumé de la configuration</h2>
<div id="configSummary" class="config-summary">
<p class="loading">Chargement...</p>
</div>
</div>
<!-- Messages -->
<div id="messageBox" class="message-box hidden"></div>
</div>
<script src="/static/js/admin_latest.js"></script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Tags de Parsing</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>🏷️ Tags de Parsing</h1>
<p class="subtitle">Mots-clés utilisés pour couper les titres de torrents</p>
<nav class="main-nav">
<a href="/">🔍 Recherche</a>
<a href="/latest">🎬 Nouveautés</a>
<a href="/admin/latest">⚙️ Catégories</a>
<a href="/admin/parsing" class="active">🏷️ Tags</a>
</nav>
</header>
<!-- Instructions -->
<div class="admin-info">
<h3> Comment ça marche</h3>
<p>Ces tags sont utilisés pour <strong>couper</strong> les titres de torrents avant de chercher sur TMDb/Last.fm.</p>
<p>Exemple: <code>Avatar.2009.MULTi.1080p.BluRay</code> → si "MULTi" est dans la liste, le titre sera coupé à <code>Avatar</code></p>
<p><strong>⚠️ Attention:</strong> N'ajoutez pas de mots qui pourraient être des vrais titres (ex: "Intégrale", "Complete", "Extended").</p>
</div>
<!-- Tags actuels -->
<div class="admin-section">
<h2>Tags de coupure actuels</h2>
<div class="tags-editor">
<div id="tagsList" class="tags-list-editor">
<p class="loading">Chargement...</p>
</div>
<div class="add-tag-form">
<input type="text" id="newTagInput" placeholder="Nouveau tag (ex: VOSTFR)" maxlength="30">
<button id="addTagBtn" class="btn-primary"> Ajouter</button>
</div>
</div>
<div class="config-actions">
<button id="saveTagsBtn" class="btn-primary">💾 Sauvegarder</button>
<button id="resetTagsBtn" class="btn-secondary">🔄 Réinitialiser (défaut)</button>
</div>
</div>
<!-- Présets -->
<div class="admin-section">
<h2>📦 Ajouter des présets</h2>
<p class="config-description">Cliquez pour ajouter un groupe de tags courants</p>
<div class="presets-grid">
<button class="preset-btn" data-preset="langues">
🗣️ Langues<br>
<small>VFF, VFQ, VOSTFR, FRENCH...</small>
</button>
<button class="preset-btn" data-preset="resolutions">
📺 Résolutions<br>
<small>1080p, 720p, 4K, UHD...</small>
</button>
<button class="preset-btn" data-preset="sources">
📀 Sources<br>
<small>BluRay, WEB, HDTV, REMUX...</small>
</button>
<button class="preset-btn" data-preset="codecs">
🎬 Codecs<br>
<small>x264, x265, HEVC, AV1...</small>
</button>
<button class="preset-btn" data-preset="audio">
🔊 Audio<br>
<small>DTS, AC3, FLAC, Atmos...</small>
</button>
</div>
</div>
<!-- Test -->
<div class="admin-section">
<h2>🧪 Tester le parsing</h2>
<p class="config-description">Entrez un titre de torrent pour voir le résultat du nettoyage</p>
<div class="test-form">
<input type="text" id="testTitleInput" placeholder="Ex: Avatar.2009.MULTi.1080p.BluRay.x264">
<button id="testParsingBtn" class="btn-primary">🔍 Tester</button>
</div>
<div id="testResult" class="test-result hidden">
<div class="test-original">
<strong>Original:</strong> <span id="testOriginal"></span>
</div>
<div class="test-cleaned">
<strong>Nettoyé:</strong> <span id="testCleaned"></span>
</div>
</div>
</div>
<!-- Messages -->
<div id="messageBox" class="message-box hidden"></div>
</div>
<script src="/static/js/admin_parsing.js"></script>
</body>
</html>

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Flux RSS</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<div class="container">
<header class="header">
<h1>🔗 Gestion des Flux RSS</h1>
<p>Ajoutez des flux RSS pour récupérer les nouveautés de trackers non supportés par Jackett/Prowlarr</p>
</header>
<nav class="admin-nav">
<a href="/">🔍 Recherche</a>
<a href="/latest">🎬 Nouveautés</a>
<a href="/admin/latest">⚙️ Catégories</a>
<a href="/admin/parsing">🏷️ Tags</a>
<a href="/admin/rss" class="active">🔗 RSS</a>
</nav>
<!-- Formulaire d'ajout -->
<section class="admin-section">
<h2> Ajouter un flux RSS</h2>
<form id="add-feed-form" class="feed-form">
<div class="form-row">
<div class="form-group">
<label for="feed-name">Nom du flux *</label>
<input type="text" id="feed-name" placeholder="Ex: YGG Films" required>
</div>
<div class="form-group">
<label for="feed-category">Catégorie *</label>
<select id="feed-category" required>
<option value="">-- Sélectionner --</option>
<option value="movies">🎬 Films</option>
<option value="tv">📺 Séries</option>
<option value="anime">🎌 Anime</option>
<option value="music">🎵 Musique</option>
<option value="all">📦 Toutes</option>
</select>
</div>
</div>
<div class="form-group">
<label for="feed-url">URL du flux RSS *</label>
<input type="url" id="feed-url" placeholder="https://tracker.xxx/rss?cat=films&passkey={passkey}" required>
<small>Utilisez <code>{passkey}</code> comme placeholder pour le passkey</small>
</div>
<div class="form-group">
<label for="feed-passkey">Passkey (optionnel)</label>
<input type="text" id="feed-passkey" placeholder="Votre passkey privé">
<small>Sera injecté à la place de <code>{passkey}</code> dans l'URL</small>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="feed-flaresolverr">
<span>🛡️ Utiliser Flaresolverr (anti-Cloudflare)</span>
</label>
<small>Activer si le site est protégé par Cloudflare (erreur 403)</small>
</div>
<div class="form-group">
<label for="feed-cookies">Cookies de session (optionnel)</label>
<textarea id="feed-cookies" rows="2" placeholder="ygg_=abc123; cf_clearance=xyz789"></textarea>
<small>Format: <code>nom1=valeur1; nom2=valeur2</code> - Récupérez-les depuis les DevTools (F12) → Application → Cookies</small>
</div>
<div class="form-actions">
<button type="button" id="test-feed-btn" class="btn btn-secondary">🧪 Tester</button>
<button type="submit" class="btn btn-primary"> Ajouter</button>
</div>
</form>
<!-- Résultat du test -->
<div id="test-result" class="test-result hidden"></div>
</section>
<!-- Liste des flux configurés -->
<section class="admin-section">
<h2>📋 Flux RSS configurés</h2>
<div id="feeds-list" class="feeds-list">
<p class="loading">Chargement...</p>
</div>
</section>
<!-- Aide -->
<section class="admin-section help-section">
<h2>❓ Comment trouver l'URL RSS ?</h2>
<div class="help-content">
<h4>YGGTorrent</h4>
<ol>
<li>Connectez-vous à YGG</li>
<li>Allez dans votre profil → "Mon RSS"</li>
<li>Copiez l'URL avec votre passkey</li>
<li>Format: <code>https://www3.yggtorrent.xxx/rss?cat=XXX&passkey=VOTRE_PASSKEY</code></li>
</ol>
<h4>Autres trackers privés</h4>
<ol>
<li>Cherchez "RSS" dans les paramètres du tracker</li>
<li>Générez un flux personnalisé avec les catégories souhaitées</li>
<li>Copiez l'URL (contient généralement un passkey ou token)</li>
</ol>
<h4>Catégories YGG courantes</h4>
<table class="help-table">
<tr><td>Films</td><td><code>cat=2145</code></td></tr>
<tr><td>Séries</td><td><code>cat=2184</code></td></tr>
<tr><td>Anime</td><td><code>cat=2179</code></td></tr>
<tr><td>Musique</td><td><code>cat=2139</code></td></tr>
</table>
</div>
</section>
</div>
<script src="/static/js/admin_rss.js"></script>
</body>
</html>

129
app/templates/discover.html Normal file
View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Découvrir</title>
<!-- Chargement du thème (en premier pour éviter le flash) -->
<script src="/static/js/theme-loader.js"></script>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#e63946">
<meta name="description" content="Découvrez les nouveautés cinéma et TV">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Lycostorrent">
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/themes.css">
<link rel="stylesheet" href="/static/css/discover.css">
<link rel="stylesheet" href="/static/css/cache-info.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>🌟 Lycostorrent</h1>
<p class="subtitle">Découvrez les nouveautés cinéma & TV</p>
<nav class="main-nav" id="mainNav">
<!-- Navigation générée dynamiquement -->
</nav>
</header>
<!-- Catégories simplifiées : Films / Séries -->
<div class="discover-tabs">
<button class="discover-tab active" data-category="movies">🎬 Films récents</button>
<button class="discover-tab" data-category="tv">📺 Séries en cours</button>
</div>
<!-- Info cache -->
<div id="cacheInfo" class="cache-info hidden">
<span class="cache-badge">📦 Cache</span>
<span id="cacheTimestamp" class="cache-timestamp"></span>
<button id="refreshLiveBtn" class="btn-refresh" onclick="refreshLive()" title="Actualiser en direct">🔄</button>
</div>
<!-- Grille de résultats -->
<div class="discover-grid" id="discoverGrid">
<!-- Rempli dynamiquement -->
</div>
<!-- Loader -->
<div class="discover-loader hidden" id="discoverLoader">
<div class="spinner"></div>
<span>Chargement...</span>
</div>
<!-- Message d'état -->
<div class="discover-empty hidden" id="discoverEmpty">
<span class="empty-icon">🎬</span>
<p>Sélectionnez une catégorie pour découvrir les nouveautés</p>
</div>
<!-- Modal détails -->
<div class="modal-overlay hidden" id="detailModal">
<div class="modal-content detail-modal">
<button class="modal-close" onclick="closeDetailModal()"></button>
<div class="detail-header">
<img src="" alt="" class="detail-poster" id="detailPoster">
<div class="detail-info">
<h2 id="detailTitle"></h2>
<div class="detail-meta">
<span class="detail-year" id="detailYear"></span>
<span class="detail-rating" id="detailRating">⭐ --</span>
</div>
<p class="detail-overview" id="detailOverview"></p>
<div class="detail-genres" id="detailGenres"></div>
</div>
</div>
<!-- Bande-annonce YouTube -->
<div class="detail-trailer hidden" id="detailTrailer">
<h3>🎬 Bande-annonce</h3>
<div class="trailer-container">
<iframe id="trailerFrame" src="" frameborder="0" allowfullscreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture">
</iframe>
</div>
</div>
<div class="detail-torrents">
<h3>🔍 Torrents disponibles</h3>
<div class="torrents-loading hidden" id="torrentsLoading">
<div class="spinner-small"></div>
<span>Recherche sur vos trackers...</span>
</div>
<div class="torrents-list" id="torrentsList">
<!-- Rempli dynamiquement -->
</div>
<div class="torrents-empty hidden" id="torrentsEmpty">
<p>Aucun torrent trouvé pour ce titre</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="app-footer">
<span>Lycostorrent v<span id="app-version">1.0.0</span></span>
<span class="tmdb-credit">Données fournies par <a href="https://www.themoviedb.org" target="_blank">TMDb</a></span>
</footer>
</div>
<script src="/static/js/discover.js"></script>
<script src="/static/js/nav.js"></script>
<script>
fetch('/api/version').then(r => r.json()).then(data => {
if (data.version) document.getElementById('app-version').textContent = data.version;
}).catch(() => {});
</script>
</body>
</html>

133
app/templates/index.html Normal file
View File

@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Recherche</title>
<!-- Chargement du thème (en premier pour éviter le flash) -->
<script src="/static/js/theme-loader.js"></script>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#e63946">
<meta name="description" content="Recherche de torrents avec enrichissement TMDb">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Lycostorrent">
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/themes.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>🔍 Lycostorrent</h1>
<p class="subtitle">Recherche de torrents</p>
<nav class="main-nav" id="mainNav">
<!-- Navigation générée dynamiquement -->
</nav>
</header>
<!-- Barre de recherche -->
<section class="search-section">
<div class="search-bar">
<input
type="text"
id="search-input"
placeholder="Rechercher un film, série, musique..."
autocomplete="off"
>
<button id="search-btn" class="btn-primary">Rechercher</button>
</div>
<!-- Catégorie -->
<div class="search-options">
<div class="option-group">
<label>Catégorie</label>
<select id="category-select">
<option value="all">Tout</option>
<option value="movies">Films</option>
<option value="tv">Séries TV</option>
<option value="audio">Audio</option>
<option value="pc">PC</option>
<option value="console">Console</option>
<option value="books">Livres</option>
<option value="other">Autre</option>
</select>
</div>
</div>
<!-- Sélection des trackers (style Nouveautés) -->
<div class="trackers-selector">
<button id="toggleTrackers" class="toggle-btn">🔧 Sélectionner les trackers</button>
<div id="trackersPanel" class="trackers-panel hidden">
<div class="trackers-actions">
<button id="selectAllTrackers">Tout sélectionner</button>
<button id="deselectAllTrackers">Tout désélectionner</button>
</div>
<div id="trackers-list" class="trackers-list">
<p class="loading">Chargement des trackers...</p>
</div>
</div>
</div>
</section>
<!-- Filtres dynamiques (masquables) -->
<section id="filters-section" class="filters-section hidden">
<div class="filters-header">
<h3>🎛️ Filtres <span id="results-count" class="results-count"></span></h3>
<button id="toggle-filters" class="btn-toggle" title="Masquer/Afficher les filtres"></button>
</div>
<div id="filters-content" class="filters-content">
<div id="filters-container" class="filters-container">
<!-- Filtres générés dynamiquement -->
</div>
<button id="clear-filters" class="btn-secondary">Effacer les filtres</button>
</div>
</section>
<!-- Résultats -->
<section id="results-section" class="results-section">
<div id="results-container">
<p class="placeholder-text">Effectuez une recherche pour voir les résultats</p>
</div>
</section>
<!-- Loading overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="spinner"></div>
<p>Recherche en cours...</p>
</div>
<!-- Footer -->
<footer class="app-footer">
<span>Lycostorrent v<span id="app-version">1.1.0</span></span>
</footer>
</div>
<script src="/static/js/search.js"></script>
<script>
fetch('/api/version').then(r => r.json()).then(data => {
if (data.version) document.getElementById('app-version').textContent = data.version;
}).catch(() => {});
// Enregistrement du Service Worker pour PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js')
.then(reg => console.log('✅ Service Worker enregistré'))
.catch(err => console.log('❌ Service Worker erreur:', err));
});
}
</script>
<script src="/static/js/nav.js"></script>
</body>
</html>

145
app/templates/latest.html Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Nouveautés</title>
<!-- Chargement du thème (en premier pour éviter le flash) -->
<script src="/static/js/theme-loader.js"></script>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#e63946">
<meta name="description" content="Dernières sorties Films, Séries et Musique">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Lycostorrent">
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/themes.css">
<link rel="stylesheet" href="/static/css/latest.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>🎬 Lycostorrent</h1>
<p class="subtitle">Dernières sorties Films, Séries & Musique</p>
<nav class="main-nav" id="mainNav">
<!-- Navigation générée dynamiquement -->
</nav>
</header>
<!-- Paramètres -->
<div class="latest-settings">
<div class="categories">
<button class="category-btn active" data-category="movies">🎥 Films</button>
<button class="category-btn" data-category="tv">📺 Séries</button>
<button class="category-btn" data-category="anime">🎌 Animes</button>
<button class="category-btn" data-category="music">🎵 Musique</button>
</div>
<div class="trackers-selector">
<button id="toggleTrackers" class="toggle-btn">🔧 Sélectionner les trackers</button>
<div id="trackersPanel" class="trackers-panel hidden">
<div class="trackers-actions">
<button id="selectAllTrackers">Tout sélectionner</button>
<button id="deselectAllTrackers">Tout désélectionner</button>
</div>
<div id="trackersList" class="trackers-list">
<p class="loading">Chargement des trackers...</p>
</div>
</div>
</div>
<div class="limit-selector">
<label>Résultats:</label>
<select id="limitSelect">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<button id="loadLatestBtn" class="btn-primary">📥 Charger</button>
</div>
</div>
<!-- Filtres par année (pastilles) -->
<div id="yearFilters" class="year-filters hidden">
<span class="filter-label">Année :</span>
<div class="year-pills">
<button class="year-pill" data-year="2026">2026</button>
<button class="year-pill" data-year="2025">2025</button>
<button class="year-pill" data-year="2024">2024</button>
<button class="year-pill" data-year="2023">2023</button>
<button class="year-pill" data-year="old">≤2022</button>
<button class="year-pill active" data-year="all">Tous</button>
</div>
<span id="filterCount" class="filter-count"></span>
</div>
<!-- Résultats -->
<div id="latestResults" class="latest-results hidden">
<div class="results-header">
<h2 id="resultsTitle">Dernières sorties</h2>
<div class="results-meta">
<span id="resultsCount">0 résultats</span>
<div id="cacheInfo" class="cache-info hidden">
<span class="cache-badge">📦 Cache</span>
<span id="cacheTimestamp" class="cache-timestamp"></span>
<button id="refreshLiveBtn" class="btn-refresh" title="Actualiser en direct">🔄</button>
</div>
</div>
</div>
<div id="resultsGrid" class="results-grid"></div>
</div>
<!-- Messages -->
<div id="messageBox" class="message-box hidden"></div>
<!-- Loader -->
<div id="loader" class="loader hidden">
<div class="spinner"></div>
<p>Chargement des nouveautés...</p>
</div>
<!-- Modal pour les détails -->
<div id="detailsModal" class="modal hidden">
<div class="modal-content">
<span class="modal-close">&times;</span>
<div id="modalBody"></div>
</div>
</div>
<!-- Footer -->
<footer class="app-footer">
<span>Lycostorrent v<span id="app-version">1.0.0</span></span>
</footer>
</div>
<script src="/static/js/latest.js"></script>
<script>
fetch('/api/version').then(r => r.json()).then(data => {
if (data.version) document.getElementById('app-version').textContent = data.version;
}).catch(() => {});
// Enregistrement du Service Worker pour PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js')
.then(reg => console.log('✅ Service Worker enregistré'))
.catch(err => console.log('❌ Service Worker erreur:', err));
});
}
</script>
<script src="/static/js/nav.js"></script>
</body>
</html>

232
app/templates/login.html Normal file
View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lycostorrent - Connexion</title>
<!-- Chargement du thème (en premier pour éviter le flash) -->
<script src="/static/js/theme-loader.js"></script>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#e63946">
<meta name="description" content="Connexion à Lycostorrent">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Lycostorrent">
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/themes.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>🔐 Lycostorrent</h1>
<p class="subtitle">Connexion requise</p>
</header>
<!-- Formulaire de connexion -->
<div class="login-wrapper">
{% if locked_message %}
<div class="alert alert-warning">
<span class="alert-icon">🔒</span>
<span>{{ locked_message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
<span>{{ error }}</span>
</div>
{% endif %}
<form method="POST" action="{{ url_for('login') }}{% if request.args.get('next') %}?next={{ request.args.get('next') }}{% endif %}" class="login-form" autocomplete="on">
<!-- Token CSRF -->
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="username">Nom d'utilisateur</label>
<input type="text"
id="username"
name="username"
class="form-input"
placeholder="admin"
autocomplete="username"
required
autofocus
{% if locked_message %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password"
id="password"
name="password"
class="form-input"
placeholder="••••••••"
autocomplete="current-password"
required
{% if locked_message %}disabled{% endif %}>
</div>
<button type="submit" class="btn-primary btn-login" {% if locked_message %}disabled{% endif %}>
{% if locked_message %}
🔒 Verrouillé
{% else %}
🔐 Se connecter
{% endif %}
</button>
</form>
<div class="login-footer">
<span class="security-badge">🛡️ Connexion sécurisée</span>
</div>
</div>
<!-- Footer -->
<footer class="app-footer">
<span>Lycostorrent</span>
</footer>
</div>
{% if locked_message %}
<script>
// Rafraîchir la page après le délai de verrouillage
setTimeout(() => {
window.location.reload();
}, 10000);
</script>
{% endif %}
<style>
/* Styles spécifiques à la page login - utilise les variables CSS du thème */
.login-wrapper {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
color: var(--text-color);
font-weight: 500;
font-size: 0.95rem;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem;
background: var(--bg-color);
border: 2px solid var(--border-color);
border-radius: 8px;
color: var(--text-color);
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.2);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-login {
width: 100%;
padding: 1rem;
font-size: 1rem;
font-weight: 600;
margin-top: 0.5rem;
}
.btn-login:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.alert-icon {
font-size: 1.25rem;
}
.alert-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.alert-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.login-footer {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
text-align: center;
}
.security-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
@media (max-width: 480px) {
.login-wrapper {
margin: 1rem;
padding: 1.5rem;
}
}
</style>
</body>
</html>

371
app/tmdb_api.py Normal file
View File

@@ -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

268
app/torrent_parser.py Normal file
View File

@@ -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}")

32
docker-compose.yml Normal file
View File

@@ -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

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
flask==3.0.0
requests==2.31.0
python-dateutil==2.8.2
APScheduler>=3.10.0