Files
Lycostorrent/app/indexer_manager.py
2026-03-23 20:59:26 +01:00

269 lines
9.9 KiB
Python

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