"""Transmission RPC adapter. Auth model: Transmission uses HTTP Basic auth + an ``X-Transmission-Session-Id`` header. The session ID rotates and is returned on every 409 response — the adapter caches the latest value and replays the original request transparently. Reference: https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md """ from __future__ import annotations import asyncio import base64 import threading from typing import List, Optional import requests as http_requests from config.settings import config_manager from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.transmission") # Transmission RPC status codes. Defined as numeric constants in the # RPC spec — converted to the adapter-uniform string set here. _TRANSMISSION_STATUS = { 0: "paused", 1: "queued", # queued to check files 2: "queued", # checking files 3: "queued", # queued to download 4: "downloading", 5: "queued", # queued to seed 6: "seeding", } def _map_state(status_code: int, percent_done: float) -> str: base = _TRANSMISSION_STATUS.get(status_code, "error") # Transmission reports 'paused' (0) for both never-started and # fully-downloaded-but-not-seeding. Differentiate by progress. if status_code == 0 and percent_done >= 1.0: return "completed" return base class TransmissionAdapter: """Transmission RPC adapter (transmission-rpc protocol v17+).""" DEFAULT_TIMEOUT = 15 def __init__(self) -> None: self._session_id: Optional[str] = None self._session_id_lock = threading.Lock() self._load_config() def _load_config(self) -> None: url = normalize_client_url(config_manager.get('torrent_client.url', '')) # Transmission's RPC endpoint is always /transmission/rpc — if the # user pasted a bare host URL, append it. If they pasted the full # /transmission/rpc URL, leave it. if url and not url.endswith('/transmission/rpc'): if '/transmission' not in url: url = f"{url}/transmission/rpc" self._url = url self._username = config_manager.get('torrent_client.username', '') or '' self._password = config_manager.get('torrent_client.password', '') or '' self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync' self._save_path = config_manager.get('torrent_client.save_path', '') or '' with self._session_id_lock: self._session_id = None def reload_settings(self) -> None: self._load_config() def is_configured(self) -> bool: return bool(self._url) async def check_connection(self) -> bool: if not self.is_configured(): return False loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._check_connection_sync) def _check_connection_sync(self) -> bool: resp = self._rpc('session-get', {}) return resp is not None def _rpc(self, method: str, arguments: dict) -> Optional[dict]: """One Transmission RPC round-trip. Handles the 409 session-id renegotiation transparently — Transmission rejects the first call with HTTP 409 and a fresh ``X-Transmission-Session-Id`` header, which subsequent calls must echo back.""" if not self._url: return None auth = (self._username, self._password) if self._username else None payload = {'method': method, 'arguments': arguments} for _attempt in range(2): try: with self._session_id_lock: sid = self._session_id headers = {'Content-Type': 'application/json'} if sid: headers['X-Transmission-Session-Id'] = sid resp = http_requests.post( self._url, json=payload, headers=headers, auth=auth, timeout=self.DEFAULT_TIMEOUT, ) if resp.status_code == 409: # Pick up the new session id and retry. new_sid = resp.headers.get('X-Transmission-Session-Id') if not new_sid: logger.error("Transmission 409 with no X-Transmission-Session-Id header") return None with self._session_id_lock: self._session_id = new_sid continue if not resp.ok: logger.warning("Transmission RPC %s returned HTTP %s", method, resp.status_code) return None data = resp.json() if data.get('result') != 'success': logger.warning("Transmission RPC %s result=%s", method, data.get('result')) return None return data.get('arguments', {}) except Exception as e: logger.error("Transmission RPC %s failed: %s", method, e) return None return None async def add_torrent( self, url_or_magnet: str, category: str = "soulsync", save_path: Optional[str] = None, ) -> Optional[str]: loop = asyncio.get_event_loop() return await loop.run_in_executor( None, self._add_torrent_sync, url_or_magnet, category, save_path ) def _add_torrent_sync( self, url_or_magnet: str, category: str, save_path: Optional[str], ) -> Optional[str]: args: dict = {'filename': url_or_magnet, 'labels': [category or self._category]} if save_path or self._save_path: args['download-dir'] = save_path or self._save_path return self._add_torrent_finish(args) async def add_torrent_file( self, file_bytes: bytes, category: str = "soulsync", save_path: Optional[str] = None, ) -> Optional[str]: loop = asyncio.get_event_loop() return await loop.run_in_executor( None, self._add_torrent_file_sync, file_bytes, category, save_path ) def _add_torrent_file_sync( self, file_bytes: bytes, category: str, save_path: Optional[str], ) -> Optional[str]: args: dict = { 'metainfo': base64.b64encode(file_bytes).decode('ascii'), 'labels': [category or self._category], } if save_path or self._save_path: args['download-dir'] = save_path or self._save_path return self._add_torrent_finish(args) def _add_torrent_finish(self, args: dict) -> Optional[str]: data = self._rpc('torrent-add', args) if not data: return None # torrent-add returns either ``torrent-added`` (new torrent) or # ``torrent-duplicate`` (already exists). Both carry the hash. torrent = data.get('torrent-added') or data.get('torrent-duplicate') if not torrent: return None return str(torrent.get('hashString') or '') or None async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._get_status_sync, torrent_id) def _get_status_sync(self, torrent_id: str) -> Optional[TorrentStatus]: data = self._rpc('torrent-get', { 'ids': [torrent_id], 'fields': self._STATUS_FIELDS, }) if not data or not data.get('torrents'): return None return self._parse_status(data['torrents'][0]) async def get_all(self) -> List[TorrentStatus]: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._get_all_sync) def _get_all_sync(self) -> List[TorrentStatus]: data = self._rpc('torrent-get', {'fields': self._STATUS_FIELDS}) if not data: return [] return [self._parse_status(t) for t in data.get('torrents', [])] _STATUS_FIELDS = [ 'hashString', 'name', 'status', 'percentDone', 'totalSize', 'downloadedEver', 'rateDownload', 'rateUpload', 'peersSendingToUs', 'peersGettingFromUs', 'eta', 'downloadDir', 'errorString', ] def _parse_status(self, item: dict) -> TorrentStatus: progress = float(item.get('percentDone') or 0.0) status_code = int(item.get('status') or 0) eta_raw = item.get('eta') # Transmission returns -1 / -2 for "unknown" — surface as None. eta = eta_raw if isinstance(eta_raw, int) and eta_raw > 0 else None return TorrentStatus( id=str(item.get('hashString') or ''), name=item.get('name') or '', state=_map_state(status_code, progress), progress=progress, size=int(item.get('totalSize') or 0), downloaded=int(item.get('downloadedEver') or 0), download_speed=int(item.get('rateDownload') or 0), upload_speed=int(item.get('rateUpload') or 0), seeders=int(item.get('peersSendingToUs') or 0), peers=int(item.get('peersGettingFromUs') or 0), eta=eta, save_path=item.get('downloadDir'), error=item.get('errorString') or None, ) async def remove(self, torrent_id: str, delete_files: bool = False) -> bool: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._remove_sync, torrent_id, delete_files) def _remove_sync(self, torrent_id: str, delete_files: bool) -> bool: data = self._rpc('torrent-remove', { 'ids': [torrent_id], 'delete-local-data': bool(delete_files), }) return data is not None async def pause(self, torrent_id: str) -> bool: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._pause_sync, torrent_id) def _pause_sync(self, torrent_id: str) -> bool: return self._rpc('torrent-stop', {'ids': [torrent_id]}) is not None async def resume(self, torrent_id: str) -> bool: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._resume_sync, torrent_id) def _resume_sync(self, torrent_id: str) -> bool: return self._rpc('torrent-start', {'ids': [torrent_id]}) is not None