Initial commit

This commit is contained in:
2026-03-23 20:59:26 +01:00
commit 16c95f747b
56 changed files with 21177 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
# Plugins Clients Torrent - Lycostorrent
Ce dossier contient les plugins pour les clients torrent.
## Plugins disponibles
| Plugin | Fichier | Description |
|--------|---------|-------------|
| qBittorrent | `qbittorrent.py` | Client qBittorrent via API Web |
## Créer un nouveau plugin
### 1. Créer le fichier
Créer un fichier `mon_client.py` dans ce dossier.
### 2. Implémenter la classe
```python
from .base import TorrentClientPlugin, TorrentClientConfig, TorrentInfo
from typing import Optional, List
class MonClientPlugin(TorrentClientPlugin):
"""Plugin pour MonClient."""
# Métadonnées (obligatoires)
PLUGIN_NAME = "MonClient"
PLUGIN_DESCRIPTION = "Support pour MonClient"
PLUGIN_VERSION = "1.0.0"
PLUGIN_AUTHOR = "VotreNom"
def connect(self) -> bool:
"""Établit la connexion."""
# Votre code ici
pass
def disconnect(self) -> None:
"""Ferme la connexion."""
pass
def is_connected(self) -> bool:
"""Vérifie la connexion."""
pass
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."""
pass
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."""
pass
def get_torrents(self) -> List[TorrentInfo]:
"""Liste tous les torrents."""
pass
def get_torrent(self, torrent_hash: str) -> Optional[TorrentInfo]:
"""Récupère un torrent par son hash."""
pass
def pause_torrent(self, torrent_hash: str) -> bool:
"""Met en pause."""
pass
def resume_torrent(self, torrent_hash: str) -> bool:
"""Reprend le téléchargement."""
pass
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
"""Supprime un torrent."""
pass
def get_categories(self) -> List[str]:
"""Liste les catégories."""
pass
# Important : exposer la classe pour l'auto-découverte
PLUGIN_CLASS = MonClientPlugin
```
### 3. Méthodes obligatoires
| Méthode | Description |
|---------|-------------|
| `connect()` | Connexion au client |
| `disconnect()` | Déconnexion |
| `is_connected()` | Vérifie la connexion |
| `add_torrent_url()` | Ajoute via magnet/URL |
| `add_torrent_file()` | Ajoute via fichier .torrent |
| `get_torrents()` | Liste les torrents |
| `get_torrent()` | Info d'un torrent |
| `pause_torrent()` | Pause |
| `resume_torrent()` | Reprise |
| `delete_torrent()` | Suppression |
| `get_categories()` | Liste catégories |
### 4. Structure TorrentInfo
```python
@dataclass
class TorrentInfo:
hash: str # Hash du torrent
name: str # Nom
size: int # Taille en bytes
progress: float # 0.0 à 1.0
status: str # downloading, seeding, paused, error, queued, checking
download_speed: int # bytes/s
upload_speed: int # bytes/s
seeds: int # Nombre de seeds
peers: int # Nombre de peers
save_path: str # Chemin de sauvegarde
```
### 5. Configuration
La configuration est passée via `TorrentClientConfig` :
```python
@dataclass
class TorrentClientConfig:
host: str # Adresse (ex: "192.168.1.100")
port: int # Port (ex: 8080)
username: str # Utilisateur
password: str # Mot de passe
use_ssl: bool # Utiliser HTTPS
```
## Plugins à créer
- [ ] Transmission (`transmission.py`)
- [ ] Deluge (`deluge.py`)
- [ ] ruTorrent (`rutorrent.py`)
- [ ] Vuze (`vuze.py`)
- [ ] µTorrent (`utorrent.py`)
## Test du plugin
```python
from plugins.torrent_clients import create_client
client = create_client('monclient', {
'host': 'localhost',
'port': 8080,
'username': 'admin',
'password': 'password'
})
result = client.test_connection()
print(result)
```

View File

