Initial commit
This commit is contained in:
154
app/plugins/torrent_clients/README.md
Normal file
154
app/plugins/torrent_clients/README.md
Normal 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)
|
||||
```
|
||||
262
app/plugins/torrent_clients/__init__.py
Normal file
262
app/plugins/torrent_clients/__init__.py
Normal 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()
|
||||
219
app/plugins/torrent_clients/base.py
Normal file
219
app/plugins/torrent_clients/base.py
Normal 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)
|
||||
}
|
||||
484
app/plugins/torrent_clients/qbittorrent.py
Normal file
484
app/plugins/torrent_clients/qbittorrent.py
Normal 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
|
||||
427
app/plugins/torrent_clients/transmission.py
Normal file
427
app/plugins/torrent_clients/transmission.py
Normal 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
|
||||
Reference in New Issue
Block a user