Aria2-rust 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: Aria2_rust
3
+ Version: 0.3.0
4
+ Summary: Python SDK for aria2-rust JSON-RPC interface
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: httpx>=0.25
7
+ Requires-Dist: typing-extensions>=4.0
8
+ Requires-Dist: websockets>=12.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
11
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
12
+ Requires-Dist: pytest>=7.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.20; extra == 'dev'
@@ -0,0 +1,9 @@
1
+ aria2_rust_client/__init__.py,sha256=JZF-ocVz8JulvKYCdwy93xa0kosx9FqT_5MxL5XAKzo,639
2
+ aria2_rust_client/client.py,sha256=j8THCJcBkhuC0aqo98mpqSra8O8P4veFKBKotNm8PW8,7815
3
+ aria2_rust_client/errors.py,sha256=Fs5CLfLb1sQHMMvBzkQp5oPxowyhP54-mZD2y7P8D7o,836
4
+ aria2_rust_client/events.py,sha256=YUUL9NCyCgNu57kqBCQJo3i-lrWgqdfufLnUOU1zJPk,4765
5
+ aria2_rust_client/transport.py,sha256=4JIswNEr2hkRqyFppTOQzzm-93gSOUosRoOnuwpBWB4,9070
6
+ aria2_rust_client/types.py,sha256=bjZUhRVtIVjm-iQPfiVOCDnlO1Bf6brFobLprpjDFQs,5916
7
+ aria2_rust-0.3.0.dist-info/METADATA,sha256=cgvGIdVDOvOl9Jwj4EhUIhUK1Cb_lFKL2VBmPkBUa8A,433
8
+ aria2_rust-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ aria2_rust-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,33 @@
1
+ from .client import Aria2Client
2
+ from .errors import Aria2Error, AuthError, ConnectionError, RpcError, TimeoutError
3
+ from .events import EventSubscriber
4
+ from .types import (
5
+ DownloadEvent,
6
+ DownloadStatus,
7
+ EventType,
8
+ FileInfo,
9
+ GlobalStat,
10
+ SessionInfo,
11
+ StatusInfo,
12
+ UriEntry,
13
+ VersionInfo,
14
+ )
15
+
16
+ __all__ = [
17
+ "Aria2Client",
18
+ "Aria2Error",
19
+ "AuthError",
20
+ "ConnectionError",
21
+ "RpcError",
22
+ "TimeoutError",
23
+ "EventSubscriber",
24
+ "DownloadEvent",
25
+ "DownloadStatus",
26
+ "EventType",
27
+ "FileInfo",
28
+ "GlobalStat",
29
+ "SessionInfo",
30
+ "StatusInfo",
31
+ "UriEntry",
32
+ "VersionInfo",
33
+ ]
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from typing_extensions import Self
7
+
8
+ from .errors import Aria2Error
9
+ from .events import EventSubscriber
10
+ from .transport import HttpTransport, Transport, WebSocketTransport
11
+ from .types import (
12
+ EventType,
13
+ GlobalStat,
14
+ SessionInfo,
15
+ StatusInfo,
16
+ VersionInfo,
17
+ )
18
+
19
+
20
+ def _http_to_ws(url: str) -> str:
21
+ if url.startswith("https://"):
22
+ return "wss://" + url[8:]
23
+ if url.startswith("http://"):
24
+ return "ws://" + url[7:]
25
+ return url
26
+
27
+
28
+ class Aria2Client:
29
+ def __init__(
30
+ self,
31
+ url: str = "http://localhost:6800/jsonrpc",
32
+ token: Optional[str] = None,
33
+ timeout: float = 30.0,
34
+ ) -> None:
35
+ self._url = url
36
+ self._token = token
37
+ self._timeout = timeout
38
+ self._transport: Transport
39
+
40
+ if url.startswith("ws://") or url.startswith("wss://"):
41
+ self._transport = WebSocketTransport(url, token, timeout)
42
+ else:
43
+ self._transport = HttpTransport(url, token, timeout)
44
+
45
+ async def __aenter__(self) -> Self:
46
+ return self
47
+
48
+ async def __aexit__(self, *args: Any) -> None:
49
+ await self.close()
50
+
51
+ async def _call(self, method: str, params: Optional[list] = None) -> Any:
52
+ return await self._transport.send_request(method, params or [])
53
+
54
+ async def add_uri(
55
+ self, uris: List[str], options: Optional[Dict] = None
56
+ ) -> str:
57
+ params: list = [uris]
58
+ if options is not None:
59
+ params.append(options)
60
+ result = await self._call("aria2.addUri", params)
61
+ return str(result)
62
+
63
+ async def add_torrent(
64
+ self, torrent: bytes, options: Optional[Dict] = None
65
+ ) -> str:
66
+ encoded = base64.b64encode(torrent).decode("ascii")
67
+ params: list = [encoded]
68
+ if options is not None:
69
+ params.append(options)
70
+ result = await self._call("aria2.addTorrent", params)
71
+ return str(result)
72
+
73
+ async def add_metalink(
74
+ self, metalink: bytes, options: Optional[Dict] = None
75
+ ) -> str:
76
+ encoded = base64.b64encode(metalink).decode("ascii")
77
+ params: list = [encoded]
78
+ if options is not None:
79
+ params.append(options)
80
+ result = await self._call("aria2.addMetalink", params)
81
+ return str(result)
82
+
83
+ async def remove(self, gid: str) -> str:
84
+ result = await self._call("aria2.remove", [gid])
85
+ return str(result)
86
+
87
+ async def pause(self, gid: str) -> str:
88
+ result = await self._call("aria2.pause", [gid])
89
+ return str(result)
90
+
91
+ async def unpause(self, gid: str) -> str:
92
+ result = await self._call("aria2.unpause", [gid])
93
+ return str(result)
94
+
95
+ async def force_pause(self, gid: str) -> str:
96
+ result = await self._call("aria2.forcePause", [gid])
97
+ return str(result)
98
+
99
+ async def force_remove(self, gid: str) -> str:
100
+ result = await self._call("aria2.forceRemove", [gid])
101
+ return str(result)
102
+
103
+ async def force_unpause(self, gid: str) -> str:
104
+ result = await self._call("aria2.forceUnpause", [gid])
105
+ return str(result)
106
+
107
+ async def tell_status(
108
+ self, gid: str, keys: Optional[List[str]] = None
109
+ ) -> StatusInfo:
110
+ params: list = [gid]
111
+ if keys is not None:
112
+ params.append(keys)
113
+ result = await self._call("aria2.tellStatus", params)
114
+ if isinstance(result, dict):
115
+ return StatusInfo.from_dict(result)
116
+ raise Aria2Error(f"Unexpected result type for tellStatus: {type(result)}")
117
+
118
+ async def tell_active(
119
+ self, keys: Optional[List[str]] = None
120
+ ) -> List[StatusInfo]:
121
+ params: list = []
122
+ if keys is not None:
123
+ params.append(keys)
124
+ result = await self._call("aria2.tellActive", params)
125
+ if isinstance(result, list):
126
+ return [StatusInfo.from_dict(item) for item in result if isinstance(item, dict)]
127
+ raise Aria2Error(f"Unexpected result type for tellActive: {type(result)}")
128
+
129
+ async def tell_waiting(
130
+ self, offset: int, num: int, keys: Optional[List[str]] = None
131
+ ) -> List[StatusInfo]:
132
+ params: list = [offset, num]
133
+ if keys is not None:
134
+ params.append(keys)
135
+ result = await self._call("aria2.tellWaiting", params)
136
+ if isinstance(result, list):
137
+ return [StatusInfo.from_dict(item) for item in result if isinstance(item, dict)]
138
+ raise Aria2Error(f"Unexpected result type for tellWaiting: {type(result)}")
139
+
140
+ async def tell_stopped(
141
+ self, offset: int, num: int, keys: Optional[List[str]] = None
142
+ ) -> List[StatusInfo]:
143
+ params: list = [offset, num]
144
+ if keys is not None:
145
+ params.append(keys)
146
+ result = await self._call("aria2.tellStopped", params)
147
+ if isinstance(result, list):
148
+ return [StatusInfo.from_dict(item) for item in result if isinstance(item, dict)]
149
+ raise Aria2Error(f"Unexpected result type for tellStopped: {type(result)}")
150
+
151
+ async def get_global_stat(self) -> GlobalStat:
152
+ result = await self._call("aria2.getGlobalStat")
153
+ if isinstance(result, dict):
154
+ return GlobalStat.from_dict(result)
155
+ raise Aria2Error(f"Unexpected result type for getGlobalStat: {type(result)}")
156
+
157
+ async def purge_download_result(self) -> str:
158
+ result = await self._call("aria2.purgeDownloadResult")
159
+ return str(result) if result is not None else "OK"
160
+
161
+ async def remove_download_result(self, gid: str) -> str:
162
+ result = await self._call("aria2.removeDownloadResult", [gid])
163
+ return str(result) if result is not None else "OK"
164
+
165
+ async def get_global_option(self) -> Dict:
166
+ result = await self._call("aria2.getGlobalOption")
167
+ return result if isinstance(result, dict) else {}
168
+
169
+ async def change_global_option(self, options: Dict) -> str:
170
+ result = await self._call("aria2.changeGlobalOption", [options])
171
+ return str(result) if result is not None else "OK"
172
+
173
+ async def get_option(self, gid: str) -> Dict:
174
+ result = await self._call("aria2.getOption", [gid])
175
+ return result if isinstance(result, dict) else {}
176
+
177
+ async def change_option(self, gid: str, options: Dict) -> str:
178
+ result = await self._call("aria2.changeOption", [gid, options])
179
+ return str(result) if result is not None else "OK"
180
+
181
+ async def get_version(self) -> VersionInfo:
182
+ result = await self._call("aria2.getVersion")
183
+ if isinstance(result, dict):
184
+ return VersionInfo.from_dict(result)
185
+ raise Aria2Error(f"Unexpected result type for getVersion: {type(result)}")
186
+
187
+ async def get_session_info(self) -> SessionInfo:
188
+ result = await self._call("aria2.getSessionInfo")
189
+ if isinstance(result, dict):
190
+ return SessionInfo.from_dict(result)
191
+ raise Aria2Error(f"Unexpected result type for getSessionInfo: {type(result)}")
192
+
193
+ async def shutdown(self) -> str:
194
+ result = await self._call("aria2.shutdown")
195
+ return str(result) if result is not None else "OK"
196
+
197
+ async def force_shutdown(self) -> str:
198
+ result = await self._call("aria2.forceShutdown")
199
+ return str(result) if result is not None else "OK"
200
+
201
+ async def save_session(self) -> str:
202
+ result = await self._call("aria2.saveSession")
203
+ return str(result) if result is not None else "OK"
204
+
205
+ async def subscribe_events(
206
+ self, filter: Optional[List[EventType]] = None
207
+ ) -> EventSubscriber:
208
+ ws_url = _http_to_ws(self._url)
209
+ subscriber = EventSubscriber(ws_url, self._token, filter)
210
+ await subscriber.start()
211
+ return subscriber
212
+
213
+ async def close(self) -> None:
214
+ await self._transport.close()
@@ -0,0 +1,32 @@
1
+ class Aria2Error(Exception):
2
+ code: int = -1
3
+
4
+ def __init__(self, message: str, code: int = -1) -> None:
5
+ self.code = code
6
+ super().__init__(message)
7
+
8
+
9
+ class ConnectionError(Aria2Error):
10
+ code: int = -2
11
+
12
+ def __init__(self, message: str = "Connection error", code: int = -2) -> None:
13
+ super().__init__(message, code)
14
+
15
+
16
+ class AuthError(Aria2Error):
17
+ code: int = -3
18
+
19
+ def __init__(self, message: str = "Authentication error", code: int = -3) -> None:
20
+ super().__init__(message, code)
21
+
22
+
23
+ class RpcError(Aria2Error):
24
+ def __init__(self, message: str, code: int = -1) -> None:
25
+ super().__init__(message, code)
26
+
27
+
28
+ class TimeoutError(Aria2Error):
29
+ code: int = -4
30
+
31
+ def __init__(self, message: str = "Request timeout", code: int = -4) -> None:
32
+ super().__init__(message, code)
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import List, Optional
6
+
7
+ try:
8
+ from typing import Self
9
+ except ImportError:
10
+ from typing_extensions import Self
11
+
12
+ from .errors import ConnectionError, TimeoutError
13
+ from .types import DownloadEvent, EventType
14
+
15
+ _EVENT_METHOD_MAP: dict[str, EventType] = {
16
+ "aria2.onDownloadStart": EventType.DOWNLOAD_START,
17
+ "aria2.onDownloadPause": EventType.DOWNLOAD_PAUSE,
18
+ "aria2.onDownloadStop": EventType.DOWNLOAD_STOP,
19
+ "aria2.onDownloadComplete": EventType.DOWNLOAD_COMPLETE,
20
+ "aria2.onDownloadError": EventType.DOWNLOAD_ERROR,
21
+ "aria2.onBtDownloadComplete": EventType.BT_DOWNLOAD_COMPLETE,
22
+ }
23
+
24
+
25
+ class EventSubscriber:
26
+ def __init__(
27
+ self,
28
+ ws_url: str,
29
+ token: Optional[str] = None,
30
+ filter: Optional[List[EventType]] = None,
31
+ ) -> None:
32
+ self._ws_url = ws_url
33
+ self._token = token
34
+ self._filter = set(filter) if filter is not None else None
35
+ self._ws: Any = None
36
+ self._listener_task: Optional[asyncio.Task] = None
37
+ self._queue: asyncio.Queue[Optional[DownloadEvent]] = asyncio.Queue()
38
+ self._closed = False
39
+ self._reconnect_attempts = 0
40
+ self._max_reconnect_attempts = 5
41
+
42
+ def _should_include(self, event: DownloadEvent) -> bool:
43
+ if self._filter is None:
44
+ return True
45
+ return event.event_type in self._filter
46
+
47
+ async def _connect(self) -> None:
48
+ try:
49
+ import websockets
50
+
51
+ self._ws = await websockets.connect(self._ws_url)
52
+ self._reconnect_attempts = 0
53
+ except Exception as exc:
54
+ raise ConnectionError(f"Failed to connect WebSocket: {exc}") from exc
55
+
56
+ async def _listen(self) -> None:
57
+ while not self._closed:
58
+ if self._ws is None:
59
+ try:
60
+ await self._connect()
61
+ except ConnectionError:
62
+ if not await self._try_reconnect():
63
+ break
64
+ continue
65
+
66
+ try:
67
+ async for raw_message in self._ws:
68
+ try:
69
+ message = json.loads(raw_message)
70
+ except (json.JSONDecodeError, TypeError):
71
+ continue
72
+
73
+ method = message.get("method", "")
74
+ if not method.startswith("aria2.on"):
75
+ continue
76
+
77
+ params = message.get("params", [{}])
78
+ event_params = params[0] if isinstance(params, list) and params else {}
79
+ if not isinstance(event_params, dict):
80
+ event_params = {}
81
+
82
+ event = DownloadEvent.from_rpc_notification(method, event_params)
83
+ if self._should_include(event):
84
+ await self._queue.put(event)
85
+ except asyncio.CancelledError:
86
+ break
87
+ except Exception:
88
+ if self._closed:
89
+ break
90
+ if not await self._try_reconnect():
91
+ break
92
+
93
+ await self._queue.put(None)
94
+
95
+ async def _try_reconnect(self) -> bool:
96
+ if self._closed:
97
+ return False
98
+ if self._reconnect_attempts >= self._max_reconnect_attempts:
99
+ return False
100
+
101
+ backoff = min(2**self._reconnect_attempts, 16)
102
+ self._reconnect_attempts += 1
103
+
104
+ try:
105
+ await asyncio.sleep(backoff)
106
+ except asyncio.CancelledError:
107
+ return False
108
+
109
+ try:
110
+ await self._connect()
111
+ return True
112
+ except ConnectionError:
113
+ return await self._try_reconnect()
114
+
115
+ async def start(self) -> None:
116
+ await self._connect()
117
+ self._listener_task = asyncio.create_task(self._listen())
118
+
119
+ def __aiter__(self) -> Self:
120
+ return self
121
+
122
+ async def __anext__(self) -> DownloadEvent:
123
+ if self._closed:
124
+ raise StopAsyncIteration
125
+
126
+ event = await self._queue.get()
127
+ if event is None:
128
+ raise StopAsyncIteration
129
+ return event
130
+
131
+ async def close(self) -> None:
132
+ self._closed = True
133
+
134
+ if self._listener_task is not None:
135
+ self._listener_task.cancel()
136
+ try:
137
+ await self._listener_task
138
+ except asyncio.CancelledError:
139
+ pass
140
+ self._listener_task = None
141
+
142
+ if self._ws is not None:
143
+ try:
144
+ await self._ws.close()
145
+ except Exception:
146
+ pass
147
+ self._ws = None
148
+
149
+ while not self._queue.empty():
150
+ try:
151
+ self._queue.get_nowait()
152
+ except asyncio.QueueEmpty:
153
+ break
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
6
+
7
+ import httpx
8
+
9
+ from .errors import AuthError, ConnectionError, RpcError, TimeoutError
10
+
11
+
12
+ def _is_auth_rpc_error(code: Any, message: str) -> bool:
13
+ """Recognize auth failures without confusing aria2 domain code 1 errors."""
14
+ if code == -32001:
15
+ return True
16
+
17
+ normalized = message.casefold()
18
+ return any(
19
+ marker in normalized
20
+ for marker in (
21
+ "unauthorized",
22
+ "auth fail",
23
+ "authentication",
24
+ "authorization",
25
+ "invalid token",
26
+ "token required",
27
+ )
28
+ )
29
+
30
+
31
+ @runtime_checkable
32
+ class Transport(Protocol):
33
+ async def send_request(self, method: str, params: list) -> Any:
34
+ ...
35
+
36
+ async def close(self) -> None:
37
+ ...
38
+
39
+
40
+ class HttpTransport:
41
+ def __init__(
42
+ self,
43
+ url: str,
44
+ token: Optional[str] = None,
45
+ timeout: float = 30.0,
46
+ ) -> None:
47
+ self._url = url
48
+ self._token = token
49
+ self._timeout = timeout
50
+ self._id_counter = 0
51
+ self._client = httpx.AsyncClient(timeout=timeout)
52
+
53
+ def _next_id(self) -> int:
54
+ self._id_counter += 1
55
+ return self._id_counter
56
+
57
+ def _build_request(self, method: str, params: list) -> Dict[str, Any]:
58
+ request_params: list = []
59
+ if self._token is not None:
60
+ request_params.append(f"token:{self._token}")
61
+ request_params.extend(params)
62
+ return {
63
+ "jsonrpc": "2.0",
64
+ "id": self._next_id(),
65
+ "method": method,
66
+ "params": request_params,
67
+ }
68
+
69
+ async def send_request(self, method: str, params: list) -> Any:
70
+ payload = self._build_request(method, params)
71
+ try:
72
+ response = await self._client.post(self._url, json=payload)
73
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
74
+ raise ConnectionError(f"Connection error: {exc}") from exc
75
+ except httpx.TimeoutException as exc:
76
+ raise TimeoutError(f"Request timed out: {exc}") from exc
77
+
78
+ # aria2 returns a JSON-RPC error body with HTTP 400 for execution
79
+ # failures. Decode the body before treating the HTTP status as a
80
+ # transport failure, just like the Node.js adapter.
81
+ try:
82
+ data = response.json()
83
+ except (TypeError, ValueError) as exc:
84
+ try:
85
+ response.raise_for_status()
86
+ except httpx.HTTPStatusError as status_error:
87
+ if status_error.response.status_code in (401, 403):
88
+ raise AuthError(f"Authentication failed: {status_error}") from status_error
89
+ raise ConnectionError(
90
+ f"HTTP error {status_error.response.status_code}: {status_error}"
91
+ ) from status_error
92
+ raise ConnectionError("Invalid JSON response") from exc
93
+
94
+ if "error" in data:
95
+ err = data["error"]
96
+ err_msg = err.get("message", "Unknown RPC error")
97
+ err_code = err.get("code", -1)
98
+ if _is_auth_rpc_error(err_code, err_msg):
99
+ raise AuthError(err_msg, err_code)
100
+ raise RpcError(err_msg, err_code)
101
+
102
+ try:
103
+ response.raise_for_status()
104
+ except httpx.HTTPStatusError as exc:
105
+ if exc.response.status_code in (401, 403):
106
+ raise AuthError(f"Authentication failed: {exc}") from exc
107
+ raise ConnectionError(f"HTTP error {exc.response.status_code}: {exc}") from exc
108
+ except httpx.HTTPError as exc:
109
+ raise ConnectionError(f"Connection error: {exc}") from exc
110
+
111
+ return data.get("result")
112
+
113
+ async def close(self) -> None:
114
+ await self._client.aclose()
115
+
116
+
117
+ class WebSocketTransport:
118
+ def __init__(
119
+ self,
120
+ url: str,
121
+ token: Optional[str] = None,
122
+ timeout: float = 30.0,
123
+ ) -> None:
124
+ self._url = url
125
+ self._token = token
126
+ self._timeout = timeout
127
+ self._id_counter = 0
128
+ self._ws: Any = None
129
+ self._pending: Dict[int, asyncio.Future] = {}
130
+ self._listener_task: Optional[asyncio.Task] = None
131
+ self._event_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None
132
+ self._connected = False
133
+
134
+ def _next_id(self) -> int:
135
+ self._id_counter += 1
136
+ return self._id_counter
137
+
138
+ def set_event_callback(
139
+ self, callback: Callable[[str, Dict[str, Any]], None]
140
+ ) -> None:
141
+ self._event_callback = callback
142
+
143
+ async def _ensure_connected(self) -> None:
144
+ if self._connected and self._ws is not None:
145
+ return
146
+ try:
147
+ import websockets
148
+
149
+ self._ws = await websockets.connect(
150
+ self._url, open_timeout=self._timeout
151
+ )
152
+ self._connected = True
153
+ self._listener_task = asyncio.create_task(self._listen())
154
+ except asyncio.TimeoutError as exc:
155
+ raise TimeoutError(f"WebSocket connection timed out: {exc}") from exc
156
+ except Exception as exc:
157
+ raise ConnectionError(f"WebSocket connection failed: {exc}") from exc
158
+
159
+ async def _listen(self) -> None:
160
+ try:
161
+ async for raw_message in self._ws:
162
+ try:
163
+ message = json.loads(raw_message)
164
+ except (json.JSONDecodeError, TypeError):
165
+ continue
166
+
167
+ if "method" in message and message["method"].startswith("aria2.on"):
168
+ if self._event_callback is not None:
169
+ params = message.get("params", [{}])
170
+ event_params = params[0] if isinstance(params, list) and params else {}
171
+ if not isinstance(event_params, dict):
172
+ event_params = {}
173
+ try:
174
+ self._event_callback(message["method"], event_params)
175
+ except Exception:
176
+ pass
177
+ continue
178
+
179
+ msg_id = message.get("id")
180
+ if msg_id is not None and msg_id in self._pending:
181
+ future = self._pending.pop(msg_id)
182
+ if not future.done():
183
+ if "error" in message:
184
+ future.set_exception(
185
+ RpcError(
186
+ message["error"].get("message", "Unknown RPC error"),
187
+ message["error"].get("code", -1),
188
+ )
189
+ )
190
+ else:
191
+ future.set_result(message.get("result"))
192
+ except asyncio.CancelledError:
193
+ pass
194
+ except Exception:
195
+ for future in self._pending.values():
196
+ if not future.done():
197
+ future.set_exception(
198
+ ConnectionError("WebSocket connection lost")
199
+ )
200
+ self._pending.clear()
201
+ finally:
202
+ self._connected = False
203
+
204
+ def _build_request(self, method: str, params: list) -> Dict[str, Any]:
205
+ request_params: list = []
206
+ if self._token is not None:
207
+ request_params.append(f"token:{self._token}")
208
+ request_params.extend(params)
209
+ return {
210
+ "jsonrpc": "2.0",
211
+ "id": self._next_id(),
212
+ "method": method,
213
+ "params": request_params,
214
+ }
215
+
216
+ async def send_request(self, method: str, params: list) -> Any:
217
+ await self._ensure_connected()
218
+ payload = self._build_request(method, params)
219
+ request_id = payload["id"]
220
+ loop = asyncio.get_running_loop()
221
+ future: asyncio.Future = loop.create_future()
222
+ self._pending[request_id] = future
223
+
224
+ try:
225
+ await self._ws.send(json.dumps(payload))
226
+ except Exception as exc:
227
+ self._pending.pop(request_id, None)
228
+ raise ConnectionError(f"Failed to send WebSocket message: {exc}") from exc
229
+
230
+ try:
231
+ return await asyncio.wait_for(future, timeout=self._timeout)
232
+ except asyncio.TimeoutError:
233
+ self._pending.pop(request_id, None)
234
+ raise TimeoutError(f"Request timed out after {self._timeout}s")
235
+
236
+ async def close(self) -> None:
237
+ if self._listener_task is not None:
238
+ self._listener_task.cancel()
239
+ try:
240
+ await self._listener_task
241
+ except asyncio.CancelledError:
242
+ pass
243
+ self._listener_task = None
244
+
245
+ if self._ws is not None:
246
+ try:
247
+ await self._ws.close()
248
+ except Exception:
249
+ pass
250
+ self._ws = None
251
+
252
+ for future in self._pending.values():
253
+ if not future.done():
254
+ future.set_exception(ConnectionError("Transport closed"))
255
+ self._pending.clear()
256
+ self._connected = False
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ def _camel_to_snake(name: str) -> str:
10
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
11
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
12
+
13
+
14
+ def _convert_keys(data: Dict[str, Any]) -> Dict[str, Any]:
15
+ return {_camel_to_snake(k): v for k, v in data.items()}
16
+
17
+
18
+ class EventType(str, Enum):
19
+ DOWNLOAD_START = "DOWNLOAD_START"
20
+ DOWNLOAD_PAUSE = "DOWNLOAD_PAUSE"
21
+ DOWNLOAD_STOP = "DOWNLOAD_STOP"
22
+ DOWNLOAD_COMPLETE = "DOWNLOAD_COMPLETE"
23
+ DOWNLOAD_ERROR = "DOWNLOAD_ERROR"
24
+ BT_DOWNLOAD_COMPLETE = "BT_DOWNLOAD_COMPLETE"
25
+
26
+
27
+ class DownloadStatus(str, Enum):
28
+ ACTIVE = "active"
29
+ WAITING = "waiting"
30
+ PAUSED = "paused"
31
+ ERROR = "error"
32
+ COMPLETE = "complete"
33
+ REMOVED = "removed"
34
+
35
+
36
+ _EVENT_METHOD_MAP: Dict[str, EventType] = {
37
+ "aria2.onDownloadStart": EventType.DOWNLOAD_START,
38
+ "aria2.onDownloadPause": EventType.DOWNLOAD_PAUSE,
39
+ "aria2.onDownloadStop": EventType.DOWNLOAD_STOP,
40
+ "aria2.onDownloadComplete": EventType.DOWNLOAD_COMPLETE,
41
+ "aria2.onDownloadError": EventType.DOWNLOAD_ERROR,
42
+ "aria2.onBtDownloadComplete": EventType.BT_DOWNLOAD_COMPLETE,
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class UriEntry:
48
+ uri: Optional[str] = None
49
+ status: Optional[str] = None
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: Dict[str, Any]) -> UriEntry:
53
+ converted = _convert_keys(data)
54
+ return cls(
55
+ uri=converted.get("uri"),
56
+ status=converted.get("status"),
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class FileInfo:
62
+ index: Optional[str] = None
63
+ path: Optional[str] = None
64
+ length: Optional[str] = None
65
+ completed_length: Optional[str] = None
66
+ selected: Optional[str] = None
67
+ uris: List[UriEntry] = field(default_factory=list)
68
+
69
+ @classmethod
70
+ def from_dict(cls, data: Dict[str, Any]) -> FileInfo:
71
+ converted = _convert_keys(data)
72
+ uris_data = converted.get("uris") or []
73
+ uris = [UriEntry.from_dict(u) if isinstance(u, dict) else u for u in uris_data]
74
+ return cls(
75
+ index=converted.get("index"),
76
+ path=converted.get("path"),
77
+ length=converted.get("length"),
78
+ completed_length=converted.get("completed_length"),
79
+ selected=converted.get("selected"),
80
+ uris=uris,
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class StatusInfo:
86
+ gid: Optional[str] = None
87
+ total_length: Optional[str] = None
88
+ completed_length: Optional[str] = None
89
+ upload_length: Optional[str] = None
90
+ download_speed: Optional[str] = None
91
+ upload_speed: Optional[str] = None
92
+ error_code: Optional[str] = None
93
+ status: Optional[str] = None
94
+ dir: Optional[str] = None
95
+ files: List[FileInfo] = field(default_factory=list)
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: Dict[str, Any]) -> StatusInfo:
99
+ converted = _convert_keys(data)
100
+ files_data = converted.get("files") or []
101
+ files = [FileInfo.from_dict(f) if isinstance(f, dict) else f for f in files_data]
102
+ return cls(
103
+ gid=converted.get("gid"),
104
+ total_length=converted.get("total_length"),
105
+ completed_length=converted.get("completed_length"),
106
+ upload_length=converted.get("upload_length"),
107
+ download_speed=converted.get("download_speed"),
108
+ upload_speed=converted.get("upload_speed"),
109
+ error_code=converted.get("error_code"),
110
+ status=converted.get("status"),
111
+ dir=converted.get("dir"),
112
+ files=files,
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class GlobalStat:
118
+ download_speed: Optional[str] = None
119
+ upload_speed: Optional[str] = None
120
+ num_active: Optional[str] = None
121
+ num_waiting: Optional[str] = None
122
+ num_stopped: Optional[str] = None
123
+ num_stopped_total: Optional[str] = None
124
+
125
+ @classmethod
126
+ def from_dict(cls, data: Dict[str, Any]) -> GlobalStat:
127
+ converted = _convert_keys(data)
128
+ return cls(
129
+ download_speed=converted.get("download_speed"),
130
+ upload_speed=converted.get("upload_speed"),
131
+ num_active=converted.get("num_active"),
132
+ num_waiting=converted.get("num_waiting"),
133
+ num_stopped=converted.get("num_stopped"),
134
+ num_stopped_total=converted.get("num_stopped_total"),
135
+ )
136
+
137
+
138
+ @dataclass
139
+ class VersionInfo:
140
+ version: Optional[str] = None
141
+ enabled_features: List[str] = field(default_factory=list)
142
+
143
+ @classmethod
144
+ def from_dict(cls, data: Dict[str, Any]) -> VersionInfo:
145
+ converted = _convert_keys(data)
146
+ features = converted.get("enabled_features") or []
147
+ return cls(
148
+ version=converted.get("version"),
149
+ enabled_features=list(features) if isinstance(features, (list, tuple)) else [],
150
+ )
151
+
152
+
153
+ @dataclass
154
+ class SessionInfo:
155
+ session_id: Optional[str] = None
156
+
157
+ @classmethod
158
+ def from_dict(cls, data: Dict[str, Any]) -> SessionInfo:
159
+ converted = _convert_keys(data)
160
+ return cls(session_id=converted.get("session_id"))
161
+
162
+
163
+ @dataclass
164
+ class DownloadEvent:
165
+ event_type: EventType
166
+ gid: Optional[str] = None
167
+ error_code: Optional[str] = None
168
+ files: Optional[List[FileInfo]] = None
169
+
170
+ @classmethod
171
+ def from_rpc_notification(cls, method: str, params: Dict[str, Any]) -> DownloadEvent:
172
+ event_type = _EVENT_METHOD_MAP.get(method)
173
+ if event_type is None:
174
+ event_type = EventType.DOWNLOAD_START
175
+ converted = _convert_keys(params)
176
+ files_data = converted.get("files")
177
+ files = None
178
+ if files_data and isinstance(files_data, list):
179
+ files = [FileInfo.from_dict(f) if isinstance(f, dict) else f for f in files_data]
180
+ return cls(
181
+ event_type=event_type,
182
+ gid=converted.get("gid"),
183
+ error_code=converted.get("error_code"),
184
+ files=files,
185
+ )