@@ -0,0 +1,262 @@
"""
Gestionnaire de plugins pour les clients torrent.
Découvre automatiquement les plugins disponibles.
"""
import os
import importlib
import logging
from typing import Dict, List, Optional, Type, Any
from .base import TorrentClientPlugin, TorrentClientConfig
logger = logging.getLogger(__name__)
# Registre des plugins disponibles
_plugins: Dict[str, Type[TorrentClientPlugin]] = {}
# Instance active du client
_active_client: Optional[TorrentClientPlugin] = None
_active_config: Optional[Dict[str, Any]] = None
def discover_plugins() -> Dict[str, Type[TorrentClientPlugin]]:
"""
Découvre automatiquement tous les plugins dans le dossier.
Returns:
Dictionnaire {nom_plugin: classe_plugin}
"""
global _plugins
_plugins = {}
plugins_dir = os.path.dirname(__file__)
for filename in os.listdir(plugins_dir):
if filename.endswith('.py') and filename not in ('__init__.py', 'base.py'):
module_name = filename[:-3]
try:
module = importlib.import_module(f'.{module_name}', package=__package__)
# Chercher PLUGIN_CLASS ou une classe héritant de TorrentClientPlugin
if hasattr(module, 'PLUGIN_CLASS'):
plugin_class = module.PLUGIN_CLASS
_plugins[plugin_class.PLUGIN_NAME.lower()] = plugin_class
logger.info(f"✅ Plugin chargé: {plugin_class.PLUGIN_NAME} v{plugin_class.PLUGIN_VERSION}")
except Exception as e:
logger.warning(f"⚠️ Impossible de charger le plugin {module_name}: {e}")
return _plugins
def get_available_plugins() -> List[Dict[str, str]]:
"""
Retourne la liste des plugins disponibles.
Returns:
Liste de dictionnaires avec les infos de chaque plugin
"""
if not _plugins:
discover_plugins()
return [
{
"id": name,
"name": plugin.PLUGIN_NAME,
"description": plugin.PLUGIN_DESCRIPTION,
"version": plugin.PLUGIN_VERSION,
"author": plugin.PLUGIN_AUTHOR
}
for name, plugin in _plugins.items()
]
def get_plugin(name: str) -> Optional[Type[TorrentClientPlugin]]:
"""
Récupère une classe de plugin par son nom.
Args:
name: Nom du plugin (insensible à la casse)
Returns:
Classe du plugin ou None
"""
if not _plugins:
discover_plugins()
return _plugins.get(name.lower())
def create_client(plugin_name: str, config: Dict[str, Any]) -> Optional[TorrentClientPlugin]:
"""
Crée une instance de client torrent.
Args:
plugin_name: Nom du plugin à utiliser
config: Configuration (host, port, username, password, use_ssl, path)
Returns:
Instance du client ou None
"""
plugin_class = get_plugin(plugin_name)
if not plugin_class:
logger.error(f"❌ Plugin non trouvé: {plugin_name}")
return None
try:
from urllib.parse import urlparse
# Nettoyer le host (enlever http:// ou https:// si présent)
host = config.get('host', 'localhost')
path = config.get('path', '')
use_ssl = config.get('use_ssl', False)
port = config.get('port', None) # None = pas spécifié
# Si l'utilisateur a entré une URL complète, l'analyser
if host.startswith('http://') or host.startswith('https://'):
parsed = urlparse(host)
host = parsed.netloc or parsed.path.split('/')[0]
use_ssl = parsed.scheme == 'https'
# Extraire le port de l'URL si présent (ex: host:8080)
if ':' in host:
host_part, port_part = host.rsplit(':', 1)
if port_part.isdigit():
host = host_part
if port is None: # Seulement si pas déjà spécifié
port = int(port_part)
# Extraire le chemin de l'URL si pas déjà fourni
if parsed.path and not path:
path = parsed.path.rstrip('/')
# Enlever le trailing slash du host
host = host.rstrip('/')
# Gérer le port
# - Si port est None ou vide: pas de port explicite (0)
# - Si port est un nombre valide: l'utiliser
# - Rétrocompatibilité: les anciennes configs avec port=8080 fonctionnent
if port is None or port == '':
port = 0
elif isinstance(port, str):
port = int(port) if port.strip().isdigit() else 0
else:
port = int(port) if port else 0
client_config = TorrentClientConfig(
host=host,
port=port,
username=config.get('username', ''),
password=config.get('password', ''),
use_ssl=use_ssl,
path=path
)
logger.info(f"🔧 Configuration client: {client_config.base_url}")
client = plugin_class(client_config)
return client
except Exception as e:
logger.error(f"❌ Erreur création client {plugin_name}: {e}")
return None
def get_active_client() -> Optional[TorrentClientPlugin]:
"""Retourne le client actif ou None."""
global _active_client
return _active_client
def set_active_client(client: Optional[TorrentClientPlugin], config: Optional[Dict[str, Any]] = None) -> None:
"""Définit le client actif."""
global _active_client, _active_config
# Déconnecter l'ancien client
if _active_client:
try:
_active_client.disconnect()
except:
pass
_active_client = client
_active_config = config
def get_active_config() -> Optional[Dict[str, Any]]:
"""Retourne la configuration du client actif."""
return _active_config
def load_client_from_config(config_path: str = '/app/config/torrent_client.json') -> Optional[TorrentClientPlugin]:
"""
Charge le client depuis un fichier de configuration.
Args:
config_path: Chemin du fichier de configuration
Returns:
Instance du client ou None
"""
import json
try:
if not os.path.exists(config_path):
return None
with open(config_path, 'r') as f:
config = json.load(f)
if not config.get('enabled', False):
logger.info(" Client torrent désactivé")
return None
plugin_name = config.get('plugin', '')
if not plugin_name:
return None
client = create_client(plugin_name, config)
if client and client.connect():
set_active_client(client, config)
logger.info(f"✅ Client torrent actif: {plugin_name}")
return client
return None
except Exception as e:
logger.error(f"❌ Erreur chargement config client torrent: {e}")
return None
def save_client_config(config: Dict[str, Any], config_path: str = '/app/config/torrent_client.json') -> bool:
"""
Sauvegarde la configuration du client torrent.
Args:
config: Configuration à sauvegarder
config_path: Chemin du fichier
Returns:
True si succès
"""
import json
try:
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
return True
except Exception as e:
logger.error(f"❌ Erreur sauvegarde config client torrent: {e}")
return False
# Découvrir les plugins au chargement du module
discover_plugins()

