boltdown 0.1.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.
- boltdown/__init__.py +47 -0
- boltdown/_version.py +1 -0
- boltdown/aria2.py +242 -0
- boltdown/client.py +443 -0
- boltdown/exceptions.py +27 -0
- boltdown/magnet.py +90 -0
- boltdown/models.py +70 -0
- boltdown/storage.py +200 -0
- boltdown-0.1.0.dist-info/METADATA +217 -0
- boltdown-0.1.0.dist-info/RECORD +13 -0
- boltdown-0.1.0.dist-info/WHEEL +5 -0
- boltdown-0.1.0.dist-info/licenses/LICENSE +25 -0
- boltdown-0.1.0.dist-info/top_level.txt +1 -0
boltdown/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
boltdown -- Lightning-fast torrent download manager for Python.
|
|
3
|
+
|
|
4
|
+
Public API surface::
|
|
5
|
+
|
|
6
|
+
from boltdown import BoltdownClient, DownloadTask
|
|
7
|
+
from boltdown.exceptions import (
|
|
8
|
+
BoltdownError, Aria2NotFoundError, Aria2RpcError,
|
|
9
|
+
InvalidMagnetError, TaskNotFoundError, StorageError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
Basic usage::
|
|
13
|
+
|
|
14
|
+
with BoltdownClient(download_dir="./downloads") as client:
|
|
15
|
+
task = client.add_magnet("magnet:?xt=urn:btih:...")
|
|
16
|
+
print(task.status) # 'downloading'
|
|
17
|
+
|
|
18
|
+
tasks = client.list_tasks()
|
|
19
|
+
client.pause(task.id)
|
|
20
|
+
client.resume(task.id)
|
|
21
|
+
client.remove(task.id, delete_files=False)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .client import BoltdownClient
|
|
26
|
+
from .exceptions import (
|
|
27
|
+
Aria2NotFoundError,
|
|
28
|
+
Aria2RpcError,
|
|
29
|
+
BoltdownError,
|
|
30
|
+
InvalidMagnetError,
|
|
31
|
+
StorageError,
|
|
32
|
+
TaskNotFoundError,
|
|
33
|
+
)
|
|
34
|
+
from .models import DownloadTask
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"BoltdownClient",
|
|
39
|
+
"DownloadTask",
|
|
40
|
+
# Exceptions
|
|
41
|
+
"BoltdownError",
|
|
42
|
+
"Aria2NotFoundError",
|
|
43
|
+
"Aria2RpcError",
|
|
44
|
+
"InvalidMagnetError",
|
|
45
|
+
"TaskNotFoundError",
|
|
46
|
+
"StorageError",
|
|
47
|
+
]
|
boltdown/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
boltdown/aria2.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
boltdown -- aria2c process lifecycle and JSON-RPC wrapper
|
|
3
|
+
Uses only stdlib (urllib) -- no `requests` dependency.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
import urllib.error
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from .exceptions import Aria2NotFoundError, Aria2RpcError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_DEFAULT_PORT = 6800
|
|
23
|
+
_DEFAULT_SECRET = ""
|
|
24
|
+
_STARTUP_WAIT = 2.0 # seconds to wait for aria2c to bind its RPC port
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_aria2c(hint_dirs: list[str] | None = None) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Locate the aria2c binary.
|
|
30
|
+
|
|
31
|
+
Search order:
|
|
32
|
+
1. Each directory in *hint_dirs* (e.g., the caller's working directory).
|
|
33
|
+
2. The system PATH via ``shutil.which``.
|
|
34
|
+
|
|
35
|
+
Returns the resolved path string.
|
|
36
|
+
Raises :exc:`~boltdown.exceptions.Aria2NotFoundError` if not found.
|
|
37
|
+
"""
|
|
38
|
+
import shutil
|
|
39
|
+
|
|
40
|
+
candidates: list[str] = []
|
|
41
|
+
|
|
42
|
+
# 1 -- caller-supplied hints
|
|
43
|
+
for d in (hint_dirs or []):
|
|
44
|
+
for name in ("aria2c.exe", "aria2c"):
|
|
45
|
+
candidates.append(os.path.join(d, name))
|
|
46
|
+
|
|
47
|
+
# 2 -- PATH
|
|
48
|
+
for name in ("aria2c.exe", "aria2c"):
|
|
49
|
+
found = shutil.which(name)
|
|
50
|
+
if found:
|
|
51
|
+
candidates.append(found)
|
|
52
|
+
|
|
53
|
+
for path in candidates:
|
|
54
|
+
if os.path.isfile(path):
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
raise Aria2NotFoundError(
|
|
58
|
+
"aria2c binary not found. "
|
|
59
|
+
"Download from https://github.com/aria2/aria2/releases and add it to PATH "
|
|
60
|
+
"or pass aria2c_path= to BoltdownClient."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Aria2Rpc:
|
|
65
|
+
"""
|
|
66
|
+
Thin wrapper around the aria2 JSON-RPC interface.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
port : RPC listen port (default 6800).
|
|
71
|
+
secret : RPC secret token.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, port: int = _DEFAULT_PORT, secret: str = _DEFAULT_SECRET) -> None:
|
|
75
|
+
self._url = f"http://127.0.0.1:{port}/jsonrpc"
|
|
76
|
+
self._secret = secret
|
|
77
|
+
self._seq = 0
|
|
78
|
+
self._seq_lock = threading.Lock() # guard concurrent increments
|
|
79
|
+
|
|
80
|
+
# -- Low-level call ────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def call(self, method: str, params: list | None = None) -> Any:
|
|
83
|
+
"""
|
|
84
|
+
Invoke an aria2 RPC method.
|
|
85
|
+
|
|
86
|
+
Returns the ``result`` value on success.
|
|
87
|
+
Raises :exc:`~boltdown.exceptions.Aria2RpcError` on aria2-level errors.
|
|
88
|
+
Returns ``None`` on network / connection errors (aria2 may not be up yet).
|
|
89
|
+
"""
|
|
90
|
+
with self._seq_lock:
|
|
91
|
+
self._seq += 1
|
|
92
|
+
seq = self._seq
|
|
93
|
+
payload = json.dumps({
|
|
94
|
+
"jsonrpc": "2.0",
|
|
95
|
+
"id": str(seq),
|
|
96
|
+
"method": method,
|
|
97
|
+
"params": ([f"token:{self._secret}"] if self._secret else []) + (params or []),
|
|
98
|
+
}).encode()
|
|
99
|
+
|
|
100
|
+
req = urllib.request.Request(
|
|
101
|
+
self._url,
|
|
102
|
+
data=payload,
|
|
103
|
+
headers={"Content-Type": "application/json"},
|
|
104
|
+
method="POST",
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
108
|
+
body = json.loads(resp.read())
|
|
109
|
+
except urllib.error.URLError as exc:
|
|
110
|
+
logger.debug("aria2 RPC unreachable (%s): %s", method, exc)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
if "error" in body:
|
|
114
|
+
raise Aria2RpcError(
|
|
115
|
+
f"aria2 RPC error [{method}]: {body['error'].get('message', body['error'])}"
|
|
116
|
+
)
|
|
117
|
+
return body.get("result")
|
|
118
|
+
|
|
119
|
+
# -- Convenience wrappers ──────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def add_uri(self, uris: list[str]) -> Optional[str]:
|
|
122
|
+
"""Add download by URI (e.g. magnet link). Returns GID or None."""
|
|
123
|
+
return self.call("aria2.addUri", [uris])
|
|
124
|
+
|
|
125
|
+
def add_torrent(self, torrent_b64: str) -> Optional[str]:
|
|
126
|
+
"""Add .torrent file (base64-encoded). Returns GID or None."""
|
|
127
|
+
return self.call("aria2.addTorrent", [torrent_b64])
|
|
128
|
+
|
|
129
|
+
def pause(self, gid: str) -> bool:
|
|
130
|
+
result = self.call("aria2.pause", [gid])
|
|
131
|
+
return result == gid
|
|
132
|
+
|
|
133
|
+
def unpause(self, gid: str) -> bool:
|
|
134
|
+
result = self.call("aria2.unpause", [gid])
|
|
135
|
+
return result == gid
|
|
136
|
+
|
|
137
|
+
def remove(self, gid: str) -> bool:
|
|
138
|
+
result = self.call("aria2.remove", [gid])
|
|
139
|
+
return result == gid
|
|
140
|
+
|
|
141
|
+
def tell_active(self) -> list[dict]:
|
|
142
|
+
return self.call("aria2.tellActive") or []
|
|
143
|
+
|
|
144
|
+
def tell_waiting(self, offset: int = 0, num: int = 100) -> list[dict]:
|
|
145
|
+
return self.call("aria2.tellWaiting", [offset, num]) or []
|
|
146
|
+
|
|
147
|
+
def tell_stopped(self, offset: int = 0, num: int = 100) -> list[dict]:
|
|
148
|
+
return self.call("aria2.tellStopped", [offset, num]) or []
|
|
149
|
+
|
|
150
|
+
def shutdown(self) -> None:
|
|
151
|
+
self.call("aria2.shutdown")
|
|
152
|
+
|
|
153
|
+
def is_alive(self) -> bool:
|
|
154
|
+
"""Return True if the aria2 RPC server is responding."""
|
|
155
|
+
try:
|
|
156
|
+
result = self.call("aria2.getVersion")
|
|
157
|
+
return result is not None
|
|
158
|
+
except Exception:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Aria2Process:
|
|
163
|
+
"""
|
|
164
|
+
Manages the lifecycle of a locally-spawned aria2c process.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
binary : Full path to the aria2c executable.
|
|
169
|
+
download_dir : Directory where files are saved.
|
|
170
|
+
port : RPC listen port.
|
|
171
|
+
secret : RPC token.
|
|
172
|
+
extra_args : Additional command-line args forwarded to aria2c.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
binary: str,
|
|
178
|
+
download_dir: str,
|
|
179
|
+
port: int = _DEFAULT_PORT,
|
|
180
|
+
secret: str = _DEFAULT_SECRET,
|
|
181
|
+
extra_args: list[str] | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
self._binary = binary
|
|
184
|
+
self._download_dir = download_dir
|
|
185
|
+
self._port = port
|
|
186
|
+
self._secret = secret
|
|
187
|
+
self._extra_args = extra_args or []
|
|
188
|
+
self._process: Optional[subprocess.Popen] = None
|
|
189
|
+
|
|
190
|
+
def start(self) -> None:
|
|
191
|
+
"""Launch aria2c in RPC daemon mode."""
|
|
192
|
+
os.makedirs(self._download_dir, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
cmd = [
|
|
195
|
+
self._binary,
|
|
196
|
+
"--enable-rpc",
|
|
197
|
+
f"--rpc-listen-port={self._port}",
|
|
198
|
+
"--rpc-listen-all=false",
|
|
199
|
+
f"--dir={self._download_dir}",
|
|
200
|
+
"--max-connection-per-server=16",
|
|
201
|
+
"--min-split-size=1M",
|
|
202
|
+
"--split=16",
|
|
203
|
+
"--continue=true",
|
|
204
|
+
"--seed-time=0",
|
|
205
|
+
"--bt-max-peers=50",
|
|
206
|
+
"--quiet=true",
|
|
207
|
+
]
|
|
208
|
+
if self._secret:
|
|
209
|
+
cmd.append(f"--rpc-secret={self._secret}")
|
|
210
|
+
|
|
211
|
+
cmd.extend(self._extra_args)
|
|
212
|
+
|
|
213
|
+
creation_flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
214
|
+
self._process = subprocess.Popen(
|
|
215
|
+
cmd,
|
|
216
|
+
stdout=subprocess.DEVNULL,
|
|
217
|
+
stderr=subprocess.DEVNULL,
|
|
218
|
+
creationflags=creation_flags,
|
|
219
|
+
)
|
|
220
|
+
time.sleep(_STARTUP_WAIT)
|
|
221
|
+
logger.info("aria2c started (PID %s) on port %s.", self._process.pid, self._port)
|
|
222
|
+
|
|
223
|
+
def stop(self, rpc: "Aria2Rpc | None" = None) -> None:
|
|
224
|
+
"""Gracefully shut down aria2c."""
|
|
225
|
+
if rpc:
|
|
226
|
+
try:
|
|
227
|
+
rpc.shutdown()
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
if self._process and self._process.poll() is None:
|
|
231
|
+
try:
|
|
232
|
+
self._process.wait(timeout=5)
|
|
233
|
+
except subprocess.TimeoutExpired:
|
|
234
|
+
self._process.terminate()
|
|
235
|
+
logger.info("aria2c process stopped.")
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def pid(self) -> Optional[int]:
|
|
239
|
+
return self._process.pid if self._process else None
|
|
240
|
+
|
|
241
|
+
def is_running(self) -> bool:
|
|
242
|
+
return self._process is not None and self._process.poll() is None
|