unit3dprep 1.0.6__tar.gz → 1.1.1__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.
Files changed (59) hide show
  1. {unit3dprep-1.0.6/unit3dprep.egg-info → unit3dprep-1.1.1}/PKG-INFO +2 -2
  2. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/README.md +1 -1
  3. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/pyproject.toml +1 -1
  4. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/i18n.py +3 -3
  5. unit3dprep-1.1.1/unit3dprep/web/api/reseed.py +142 -0
  6. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/search.py +0 -6
  7. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/app.py +2 -0
  8. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/clients.py +122 -2
  9. unit3dprep-1.1.1/unit3dprep/web/dist/assets/index-BkzkykEO.js +255 -0
  10. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/dist/index.html +1 -1
  11. unit3dprep-1.1.1/unit3dprep/web/reseed.py +537 -0
  12. {unit3dprep-1.0.6 → unit3dprep-1.1.1/unit3dprep.egg-info}/PKG-INFO +2 -2
  13. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep.egg-info/SOURCES.txt +3 -1
  14. unit3dprep-1.0.6/unit3dprep/web/dist/assets/index-DwUQS7yk.js +0 -255
  15. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/LICENSE +0 -0
  16. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/MANIFEST.in +0 -0
  17. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/setup.cfg +0 -0
  18. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/__init__.py +0 -0
  19. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/cli.py +0 -0
  20. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/core.py +0 -0
  21. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/media.py +0 -0
  22. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/upload.py +0 -0
  23. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/__init__.py +0 -0
  24. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/_env.py +0 -0
  25. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/__init__.py +0 -0
  26. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/auth.py +0 -0
  27. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/fs.py +0 -0
  28. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/library.py +0 -0
  29. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/logs.py +0 -0
  30. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/queue.py +0 -0
  31. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/quickupload.py +0 -0
  32. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/settings.py +0 -0
  33. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/tmdb.py +0 -0
  34. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/trackers.py +0 -0
  35. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/uploaded.py +0 -0
  36. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/version.py +0 -0
  37. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/webup.py +0 -0
  38. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/api/wizard.py +0 -0
  39. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/auth.py +0 -0
  40. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/config.py +0 -0
  41. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/db.py +0 -0
  42. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  43. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  44. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  45. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
  46. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/duplicate_check.py +0 -0
  47. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/lang_cache.py +0 -0
  48. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/logbuf.py +0 -0
  49. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/tmdb_cache.py +0 -0
  50. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/trackers.py +0 -0
  51. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/webup_client.py +0 -0
  52. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/webup_job_fix.py +0 -0
  53. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/webup_logclass.py +0 -0
  54. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/webup_orchestrator.py +0 -0
  55. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep/web/webup_ws.py +0 -0
  56. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep.egg-info/dependency_links.txt +0 -0
  57. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep.egg-info/entry_points.txt +0 -0
  58. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep.egg-info/requires.txt +0 -0
  59. {unit3dprep-1.0.6 → unit3dprep-1.1.1}/unit3dprep.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unit3dprep
3
- Version: 1.0.6
3
+ Version: 1.1.1
4
4
  Summary: Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)
5
5
  Author: Davide Sidoti
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -723,7 +723,7 @@ Dynamic: license-file
723
723
  ![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222222?style=for-the-badge&logo=github%20Pages&logoColor=white)
724
724
 
