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 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