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