Initial commit
946
CHANGELOG.md
Normal 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
@@ -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
@@ -0,0 +1,198 @@
|
||||
# 🐺 Lycostorrent
|
||||
|
||||
**Version 1.0.0** | Interface de recherche de torrents multi-sources
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📋 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
@@ -0,0 +1 @@
|
||||
2.0.0
|
||||
435
app/cache_manager.py
Normal 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
@@ -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)
|
||||
74
app/config/filters_config.json
Normal 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
@@ -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
@@ -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
@@ -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
154
app/plugins/torrent_clients/README.md
Normal 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)
|
||||
```
|
||||
262
app/plugins/torrent_clients/__init__.py
Normal 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()
|
||||
219
app/plugins/torrent_clients/base.py
Normal 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)
|
||||
}
|
||||
484
app/plugins/torrent_clients/qbittorrent.py
Normal 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
|
||||
427
app/plugins/torrent_clients/transmission.py
Normal 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
@@ -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
@@ -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
@@ -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
48
app/static/css/cache-info.css
Normal 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
@@ -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
1168
app/static/css/style.css
Normal file
168
app/static/css/themes.css
Normal 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;
|
||||
}
|
||||
BIN
app/static/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/static/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/static/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/static/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
app/static/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/static/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
app/static/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 671 B |
BIN
app/static/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 886 B |
1672
app/static/js/admin.js
Normal file
365
app/static/js/admin_latest.js
Normal 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;
|
||||
}
|
||||
227
app/static/js/admin_parsing.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
8
app/static/js/theme-loader.js
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||
105
app/templates/admin_latest.html
Normal 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>
|
||||
108
app/templates/admin_parsing.html
Normal 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>
|
||||
126
app/templates/admin_rss.html
Normal 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
@@ -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
@@ -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
@@ -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">×</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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
flask==3.0.0
|
||||
requests==2.31.0
|
||||
python-dateutil==2.8.2
|
||||
APScheduler>=3.10.0
|
||||