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.
- aria2_rust-0.3.0.dist-info/METADATA +13 -0
- aria2_rust-0.3.0.dist-info/RECORD +9 -0
- aria2_rust-0.3.0.dist-info/WHEEL +4 -0
- aria2_rust_client/__init__.py +33 -0
- aria2_rust_client/client.py +214 -0
- aria2_rust_client/errors.py +32 -0
- aria2_rust_client/events.py +153 -0
- aria2_rust_client/transport.py +256 -0
- aria2_rust_client/types.py +185 -0
|
@@ -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,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
|
+
)
|