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