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

484 lines
17 KiB
Python

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