View File

@@ -0,0 +1,219 @@
"""
Classe de base pour les plugins de clients torrent.
Tous les plugins doivent hériter de cette classe.
"""
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
@dataclass
class TorrentClientConfig:
"""Configuration d'un client torrent"""
host: str
port: int = 0 # 0 = pas de port explicite (utiliser le port par défaut du protocole)
username: str = ""
password: str = ""
use_ssl: bool = False
path: str = "" # Chemin optionnel (ex: /qbittorrent)
@property
def base_url(self) -> str:
protocol = "https" if self.use_ssl else "http"
# Si le port est 0 ou vide, ne pas l'inclure dans l'URL
if self.port and self.port > 0:
url = f"{protocol}://{self.host}:{self.port}"
else:
url = f"{protocol}://{self.host}"
# Ajouter le chemin s'il existe
if self.path:
# S'assurer que le chemin commence par / et ne finit pas par /
path = self.path.strip('/')
if path:
url = f"{url}/{path}"
return url
@dataclass
class TorrentInfo:
"""Informations sur un torrent"""
hash: str
name: str
size: int
progress: float # 0.0 à 1.0
status: str # downloading, seeding, paused, error, etc.
download_speed: int # bytes/s
upload_speed: int # bytes/s
seeds: int
peers: int
save_path: str
class TorrentClientPlugin(ABC):
"""
Classe abstraite pour les plugins de clients torrent.
Pour créer un nouveau plugin :
1. Créer un fichier dans plugins/torrent_clients/
2. Hériter de TorrentClientPlugin
3. Implémenter toutes les méthodes abstraites
4. Définir PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION
"""
# Métadonnées du plugin (à surcharger)
PLUGIN_NAME: str = "Base Plugin"
PLUGIN_DESCRIPTION: str = "Plugin de base"
PLUGIN_VERSION: str = "1.0.0"
PLUGIN_AUTHOR: str = "Unknown"
# Capacités du plugin
SUPPORTS_TORRENT_FILES: bool = True # Supporte les URLs .torrent en plus des magnets
def __init__(self, config: TorrentClientConfig):
self.config = config
self._connected = False
@abstractmethod
def connect(self) -> bool:
"""
Établit la connexion avec le client torrent.
Retourne True si la connexion réussit.
"""
pass
@abstractmethod
def disconnect(self) -> None:
"""Ferme la connexion avec le client torrent."""
pass
@abstractmethod
def is_connected(self) -> bool:
"""Vérifie si la connexion est active."""
pass
@abstractmethod
def add_torrent_url(self, url: str, save_path: Optional[str] = None,
category: Optional[str] = None, paused: bool = False) -> bool:
"""
Ajoute un torrent via son URL (magnet ou .torrent).
Args:
url: URL du torrent (magnet:// ou http://.torrent)
save_path: Chemin de sauvegarde (optionnel)
category: Catégorie/Label (optionnel)
paused: Démarrer en pause (défaut: False)
Returns:
True si l'ajout réussit
"""
pass
@abstractmethod
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 son contenu fichier.
Args:
file_content: Contenu binaire du fichier .torrent
filename: Nom du fichier
save_path: Chemin de sauvegarde (optionnel)
category: Catégorie/Label (optionnel)
paused: Démarrer en pause (défaut: False)
Returns:
True si l'ajout réussit
"""
pass
@abstractmethod
def get_torrents(self) -> List[TorrentInfo]:
"""
Récupère la liste de tous les torrents.
Returns:
Liste des torrents
"""
pass
@abstractmethod
def get_torrent(self, torrent_hash: str) -> Optional[TorrentInfo]:
"""
Récupère les informations d'un torrent spécifique.
Args:
torrent_hash: Hash du torrent
Returns:
Informations du torrent ou None si non trouvé
"""
pass
@abstractmethod
def pause_torrent(self, torrent_hash: str) -> bool:
"""Met un torrent en pause."""
pass
@abstractmethod
def resume_torrent(self, torrent_hash: str) -> bool:
"""Reprend un torrent en pause."""
pass
@abstractmethod
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
"""
Supprime un torrent.
Args:
torrent_hash: Hash du torrent
delete_files: Supprimer aussi les fichiers téléchargés
Returns:
True si la suppression réussit
"""
pass
@abstractmethod
def get_categories(self) -> List[str]:
"""Récupère la liste des catégories/labels disponibles."""
pass
def get_info(self) -> Dict[str, Any]:
"""Retourne les informations du plugin."""
return {
"name": self.PLUGIN_NAME,
"description": self.PLUGIN_DESCRIPTION,
"version": self.PLUGIN_VERSION,
"author": self.PLUGIN_AUTHOR,
"connected": self.is_connected()
}
def test_connection(self) -> Dict[str, Any]:
"""
Teste la connexion au client.
Returns:
{"success": bool, "message": str, "version": str (si connecté)}
"""
try:
if self.connect():
return {
"success": True,
"message": "Connexion réussie",
"client": self.PLUGIN_NAME
}
else:
return {
"success": False,
"message": "Échec de la connexion"
}
except Exception as e:
return {
"success": False,
"message": str(e)
}

View File

@@ -0,0 +1,484 @@
"""
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

