From 7eede1280d2dce6be3fff31a41b74ce38b7ae604 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 30 Dec 2025 05:27:41 +0000 Subject: [PATCH] Add legacy Transmission protocol support for pre-4.1.0 daemons --- server.py | 269 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 240 insertions(+), 29 deletions(-) diff --git a/server.py b/server.py index 95dc024..667a26d 100644 --- a/server.py +++ b/server.py @@ -88,13 +88,43 @@ class TransmissionClient: Handles: - HTTP Basic authentication (optional) - CSRF token management (X-Transmission-Session-Id) - - JSON-RPC 2.0 protocol + - JSON-RPC 2.0 protocol (new) and legacy protocol (old) + - Auto-detection of Transmission version """ + # Method name mapping: new (4.1.0+) -> old (pre-4.1.0) + METHOD_ALIASES = { + "session_get": "session-get", + "session_set": "session-set", + "session_stats": "session-stats", + "session_close": "session-close", + "torrent_get": "torrent-get", + "torrent_set": "torrent-set", + "torrent_add": "torrent-add", + "torrent_remove": "torrent-remove", + "torrent_start": "torrent-start", + "torrent_start_now": "torrent-start-now", + "torrent_stop": "torrent-stop", + "torrent_verify": "torrent-verify", + "torrent_reannounce": "torrent-reannounce", + "torrent_set_location": "torrent-set-location", + "torrent_rename_path": "torrent-rename-path", + "blocklist_update": "blocklist-update", + "port_test": "port-test", + "free_space": "free-space", + "group_get": "group-get", + "group_set": "group-set", + "queue_move_top": "queue-move-top", + "queue_move_up": "queue-move-up", + "queue_move_down": "queue-move-down", + "queue_move_bottom": "queue-move-bottom", + } + def __init__(self, config: InstanceConfig): self.config = config self._session_id: str | None = None self._request_id = 0 + self._use_legacy_methods: bool | None = None # Auto-detected # Build auth if credentials provided auth = None @@ -116,31 +146,85 @@ class TransmissionClient: self._request_id += 1 return self._request_id - async def rpc( - self, method: str, params: dict[str, Any] | None = None - ) -> dict[str, Any]: + def _get_method_name(self, method: str) -> str: + """Get the appropriate method name based on detected version.""" + if self._use_legacy_methods and method in self.METHOD_ALIASES: + return self.METHOD_ALIASES[method] + return method + + def _convert_params_to_legacy( + self, params: dict[str, Any] | None + ) -> dict[str, Any] | None: + """Convert snake_case param keys to camelCase for legacy Transmission.""" + if not params or not self._use_legacy_methods: + return params + + def to_camel_case(snake_str: str) -> str: + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + converted = {} + for key, value in params.items(): + # Convert snake_case to camelCase + new_key = to_camel_case(key) + # Handle nested arrays of field names + if isinstance(value, list) and key == "fields": + value = [to_camel_case(f) if "_" in f else f for f in value] + converted[new_key] = value + return converted + + def _convert_result_from_legacy(self, result: Any) -> Any: + """Convert camelCase result keys to snake_case from legacy Transmission.""" + if not self._use_legacy_methods: + return result + + def to_snake_case(camel_str: str) -> str: + import re + + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_str) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + if isinstance(result, dict): + return { + to_snake_case(k): self._convert_result_from_legacy(v) + for k, v in result.items() + } + elif isinstance(result, list): + return [self._convert_result_from_legacy(item) for item in result] + return result + + async def _raw_rpc( + self, + method: str, + params: dict[str, Any] | None = None, + use_jsonrpc: bool = True, + ) -> tuple[dict[str, Any], bool]: """ - Execute a JSON-RPC 2.0 call to the Transmission daemon. + Execute a raw RPC call and return (data, success). Args: - method: RPC method name (e.g., "torrent_get", "session_stats") + method: RPC method name params: Method parameters + use_jsonrpc: Whether to use JSON-RPC 2.0 format Returns: - The 'result' field from the response, or error details - - Raises: - httpx.HTTPError: On network/HTTP errors after retry + Tuple of (response data, whether call succeeded) """ - payload = { - "jsonrpc": "2.0", - "method": method, - "id": self._next_request_id(), - } - if params: - payload["params"] = params + payload: dict[str, Any] + if use_jsonrpc: + payload = { + "jsonrpc": "2.0", + "method": method, + "id": self._next_request_id(), + } + if params: + payload["params"] = params + else: + # Legacy format + payload = {"method": method} + if params: + payload["arguments"] = params - # Try request, handle CSRF token refresh on 409 response = None for attempt in range(2): headers = {} @@ -154,7 +238,6 @@ class TransmissionClient: ) if response.status_code == 409: - # CSRF token expired/missing - extract new one and retry self._session_id = response.headers.get("X-Transmission-Session-Id") if not self._session_id: raise ValueError("Got 409 but no X-Transmission-Session-Id header") @@ -166,19 +249,147 @@ class TransmissionClient: if response is None: raise RuntimeError("No response received from Transmission") - data = response.json() + return response.json(), True - # Handle JSON-RPC error response - if "error" in data: - error = data["error"] - return { - "error": True, - "code": error.get("code"), - "message": error.get("message"), - "data": error.get("data"), + async def rpc( + self, method: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + """ + Execute an RPC call to the Transmission daemon. + + Automatically detects and handles both: + - JSON-RPC 2.0 with snake_case (Transmission 4.1.0+) + - Legacy protocol with camelCase (older versions) + + Args: + method: RPC method name (use snake_case, e.g., "torrent_get", "session_stats") + params: Method parameters (use snake_case keys) + + Returns: + The result from the response (converted to snake_case if legacy) + """ + # Auto-detect protocol on first call + if self._use_legacy_methods is None: + await self._detect_version() + + # Convert method name and params for legacy if needed + actual_method = self._get_method_name(method) + actual_params = self._convert_params_to_legacy(params) + + data, _ = await self._raw_rpc( + actual_method, actual_params, use_jsonrpc=not self._use_legacy_methods + ) + + # Handle response based on protocol + if self._use_legacy_methods: + # Legacy format: {"result": "success", "arguments": {...}} + if data.get("result") != "success": + return { + "error": True, + "message": data.get("result", "Unknown error"), + } + result = data.get("arguments", {}) + return self._convert_result_from_legacy(result) + else: + # JSON-RPC 2.0 format + if "error" in data: + error = data["error"] + if isinstance(error, dict): + return { + "error": True, + "code": error.get("code"), + "message": error.get("message"), + "data": error.get("data"), + } + return {"error": True, "message": str(error)} + return data.get("result", {}) + + async def _detect_version(self) -> None: + """Detect whether this is a legacy or modern Transmission daemon.""" + # Try modern JSON-RPC 2.0 first + try: + payload = { + "jsonrpc": "2.0", + "method": "session_get", + "id": self._next_request_id(), + "params": {"fields": ["version"]}, } - return data.get("result", {}) + response = None + for attempt in range(2): + headers = {} + if self._session_id: + headers["X-Transmission-Session-Id"] = self._session_id + + response = await self._client.post( + self.config.rpc_url, + json=payload, + headers=headers, + ) + + if response.status_code == 409: + self._session_id = response.headers.get("X-Transmission-Session-Id") + continue + break + + if response is not None: + data = response.json() + # Check if this looks like a successful JSON-RPC 2.0 response + if "result" in data and isinstance(data["result"], dict): + self._use_legacy_methods = False + return + # Check for method not recognized error - means we need legacy + if "error" in data: + error = data["error"] + if ( + isinstance(error, dict) + and "method name not recognized" + in str(error.get("message", "")).lower() + ): + self._use_legacy_methods = True + return + if ( + isinstance(error, str) + and "method name not recognized" in error.lower() + ): + self._use_legacy_methods = True + return + + except Exception: + pass + + # Try legacy format + try: + payload = {"method": "session-get", "arguments": {"fields": ["version"]}} + + response = None + for attempt in range(2): + headers = {} + if self._session_id: + headers["X-Transmission-Session-Id"] = self._session_id + + response = await self._client.post( + self.config.rpc_url, + json=payload, + headers=headers, + ) + + if response.status_code == 409: + self._session_id = response.headers.get("X-Transmission-Session-Id") + continue + break + + if response is not None: + data = response.json() + if data.get("result") == "success": + self._use_legacy_methods = True + return + + except Exception: + pass + + # Default to modern + self._use_legacy_methods = False async def health_check(self) -> dict[str, Any]: """Check connectivity to this instance."""