484 lines
17 KiB
Python
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 |