725
725
  [![PyPI](https://img.shields.io/pypi/v/unit3dprep?style=for-the-badge&logo=pypi&logoColor=white&label=PyPI)](https://pypi.org/project/unit3dprep/)
726
- [![Docker Hub](https://img.shields.io/docker/v/hashdeveloper512/unit3dprep?style=for-the-badge&logo=docker&logoColor=white&label=Docker%20Hub&sort=semver)](https://hub.docker.com/r/hashdeveloper512/unit3dprep)
726
+ [![Docker Hub](https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/hashdeveloper512/unit3dprep)
727
727
 
728
728
  Web UI + CLI di pre-flight per tracker Unit3D, accoppiata via HTTP a [`Unit3DWebUp`](https://pypi.org/project/Unit3DwebUp/) come backend di upload.
729
729
  Verifica tracce audio italiane, rinomina secondo la [nomenclatura ItaTorrents](docs/nomenclatura.md), crea hardlink in `~/seedings/` e orchestra il flusso `setenv → scan → maketorrent → upload → seed` su `Unit3DWebUp` con log live via WebSocket/SSE.
@@ -9,7 +9,7 @@
9
9
  ![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222222?style=for-the-badge&logo=github%20Pages&logoColor=white)
10
10
 
11
11
  [![PyPI](https://img.shields.io/pypi/v/unit3dprep?style=for-the-badge&logo=pypi&logoColor=white&label=PyPI)](https://pypi.org/project/unit3dprep/)
12
- [![Docker Hub](https://img.shields.io/docker/v/hashdeveloper512/unit3dprep?style=for-the-badge&logo=docker&logoColor=white&label=Docker%20Hub&sort=semver)](https://hub.docker.com/r/hashdeveloper512/unit3dprep)
12
+ [![Docker Hub](https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/hashdeveloper512/unit3dprep)
13
13
 
14
14
  Web UI + CLI di pre-flight per tracker Unit3D, accoppiata via HTTP a [`Unit3DWebUp`](https://pypi.org/project/Unit3DwebUp/) come backend di upload.
15
15
  Verifica tracce audio italiane, rinomina secondo la [nomenclatura ItaTorrents](docs/nomenclatura.md), crea hardlink in `~/seedings/` e orchestra il flusso `setenv → scan → maketorrent → upload → seed` su `Unit3DWebUp` con log live via WebSocket/SSE.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unit3dprep"
7
- version = "1.0.6"
7
+ version = "1.1.1"
8
8
  description = "Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -68,9 +68,9 @@ CATALOG: dict[str, dict[str, str]] = {
68
68
  "it": "Tracker sconosciuto '{tracker}'",
69
69
  "en": "Unknown tracker '{tracker}'",
70
70
  },
71
- "err.reseed_not_implemented": {
72
- "it": "Reseed per tracker id non implementato",
73
- "en": "Reseed by tracker id not implemented",
71
+ "err.reseed_session_expired": {
72
+ "it": "Sessione reseed non trovata o scaduta",
73
+ "en": "Reseed session not found or expired",
74
74
  },
75
75
  "err.record_not_found": {
76
76
  "it": "Record non trovato",
@@ -0,0 +1,142 @@
1
+ """Reseed endpoints: discover 0-seed ITT torrents already on disk and re-seed.
2
+
3
+ - `GET /api/reseed/scan` — SSE, batched candidate discovery (library↔ITT)
4
+ - `GET /api/reseed/suggest` — torrent meta + size-matched local files (manual)
5
+ - `POST /api/reseed/start` — create a reseed session, returns a token
6
+ - `GET /api/reseed/{tok}/run`— SSE, run the reseed (download → qBit → hardlink → recheck)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import secrets
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, AsyncGenerator
16
+
17
+ from fastapi import APIRouter, HTTPException, Request
18
+ from fastapi.responses import JSONResponse
19
+ from pydantic import BaseModel
20
+ from sse_starlette.sse import EventSourceResponse
21
+
22
+ from ...i18n import get_request_lang, t as _i18n_t
23
+ from ...media import media_root, seedings_root
24
+ from .. import config as web_config
25
+ from ..logbuf import emit as log_emit
26
+ from ..reseed import perform_reseed, stream_reseed_candidates, suggest_local_files
27
+
28
+ router = APIRouter(prefix="/api", tags=["reseed"])
29
+
30
+ _sessions: dict[str, dict[str, Any]] = {}
31
+ _created: dict[str, float] = {}
32
+ _TTL = 3600
33
+
34
+
35
+ def _cleanup() -> None:
36
+ now = time.time()
37
+ for tok in [t for t, ct in _created.items() if now - ct > _TTL]:
38
+ _sessions.pop(tok, None)
39
+ _created.pop(tok, None)
40
+
41
+
42
+ def _create(state: dict[str, Any]) -> str:
43
+ _cleanup()
44
+ tok = secrets.token_urlsafe(24)
45
+ _sessions[tok] = state
46
+ _created[tok] = time.time()
47
+ return tok
48
+
49
+
50
+ def _validate_source(p: str, lang: str | None = None) -> Path:
51
+ resolved = Path(p).resolve()
52
+ allowed = [media_root().resolve(), seedings_root().resolve()]
53
+ if not any(str(resolved).startswith(str(a)) for a in allowed):
54
+ raise HTTPException(403, _i18n_t("err.path_outside", lang))
55
+ if not resolved.exists():
56
+ raise HTTPException(404, _i18n_t("err.path_not_found_at", lang, path=str(resolved)))
57
+ return resolved
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Discovery
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @router.get("/reseed/scan")
66
+ async def reseed_scan(category: str, offset: int = 0, limit: int = 20, max_seeders: int = 0):
67
+ cfg = web_config.load()
68
+ safe_limit = max(1, min(int(limit or 20), 100))
69
+ safe_offset = max(0, int(offset or 0))
70
+ safe_max_seeders = max(0, min(int(max_seeders or 0), 100))
71
+
72
+ async def generate() -> AsyncGenerator[dict, None]:
73
+ async for kind, data in stream_reseed_candidates(
74
+ cfg, category, offset=safe_offset, limit=safe_limit,
75
+ max_seeders=safe_max_seeders,
76
+ ):
77
+ yield {"event": kind, "data": json.dumps(data)}
78
+
79
+ return EventSourceResponse(generate())
80
+
81
+
82
+ @router.get("/reseed/suggest")
83
+ async def reseed_suggest(torrent_id: int):
84
+ cfg = web_config.load()
85
+ return JSONResponse(await suggest_local_files(cfg, torrent_id))
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Reseed run
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ class StartBody(BaseModel):
94
+ tracker: str = "ITT"
95
+ torrent_id: int
96
+ source_path: str
97
+ category: str = ""
98
+ kind: str = ""
99
+ title: str = ""
100
+
101
+
102
+ @router.post("/reseed/start")
103
+ async def reseed_start(request: Request, body: StartBody):
104
+ lang = get_request_lang(request)
105
+ src = _validate_source(body.source_path, lang)
106
+ state = {
107
+ "tracker": (body.tracker or "ITT").upper(),
108
+ "torrent_id": int(body.torrent_id),
109
+ "source_path": str(src),
110
+ "category": body.category,
111
+ "kind": body.kind,
112
+ "title": body.title,
113
+ }
114
+ tok = _create(state)
115
+ return JSONResponse({"token": tok})
116
+
117
+
118
+ @router.get("/reseed/{tok}/run")
119
+ async def reseed_run(tok: str, request: Request):
120
+ lang = get_request_lang(request)
121
+ state = _sessions.get(tok)
122
+ if state is None:
123
+ raise HTTPException(404, _i18n_t("err.reseed_session_expired", lang))
124
+ cfg = web_config.load()
125
+
126
+ async def generate() -> AsyncGenerator[dict, None]:
127
+ async for ev in perform_reseed(
128
+ cfg,
129
+ tracker=state["tracker"],
130
+ torrent_id=state["torrent_id"],
131
+ source_path=state["source_path"],
132
+ category=state.get("category", ""),
133
+ kind=state.get("kind", ""),
134
+ title=state.get("title", ""),
135
+ ):
136
+ if ev["event"] == "log":
137
+ log_emit("info", ev["data"], "reseed", source="reseed")
138
+ elif ev["event"] == "error":
139
+ log_emit("error", ev["data"], "reseed", source="reseed")
140
+ yield ev
141
+
142
+ return EventSourceResponse(generate())
@@ -32,9 +32,3 @@ async def search(request: Request, q: str, tracker: str = "ITT"):
32
32
  "tracker": tracker.upper(),
33
33
  "results": [r.to_dict() for r in results],
34
34
  })
35
-
36
-
37
- @router.post("/reseed/{tracker}/{torrent_id}")
38
- async def reseed(request: Request, tracker: str, torrent_id: int):
39
- # Placeholder: trigger a unit3dup reseed run for a known torrent id.
40
- raise HTTPException(501, t("err.reseed_not_implemented", get_request_lang(request)))
@@ -23,6 +23,7 @@ from .api import (
23
23
  logs as logs_api,
24
24
  quickupload as quickupload_api,
25
25
  queue as queue_api,
26
+ reseed as reseed_api,
26
27
  search as search_api,
27
28
  settings as settings_api,
28
29
  tmdb as tmdb_api,
@@ -124,6 +125,7 @@ for r in (
124
125
  logs_api.router,
125
126
  queue_api.router,
126
127
  quickupload_api.router,
128
+ reseed_api.router,
127
129
  search_api.router,
128
130
  settings_api.router,
129
131
  tmdb_api.router,
@@ -40,6 +40,33 @@ class TorrentClient(ABC):
40
40
  @abstractmethod
41
41
  async def remove(self, torrent_hash: str, delete_files: bool = False) -> None: ...
42
42
 
43
+ async def add_torrent(
44
+ self,
45
+ torrent_bytes: bytes,
46
+ *,
47
+ save_path: str,
48
+ paused: bool = True,
49
+ skip_checking: bool = False,
50
+ category: str | None = None,
51
+ tags: str | None = None,
52
+ ) -> None:
53
+ raise NotImplementedError(f"{self.name} client does not support add_torrent")
54
+
55
+ async def torrent_files(self, torrent_hash: str) -> list[dict]:
56
+ raise NotImplementedError(f"{self.name} client does not support torrent_files")
57
+
58
+ async def info_one(self, torrent_hash: str) -> dict | None:
59
+ raise NotImplementedError(f"{self.name} client does not support info_one")
60
+
61
+ async def recheck(self, torrent_hash: str) -> None:
62
+ raise NotImplementedError(f"{self.name} client does not support recheck")
63
+
64
+ async def resume(self, torrent_hash: str) -> None:
65
+ raise NotImplementedError(f"{self.name} client does not support resume")
66
+
67
+ async def pause(self, torrent_hash: str) -> None:
68
+ raise NotImplementedError(f"{self.name} client does not support pause")
69
+
43
70
 
44
71
  # ---------------------------------------------------------------------------
45
72
  # qBittorrent Web API (v2)
@@ -114,11 +141,104 @@ class QBittorrentClient(TorrentClient):
114
141
  return out
115
142
 
116
143
  async def reseed(self, torrent_hash: str) -> None:
144
+ await self.recheck(torrent_hash)
145
+ await self.resume(torrent_hash)
146
+
147
+ async def add_torrent(
148
+ self,
149
+ torrent_bytes: bytes,
150
+ *,
151
+ save_path: str,
152
+ paused: bool = True,
153
+ skip_checking: bool = False,
154
+ category: str | None = None,
155
+ tags: str | None = None,
156
+ ) -> None:
157
+ """Add a .torrent (raw bytes) pointing at `save_path`.
158
+
159
+ For reseed the caller adds **paused** (so it never announces before the
160
+ content is in place), hardlinks the matching files into the layout qBit
161
+ reports via `torrent_files()`, then triggers an explicit `recheck()`.
162
+ Keep `skip_checking` False so the initial progress reflects reality
163
+ (0% until the content is hardlinked) rather than a spurious 100%. qBit's
164
+ add endpoint does not return the infohash; callers diff `list()`
165
+ before/after.
166
+ """
167
+ cli = await self._http()
168
+ flag = "true" if paused else "false"
169
+ data: dict[str, str] = {
170
+ "savepath": save_path,
171
+ # qBit 5.0 renamed the add param `paused` → `stopped`; send both so
172
+ # the torrent is correctly added stopped on either version (the
173
+ # unknown one is ignored).
174
+ "paused": flag,
175
+ "stopped": flag,
176
+ "skip_checking": "true" if skip_checking else "false",
177
+ "autoTMM": "false",
178
+ }
179
+ if category:
180
+ data["category"] = category
181
+ if tags:
182
+ data["tags"] = tags
183
+ files = {"torrents": ("reseed.torrent", torrent_bytes, "application/x-bittorrent")}
184
+ r = await cli.post("/api/v2/torrents/add", data=data, files=files)
185
+ r.raise_for_status()
186
+ if r.text.strip().lower() == "fails.":
187
+ raise RuntimeError("qBittorrent rejected the .torrent file")
188
+
189
+ async def torrent_files(self, torrent_hash: str) -> list[dict]:
190
+ """Files qBit expects for a torrent: ``[{name, size, ...}]``.
191
+
192
+ ``name`` is the path relative to the torrent root — the source of
193
+ truth for where to hardlink the local content under the save path.
194
+ """
195
+ cli = await self._http()
196
+ r = await cli.get("/api/v2/torrents/files", params={"hash": torrent_hash})
197
+ r.raise_for_status()
198
+ data = r.json()
199
+ return data if isinstance(data, list) else []
200
+
201
+ async def info_one(self, torrent_hash: str) -> dict | None:
202
+ """Raw qBit torrent info for a single hash (raw `state` + `progress`)."""
203
+ cli = await self._http()
204
+ r = await cli.get("/api/v2/torrents/info", params={"hashes": torrent_hash})
205
+ r.raise_for_status()
206
+ data = r.json()
207
+ if isinstance(data, list) and data:
208
+ return data[0]
209
+ return None
210
+
211
+ async def _torrent_action(self, paths: list[str], data: dict[str, str]) -> None:
212
+ """POST a torrent action tolerating the qBittorrent 5.0 endpoint rename
213
+ (resume→start, pause→stop): try each path in order, treat a 404 as
214
+ "wrong version, try the next", and surface any other error."""
215
+ cli = await self._http()
216
+ last: httpx.Response | None = None
217
+ for path in paths:
218
+ r = await cli.post(path, data=data)
219
+ if r.status_code != 404:
220
+ r.raise_for_status()
221
+ return
222
+ last = r
223
+ if last is not None:
224
+ last.raise_for_status()
225
+
226
+ async def recheck(self, torrent_hash: str) -> None:
117
227
  cli = await self._http()
118
228
  r = await cli.post("/api/v2/torrents/recheck", data={"hashes": torrent_hash})
119
229
  r.raise_for_status()
120
- r2 = await cli.post("/api/v2/torrents/resume", data={"hashes": torrent_hash})
121
- r2.raise_for_status()
230
+
231
+ async def resume(self, torrent_hash: str) -> None:
232
+ await self._torrent_action(
233
+ ["/api/v2/torrents/start", "/api/v2/torrents/resume"],
234
+ {"hashes": torrent_hash},
235
+ )
236
+
237
+ async def pause(self, torrent_hash: str) -> None:
238
+ await self._torrent_action(
239
+ ["/api/v2/torrents/stop", "/api/v2/torrents/pause"],
240
+ {"hashes": torrent_hash},
241
+ )
122
242
 
123
243
  async def remove(self, torrent_hash: str, delete_files: bool = False) -> None:
124
244
  cli = await self._http()