deluge-web-client 0.1.0a2__tar.gz

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.

Potentially problematic release.


This version of deluge-web-client might be problematic. Click here for more details.

@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.1
2
+ Name: deluge-web-client
3
+ Version: 0.1.0a2
4
+ Summary: Deluge Web Client
5
+ License: MIT
6
+ Author: jlw_4049
7
+ Author-email: jessielw4049@gmail.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ <div align="center">
17
+
18
+ Deluge Web API Client
19
+ ==========================
20
+
21
+ Python client implementation for Deluge Web API
22
+
23
+
24
+ </div>
25
+
26
+ Currently supports qBittorrent [v2.1.1](https://deluge-torrent.org).
27
+
28
+ User Guide and API Reference available on [Read the Docs](https://deluge-web-client.readthedocs.io).
29
+
30
+ Features
31
+ ------------
32
+ * Access to most of the Web API as well as most of the **core** methods (accessed via RPC). [Web API](https://deluge.readthedocs.io/en/deluge-2.0.1/reference/webapi.html) / [RPC API](https://deluge.readthedocs.io/en/deluge-2.0.1/reference/api.html)
33
+ * Allows you to use direct **http** connections, allowing access via **reverse proxy** or any **direct url**.
34
+
35
+ Installation
36
+ ------------
37
+ Install via pip from [PyPI](https://pypi.org/project/qbittorrent-api/)
38
+ ```bash
39
+ python -m pip install qbittorrent-api
40
+ ```
41
+
42
+ Getting Started
43
+ ---------------
44
+ ```python
45
+ import qbittorrentapi
46
+
47
+ # instantiate a Client using the appropriate WebUI configuration
48
+ conn_info = dict(
49
+ host="localhost",
50
+ port=8080,
51
+ username="admin",
52
+ password="adminadmin",
53
+ )
54
+ qbt_client = qbittorrentapi.Client(**conn_info)
55
+
56
+ # the Client will automatically acquire/maintain a logged-in state
57
+ # in line with any request. therefore, this is not strictly necessary;
58
+ # however, you may want to test the provided login credentials.
59
+ try:
60
+ qbt_client.auth_log_in()
61
+ except qbittorrentapi.LoginFailed as e:
62
+ print(e)
63
+
64
+ # if the Client will not be long-lived or many Clients may be created
65
+ # in a relatively short amount of time, be sure to log out:
66
+ qbt_client.auth_log_out()
67
+
68
+ # or use a context manager:
69
+ with qbittorrentapi.Client(**conn_info) as qbt_client:
70
+ if qbt_client.torrents_add(urls="...") != "Ok.":
71
+ raise Exception("Failed to add torrent.")
72
+
73
+ # display qBittorrent info
74
+ print(f"qBittorrent: {qbt_client.app.version}")
75
+ print(f"qBittorrent Web API: {qbt_client.app.web_api_version}")
76
+ for k, v in qbt_client.app.build_info.items():
77
+ print(f"{k}: {v}")
78
+
79
+ # retrieve and show all torrents
80
+ for torrent in qbt_client.torrents_info():
81
+ print(f"{torrent.hash[-6:]}: {torrent.name} ({torrent.state})")
82
+
83
+ # stop all torrents
84
+ qbt_client.torrents.stop.all()
85
+ ```
86
+
@@ -0,0 +1,70 @@
1
+ <div align="center">
2
+
3
+ Deluge Web API Client
4
+ ==========================
5
+
6
+ Python client implementation for Deluge Web API
7
+
8
+
9
+ </div>
10
+
11
+ Currently supports qBittorrent [v2.1.1](https://deluge-torrent.org).
12
+
13
+ User Guide and API Reference available on [Read the Docs](https://deluge-web-client.readthedocs.io).
14
+
15
+ Features
16
+ ------------
17
+ * Access to most of the Web API as well as most of the **core** methods (accessed via RPC). [Web API](https://deluge.readthedocs.io/en/deluge-2.0.1/reference/webapi.html) / [RPC API](https://deluge.readthedocs.io/en/deluge-2.0.1/reference/api.html)
18
+ * Allows you to use direct **http** connections, allowing access via **reverse proxy** or any **direct url**.
19
+
20
+ Installation
21
+ ------------
22
+ Install via pip from [PyPI](https://pypi.org/project/qbittorrent-api/)
23
+ ```bash
24
+ python -m pip install qbittorrent-api
25
+ ```
26
+
27
+ Getting Started
28
+ ---------------
29
+ ```python
30
+ import qbittorrentapi
31
+
32
+ # instantiate a Client using the appropriate WebUI configuration
33
+ conn_info = dict(
34
+ host="localhost",
35
+ port=8080,
36
+ username="admin",
37
+ password="adminadmin",
38
+ )
39
+ qbt_client = qbittorrentapi.Client(**conn_info)
40
+
41
+ # the Client will automatically acquire/maintain a logged-in state
42
+ # in line with any request. therefore, this is not strictly necessary;
43
+ # however, you may want to test the provided login credentials.
44
+ try:
45
+ qbt_client.auth_log_in()
46
+ except qbittorrentapi.LoginFailed as e:
47
+ print(e)
48
+
49
+ # if the Client will not be long-lived or many Clients may be created
50
+ # in a relatively short amount of time, be sure to log out:
51
+ qbt_client.auth_log_out()
52
+
53
+ # or use a context manager:
54
+ with qbittorrentapi.Client(**conn_info) as qbt_client:
55
+ if qbt_client.torrents_add(urls="...") != "Ok.":
56
+ raise Exception("Failed to add torrent.")
57
+
58
+ # display qBittorrent info
59
+ print(f"qBittorrent: {qbt_client.app.version}")
60
+ print(f"qBittorrent Web API: {qbt_client.app.web_api_version}")
61
+ for k, v in qbt_client.app.build_info.items():
62
+ print(f"{k}: {v}")
63
+
64
+ # retrieve and show all torrents
65
+ for torrent in qbt_client.torrents_info():
66
+ print(f"{torrent.hash[-6:]}: {torrent.name} ({torrent.state})")
67
+
68
+ # stop all torrents
69
+ qbt_client.torrents.stop.all()
70
+ ```
@@ -0,0 +1,3 @@
1
+ from .client import DelugeWebClient
2
+ from .response import Response
3
+ from .exceptions import DelugeWebClientError
@@ -0,0 +1,554 @@
1
+ import base64
2
+ import requests
3
+ from collections.abc import Iterable
4
+ from os import PathLike
5
+ from pathlib import Path
6
+ from typing import Union
7
+
8
+ from .exceptions import DelugeWebClientError
9
+ from .response import Response
10
+
11
+
12
+ class DelugeWebClient:
13
+ HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}
14
+ LOGIN_RETRIES = 3
15
+
16
+ def __init__(self, url: str, password: str) -> None:
17
+ self.session = requests.Session()
18
+ self.url = self._build_url(url)
19
+ self.password = password
20
+
21
+ def __enter__(self) -> "DelugeWebClient":
22
+ """Login and connect to client while using with statement."""
23
+ self.login()
24
+ return self
25
+
26
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
27
+ """End of with statement."""
28
+ return False
29
+
30
+ def login(self, timeout: int = 30) -> Response:
31
+ """
32
+ Log in to Web UI and connects to the first available host, retrying if needed.
33
+
34
+ Args:
35
+ timeout (int): Timeout for the login and connection attempts.
36
+
37
+ Returns:
38
+ Response: A summary response indicating the success or failure of the login and connection attempts.
39
+ """
40
+ login_response = self._attempt_login(timeout)
41
+ if not login_response.result:
42
+ return Response(result=False, error="Login failed", id=None)
43
+
44
+ # check if already connected
45
+ if self.check_connected(timeout).result:
46
+ return Response(result=True, error=None, id=None)
47
+
48
+ # attempt to connect to the first available host with retries
49
+ connection_response = self._connect_to_first_host(timeout)
50
+ if not connection_response.get(
51
+ "connected_now", Response(None, None, None)
52
+ ).result:
53
+ return Response(result=False, error="Failed to connect to host", id=None)
54
+
55
+ return Response(result=True, error=None, id=None)
56
+
57
+ def _attempt_login(self, timeout: int) -> Response:
58
+ """Attempt to log in to the Web UI"""
59
+ login_payload = {"method": "auth.login", "params": [self.password], "id": 1}
60
+ return self.execute_call(login_payload, timeout)
61
+
62
+ def _connect_to_first_host(self, timeout: int) -> dict[str, Response]:
63
+ """Connect to the first available host with retry logic"""
64
+ hosts = self.get_hosts()
65
+
66
+ if hosts.result:
67
+ for _ in range(self.LOGIN_RETRIES):
68
+ host_id = hosts.result[0][0]
69
+ connect_response = self.connect_to_host(host_id)
70
+
71
+ if connect_response.result:
72
+ return {"connected_now": self.check_connected(timeout)}
73
+
74
+ return {
75
+ "connected_now": Response(
76
+ result=False, error="All attempts failed", id=None
77
+ )
78
+ }
79
+
80
+ def disconnect(self, timeout: int = 30) -> Response:
81
+ """
82
+ Disconnects from the Web UI.
83
+ Note: This disconnects from all of your logged in instances outside of this program as well
84
+ that is tied to that user/password. Only use this IF needed not on each call.
85
+ """
86
+ payload = {"method": "web.disconnect", "params": [], "id": 1}
87
+ return self.execute_call(payload, timeout)
88
+
89
+ def upload_torrent(
90
+ self,
91
+ torrent_path: Union[PathLike[str], Path],
92
+ add_paused: bool = False,
93
+ seed_mode: bool = False,
94
+ auto_managed: bool = False,
95
+ save_directory: str = None,
96
+ label: str = None,
97
+ timeout: int = 30,
98
+ ) -> Response:
99
+ """
100
+ Opens the torrent path building out the payload as needed to
101
+ upload a single torrent to the client.
102
+
103
+ Args:
104
+ torrent_path (PathLike[str], Path): Path to torrent file (example.torrent).
105
+ save_directory (str, optional): Defined path where the file should go on the host. Defaults to None.
106
+ label (str, optional): Label to apply to uploaded torrent. Defaults to None.
107
+ timeout (int): Time to timeout.
108
+
109
+ Returns:
110
+ Response: Response object.
111
+ """
112
+ torrent_path = Path(torrent_path)
113
+ with open(torrent_path, "rb") as tf:
114
+ args = {
115
+ "add_paused": add_paused,
116
+ "seed_mode": seed_mode,
117
+ "auto_managed": auto_managed,
118
+ }
119
+ if save_directory:
120
+ args["download_location"] = str(save_directory)
121
+ params = [
122
+ str(torrent_path),
123
+ str(base64.b64encode(tf.read()), encoding="utf-8"),
124
+ args,
125
+ ]
126
+ payload = {"method": "core.add_torrent_file", "params": params, "id": 1}
127
+ return self._upload_helper(payload, label, timeout)
128
+
129
+ def upload_torrents(
130
+ self,
131
+ torrents: Iterable[Union[PathLike[str], Path]],
132
+ save_directory: str = None,
133
+ label: str = None,
134
+ timeout: int = 30,
135
+ ) -> dict[str, Response]:
136
+ """
137
+ Uploads multiple torrents.
138
+
139
+ Args:
140
+ torrents (Iterable[Union[PathLike[str], Path]]): A list or other iterable of torrent file paths.
141
+ save_directory (str, optional): Defined path where the file should go on the host. Defaults to None.
142
+ label (str, optional): Label to apply to uploaded torrents. Defaults to None.
143
+ timeout (int): Time to timeout.
144
+
145
+ Returns:
146
+ dict[str, Response]: A dictionary of torrent name and Response objects for each torrent.
147
+ """
148
+ results = {}
149
+ for torrent_path in torrents:
150
+ torrent_path = Path(torrent_path)
151
+ try:
152
+ response = self.upload_torrent(
153
+ torrent_path, save_directory, label, timeout
154
+ )
155
+ results[torrent_path.stem] = response
156
+ except Exception as e:
157
+ raise DelugeWebClientError(
158
+ f"Failed to upload {torrent_path.name}:\n{e}"
159
+ )
160
+
161
+ return results
162
+
163
+ def add_torrent_magnet(
164
+ self,
165
+ uri: str,
166
+ add_paused: bool = False,
167
+ seed_mode: bool = False,
168
+ auto_managed: bool = False,
169
+ save_directory: str = None,
170
+ label: str = None,
171
+ timeout: int = 30,
172
+ ):
173
+ """Adds a torrent from a magnet link.
174
+
175
+ Args:
176
+ uri (str): Magnet input
177
+ save_directory (str, optional): Defined path where the file should go on the host. Defaults to None.
178
+ label (str, optional): Label to apply to uploaded torrent. Defaults to None.
179
+ timeout (int): Time to timeout.
180
+
181
+ Returns:
182
+ Response: Response object.
183
+ """
184
+ args = {
185
+ "add_paused": add_paused,
186
+ "seed_mode": seed_mode,
187
+ "auto_managed": auto_managed,
188
+ }
189
+ if save_directory:
190
+ args["download_location"] = str(save_directory)
191
+ payload = {
192
+ "method": "core.add_torrent_magnet",
193
+ "params": [str(uri), args],
194
+ "id": 1,
195
+ }
196
+ return self._upload_helper(payload, label, timeout)
197
+
198
+ def add_torrent_url(
199
+ self,
200
+ url: str,
201
+ add_paused: bool = False,
202
+ seed_mode: bool = False,
203
+ auto_managed: bool = False,
204
+ save_directory: str = None,
205
+ label: str = None,
206
+ timeout: int = 30,
207
+ ):
208
+ """Adds a torrent from a URL.
209
+
210
+ Args:
211
+ url (str): URL input
212
+ save_directory (str, optional): Defined path where the file should go on the host. Defaults to None.
213
+ label (str, optional): Label to apply to uploaded torrent. Defaults to None.
214
+ timeout (int): Time to timeout.
215
+
216
+ Returns:
217
+ Response: Response object.
218
+ """
219
+ args = {
220
+ "add_paused": add_paused,
221
+ "seed_mode": seed_mode,
222
+ "auto_managed": auto_managed,
223
+ }
224
+ if save_directory:
225
+ args["download_location"] = str(save_directory)
226
+ payload = {
227
+ "method": "core.add_torrent_url",
228
+ "params": [str(url), args],
229
+ "id": 1,
230
+ }
231
+ return self._upload_helper(payload, label, timeout)
232
+
233
+ def _upload_helper(self, payload: dict, label: str, timeout: int) -> Response:
234
+ with self.session.post(
235
+ self.url, headers=self.HEADERS, json=payload, timeout=timeout
236
+ ) as response:
237
+ if response.ok:
238
+ result = response.json()
239
+ info_hash = str(result["result"])
240
+ if label:
241
+ self._apply_label(info_hash, str(label), timeout)
242
+ self.resume_torrent(info_hash, timeout)
243
+ return Response(result=info_hash, error=None, id=1)
244
+ else:
245
+ response.raise_for_status()
246
+
247
+ def _apply_label(
248
+ self, info_hash: str, label: str, timeout: int
249
+ ) -> tuple[bool, bool]:
250
+ """
251
+ Used internally to add and apply labels as needed for
252
+ the `upload_torrent` method
253
+
254
+ Args:
255
+ info_hash (str): Info has of torrent.
256
+ label (str): Label to apply it to (automatically set to lowercase internally).
257
+ timeout (int): Time to timeout.
258
+
259
+ Returns:
260
+ tuple (bool, bool): add_label(), set_label().
261
+ """
262
+ add_label = self.add_label(label, timeout)
263
+ set_label = self.set_label(info_hash, label, timeout)
264
+ return add_label, set_label
265
+
266
+ def get_free_space(self, path: PathLike[str] = None, timeout: int = 30) -> Response:
267
+ """Gets free space"""
268
+ payload = {
269
+ "method": "core.get_free_space",
270
+ "params": [str(path)] if path else [],
271
+ "id": 1,
272
+ }
273
+ return self.execute_call(payload, timeout)
274
+
275
+ def get_path_size(self, path: PathLike[str] = None, timeout: int = 30) -> Response:
276
+ """
277
+ Gets path size.
278
+
279
+ Returns the size of the file or folder `path` and `-1` if the path is
280
+ unaccessible (non-existent or insufficient privs)
281
+ """
282
+ payload = {
283
+ "method": "core.get_path_size",
284
+ "params": [str(path)] if path else [],
285
+ "id": 1,
286
+ }
287
+ return self.execute_call(payload, timeout)
288
+
289
+ def get_labels(self, timeout: int = 30) -> Response:
290
+ """Gets defined labels"""
291
+ payload = {"method": "label.get_labels", "params": [], "id": 1}
292
+ return self.execute_call(payload, timeout)
293
+
294
+ def set_label(self, info_hash: str, label: str, timeout: int = 30) -> Response:
295
+ """Sets the label for a specific torrent"""
296
+ payload = {
297
+ "method": "label.set_torrent",
298
+ "params": [info_hash, label.lower()],
299
+ "id": 1,
300
+ }
301
+ return self.execute_call(payload, timeout)
302
+
303
+ def add_label(self, label: str, timeout: int = 30) -> Response:
304
+ """Adds a label to the client, ignoring labels if they already exist"""
305
+ payload = {
306
+ "method": "label.add",
307
+ "params": [label.lower()],
308
+ "id": 1,
309
+ }
310
+ response = self.execute_call(payload, handle_error=False, timeout=timeout)
311
+ if response.error != None:
312
+ if "Label already exists" not in response.error.get("message"):
313
+ raise DelugeWebClientError(f"Error adding label:\n{response.error}")
314
+ return response
315
+
316
+ def get_libtorrent_version(self, timeout: int = 30) -> Response:
317
+ """Gets libtorrent version"""
318
+ payload = {
319
+ "method": "core.get_libtorrent_version",
320
+ "params": [],
321
+ "id": 1,
322
+ }
323
+ return self.execute_call(payload, timeout)
324
+
325
+ def get_listen_port(self, timeout: int = 30) -> Response:
326
+ """Gets listen port"""
327
+ payload = {
328
+ "method": "core.get_listen_port",
329
+ "params": [],
330
+ "id": 1,
331
+ }
332
+ return self.execute_call(payload, timeout)
333
+
334
+ def get_plugins(self, timeout: int = 30) -> Response:
335
+ """Gets plugins"""
336
+ payload = {"method": "web.get_plugins", "params": [], "id": 1}
337
+ return self.execute_call(payload, timeout)
338
+
339
+ def get_torrent_files(self, torrent_id: str, timeout: int = 30) -> Response:
340
+ """Gets the files for a torrent in tree format"""
341
+ payload = {"method": "web.get_torrent_files", "params": [torrent_id], "id": 1}
342
+ return self.execute_call(payload, timeout)
343
+
344
+ def get_torrent_status(
345
+ self,
346
+ torrent_id: str,
347
+ keys: list[str] = [],
348
+ diff: bool = False,
349
+ timeout: int = 30,
350
+ ) -> Response:
351
+ """
352
+ Gets the status for a torrent.
353
+
354
+ Args:
355
+ torrent_id (str): Torrent hash of for a single torrent.
356
+ keys (dict): Filtering criteria for torrents.
357
+ diff (bool): Whether to return the status difference.
358
+ timeout (int): Time to timeout for the call.
359
+
360
+ Returns:
361
+ Response: The response from the API call.
362
+ """
363
+ payload = {
364
+ "method": "core.get_torrent_status",
365
+ "params": [torrent_id, keys, diff],
366
+ "id": 1,
367
+ }
368
+ return self.execute_call(payload, timeout)
369
+
370
+ def get_torrents_status(
371
+ self,
372
+ filter_dict: dict = {},
373
+ keys: list[str] = [],
374
+ diff: bool = False,
375
+ timeout: int = 30,
376
+ ) -> Response:
377
+ """
378
+ Gets the status for multiple torrents, returns all torrents,
379
+ optionally filtered by filter_dict
380
+
381
+ Args:
382
+ filter_dict (dict): Filtering criteria for torrents.
383
+ keys (list[str]): List of specific torrent IDs to fetch status for.
384
+ diff (bool): Whether to return the status difference.
385
+ timeout (int): Time to timeout for the call.
386
+
387
+ Returns:
388
+ Response: The response from the API call.
389
+ """
390
+ payload = {
391
+ "method": "core.get_torrents_status",
392
+ "params": [filter_dict, keys, diff],
393
+ "id": 1,
394
+ }
395
+ return self.execute_call(payload, timeout)
396
+
397
+ def check_connected(self, timeout: int = 30) -> Response:
398
+ """
399
+ Use the `web.connected` method to get a boolean response if the Web UI is
400
+ connected to a deluged host
401
+ """
402
+ payload = {"method": "web.connected", "params": [], "id": 1}
403
+ return self.execute_call(payload, timeout)
404
+
405
+ def get_hosts(self, timeout: int = 30) -> Response:
406
+ """Returns hosts we're connected to currently"""
407
+ payload = {"method": "web.get_hosts", "params": [], "id": 1}
408
+ return self.execute_call(payload, timeout)
409
+
410
+ def get_host_status(self, host_id: str, timeout: int = 30) -> Response:
411
+ """Get the deluged host status `<hostID>`"""
412
+ payload = {"method": "web.get_host_status", "params": [host_id], "id": 1}
413
+ return self.execute_call(payload, timeout)
414
+
415
+ def connect_to_host(self, host_id: str, timeout: int = 30) -> Response:
416
+ """To connect to deluged with `<hostID>`"""
417
+ payload = {"method": "web.connect", "params": [host_id], "id": 1}
418
+ return self.execute_call(payload, timeout)
419
+
420
+ def test_listen_port(self, timeout: int = 30) -> bool:
421
+ """Checks if the active port is open
422
+
423
+ Returns:
424
+ bool: If active port is opened or closed
425
+ """
426
+ payload = {"method": "core.test_listen_port", "params": [], "id": 1}
427
+ return self.execute_call(payload, timeout).result
428
+
429
+ def pause_torrent(self, torrent_id: str, timeout: int = 30) -> Response:
430
+ """Pause a specific torrent"""
431
+ payload = {
432
+ "method": "core.pause_torrent",
433
+ "params": [torrent_id],
434
+ "id": 1,
435
+ }
436
+ return self.execute_call(payload, timeout)
437
+
438
+ def pause_torrents(self, torrent_ids: list, timeout: int = 30) -> Response:
439
+ """Pause a list of torrents"""
440
+ if torrent_ids:
441
+ payload = {
442
+ "method": "core.pause_torrents",
443
+ "params": [torrent_ids],
444
+ "id": 1,
445
+ }
446
+ return self.execute_call(payload, timeout)
447
+
448
+ def remove_torrent(self, torrent_id: str, timeout: int = 30) -> Response:
449
+ """Removes a specific torrent"""
450
+ payload = {
451
+ "method": "core.remove_torrent",
452
+ "params": [torrent_id],
453
+ "id": 1,
454
+ }
455
+ return self.execute_call(payload, timeout)
456
+
457
+ def remove_torrents(self, torrent_ids: list, timeout: int = 30) -> Response:
458
+ """Removes a list of torrents"""
459
+ if torrent_ids:
460
+ payload = {
461
+ "method": "core.remove_torrents",
462
+ "params": [torrent_ids],
463
+ "id": 1,
464
+ }
465
+ return self.execute_call(payload, timeout)
466
+
467
+ def resume_torrent(self, torrent_id: str, timeout: int = 30) -> Response:
468
+ """Resumes a specific torrent"""
469
+ payload = {
470
+ "method": "core.resume_torrent",
471
+ "params": [torrent_id],
472
+ "id": 1,
473
+ }
474
+ return self.execute_call(payload, timeout)
475
+
476
+ def resume_torrents(self, torrent_ids: list, timeout: int = 30) -> Response:
477
+ """Resumes a list of torrents"""
478
+ if torrent_ids:
479
+ payload = {
480
+ "method": "core.resume_torrents",
481
+ "params": [torrent_ids],
482
+ "id": 1,
483
+ }
484
+ return self.execute_call(payload, timeout)
485
+
486
+ def set_torrent_trackers(
487
+ self, torrent_id: str, trackers: list[dict[str, any]], timeout: int = 30
488
+ ) -> Response:
489
+ """Sets a torrents tracker list. trackers will be ``[{"url", "tier"}]``"""
490
+ if torrent_id:
491
+ payload = {
492
+ "method": "core.set_torrent_trackers",
493
+ "params": [torrent_id, trackers],
494
+ "id": 1,
495
+ }
496
+ return self.execute_call(payload, timeout)
497
+
498
+ def execute_call(
499
+ self, payload: dict, handle_error: bool = True, timeout: int = 30
500
+ ) -> Response:
501
+ """
502
+ Helper method to execute most calls to the Web UI as needed.
503
+
504
+ Args:
505
+ payload (dict): Payload object to be called.
506
+ handle_error (bool, optional): Handle errors here or allow the caller to handle
507
+ the error. Defaults to True.
508
+ timeout (int): Time to timeout.
509
+
510
+ Returns:
511
+ Response: Response object for each call.
512
+ """
513
+ with self.session.post(
514
+ self.url, headers=self.HEADERS, json=payload, timeout=timeout
515
+ ) as response:
516
+ if response.ok:
517
+ response_json = response.json()
518
+ data = Response(
519
+ result=response_json.get("result"),
520
+ error=self._normalize_exception(response_json.get("error")),
521
+ id=response_json.get("id"),
522
+ )
523
+ if handle_error and data.error:
524
+ raise DelugeWebClientError(
525
+ f"Payload: {payload}, Error: {data.error}"
526
+ )
527
+ return data
528
+ else:
529
+ raise DelugeWebClientError(
530
+ f"Failed to execute call. Response code: {response.status_code}. Reason: {response.reason}"
531
+ )
532
+
533
+ @staticmethod
534
+ def _normalize_exception(exc_str: any) -> Union[str, None]:
535
+ """
536
+ Removes the un-needed ending square bracket and stripping extra white
537
+ space if input is a string
538
+ """
539
+ if exc_str:
540
+ if isinstance(exc_str, str):
541
+ return exc_str.rstrip("]").strip()
542
+ else:
543
+ return exc_str
544
+
545
+ @staticmethod
546
+ def _build_url(url: str) -> str:
547
+ """Automatically fixes urls as needed to access the json api endpoint"""
548
+ if not url.endswith("/"):
549
+ url += "/"
550
+
551
+ if "json" not in url:
552
+ url += "json/"
553
+
554
+ return url.rstrip("/")
@@ -0,0 +1,2 @@
1
+ class DelugeWebClientError(Exception):
2
+ pass
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+ from typing import Union
3
+
4
+
5
+ @dataclass
6
+ class Response:
7
+ __slots__ = ("result", "error", "id")
8
+ result: Union[bool, str, list]
9
+ error: Union[None, str]
10
+ id: int
@@ -0,0 +1,22 @@
1
+ [tool.poetry]
2
+ name = "deluge-web-client"
3
+ version = "0.1.0-alpha2"
4
+ description = "Deluge Web Client"
5
+ authors = ["jlw_4049 <jessielw4049@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.11"
11
+ requests = "^2.32.3"
12
+
13
+
14
+ [tool.poetry.group.dev.dependencies]
15
+ black = "^24.8.0"
16
+ sphinx = "7.4.7"
17
+ sphinx-autodoc-typehints = "2.3.0"
18
+ sphinx-rtd-theme = "2.0.0"
19
+
20
+ [build-system]
21
+ requires = ["poetry-core"]
22
+ build-backend = "poetry.core.masonry.api"