View File

@@ -0,0 +1,427 @@
"""
Plugin Transmission pour Lycostorrent.
Utilise l'API RPC de Transmission.
"""
import requests
import json
import base64
import logging
from typing import Optional, List, Dict, Any
from .base import TorrentClientPlugin, TorrentClientConfig, TorrentInfo
logger = logging.getLogger(__name__)
class TransmissionPlugin(TorrentClientPlugin):
"""Plugin pour Transmission via son API RPC."""
PLUGIN_NAME = "Transmission"
PLUGIN_DESCRIPTION = "Client torrent Transmission (API RPC)"
PLUGIN_VERSION = "1.2.0"
PLUGIN_AUTHOR = "Lycostorrent"
# Transmission supporte les fichiers .torrent (avec téléchargement préalable)
SUPPORTS_TORRENT_FILES = True
# Mapping des statuts Transmission vers statuts génériques
# 0: stopped, 1: check pending, 2: checking, 3: download pending
# 4: downloading, 5: seed pending, 6: seeding
STATUS_MAP = {
0: 'paused',
1: 'checking',
2: 'checking',
3: 'queued',
4: 'downloading',
5: 'queued',
6: 'seeding'
}
def __init__(self, config: TorrentClientConfig):
super().__init__(config)
self._session = requests.Session()
self._session_id = 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 rpc_url(self) -> str:
return f"{self.config.base_url}/transmission/rpc"
def _get_session_id(self) -> bool:
"""Récupère le X-Transmission-Session-Id requis par l'API."""
try:
response = self._session.post(self.rpc_url, timeout=10)
# Transmission retourne 409 avec le session ID dans les headers
if response.status_code == 409:
self._session_id = response.headers.get('X-Transmission-Session-Id')
if self._session_id:
self._session.headers['X-Transmission-Session-Id'] = self._session_id
return True
# Si on a déjà un session ID valide
if response.status_code == 200:
return True
return False
except Exception as e:
logger.error(f"❌ Transmission: Erreur récupération session ID: {e}")
return False
def _rpc_call(self, method: str, arguments: Dict = None) -> Optional[Dict]:
"""Effectue un appel RPC à Transmission."""
if not self._session_id:
if not self._get_session_id():
return None
payload = {"method": method}
if arguments:
payload["arguments"] = arguments
try:
response = self._session.post(
self.rpc_url,
json=payload,
timeout=30
)
# Si session expirée, renouveler et réessayer
if response.status_code == 409:
self._session_id = response.headers.get('X-Transmission-Session-Id')
self._session.headers['X-Transmission-Session-Id'] = self._session_id
response = self._session.post(
self.rpc_url,
json=payload,
timeout=30
)
if response.status_code == 200:
data = response.json()
if data.get('result') == 'success':
return data.get('arguments', {})
else:
logger.error(f"❌ Transmission RPC error: {data.get('result')}")
return None
elif response.status_code == 401:
logger.error("❌ Transmission: Authentification requise")
return None
else:
logger.error(f"❌ Transmission: Erreur HTTP {response.status_code}")
return None
except Exception as e:
logger.error(f"❌ Transmission RPC: {e}")
return None
def connect(self) -> bool:
"""Connexion à Transmission via l'API RPC."""
try:
# Récupérer le session ID
if not self._get_session_id():
logger.error(f"❌ Transmission: Impossible de se connecter à {self.config.base_url}")
return False
# Tester avec un appel simple
result = self._rpc_call("session-get")
if result:
self._connected = True
version = result.get('version', 'unknown')
logger.info(f"✅ Connecté à Transmission {version}: {self.config.host}")
return True
else:
return False
except requests.exceptions.ConnectionError:
logger.error(f"❌ Transmission: Impossible de se connecter à {self.config.base_url}")
return False
except Exception as e:
logger.error(f"❌ Transmission: {e}")
return False
def disconnect(self) -> None:
"""Ferme la connexion avec Transmission."""
self._connected = False
self._session_id = None
def is_connected(self) -> bool:
"""Vérifie si la connexion est active."""
if not self._connected or not self._session_id:
return False
result = self._rpc_call("session-get")
return result is not None
def _ensure_connected(self) -> bool:
"""S'assure que la connexion est active, reconnecte si nécessaire."""
if not self._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:'):
arguments = {"filename": url}
if save_path:
arguments["download-dir"] = save_path
if paused:
arguments["paused"] = True
result = self._rpc_call("torrent-add", arguments)
if result:
if "torrent-added" in result:
logger.info(f"✅ Torrent ajouté: {result['torrent-added'].get('name', url[:50])}")
return True
elif "torrent-duplicate" in result:
logger.warning(f"⚠️ Torrent déjà présent: {result['torrent-duplicate'].get('name', url[:50])}")
return True
return False
# Si c'est une URL .torrent, télécharger le fichier et l'envoyer en base64
try:
logger.info(f"📥 Téléchargement du fichier torrent: {url[:80]}...")
# Télécharger le fichier torrent (sans suivre les redirections automatiquement)
response = self._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)
# Sinon suivre la redirection
response = self._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 (commence par 'd' pour dictionnaire bencode)
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 (format bencode)")
return False
# Envoyer en base64
metainfo = base64.b64encode(content).decode('utf-8')
arguments = {"metainfo": metainfo}
if save_path:
arguments["download-dir"] = save_path
if paused:
arguments["paused"] = True
result = self._rpc_call("torrent-add", arguments)
if result:
if "torrent-added" in result:
logger.info(f"✅ Torrent ajouté: {result['torrent-added'].get('name', 'unknown')}")
return True
elif "torrent-duplicate" in result:
logger.warning(f"⚠️ Torrent déjà présent: {result['torrent-duplicate'].get('name', 'unknown')}")
return True
return False
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
# Encoder le fichier en base64
metainfo = base64.b64encode(file_content).decode('utf-8')
arguments = {"metainfo": metainfo}
if save_path:
arguments["download-dir"] = save_path
if paused:
arguments["paused"] = True
result = self._rpc_call("torrent-add", arguments)
if result:
if "torrent-added" in result:
logger.info(f"✅ Torrent ajouté: {filename}")
return True
elif "torrent-duplicate" in result:
logger.warning(f"⚠️ Torrent déjà présent: {filename}")
return True
return False
def get_torrents(self) -> List[TorrentInfo]:
"""Récupère la liste de tous les torrents."""
if not self._ensure_connected():
return []
fields = [
"id", "hashString", "name", "totalSize", "percentDone",
"status", "rateDownload", "rateUpload", "seeders", "peersGettingFromUs",
"downloadDir", "error", "errorString"
]
result = self._rpc_call("torrent-get", {"fields": fields})
if not result or "torrents" not in result:
return []
torrents = []
for t in result["torrents"]:
status = self.STATUS_MAP.get(t.get('status', 0), 'unknown')
if t.get('error', 0) > 0:
status = 'error'
torrents.append(TorrentInfo(
hash=t.get('hashString', ''),
name=t.get('name', ''),
size=t.get('totalSize', 0),
progress=t.get('percentDone', 0),
status=status,
download_speed=t.get('rateDownload', 0),
upload_speed=t.get('rateUpload', 0),
seeds=t.get('seeders', 0) or 0,
peers=t.get('peersGettingFromUs', 0) or 0,
save_path=t.get('downloadDir', '')
))
return torrents
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
fields = [
"id", "hashString", "name", "totalSize", "percentDone",
"status", "rateDownload", "rateUpload", "seeders", "peersGettingFromUs",
"downloadDir", "error", "errorString"
]
# Transmission utilise le hash pour identifier les torrents
result = self._rpc_call("torrent-get", {
"ids": [torrent_hash],
"fields": fields
})
if not result or "torrents" not in result or not result["torrents"]:
return None
t = result["torrents"][0]
status = self.STATUS_MAP.get(t.get('status', 0), 'unknown')
if t.get('error', 0) > 0:
status = 'error'
return TorrentInfo(
hash=t.get('hashString', ''),
name=t.get('name', ''),
size=t.get('totalSize', 0),
progress=t.get('percentDone', 0),
status=status,
download_speed=t.get('rateDownload', 0),
upload_speed=t.get('rateUpload', 0),
seeds=t.get('seeders', 0) or 0,
peers=t.get('peersGettingFromUs', 0) or 0,
save_path=t.get('downloadDir', '')
)
def pause_torrent(self, torrent_hash: str) -> bool:
"""Met un torrent en pause."""
if not self._ensure_connected():
return False
result = self._rpc_call("torrent-stop", {"ids": [torrent_hash]})
return result is not None
def resume_torrent(self, torrent_hash: str) -> bool:
"""Reprend un torrent en pause."""
if not self._ensure_connected():
return False
result = self._rpc_call("torrent-start", {"ids": [torrent_hash]})
return result is not None
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
"""Supprime un torrent."""
if not self._ensure_connected():
return False
result = self._rpc_call("torrent-remove", {
"ids": [torrent_hash],
"delete-local-data": delete_files
})
return result is not None
def get_categories(self) -> List[str]:
"""
Récupère la liste des catégories.
Note: Transmission n'a pas de système de catégories natif,
on retourne une liste vide.
"""
return []
def get_version(self) -> Optional[str]:
"""Récupère la version de Transmission."""
if not self._ensure_connected():
return None
result = self._rpc_call("session-get")
if result:
return result.get('version')
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 = TransmissionPlugin