Add legacy Transmission protocol support for pre-4.1.0 daemons
All checks were successful
Build and Push Docker Image / build (push) Successful in 7s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7s
This commit is contained in:
269
server.py
269
server.py
@@ -88,13 +88,43 @@ class TransmissionClient:
|
|||||||
Handles:
|
Handles:
|
||||||
- HTTP Basic authentication (optional)
|
- HTTP Basic authentication (optional)
|
||||||
- CSRF token management (X-Transmission-Session-Id)
|
- 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):
|
def __init__(self, config: InstanceConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._session_id: str | None = None
|
self._session_id: str | None = None
|
||||||
self._request_id = 0
|
self._request_id = 0
|
||||||
|
self._use_legacy_methods: bool | None = None # Auto-detected
|
||||||
|
|
||||||
# Build auth if credentials provided
|
# Build auth if credentials provided
|
||||||
auth = None
|
auth = None
|
||||||
@@ -116,31 +146,85 @@ class TransmissionClient:
|
|||||||
self._request_id += 1
|
self._request_id += 1
|
||||||
return self._request_id
|
return self._request_id
|
||||||
|
|
||||||
async def rpc(
|
def _get_method_name(self, method: str) -> str:
|
||||||
self, method: str, params: dict[str, Any] | None = None
|
"""Get the appropriate method name based on detected version."""
|
||||||
) -> dict[str, Any]:
|
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:
|
Args:
|
||||||
method: RPC method name (e.g., "torrent_get", "session_stats")
|
method: RPC method name
|
||||||
params: Method parameters
|
params: Method parameters
|
||||||
|
use_jsonrpc: Whether to use JSON-RPC 2.0 format
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The 'result' field from the response, or error details
|
Tuple of (response data, whether call succeeded)
|
||||||
|
|
||||||
Raises:
|
|
||||||
httpx.HTTPError: On network/HTTP errors after retry
|
|
||||||
"""
|
"""
|
||||||
payload = {
|
payload: dict[str, Any]
|
||||||
"jsonrpc": "2.0",
|
if use_jsonrpc:
|
||||||
"method": method,
|
payload = {
|
||||||
"id": self._next_request_id(),
|
"jsonrpc": "2.0",
|
||||||
}
|
"method": method,
|
||||||
if params:
|
"id": self._next_request_id(),
|
||||||
payload["params"] = params
|
}
|
||||||
|
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
|
response = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
headers = {}
|
headers = {}
|
||||||
@@ -154,7 +238,6 @@ class TransmissionClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 409:
|
if response.status_code == 409:
|
||||||
# CSRF token expired/missing - extract new one and retry
|
|
||||||
self._session_id = response.headers.get("X-Transmission-Session-Id")
|
self._session_id = response.headers.get("X-Transmission-Session-Id")
|
||||||
if not self._session_id:
|
if not self._session_id:
|
||||||
raise ValueError("Got 409 but no X-Transmission-Session-Id header")
|
raise ValueError("Got 409 but no X-Transmission-Session-Id header")
|
||||||
@@ -166,19 +249,147 @@ class TransmissionClient:
|
|||||||
if response is None:
|
if response is None:
|
||||||
raise RuntimeError("No response received from Transmission")
|
raise RuntimeError("No response received from Transmission")
|
||||||
|
|
||||||
data = response.json()
|
return response.json(), True
|
||||||
|
|
||||||
# Handle JSON-RPC error response
|
async def rpc(
|
||||||
if "error" in data:
|
self, method: str, params: dict[str, Any] | None = None
|
||||||
error = data["error"]
|
) -> dict[str, Any]:
|
||||||
return {
|
"""
|
||||||
"error": True,
|
Execute an RPC call to the Transmission daemon.
|
||||||
"code": error.get("code"),
|
|
||||||
"message": error.get("message"),
|
Automatically detects and handles both:
|
||||||
"data": error.get("data"),
|
- 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]:
|
async def health_check(self) -> dict[str, Any]:
|
||||||
"""Check connectivity to this instance."""
|
"""Check connectivity to this instance."""
|
||||||
|
|||||||
Reference in New Issue
Block a user