├── .gitattributes ├── .gitignore ├── .idea ├── MypyConfig.xml ├── clutch.iml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── .readthedocs.yml ├── LICENSE ├── README.rst ├── clutch ├── __init__.py ├── client.py ├── method │ ├── __init__.py │ ├── method.py │ ├── misc.py │ ├── queue.py │ ├── session.py │ ├── shared.py │ └── torrent.py ├── network │ ├── __init__.py │ ├── connection.py │ ├── rpc │ │ ├── __init__.py │ │ ├── convert.py │ │ └── message.py │ ├── session.py │ └── utility.py ├── py.typed └── schema │ ├── __init__.py │ ├── request │ ├── __init__.py │ ├── misc │ │ ├── __init__.py │ │ ├── bandwidth_group.py │ │ └── port_test.py │ ├── session │ │ ├── __init__.py │ │ ├── accessor.py │ │ ├── mutator.py │ │ └── shared.py │ └── torrent │ │ ├── __init__.py │ │ ├── accessor.py │ │ ├── add.py │ │ └── mutator.py │ └── user │ ├── __init__.py │ ├── method │ ├── __init__.py │ ├── misc.py │ ├── session │ │ ├── __init__.py │ │ ├── accessor.py │ │ ├── mutator.py │ │ └── shared.py │ ├── shared.py │ └── torrent │ │ ├── __init__.py │ │ ├── accessor.py │ │ ├── action.py │ │ ├── add.py │ │ ├── move.py │ │ ├── mutator.py │ │ ├── remove.py │ │ └── rename.py │ └── response │ ├── __init__.py │ ├── misc.py │ ├── session │ ├── __init__.py │ ├── accessor.py │ └── stats.py │ └── torrent │ ├── __init__.py │ ├── accessor.py │ ├── add.py │ └── rename.py ├── docker ├── clutch.df ├── docker-compose.yml ├── integration-test-wait.sh ├── integration-wait.df ├── integration_resources │ ├── client_setup.py │ ├── data │ │ ├── ion.txt │ │ └── little_women │ │ │ └── little_women.txt │ └── watch │ │ ├── ion.torrent │ │ └── little_women.torrent ├── settings.json └── transmission.df ├── docs ├── Makefile ├── make.bat └── source │ ├── clutch.method.rst │ ├── clutch.network.rpc.rst │ ├── clutch.network.rst │ ├── clutch.rst │ ├── clutch.schema.request.misc.rst │ ├── clutch.schema.request.rst │ ├── clutch.schema.request.session.rst │ ├── clutch.schema.request.torrent.rst │ ├── clutch.schema.rst │ ├── clutch.schema.user.method.rst │ ├── clutch.schema.user.method.session.rst │ ├── clutch.schema.user.method.torrent.rst │ ├── clutch.schema.user.response.rst │ ├── clutch.schema.user.response.session.rst │ ├── clutch.schema.user.response.torrent.rst │ ├── clutch.schema.user.rst │ ├── commands.rst │ ├── conf.py │ ├── examples.rst │ ├── index.rst │ ├── intro.rst │ ├── modules.rst │ ├── requirements.txt │ └── server_config.rst ├── pyproject.toml ├── requirements.txt ├── rpc-spec.md ├── scripts ├── attach-shell.sh ├── build-images.sh ├── run-containers.sh └── run-transmission.sh ├── tests ├── __init__.py ├── endtoend │ ├── __init__.py │ ├── test_accessor.py │ ├── test_action.py │ └── test_mutator.py ├── mock │ ├── __init__.py │ ├── conftest.py │ ├── test_bandwidth_groups.py │ └── test_cases.py └── unit │ ├── __init__.py │ ├── client │ ├── __init__.py │ ├── conftest.py │ ├── session │ │ ├── __init__.py │ │ ├── test_accessor.py │ │ └── test_mutator.py │ ├── test_queue.py │ └── torrent │ │ ├── __init__.py │ │ ├── test_accessor.py │ │ ├── test_action.py │ │ ├── test_add.py │ │ ├── test_move.py │ │ ├── test_mutator.py │ │ ├── test_remove.py │ │ └── test_rename.py │ ├── schema │ ├── __init__.py │ └── user │ │ ├── __init__.py │ │ └── response │ │ ├── __init__.py │ │ └── torrent │ │ ├── __init__.py │ │ └── test_accessor.py │ └── test_networking.py └── uv.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-detectable=false 2 | *.htm linguist-detectable=false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # pytype static type analyzer 134 | .pytype/ 135 | 136 | # vscode 137 | .vscode/ 138 | 139 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 140 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 141 | 142 | # User-specific stuff 143 | .idea/**/workspace.xml 144 | .idea/**/tasks.xml 145 | .idea/**/usage.statistics.xml 146 | .idea/**/dictionaries 147 | .idea/**/shelf 148 | 149 | # Generated files 150 | .idea/**/contentModel.xml 151 | 152 | # Sensitive or high-churn files 153 | .idea/**/dataSources/ 154 | .idea/**/dataSources.ids 155 | .idea/**/dataSources.local.xml 156 | .idea/**/sqlDataSources.xml 157 | .idea/**/dynamic.xml 158 | .idea/**/uiDesigner.xml 159 | .idea/**/dbnavigator.xml 160 | 161 | # Gradle 162 | .idea/**/gradle.xml 163 | .idea/**/libraries 164 | 165 | # Gradle and Maven with auto-import 166 | # When using Gradle or Maven with auto-import, you should exclude module files, 167 | # since they will be recreated, and may cause churn. Uncomment if using 168 | # auto-import. 169 | # .idea/artifacts 170 | # .idea/compiler.xml 171 | # .idea/jarRepositories.xml 172 | # .idea/modules.xml 173 | # .idea/*.iml 174 | # .idea/modules 175 | # *.iml 176 | # *.ipr 177 | 178 | # CMake 179 | cmake-build-*/ 180 | 181 | # Mongo Explorer plugin 182 | .idea/**/mongoSettings.xml 183 | 184 | # File-based project format 185 | *.iws 186 | 187 | # IntelliJ 188 | out/ 189 | 190 | # mpeltonen/sbt-idea plugin 191 | .idea_modules/ 192 | 193 | # JIRA plugin 194 | atlassian-ide-plugin.xml 195 | 196 | # Cursive Clojure plugin 197 | .idea/replstate.xml 198 | 199 | # Crashlytics plugin (for Android Studio and IntelliJ) 200 | com_crashlytics_export_strings.xml 201 | crashlytics.properties 202 | crashlytics-build.properties 203 | fabric.properties 204 | 205 | # Editor-based Rest Client 206 | .idea/httpRequests 207 | 208 | # Android studio 3.1+ serialized cache file 209 | .idea/caches/build_file_checksums.ser 210 | -------------------------------------------------------------------------------- /.idea/MypyConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/clutch.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | 13 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | tools: 10 | python: "3.12" 11 | os: ubuntu-lts-latest 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: docs/source/conf.py 16 | 17 | # Optionally set the version of Python and requirements required to build your docs 18 | python: 19 | install: 20 | - method: pip 21 | path: . 22 | - requirements: docs/source/requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Hadam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Clutch 2 | ------ 3 | 4 | .. image:: https://readthedocs.org/projects/clutch/badge/?version=latest 5 | :target: https://clutch.readthedocs.io/en/latest/?badge=latest 6 | :alt: Documentation badge 7 | 8 | .. image:: https://img.shields.io/pypi/v/transmission-clutch.svg 9 | :target: https://pypi.org/project/transmission-clutch 10 | :alt: PyPI badge 11 | 12 | .. image:: https://img.shields.io/pypi/dm/transmission-clutch.svg 13 | :target: https://pypistats.org/packages/transmission-clutch 14 | :alt: PyPI downloads badge 15 | 16 | Documentation 17 | ============= 18 | 19 | Found here: ``_ 20 | 21 | Quick start 22 | =========== 23 | 24 | Install the package: 25 | 26 | .. code-block:: console 27 | 28 | $ pip install transmission-clutch 29 | 30 | Make a client: 31 | 32 | .. code-block:: python 33 | 34 | from clutch import Client 35 | client = Client() 36 | 37 | If you find the client isn't connecting (an error will be raised), make sure you're entering the address correctly. Reference `urllib.parse.urlparse`_ for parsing rules. 38 | 39 | You can specify Transmission's address when making the client: 40 | 41 | .. code-block:: python 42 | 43 | client = Client(address="http://localhost:9091/transmission/rpc") 44 | 45 | .. _urllib.parse.urlparse: https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse 46 | 47 | RPC methods are separated into groups: torrent, session, queue and misc. 48 | 49 | Methods are called by first specifying a group: 50 | 51 | .. code-block:: python 52 | 53 | client.torrent.add(...) 54 | -------------------------------------------------------------------------------- /clutch/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | 3 | __all__ = ["Client"] 4 | -------------------------------------------------------------------------------- /clutch/client.py: -------------------------------------------------------------------------------- 1 | from clutch.method.misc import MiscellaneousMethods 2 | from clutch.method.queue import QueueMethods 3 | from clutch.method.session import SessionMethods 4 | from clutch.method.torrent import TorrentMethods 5 | from clutch.network.connection import Connection 6 | from clutch.network.session import TransmissionSession 7 | from clutch.network.utility import make_endpoint 8 | 9 | 10 | class Client: 11 | session: SessionMethods = SessionMethods() 12 | torrent: TorrentMethods = TorrentMethods() 13 | queue: QueueMethods = QueueMethods() 14 | misc: MiscellaneousMethods = MiscellaneousMethods() 15 | 16 | def __init__( 17 | self, 18 | address="http://localhost:9091/transmission/rpc", 19 | scheme=None, 20 | host=None, 21 | port=None, 22 | path=None, 23 | query=None, 24 | username=None, 25 | password=None, 26 | debug=False, 27 | ): 28 | self._endpoint: str = make_endpoint(address, scheme, host, port, path, query) 29 | self._session: TransmissionSession = TransmissionSession(username, password) 30 | self._connection: Connection = Connection(self._endpoint, self._session, debug) 31 | 32 | def set_rpc_debug(self, value: bool): 33 | self._connection.debug = value 34 | 35 | def set_connection( 36 | self, 37 | address="http://localhost:9091/transmission/rpc", 38 | scheme=None, 39 | host=None, 40 | port=None, 41 | path=None, 42 | query=None, 43 | username=None, 44 | password=None, 45 | debug=False, 46 | ): 47 | self._endpoint = make_endpoint(address, scheme, host, port, path, query) 48 | self._session = TransmissionSession(username, password) 49 | self._connection = Connection(self._endpoint, self._session, debug) 50 | -------------------------------------------------------------------------------- /clutch/method/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/method/__init__.py -------------------------------------------------------------------------------- /clutch/method/method.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | from weakref import proxy 3 | 4 | from clutch.network.connection import Connection 5 | 6 | T = TypeVar("T", bound="MethodNamespace") 7 | 8 | 9 | class MethodNamespace: 10 | _connection: Connection = ... # type: ignore 11 | 12 | def __init__(self, client=None): 13 | if client is not None: 14 | self._connection = proxy(client.__dict__["_connection"]) 15 | 16 | def __get__(self: T, instance, owner) -> T: 17 | return self.__class__(instance) 18 | -------------------------------------------------------------------------------- /clutch/method/misc.py: -------------------------------------------------------------------------------- 1 | from clutch.method.method import MethodNamespace 2 | from clutch.network.rpc.message import Request, Response 3 | from clutch.schema.request.misc.port_test import PortTestArgumentsRequest 4 | from clutch.schema.user.method.misc import IpProtocol 5 | from clutch.schema.user.response.misc import ( 6 | BandwidthGroupResponse, 7 | BlocklistResponse, 8 | FreeSpaceResponse, 9 | PortTestResponse, 10 | ) 11 | 12 | 13 | class MiscellaneousMethods(MethodNamespace): 14 | def blocklist_update(self, tag: int | None = None) -> Response[BlocklistResponse]: 15 | """Trigger an update of the client blocklist.""" 16 | return self._connection.send( 17 | Request(method="blocklist-update", tag=tag), BlocklistResponse 18 | ) 19 | 20 | def port_test( 21 | self, ip_protocol: IpProtocol | None = None, tag: int | None = None 22 | ) -> Response[PortTestResponse]: 23 | """Test the client to see if the port settings are open for connections.""" 24 | return self._connection.send( 25 | Request[PortTestArgumentsRequest]( 26 | method="port-test", arguments={"ip_protocol": ip_protocol}, tag=tag 27 | ), 28 | PortTestResponse, 29 | ) 30 | 31 | def free_space( 32 | self, path: str, tag: int | None = None 33 | ) -> Response[FreeSpaceResponse]: 34 | """Query for available free-space in the specified path.""" 35 | return self._connection.send( 36 | Request(method="free-space", arguments={"path": path}, tag=tag), 37 | FreeSpaceResponse, 38 | ) 39 | 40 | def bandwidth_groups( 41 | self, group: str | list[str] | None = None, tag: int | None = None 42 | ) -> Response[BandwidthGroupResponse]: 43 | """Query for bandwidth groups.""" 44 | return self._connection.send( 45 | Request(method="group-get", arguments={"group": group}, tag=tag), 46 | BandwidthGroupResponse, 47 | ) 48 | -------------------------------------------------------------------------------- /clutch/method/queue.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | from clutch.method.method import MethodNamespace 4 | from clutch.network.rpc.message import Request, Response 5 | from clutch.schema.user.method.shared import IdsArg 6 | 7 | 8 | @unique 9 | class QueueMovement(Enum): 10 | TOP = "queue-move-top" 11 | UP = "queue-move-up" 12 | DOWN = "queue-move-down" 13 | BOTTOM = "queue-move-bottom" 14 | 15 | 16 | class QueueMethods(MethodNamespace): 17 | def move( 18 | self, movement: QueueMovement, ids: IdsArg, tag: int | None = None 19 | ) -> Response: 20 | """Change the position of one or more torrents in the queue.""" 21 | return self._connection.send( 22 | Request(method=movement.value, arguments={"ids": ids}, tag=tag) 23 | ) 24 | -------------------------------------------------------------------------------- /clutch/method/session.py: -------------------------------------------------------------------------------- 1 | from typing import Set 2 | 3 | from clutch.method.method import MethodNamespace 4 | from clutch.network.rpc.message import Request, Response 5 | from clutch.schema.request.session.accessor import SessionAccessorArgumentsRequest 6 | from clutch.schema.request.session.mutator import SessionMutatorArgumentsRequest 7 | from clutch.schema.user.method.session.accessor import SessionAccessorField 8 | from clutch.schema.user.method.session.mutator import SessionMutatorArguments 9 | from clutch.schema.user.response.session.accessor import SessionAccessor 10 | from clutch.schema.user.response.session.stats import SessionStats 11 | 12 | 13 | class SessionMethods(MethodNamespace): 14 | def accessor( 15 | self, 16 | fields: Set[SessionAccessorField] | None = None, 17 | tag: int | None = None, 18 | ) -> Response[SessionAccessor]: 19 | """Retrieve information about one or more torrents.""" 20 | return self._connection.send( 21 | Request[SessionAccessorArgumentsRequest]( 22 | method="session-get", 23 | arguments={"accessor_fields": fields}, 24 | tag=tag, 25 | ), 26 | SessionAccessor, 27 | ) 28 | 29 | def mutator( 30 | self, arguments: SessionMutatorArguments, tag: int | None = None 31 | ) -> Response: 32 | """Set a property of one or more torrents.""" 33 | return self._connection.send( 34 | Request[SessionMutatorArgumentsRequest]( 35 | method="session-set", arguments=arguments, tag=tag 36 | ) 37 | ) 38 | 39 | def stats(self, tag: int | None = None) -> Response[SessionStats]: 40 | """Retrieve all session statistics.""" 41 | return self._connection.send( 42 | Request(method="session-stats", tag=tag), SessionStats 43 | ) 44 | 45 | def shutdown(self, tag: int | None = None) -> Response: 46 | """Shutdown the torrent client.""" 47 | return self._connection.send(Request(method="session-close", tag=tag)) 48 | -------------------------------------------------------------------------------- /clutch/method/shared.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping 2 | 3 | 4 | def combine_arguments( 5 | arguments: Mapping[str, object] | None = None, **kwargs 6 | ) -> Mapping[str, object]: 7 | if arguments is None: 8 | arguments = {} 9 | else: 10 | arguments = dict(arguments) 11 | arguments.update({k: v for k, v in kwargs.items() if v is not None}) 12 | return arguments 13 | -------------------------------------------------------------------------------- /clutch/method/torrent.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Sequence, Set, Union 2 | 3 | from clutch.method.method import MethodNamespace 4 | from clutch.method.shared import combine_arguments 5 | from clutch.network.rpc.message import Request, Response 6 | from clutch.schema.request.torrent.accessor import TorrentAccessorArgumentsRequest 7 | from clutch.schema.request.torrent.add import TorrentAddArgumentsRequest 8 | from clutch.schema.request.torrent.mutator import TorrentMutatorArgumentsRequest 9 | from clutch.schema.user.method.shared import IdsArg 10 | from clutch.schema.user.method.torrent.accessor import TorrentAccessorField, field_keys 11 | from clutch.schema.user.method.torrent.action import TorrentActionMethod 12 | from clutch.schema.user.method.torrent.add import TorrentAddArguments 13 | from clutch.schema.user.method.torrent.mutator import TorrentMutatorArguments 14 | from clutch.schema.user.response.torrent.accessor import TorrentAccessorResponse 15 | from clutch.schema.user.response.torrent.add import TorrentAdd 16 | from clutch.schema.user.response.torrent.rename import TorrentRename 17 | 18 | 19 | class TorrentMethods(MethodNamespace): 20 | def accessor( 21 | self, 22 | fields: Set[TorrentAccessorField] | None = None, 23 | *, 24 | all_fields: bool = False, 25 | ids: IdsArg | None = None, 26 | response_format: Literal["objects", "table"] | None = None, 27 | tag: int | None = None, 28 | ) -> Response[TorrentAccessorResponse]: 29 | """Retrieve information about one or more torrents.""" 30 | if response_format is None: 31 | response_format = "objects" 32 | 33 | if all_fields: 34 | fields = field_keys 35 | elif fields is None: 36 | fields = set() 37 | 38 | combined_arguments = combine_arguments( 39 | accessor_fields=fields, format=response_format, ids=ids 40 | ) 41 | return self._connection.send( 42 | Request[TorrentAccessorArgumentsRequest]( 43 | method="torrent-get", arguments=combined_arguments, tag=tag 44 | ), 45 | TorrentAccessorResponse, 46 | ) 47 | 48 | def action( 49 | self, 50 | method: TorrentActionMethod, 51 | ids: IdsArg | None = None, 52 | tag: int | None = None, 53 | ) -> Response: 54 | """Start, stop, verify or reannounce a torrent.""" 55 | return self._connection.send( 56 | Request(method=method.value, arguments={"ids": ids}, tag=tag) 57 | ) 58 | 59 | def add( 60 | self, arguments: TorrentAddArguments, tag: int | None = None 61 | ) -> Response[TorrentAdd]: 62 | """Add a new torrent.""" 63 | return self._connection.send( 64 | Request[TorrentAddArgumentsRequest]( 65 | method="torrent-add", arguments=arguments, tag=tag 66 | ), 67 | TorrentAdd, 68 | ) 69 | 70 | def mutator( 71 | self, 72 | ids: IdsArg | None = None, 73 | arguments: TorrentMutatorArguments | None = None, 74 | tag: int | None = None, 75 | ) -> Response: 76 | """Set a property of one or more torrents.""" 77 | try: 78 | arguments["tracker_list"] = "\n\n".join( 79 | "\n".join(x) for x in arguments["tracker_list"] 80 | ) 81 | except (KeyError, TypeError): 82 | pass 83 | return self._connection.send( 84 | Request[TorrentMutatorArgumentsRequest]( 85 | method="torrent-set", 86 | arguments=combine_arguments(arguments, ids=ids), 87 | tag=tag, 88 | ) 89 | ) 90 | 91 | def move( 92 | self, ids: IdsArg, location: str, move: bool = False, tag: int | None = None 93 | ) -> Response: 94 | """Change the storage location of a torrent.""" 95 | return self._connection.send( 96 | Request( 97 | method="torrent-set-location", 98 | arguments=combine_arguments(ids=ids, location=location, move=move), 99 | tag=tag, 100 | ) 101 | ) 102 | 103 | def remove( 104 | self, ids: IdsArg, delete_local_data: bool = False, tag: int | None = None 105 | ) -> Response: 106 | """Remove one or more torrents.""" 107 | return self._connection.send( 108 | Request( 109 | method="torrent-remove", 110 | arguments={"ids": ids, "delete-local-data": delete_local_data}, 111 | tag=tag, 112 | ) 113 | ) 114 | 115 | def rename( 116 | self, 117 | ids: Sequence[Union[str, int]], 118 | path: str, 119 | name: str, 120 | tag: int | None = None, 121 | ) -> Response[TorrentRename]: 122 | """Rename a file or directory in a torrent.""" 123 | return self._connection.send( 124 | Request( 125 | method="torrent-rename-path", 126 | arguments=combine_arguments(ids=ids, path=path, name=name), 127 | tag=tag, 128 | ), 129 | TorrentRename, 130 | ) 131 | -------------------------------------------------------------------------------- /clutch/network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/network/__init__.py -------------------------------------------------------------------------------- /clutch/network/connection.py: -------------------------------------------------------------------------------- 1 | from typing import Type, TypeVar 2 | 3 | from pydantic import BaseModel 4 | 5 | from clutch.network.rpc.message import Request, Response 6 | from clutch.network.session import TransmissionSession 7 | 8 | T = TypeVar("T", bound=BaseModel) 9 | 10 | 11 | class Connection: 12 | def __init__( 13 | self, endpoint: str, session: TransmissionSession, debug: bool = False 14 | ): 15 | self.endpoint = endpoint 16 | self.session = session 17 | self.debug = debug 18 | 19 | def send(self, request: Request, model: Type[T] | None = None) -> Response[T]: 20 | data = request.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8") 21 | response = self.session.post(self.endpoint, data=data) 22 | if model is not None: 23 | return Response[model].model_validate_json(response.text) # type: ignore 24 | else: 25 | return Response.model_validate_json(response.text) 26 | -------------------------------------------------------------------------------- /clutch/network/rpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/network/rpc/__init__.py -------------------------------------------------------------------------------- /clutch/network/rpc/convert.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Mapping, MutableMapping, Sequence, Union 3 | 4 | 5 | def to_underscore(key: str) -> str: 6 | camelcase_split = re.split(r"(?<=[a-z])(?=[A-Z])", key) 7 | hyphen_split = key.split("-") 8 | if len(camelcase_split) > 1: 9 | return "_".join([word.lower() for word in camelcase_split]) 10 | elif len(hyphen_split) > 1: 11 | return "_".join([word.lower() for word in hyphen_split]) 12 | else: 13 | return key 14 | 15 | 16 | def to_camel(key: str) -> str: 17 | words = key.split("_") 18 | return "".join(words[:1] + [word.capitalize() for word in words[1:]]) 19 | 20 | 21 | def to_hyphen(key: str) -> str: 22 | return "-".join(key.split("_")) 23 | 24 | 25 | def normalize_arguments( 26 | arguments: Mapping[str, object] | None = None, 27 | ) -> Mapping[str, object]: 28 | if arguments is None: 29 | return {} 30 | 31 | result: MutableMapping[str, object] = {} 32 | iterations = [(result, item) for item in arguments.items()] 33 | for iteration in iterations: 34 | parent: Union[MutableMapping[str, object], Sequence] = iteration[0] 35 | if isinstance(parent, dict): 36 | (key, value) = iteration[1] 37 | converted_key = to_underscore(key) 38 | if isinstance(value, dict): 39 | parent[converted_key] = {} 40 | iterations.extend( 41 | [(parent[converted_key], item) for item in value.items()] 42 | ) 43 | elif isinstance(value, list): 44 | parent[converted_key] = [] 45 | iterations.extend([(parent[converted_key], item) for item in value]) 46 | else: 47 | parent[converted_key] = value 48 | elif isinstance(parent, list): 49 | value = iteration[1] 50 | if isinstance(value, dict): 51 | new_element = {} 52 | parent.append(new_element) 53 | iterations.extend([(new_element, item) for item in value.items()]) 54 | elif isinstance(value, list): 55 | new_element = [] 56 | parent.append(new_element) 57 | iterations.extend([(new_element, item) for item in value]) 58 | else: 59 | parent.append(value) 60 | return result 61 | -------------------------------------------------------------------------------- /clutch/network/rpc/message.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any, Generic, TypeVar 2 | 3 | from pydantic import BaseModel, FieldSerializationInfo, PlainSerializer, field_validator 4 | 5 | from clutch.network.rpc.convert import normalize_arguments 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | def dict_not_none_ser( 11 | value: dict[str, Any], info: FieldSerializationInfo 12 | ) -> dict[str, Any]: 13 | if isinstance(value, dict) and info.exclude_none: 14 | return {k: v for k, v in value.items() if v is not None} 15 | else: 16 | return value 17 | 18 | 19 | class Request(BaseModel, Generic[T]): 20 | """RPC request container""" 21 | 22 | method: str 23 | arguments: Annotated[T | None, PlainSerializer(dict_not_none_ser)] = None 24 | tag: int | None = None 25 | 26 | 27 | class Response(BaseModel, Generic[T]): 28 | """RPC response container""" 29 | 30 | result: str 31 | arguments: T | None = None 32 | tag: int | None = None 33 | 34 | @field_validator("arguments", mode="before") 35 | @classmethod 36 | def fields_underscored(cls, v: Any): 37 | if isinstance(v, dict): 38 | return normalize_arguments(v) 39 | else: 40 | return v 41 | -------------------------------------------------------------------------------- /clutch/network/session.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | from requests import Session 4 | from requests.auth import HTTPBasicAuth 5 | 6 | 7 | class TransmissionAuth(HTTPBasicAuth): 8 | HEADER_NAME = "X-Transmission-Session-Id" 9 | HEADER_NAME_LOWER = HEADER_NAME.lower() 10 | 11 | def __init__(self, username=None, password=None): 12 | # setup any auth-related data here 13 | self.__csrf_tokens = {} 14 | super(TransmissionAuth, self).__init__(username, password) 15 | 16 | def handle_409(self, r, **kwargs): 17 | """handles CSRF token expiration and resends the request""" 18 | 19 | # cannot handle the request 20 | if self.HEADER_NAME not in r.headers: 21 | return 22 | 23 | url = r.url 24 | self.__csrf_tokens[url] = r.headers[self.HEADER_NAME] 25 | 26 | # copy the original headers 27 | new_headers = dict( 28 | (k, v) 29 | for k, v in r.request.headers.items() 30 | if k.lower() 31 | not in ("content-length", "content-type", self.HEADER_NAME_LOWER) 32 | ) 33 | 34 | new_headers["Host"] = urlparse(r.request.url).netloc 35 | new_request = r.request.copy() 36 | new_request.headers.update(new_headers) 37 | new_request.headers[self.HEADER_NAME] = self.__csrf_tokens[url] 38 | 39 | r.close() 40 | _r = r.connection.send(new_request, **kwargs) 41 | _r.history.append(r) 42 | _r.request = new_request 43 | return _r 44 | 45 | def __call__(self, r): 46 | """adds the appropriate CSRF token to the headers""" 47 | # don't use authentication if username and password aren't defined 48 | if self.username is not None and self.password is not None: 49 | r = super(TransmissionAuth, self).__call__(r) 50 | 51 | token = self.__csrf_tokens.get(r.url) 52 | if token: 53 | r.headers[self.HEADER_NAME] = token 54 | r.register_hook("response", self.handle_409) 55 | return r 56 | 57 | 58 | class TransmissionSession(Session): 59 | """ 60 | Handles Transmission CSRF Protection 61 | https://trac.transmissionbt.com/browser/trunk/extras/rpc-spec.txt#L48 62 | """ 63 | 64 | def __init__(self, username, password): 65 | super(TransmissionSession, self).__init__() 66 | self.auth = TransmissionAuth(username, password) 67 | -------------------------------------------------------------------------------- /clutch/network/utility.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlsplit, urlunsplit 2 | 3 | 4 | def make_endpoint( 5 | address: str = "http://localhost:9091/transmission/rpc", 6 | scheme: str | None = None, 7 | host: str | None = None, 8 | port: int | None = None, 9 | path: str | None = None, 10 | query: str | None = None, 11 | ) -> str: 12 | # any explicit keyword arguments override the default address 13 | url_info = urlsplit(address) 14 | if scheme is None: 15 | scheme = url_info.scheme 16 | if host is None: 17 | host = url_info.hostname 18 | if port is None: 19 | port = url_info.port 20 | if path is None: 21 | path = url_info.path 22 | if query is None: 23 | query = url_info.query 24 | return urlunsplit((scheme, f"{host}:{port}", path, query, None)) 25 | -------------------------------------------------------------------------------- /clutch/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/py.typed -------------------------------------------------------------------------------- /clutch/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/__init__.py -------------------------------------------------------------------------------- /clutch/schema/request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/request/__init__.py -------------------------------------------------------------------------------- /clutch/schema/request/misc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/request/misc/__init__.py -------------------------------------------------------------------------------- /clutch/schema/request/misc/bandwidth_group.py: -------------------------------------------------------------------------------- 1 | 2 | from pydantic import BaseModel 3 | 4 | 5 | class BandwidthGroupArgumentsRequest(BaseModel): 6 | group: str | list[str] | None = None 7 | -------------------------------------------------------------------------------- /clutch/schema/request/misc/port_test.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Tuple 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | IpProtocol = Tuple[Literal["ipv4"] | Literal["ipv6"]] 6 | 7 | 8 | class PortTestArgumentsRequest(BaseModel): 9 | ip_protocol: IpProtocol | None = Field(None, serialization_alias="ipProtocol") 10 | -------------------------------------------------------------------------------- /clutch/schema/request/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/request/session/__init__.py -------------------------------------------------------------------------------- /clutch/schema/request/session/accessor.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import BaseModel, Field, field_validator 4 | 5 | from clutch.network.rpc.convert import to_camel, to_hyphen 6 | 7 | SessionAccessorRequestField = Literal[ 8 | "alt-speed-down", 9 | "alt-speed-enabled", 10 | "alt-speed-time-begin", 11 | "alt-speed-time-day", 12 | "alt-speed-time-enabled", 13 | "alt-speed-time-end", 14 | "alt-speed-up", 15 | "blocklist-enabled", 16 | "blocklist-size", 17 | "blocklist-url", 18 | "cache-size-mb", 19 | "config-dir", 20 | "default-trackers", 21 | "dht-enabled", 22 | "download-dir", 23 | "download-queue-enabled", 24 | "download-queue-size", 25 | "encryption", 26 | "idle-seeding-limit-enabled", 27 | "idle-seeding-limit", 28 | "incomplete-dir-enabled", 29 | "incomplete-dir", 30 | "lpd-enabled", 31 | "peer-limit-global", 32 | "peer-limit-per-torrent", 33 | "peer-port-random-on-start", 34 | "peer-port", 35 | "pex-enabled", 36 | "port-forwarding-enabled", 37 | "queue-stalled-enabled", 38 | "queue-stalled-minutes", 39 | "rename-partial-files", 40 | "reqq", 41 | "rpc-version-minimum", 42 | "rpc-version-semver", 43 | "rpc-version", 44 | "script-torrent-added-enabled", 45 | "script-torrent-added-filename", 46 | "script-torrent-done-enabled", 47 | "script-torrent-done-filename", 48 | "script-torrent-done-seeding-enabled", 49 | "script-torrent-done-seeding-filename", 50 | "seed-queue-enabled", 51 | "seed-queue-size", 52 | "seedRatioLimit", 53 | "seedRatioLimited", 54 | "session-id", 55 | "speed-limit-down-enabled", 56 | "speed-limit-down", 57 | "speed-limit-up-enabled", 58 | "speed-limit-up", 59 | "start-added-torrents", 60 | "trash-original-torrent-files", 61 | "units", 62 | "utp-enabled", 63 | "version", 64 | ] 65 | 66 | 67 | class SessionAccessorArgumentsRequest(BaseModel): 68 | accessor_fields: set[SessionAccessorRequestField] | None = Field( 69 | None, serialization_alias="fields" 70 | ) 71 | 72 | @field_validator("accessor_fields", mode="before") 73 | @classmethod 74 | def accessor_fields_format(cls, v): 75 | if v is None: 76 | return v 77 | camel = ["seed_ratio_limit", "seed_ratio_limited"] 78 | result = set() 79 | try: 80 | for field in v: 81 | if field in camel: 82 | result.add(to_camel(field)) 83 | else: 84 | result.add(to_hyphen(field)) 85 | except TypeError: 86 | return v 87 | return result 88 | -------------------------------------------------------------------------------- /clutch/schema/request/session/mutator.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | 3 | from pydantic import BaseModel, Field, model_validator 4 | 5 | from clutch.schema.request.session.shared import UnitsRequest 6 | 7 | 8 | class SessionMutatorArgumentsRequest(BaseModel): 9 | alt_speed_down: int | None = Field(None, serialization_alias="alt-speed-down") 10 | alt_speed_enabled: bool | None = Field( 11 | None, serialization_alias="alt-speed-enabled" 12 | ) 13 | alt_speed_time_begin: int | None = Field( 14 | None, serialization_alias="alt-speed-time-begin" 15 | ) 16 | alt_speed_time_enabled: bool | None = Field( 17 | None, serialization_alias="alt-speed-time-enabled" 18 | ) 19 | alt_speed_time_end: int | None = Field( 20 | None, serialization_alias="alt-speed-time-end" 21 | ) 22 | alt_speed_time_day: int | None = Field( 23 | None, serialization_alias="alt-speed-time-day" 24 | ) 25 | alt_speed_up: int | None = Field(None, serialization_alias="alt-speed-up") 26 | blocklist_url: str | None = Field(None, serialization_alias="blocklist-url") 27 | blocklist_enabled: bool | None = Field( 28 | None, serialization_alias="blocklist-enabled" 29 | ) 30 | cache_size_mb: int | None = Field(None, serialization_alias="cache-size-mb") 31 | download_dir: str | None = Field(None, serialization_alias="download-dir") 32 | download_queue_size: int | None = Field( 33 | None, serialization_alias="download-queue-size" 34 | ) 35 | download_queue_enabled: bool | None = Field( 36 | None, serialization_alias="download-queue-enabled" 37 | ) 38 | dht_enabled: bool | None = Field(None, serialization_alias="dht-enabled") 39 | encryption: str | None = None 40 | idle_seeding_limit: int | None = Field( 41 | None, serialization_alias="idle-seeding-limit" 42 | ) 43 | idle_seeding_limit_enabled: bool | None = Field( 44 | None, serialization_alias="idle-seeding-limit-enabled" 45 | ) 46 | incomplete_dir: str | None = Field(None, serialization_alias="incomplete-dir") 47 | incomplete_dir_enabled: bool | None = Field( 48 | None, serialization_alias="incomplete-dir-enabled" 49 | ) 50 | lpd_enabled: bool | None = Field(None, serialization_alias="lpd-enabled") 51 | peer_limit_global: int | None = Field( 52 | None, serialization_alias="peer-limit-global" 53 | ) 54 | peer_limit_per_torrent: int | None = Field( 55 | None, serialization_alias="peer-limit-per-torrent" 56 | ) 57 | pex_enabled: bool | None = Field(None, serialization_alias="pex-enabled") 58 | peer_port: int | None = Field(None, serialization_alias="peer-port") 59 | peer_port_random_on_start: bool | None = Field( 60 | None, serialization_alias="peer-port-random-on-start" 61 | ) 62 | port_forwarding_enabled: bool | None = Field( 63 | None, serialization_alias="port-forwarding-enabled" 64 | ) 65 | queue_stalled_enabled: bool | None = Field( 66 | None, serialization_alias="queue-stalled-enabled" 67 | ) 68 | queue_stalled_minutes: int | None = Field( 69 | None, serialization_alias="queue-stalled-minutes" 70 | ) 71 | rename_partial_files: bool | None = Field( 72 | None, serialization_alias="rename-partial-files" 73 | ) 74 | script_torrent_done_filename: str | None = Field( 75 | None, serialization_alias="script-torrent-done-filename" 76 | ) 77 | script_torrent_done_enabled: bool | None = Field( 78 | None, serialization_alias="script-torrent-done-enabled" 79 | ) 80 | seed_ratio_limit: float | None = Field( 81 | None, serialization_alias="seedRatioLimit" 82 | ) 83 | seed_ratio_limited: bool | None = Field( 84 | None, serialization_alias="seedRatioLimited" 85 | ) 86 | seed_queue_size: int | None = Field(None, serialization_alias="seed-queue-size") 87 | seed_queue_enabled: bool | None = Field( 88 | None, serialization_alias="seed-queue-enabled" 89 | ) 90 | speed_limit_down: int | None = Field( 91 | None, serialization_alias="speed-limit-down" 92 | ) 93 | speed_limit_down_enabled: bool | None = Field( 94 | None, serialization_alias="speed-limit-down-enabled" 95 | ) 96 | speed_limit_up: int | None = Field(None, serialization_alias="speed-limit-up") 97 | speed_limit_up_enabled: bool | None = Field( 98 | None, serialization_alias="speed-limit-up-enabled" 99 | ) 100 | start_added_torrents: bool | None = Field( 101 | None, serialization_alias="start-added-torrents" 102 | ) 103 | trash_original_torrent_files: bool | None = Field( 104 | None, serialization_alias="trash-original-torrent-files" 105 | ) 106 | units: UnitsRequest | None = None 107 | utp_enabled: bool | None = Field(None, serialization_alias="utp-enabled") 108 | 109 | @model_validator(mode="after") 110 | def at_least_one_field(self) -> Self: 111 | dumped = self.model_dump() 112 | if len(dumped) < 1: 113 | raise ValueError("At least one valid argument must be supplied") 114 | return self 115 | -------------------------------------------------------------------------------- /clutch/schema/request/session/shared.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Tuple 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | DataRateUnits = Tuple[ 6 | Literal["KB/s"], Literal["MB/s"], Literal["GB/s"], Literal["TB/s"] 7 | ] 8 | DataSizeUnits = Tuple[Literal["KB"], Literal["MB"], Literal["GB"], Literal["TB"]] 9 | ByteDefinition = Literal[1000, 1024] 10 | 11 | 12 | class UnitsRequest(BaseModel): 13 | speed_units: DataRateUnits = Field(..., serialization_alias="speed-units") 14 | speed_bytes: ByteDefinition = Field(..., serialization_alias="speed-bytes") 15 | size_units: DataSizeUnits = Field(..., serialization_alias="size-units") 16 | size_bytes: ByteDefinition = Field(..., serialization_alias="size-bytes") 17 | memory_units: DataSizeUnits = Field(..., serialization_alias="memory-units") 18 | memory_bytes: ByteDefinition = Field(..., serialization_alias="memory-bytes") 19 | -------------------------------------------------------------------------------- /clutch/schema/request/torrent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/request/torrent/__init__.py -------------------------------------------------------------------------------- /clutch/schema/request/torrent/accessor.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Set 2 | 3 | from pydantic import BaseModel, Field, field_validator 4 | 5 | from clutch.network.rpc.convert import to_camel, to_hyphen 6 | from clutch.schema.user.method.shared import IdsArg 7 | 8 | AccessorFieldRequest = Literal[ 9 | "activityDate", 10 | "addedDate", 11 | "availability", 12 | "bandwidthPriority", 13 | "comment", 14 | "corruptEver", 15 | "creator", 16 | "dateCreated", 17 | "desiredAvailable", 18 | "doneDate", 19 | "downloadDir", 20 | "downloadedEver", 21 | "downloadLimit", 22 | "downloadLimited", 23 | "editDate", 24 | "error", 25 | "errorString", 26 | "eta", 27 | "etaIdle", 28 | "file-count", 29 | "files", 30 | "fileStats", 31 | "group", 32 | "hashString", 33 | "haveUnchecked", 34 | "haveValid", 35 | "honorsSessionLimits", 36 | "id", 37 | "isFinished", 38 | "isPrivate", 39 | "isStalled", 40 | "labels", 41 | "leftUntilDone", 42 | "magnetLink", 43 | "manualAnnounceTime", 44 | "maxConnectedPeers", 45 | "metadataPercentComplete", 46 | "name", 47 | "peer-limit", 48 | "peers", 49 | "peersConnected", 50 | "peersFrom", 51 | "peersGettingFromUs", 52 | "peersSendingToUs", 53 | "percentComplete", 54 | "percentDone", 55 | "pieces", 56 | "pieceCount", 57 | "pieceSize", 58 | "priorities", 59 | "primary-mime-type", 60 | "queuePosition", 61 | "rateDownload", 62 | "rateUpload", 63 | "recheckProgress", 64 | "secondsDownloading", 65 | "secondsSeeding", 66 | "seedIdleLimit", 67 | "seedIdleMode", 68 | "seedRatioLimit", 69 | "seedRatioMode", 70 | "sequentialDownload", 71 | "sizeWhenDone", 72 | "startDate", 73 | "status", 74 | "trackers", 75 | "trackerList", 76 | "trackerStats", 77 | "totalSize", 78 | "torrentFile", 79 | "uploadedEver", 80 | "uploadLimit", 81 | "uploadLimited", 82 | "uploadRatio", 83 | "wanted", 84 | "webseeds", 85 | "webseedsSendingToUs", 86 | ] 87 | 88 | TorrentAccessorFieldsRequest = Set[AccessorFieldRequest] 89 | 90 | 91 | class TorrentAccessorArgumentsRequest(BaseModel): 92 | ids: IdsArg | None = None 93 | format: Literal["objects", "table"] | None = None 94 | accessor_fields: TorrentAccessorFieldsRequest | None = Field( 95 | ..., serialization_alias="fields" 96 | ) 97 | 98 | @field_validator("accessor_fields", mode="before") 99 | @classmethod 100 | def accessor_fields_format(cls, v): 101 | if v is not None: 102 | hyphenated = {"peer_limit"} 103 | result = set() 104 | try: 105 | for field in v: 106 | if field in hyphenated: 107 | result.add(to_hyphen(field)) 108 | else: 109 | result.add(to_camel(field)) 110 | except TypeError: 111 | return v 112 | return result 113 | else: 114 | return v 115 | -------------------------------------------------------------------------------- /clutch/schema/request/torrent/add.py: -------------------------------------------------------------------------------- 1 | from typing import Self, Sequence 2 | 3 | from pydantic import BaseModel, Field, model_validator 4 | 5 | 6 | class Cookie(BaseModel): 7 | name: str 8 | content: str 9 | 10 | 11 | class TorrentAddArgumentsRequest(BaseModel): 12 | cookies: Sequence[Cookie] | None = None 13 | download_dir: str | None = Field(None, serialization_alias="download-dir") 14 | filename: str | None = None 15 | metainfo: str | None = None 16 | paused: bool | None = None 17 | peer_limit: int | None = Field(None, serialization_alias="peer-limit") 18 | bandwidth_priority: int | None = Field( 19 | None, serialization_alias="bandwidthPriority" 20 | ) 21 | files_wanted: Sequence[int] | None = Field( 22 | None, serialization_alias="files-wanted" 23 | ) 24 | files_unwanted: Sequence[int] | None = Field( 25 | None, serialization_alias="files-unwanted" 26 | ) 27 | priority_high: Sequence[int] | None = Field( 28 | None, serialization_alias="priority-high" 29 | ) 30 | priority_low: Sequence[int] | None = Field( 31 | None, serialization_alias="priority-low" 32 | ) 33 | priority_normal: Sequence[int] | None = Field( 34 | None, serialization_alias="priority-normal" 35 | ) 36 | 37 | @model_validator(mode="after") 38 | def check_required_exclusive_fields(self) -> Self: 39 | if self.filename is not None and self.metainfo is not None: 40 | raise ValueError("Both filename and metainfo fields are in request") 41 | if self.filename is None and self.metainfo is None: 42 | raise ValueError("Either filename or metainfo field is required in request") 43 | return self 44 | -------------------------------------------------------------------------------- /clutch/schema/request/torrent/mutator.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from clutch.schema.user.method.shared import IdsArg 6 | 7 | 8 | class TorrentMutatorArgumentsRequest(BaseModel): 9 | bandwidth_priority: int | None = Field( 10 | None, serialization_alias="bandwidthPriority" 11 | ) 12 | download_limit: int | None = Field(None, serialization_alias="downloadLimit") 13 | download_limited: bool | None = Field(None, serialization_alias="downloadLimited") 14 | edit_date: int | None = Field(None, serialization_alias="editDate") 15 | files_wanted: Sequence[int] | None = Field(None, serialization_alias="files-wanted") 16 | files_unwanted: Sequence[int] | None = Field( 17 | None, serialization_alias="files-unwanted" 18 | ) 19 | group: str | None = Field(None) 20 | honors_session_limits: bool | None = Field( 21 | None, serialization_alias="honorsSessionLimits" 22 | ) 23 | ids: IdsArg | None = None 24 | labels: Sequence[str] | None = None 25 | location: str | None = None 26 | peer_limit: int | None = Field(None, serialization_alias="peer-limit") 27 | priority_high: Sequence[int] | None = Field( 28 | None, serialization_alias="priority-high" 29 | ) 30 | priority_low: Sequence[int] | None = Field(None, serialization_alias="priority-low") 31 | priority_normal: Sequence[int] | None = Field( 32 | None, serialization_alias="priority-normal" 33 | ) 34 | queue_position: int | None = Field(None, serialization_alias="queuePosition") 35 | seed_idle_limit: int | None = Field(None, serialization_alias="seedIdleLimit") 36 | seed_idle_mode: int | None = Field(None, serialization_alias="seedIdleMode") 37 | seed_ratio_limit: float | None = Field(None, serialization_alias="seedRatioLimit") 38 | seed_ratio_mode: int | None = Field(None, serialization_alias="seedRatioMode") 39 | tracker_list: str | None = Field(None, serialization_alias="trackerList") 40 | upload_limit: int | None = Field(None, serialization_alias="uploadLimit") 41 | upload_limited: bool | None = Field(None, serialization_alias="uploadLimited") 42 | -------------------------------------------------------------------------------- /clutch/schema/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/method/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/method/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/method/misc.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class IpProtocol(Enum): 6 | IPV6 = "ipv6" 7 | IPV4 = "ipv4" 8 | -------------------------------------------------------------------------------- /clutch/schema/user/method/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/method/session/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/method/session/accessor.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | SessionAccessorField = Literal[ 4 | "alt_speed_down", 5 | "alt_speed_enabled", 6 | "alt_speed_time_begin", 7 | "alt_speed_time_day", 8 | "alt_speed_time_enabled", 9 | "alt_speed_time_end", 10 | "alt_speed_up", 11 | "blocklist_enabled", 12 | "blocklist_size", 13 | "blocklist_url", 14 | "cache_size_mb", 15 | "config_dir", 16 | "default_trackers", 17 | "dht_enabled", 18 | "download_dir", 19 | "download_queue_enabled", 20 | "download_queue_size", 21 | "encryption", 22 | "idle_seeding_limit_enabled", 23 | "idle_seeding_limit", 24 | "incomplete_dir_enabled", 25 | "incomplete_dir", 26 | "lpd_enabled", 27 | "peer_limit_global", 28 | "peer_limit_per_torrent", 29 | "peer_port_random_on_start", 30 | "peer_port", 31 | "pex_enabled", 32 | "port_forwarding_enabled", 33 | "queue_stalled_enabled", 34 | "queue_stalled_minutes", 35 | "rename_partial_files", 36 | "reqq", 37 | "rpc_version_minimum", 38 | "rpc_version_semver", 39 | "rpc_version", 40 | "script_torrent_added_enabled", 41 | "script_torrent_added_filename", 42 | "script_torrent_done_enabled", 43 | "script_torrent_done_filename", 44 | "script_torrent_done_seeding_enabled", 45 | "script_torrent_done_seeding_filename", 46 | "seed_queue_enabled", 47 | "seed_queue_size", 48 | "seed_ratio_limit", 49 | "seed_ratio_limited", 50 | "session_id", 51 | "speed_limit_down_enabled", 52 | "speed_limit_down", 53 | "speed_limit_up_enabled", 54 | "speed_limit_up", 55 | "start_added_torrents", 56 | "trash_original_torrent_files", 57 | "units", 58 | "utp_enabled", 59 | "version", 60 | ] 61 | -------------------------------------------------------------------------------- /clutch/schema/user/method/session/mutator.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from clutch.schema.user.method.session.shared import Units 4 | 5 | 6 | class SessionMutatorArguments(TypedDict, total=False): 7 | alt_speed_down: int 8 | alt_speed_enabled: bool 9 | alt_speed_time_begin: int 10 | alt_speed_time_enabled: bool 11 | alt_speed_time_end: int 12 | alt_speed_time_day: int 13 | alt_speed_up: int 14 | blocklist_url: str 15 | blocklist_enabled: bool 16 | cache_size_mb: int 17 | download_dir: str 18 | download_queue_size: int 19 | download_queue_enabled: bool 20 | dht_enabled: bool 21 | encryption: str 22 | idle_seeding_limit: int 23 | idle_seeding_limit_enabled: bool 24 | incomplete_dir: str 25 | incomplete_dir_enabled: bool 26 | lpd_enabled: bool 27 | peer_limit_global: int 28 | peer_limit_per_torrent: int 29 | pex_enabled: bool 30 | peer_port: int 31 | peer_port_random_on_start: bool 32 | port_forwarding_enabled: bool 33 | queue_stalled_enabled: bool 34 | queue_stalled_minutes: int 35 | rename_partial_files: bool 36 | script_torrent_done_filename: str 37 | script_torrent_done_enabled: bool 38 | seed_ratio_limit: float 39 | seed_ratio_limited: bool 40 | seed_queue_size: int 41 | seed_queue_enabled: bool 42 | speed_limit_down: int 43 | speed_limit_down_enabled: bool 44 | speed_limit_up: int 45 | speed_limit_up_enabled: bool 46 | start_added_torrents: bool 47 | trash_original_torrent_files: bool 48 | units: Units 49 | utp_enabled: bool 50 | -------------------------------------------------------------------------------- /clutch/schema/user/method/session/shared.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Tuple, TypedDict 2 | 3 | # Todo: change these values, match to response schema 4 | DataRateUnits = Tuple[ 5 | Literal["kB/s"], Literal["MB/s"], Literal["GB/s"], Literal["TB/s"] 6 | ] 7 | DataSizeUnits = Tuple[Literal["kB"], Literal["MB"], Literal["GB"], Literal["TB"]] 8 | ByteDefinition = Literal[1000, 1024] 9 | 10 | 11 | class Units(TypedDict): 12 | speed_units: DataRateUnits 13 | speed_bytes: ByteDefinition 14 | size_units: DataSizeUnits 15 | size_bytes: ByteDefinition 16 | memory_units: DataSizeUnits 17 | memory_bytes: ByteDefinition 18 | -------------------------------------------------------------------------------- /clutch/schema/user/method/shared.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union, Iterable 2 | 3 | TorrentId = Union[int, str] 4 | 5 | IdsArg = Union[ 6 | int, Iterable[TorrentId], Iterable[int], Iterable[str], Literal["recently_active"] 7 | ] 8 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/method/torrent/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/accessor.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Set, TypedDict 2 | 3 | from clutch.schema.user.method.shared import IdsArg 4 | 5 | TorrentAccessorField = Literal[ 6 | "activity_date", 7 | "added_date", 8 | "availability", 9 | "bandwidth_priority", 10 | "comment", 11 | "corrupt_ever", 12 | "creator", 13 | "date_created", 14 | "desired_available", 15 | "done_date", 16 | "download_dir", 17 | "downloaded_ever", 18 | "download_limit", 19 | "download_limited", 20 | "edit_date", 21 | "error", 22 | "error_string", 23 | "eta", 24 | "eta_idle", 25 | "file_count", 26 | "files", 27 | "file_stats", 28 | "group", 29 | "hash_string", 30 | "have_unchecked", 31 | "have_valid", 32 | "honors_session_limits", 33 | "id", 34 | "is_finished", 35 | "is_private", 36 | "is_stalled", 37 | "labels", 38 | "left_until_done", 39 | "magnet_link", 40 | "manual_announce_time", 41 | "max_connected_peers", 42 | "metadata_percent_complete", 43 | "name", 44 | "peer_limit", 45 | "peers", 46 | "peers_connected", 47 | "peers_from", 48 | "peers_getting_from_us", 49 | "peers_sending_to_us", 50 | "percent_complete", 51 | "percent_done", 52 | "pieces", 53 | "piece_count", 54 | "piece_size", 55 | "priorities", 56 | "primary_mime_type", 57 | "queue_position", 58 | "rate_download", 59 | "rate_upload", 60 | "recheck_progress", 61 | "seconds_downloading", 62 | "seconds_seeding", 63 | "seed_idle_limit", 64 | "seed_idle_mode", 65 | "seed_ratio_limit", 66 | "seed_ratio_mode", 67 | "sequential_download", 68 | "size_when_done", 69 | "start_date", 70 | "status", 71 | "trackers", 72 | "tracker_list", 73 | "tracker_stats", 74 | "total_size", 75 | "torrent_file", 76 | "uploaded_ever", 77 | "upload_limit", 78 | "upload_limited", 79 | "upload_ratio", 80 | "wanted", 81 | "webseeds", 82 | "webseeds_sending_to_us", 83 | ] 84 | 85 | TorrentAccessorFields = Set[TorrentAccessorField] 86 | 87 | 88 | class TorrentAccessorArgumentsOptional(TypedDict, total=False): 89 | ids: IdsArg 90 | format: Literal["objects", "table"] 91 | 92 | 93 | class TorrentAccessorArguments(TorrentAccessorArgumentsOptional): 94 | fields: TorrentAccessorFields 95 | 96 | 97 | field_keys: set[TorrentAccessorField] = { 98 | "activity_date", 99 | "added_date", 100 | "availability", 101 | "bandwidth_priority", 102 | "comment", 103 | "corrupt_ever", 104 | "creator", 105 | "date_created", 106 | "desired_available", 107 | "done_date", 108 | "download_dir", 109 | "downloaded_ever", 110 | "download_limit", 111 | "download_limited", 112 | "edit_date", 113 | "error", 114 | "error_string", 115 | "eta", 116 | "eta_idle", 117 | "file_count", 118 | "files", 119 | "file_stats", 120 | "group", 121 | "hash_string", 122 | "have_unchecked", 123 | "have_valid", 124 | "honors_session_limits", 125 | "id", 126 | "is_finished", 127 | "is_private", 128 | "is_stalled", 129 | "labels", 130 | "left_until_done", 131 | "magnet_link", 132 | "manual_announce_time", 133 | "max_connected_peers", 134 | "metadata_percent_complete", 135 | "name", 136 | "peer_limit", 137 | "peers", 138 | "peers_connected", 139 | "peers_from", 140 | "peers_getting_from_us", 141 | "peers_sending_to_us", 142 | "percent_complete", 143 | "percent_done", 144 | "pieces", 145 | "piece_count", 146 | "piece_size", 147 | "priorities", 148 | "primary_mime_type", 149 | "queue_position", 150 | "rate_download", 151 | "rate_upload", 152 | "recheck_progress", 153 | "seconds_downloading", 154 | "seconds_seeding", 155 | "seed_idle_limit", 156 | "seed_idle_mode", 157 | "seed_ratio_limit", 158 | "seed_ratio_mode", 159 | "sequential_download", 160 | "size_when_done", 161 | "start_date", 162 | "status", 163 | "trackers", 164 | "tracker_list", 165 | "tracker_stats", 166 | "total_size", 167 | "torrent_file", 168 | "uploaded_ever", 169 | "upload_limit", 170 | "upload_limited", 171 | "upload_ratio", 172 | "wanted", 173 | "webseeds", 174 | "webseeds_sending_to_us", 175 | } 176 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/action.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class TorrentActionMethod(Enum): 6 | START = "torrent-start" 7 | START_NOW = "torrent-start-now" 8 | STOP = "torrent-stop" 9 | VERIFY = "torrent-verify" 10 | REANNOUNCE = "torrent-reannounce" 11 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/add.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence, TypedDict, Union 2 | 3 | 4 | class Cookie(TypedDict): 5 | name: str 6 | content: str 7 | 8 | 9 | class TorrentAddArgumentsOptional(TypedDict, total=False): 10 | cookies: Sequence[Cookie] 11 | download_dir: str 12 | paused: bool 13 | peer_limit: int 14 | bandwidth_priority: int 15 | files_wanted: Sequence[int] 16 | files_unwanted: Sequence[int] 17 | priority_high: Sequence[int] 18 | priority_low: Sequence[int] 19 | priority_normal: Sequence[int] 20 | 21 | 22 | class TorrentAddByFilenameArguments(TorrentAddArgumentsOptional): 23 | filename: str 24 | 25 | 26 | class TorrentAddByMetainfoArguments(TorrentAddArgumentsOptional): 27 | metainfo: str 28 | 29 | 30 | TorrentAddArguments = Union[ 31 | TorrentAddByFilenameArguments, TorrentAddByMetainfoArguments 32 | ] 33 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/move.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from clutch.schema.user.method.shared import IdsArg 4 | 5 | 6 | class TorrentMoveArgumentsOptional(TypedDict, total=False): 7 | move: bool 8 | 9 | 10 | class TorrentMoveArguments(TorrentMoveArgumentsOptional): 11 | ids: IdsArg 12 | location: str 13 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/mutator.py: -------------------------------------------------------------------------------- 1 | from typing import NewType, Sequence, TypedDict 2 | 3 | from clutch.schema.user.method.shared import IdsArg 4 | 5 | Url = NewType("Url", str) 6 | 7 | 8 | class TorrentMutatorArguments(TypedDict, total=False): 9 | bandwidth_priority: int 10 | download_limit: int 11 | download_limited: bool 12 | files_wanted: Sequence[int] # empty is shorthand for all 13 | files_unwanted: Sequence[int] # empty is shorthand for all 14 | group: str 15 | honors_session_limits: bool 16 | ids: IdsArg 17 | labels: Sequence[str] 18 | location: str 19 | peer_limit: int 20 | priority_high: Sequence[int] # empty is shorthand for all 21 | priority_low: Sequence[int] # empty is shorthand for all 22 | priority_normal: Sequence[int] # empty is shorthand for all 23 | queue_position: int 24 | seed_idle_limit: int 25 | seed_idle_mode: int 26 | seed_ratio_limit: float 27 | seed_ratio_mode: int 28 | sequential_download: bool 29 | tracker_list: Sequence[Sequence[Url]] 30 | upload_limit: int 31 | upload_limited: bool 32 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/remove.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from clutch.schema.user.method.shared import IdsArg 4 | 5 | 6 | class TorrentRemoveArgumentsOptional(TypedDict, total=False): 7 | delete_local_data: bool 8 | 9 | 10 | class TorrentRemoveArguments(TorrentRemoveArgumentsOptional): 11 | ids: IdsArg 12 | -------------------------------------------------------------------------------- /clutch/schema/user/method/torrent/rename.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence, TypedDict, Union 2 | 3 | 4 | class TorrentRenameArguments(TypedDict): 5 | ids: Sequence[Union[str, int]] 6 | path: str 7 | name: str 8 | -------------------------------------------------------------------------------- /clutch/schema/user/response/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/response/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/response/misc.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from clutch.schema.user.method.misc import IpProtocol 4 | 5 | 6 | class BlocklistResponse(BaseModel): 7 | blocklist_size: int 8 | 9 | 10 | class PortTestResponse(BaseModel): 11 | port_is_open: int 12 | ip_protocol: IpProtocol | None = None 13 | 14 | 15 | class FreeSpaceResponse(BaseModel): 16 | path: str 17 | size_bytes: int 18 | total_size: int 19 | 20 | 21 | class BandwidthGroup(BaseModel): 22 | honors_session_limits: bool 23 | name: str 24 | speed_limit_down_enabled: bool 25 | speed_limit_down: int 26 | speed_limit_up_enabled: bool 27 | speed_limit_up: int 28 | 29 | 30 | class BandwidthGroupResponse(BaseModel): 31 | group: list[BandwidthGroup] 32 | -------------------------------------------------------------------------------- /clutch/schema/user/response/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/response/session/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/response/session/accessor.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any, Literal, Tuple, Union 2 | 3 | from pydantic import AfterValidator, BaseModel 4 | 5 | BinaryDataRateUnits = Tuple[ 6 | Literal["KiB/s"], Literal["MiB/s"], Literal["GiB/s"], Literal["TiB/s"] 7 | ] 8 | DecimalDataRateUnits = Tuple[ 9 | Literal["kB/s", "KB/s"], Literal["MB/s"], Literal["GB/s"], Literal["TB/s"] 10 | ] 11 | DataRateUnits = Union[BinaryDataRateUnits, DecimalDataRateUnits] 12 | 13 | BinaryDataSizeUnits = Tuple[ 14 | Literal["KiB"], Literal["MiB"], Literal["GiB"], Literal["TiB"] 15 | ] 16 | DecimalDataSizeUnits = Tuple[ 17 | Literal["kB", "KB"], Literal["MB"], Literal["GB"], Literal["TB"] 18 | ] 19 | DataSizeUnits = Union[BinaryDataSizeUnits, DecimalDataSizeUnits] 20 | 21 | ByteDefinition = Literal[1000, 1024] 22 | 23 | 24 | class Units(BaseModel): 25 | speed_units: DataRateUnits 26 | speed_bytes: ByteDefinition 27 | size_units: DataSizeUnits 28 | size_bytes: ByteDefinition 29 | memory_units: DataSizeUnits 30 | memory_bytes: ByteDefinition 31 | 32 | 33 | def validate_tiers(v: Any) -> list[list[str]]: 34 | if not isinstance(v, str): 35 | raise ValueError(f"value {v} is not a string") 36 | return [x.split() for x in v.split("\n\n")] 37 | 38 | 39 | class SessionAccessor(BaseModel): 40 | alt_speed_down: int | None 41 | alt_speed_enabled: bool | None 42 | alt_speed_time_begin: int | None 43 | alt_speed_time_enabled: bool | None 44 | alt_speed_time_end: int | None 45 | alt_speed_time_day: int | None 46 | alt_speed_up: int | None 47 | blocklist_url: str | None 48 | blocklist_size: int | None 49 | blocklist_enabled: bool | None 50 | cache_size_mb: int | None 51 | config_dir: str | None 52 | default_trackers: Annotated[list[list[str]] | None, AfterValidator(validate_tiers)] 53 | download_dir: str | None 54 | download_queue_size: int | None 55 | download_queue_enabled: bool | None 56 | dht_enabled: bool | None 57 | encryption: str | None 58 | idle_seeding_limit: int | None 59 | idle_seeding_limit_enabled: bool | None 60 | incomplete_dir: str | None 61 | incomplete_dir_enabled: bool | None 62 | lpd_enabled: bool | None 63 | peer_limit_global: int | None 64 | peer_limit_per_torrent: int | None 65 | pex_enabled: bool | None 66 | peer_port: int | None 67 | peer_port_random_on_start: bool | None 68 | port_forwarding_enabled: bool | None 69 | queue_stalled_enabled: bool | None 70 | queue_stalled_minutes: int | None 71 | rename_partial_files: bool | None 72 | reqq: int | None 73 | rpc_version: int | None 74 | rpc_version_minimum: int | None 75 | rpc_version_semver: str | None 76 | script_torrent_added_enabled: bool | None 77 | script_torrent_added_filename: str | None 78 | script_torrent_done_seeding_enabled: bool | None 79 | script_torrent_done_seeding_filename: str | None 80 | script_torrent_done_filename: str | None 81 | script_torrent_done_enabled: bool | None 82 | seed_ratio_limit: float | None 83 | seed_ratio_limited: bool | None 84 | seed_queue_size: int | None 85 | seed_queue_enabled: bool | None 86 | session_id: str | None 87 | speed_limit_down: int | None 88 | speed_limit_down_enabled: bool | None 89 | speed_limit_up: int | None 90 | speed_limit_up_enabled: bool | None 91 | start_added_torrents: bool | None 92 | trash_original_torrent_files: bool | None 93 | units: Units | None 94 | utp_enabled: bool | None 95 | version: str | None 96 | -------------------------------------------------------------------------------- /clutch/schema/user/response/session/stats.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Stats(BaseModel): 5 | uploaded_bytes: int 6 | downloaded_bytes: int 7 | files_added: int 8 | session_count: int 9 | seconds_active: int 10 | 11 | 12 | class SessionStats(BaseModel): 13 | active_torrent_count: int 14 | download_speed: int 15 | paused_torrent_count: int 16 | torrent_count: int 17 | upload_speed: int 18 | cumulative_stats: Stats 19 | current_stats: Stats 20 | -------------------------------------------------------------------------------- /clutch/schema/user/response/torrent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/clutch/schema/user/response/torrent/__init__.py -------------------------------------------------------------------------------- /clutch/schema/user/response/torrent/accessor.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | from typing import Sequence, Union 3 | 4 | from pydantic import BaseModel 5 | 6 | from clutch.schema.user.method.torrent.accessor import TorrentAccessorField 7 | 8 | 9 | @unique 10 | class Status(Enum): 11 | STOPPED = 0 # Torrent is stopped 12 | CHECK_WAIT = 1 # Torrent is queued to verify local data 13 | CHECK = 2 # Torrent is verifying local data 14 | DOWNLOAD_WAIT = 3 # Torrent is queued to download 15 | DOWNLOAD = 4 # Torrent is downloading 16 | SEED_WAIT = 5 # Torrent is queued to seed 17 | SEED = 6 # Torrent is seeding 18 | 19 | 20 | class File(BaseModel): 21 | bytes_completed: int 22 | length: int 23 | name: str 24 | begin_piece: int 25 | end_piece: int 26 | 27 | 28 | class FileStats(BaseModel): 29 | bytes_completed: int 30 | wanted: bool 31 | priority: int 32 | 33 | 34 | class Peer(BaseModel): 35 | address: str 36 | client_name: str 37 | client_is_choked: bool 38 | client_is_interested: bool 39 | flag_str: str 40 | is_downloading_from: bool 41 | is_encrypted: bool 42 | is_incoming: bool 43 | is_uploading_to: bool 44 | is_utp: bool 45 | peer_is_choked: bool 46 | peer_is_interested: bool 47 | port: int 48 | progress: float 49 | rate_to_client: int # B/s 50 | rate_to_peer: int # B/s 51 | 52 | 53 | class PeersFrom(BaseModel): 54 | from_cache: int 55 | from_dht: int 56 | from_incoming: int 57 | from_lpd: int 58 | from_ltep: int 59 | from_pex: int 60 | from_tracker: int 61 | 62 | 63 | class Tracker(BaseModel): 64 | announce: str 65 | id: int 66 | scrape: str 67 | sitename: str 68 | tier: int 69 | 70 | 71 | class TrackerStat(BaseModel): 72 | announce: str 73 | announce_state: int 74 | download_count: int 75 | has_announced: bool 76 | has_scraped: bool 77 | host: str 78 | id: int 79 | is_backup: bool 80 | last_announce_peer_count: int 81 | last_announce_result: str 82 | last_announce_start_time: int 83 | last_announce_succeeded: bool 84 | last_announce_time: int 85 | last_announce_timed_out: bool 86 | last_scrape_result: str 87 | last_scrape_start_time: int 88 | last_scrape_succeeded: bool 89 | last_scrape_time: int 90 | last_scrape_timed_out: bool 91 | leecher_count: int 92 | next_announce_time: int 93 | next_scrape_time: int 94 | scrape: str 95 | scrape_state: int 96 | seeder_count: int 97 | sitename: str 98 | tier: int 99 | 100 | 101 | class TorrentAccessorObject(BaseModel): 102 | activity_date: int | None = None 103 | added_date: int | None = None 104 | availability: list[int] | None = ( 105 | None # An array of `pieceCount` numbers representing the number of 106 | ) 107 | # connected peers that have each piece, or -1 if we already have the piece ourselves. 108 | bandwidth_priority: int | None = None 109 | comment: str | None = None 110 | corrupt_ever: int | None = None 111 | creator: str | None = None 112 | date_created: int | None = None 113 | desired_available: int | None = None 114 | done_date: int | None = None 115 | download_dir: str | None = None 116 | downloaded_ever: int | None = None 117 | download_limit: int | None = None 118 | download_limited: bool | None = None 119 | edit_date: int | None = None 120 | error: int | None = None 121 | error_string: str | None = None 122 | eta: int | None = None 123 | eta_idle: int | None = None 124 | file_count: int | None = None 125 | files: Sequence[File] | None = None 126 | file_stats: Sequence[FileStats] | None = None 127 | group: str | None = None 128 | hash_string: str | None = None 129 | have_unchecked: int | None = None 130 | have_valid: int | None = None 131 | honors_session_limits: bool | None = None 132 | id: int | None = None 133 | is_finished: bool | None = None 134 | is_private: bool | None = None 135 | is_stalled: bool | None = None 136 | labels: Sequence[str] | None = None 137 | left_until_done: int | None = None 138 | magnet_link: str | None = None 139 | manual_announce_time: int | None = None 140 | max_connected_peers: int | None = None 141 | metadata_percent_complete: float | None = None 142 | name: str | None = None 143 | peer_limit: int | None = None 144 | peers: Sequence[Peer] | None = None 145 | peers_connected: int | None = None 146 | peers_from: PeersFrom | None = None 147 | peers_getting_from_us: int | None = None 148 | peers_sending_to_us: int | None = None 149 | percent_complete: float | None = None 150 | percent_done: float | None = None 151 | pieces: str | None = None 152 | piece_count: int | None = None 153 | piece_size: int | None = None 154 | priorities: Sequence[int] | None = None 155 | primary_mime_type: str | None = None 156 | queue_position: int | None = None 157 | rate_download: int | None = None 158 | rate_upload: int | None = None 159 | recheck_progress: float | None = None 160 | seconds_downloading: int | None = None 161 | seconds_seeding: int | None = None 162 | seed_idle_limit: int | None = None 163 | seed_idle_mode: int | None = None 164 | seed_ratio_limit: float | None = None 165 | seed_ratio_mode: int | None = None 166 | sequential_download: bool | None = None 167 | size_when_done: int | None = None 168 | start_date: int | None = None 169 | status: Status | None = None 170 | trackers: Sequence[Tracker] | None = None 171 | tracker_list: str | None = ( 172 | None # string of announce URLs, one per line, with a blank line between tiers 173 | ) 174 | tracker_stats: Sequence[TrackerStat] | None = None 175 | total_size: int | None = None 176 | torrent_file: str | None = None 177 | uploaded_ever: int | None = None 178 | upload_limit: int | None = None 179 | upload_limited: bool | None = None 180 | upload_ratio: float | None = None 181 | wanted: Sequence[bool] | None = ( 182 | None # An array of `tr_torrentFileCount()` 0/1, 1 (true) if the corresponding file is to be downloaded. 183 | ) 184 | webseeds: Sequence[str] | None = None 185 | webseeds_sending_to_us: int | None = None 186 | 187 | 188 | TorrentAccessorHeader = Sequence[TorrentAccessorField] 189 | 190 | 191 | TorrentAccessorTable = Union[Sequence, TorrentAccessorHeader] 192 | 193 | 194 | class TorrentAccessorResponse(BaseModel): 195 | removed: Sequence[int] | None = None 196 | torrents: ( 197 | Union[Sequence[TorrentAccessorObject], Sequence[TorrentAccessorTable]] | None 198 | ) = None 199 | -------------------------------------------------------------------------------- /clutch/schema/user/response/torrent/add.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | 3 | from pydantic import BaseModel, model_validator 4 | 5 | 6 | class Torrent(BaseModel): 7 | id: int 8 | name: str 9 | hash_string: str 10 | 11 | 12 | class TorrentAdd(BaseModel): 13 | torrent_added: Torrent | None = None 14 | torrent_duplicate: Torrent | None = None 15 | 16 | @model_validator(mode="after") 17 | def check_exclusive_fields(self) -> Self: 18 | if self.torrent_added is not None and self.torrent_duplicate is not None: 19 | raise ValueError("Both torrent added and duplicate fields in response") 20 | return self 21 | -------------------------------------------------------------------------------- /clutch/schema/user/response/torrent/rename.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class TorrentRename(BaseModel): 5 | path: str 6 | name: str 7 | id: int 8 | -------------------------------------------------------------------------------- /docker/clutch.df: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | ENV YOUR_ENV=${YOUR_ENV} \ 4 | PYTHONFAULTHANDLER=1 \ 5 | PYTHONUNBUFFERED=1 \ 6 | PYTHONHASHSEED=random \ 7 | PIP_NO_CACHE_DIR=off \ 8 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 9 | PIP_DEFAULT_TIMEOUT=100 \ 10 | POETRY_VERSION=1.0.2 11 | 12 | # Seemingly needed to compile poetry -- need to cut this down 13 | RUN apk update && apk add gcc libc-dev make git libffi-dev openssl-dev python3-dev libxml2-dev libxslt-dev 14 | 15 | # System deps: 16 | RUN pip install "poetry==$POETRY_VERSION" 17 | 18 | # Copy only requirements to cache them in docker layer 19 | WORKDIR /code 20 | COPY poetry.lock pyproject.toml /code/ 21 | COPY ./docker/integration_resources/client_setup.py /code/ 22 | 23 | # Project initialization: 24 | RUN poetry config virtualenvs.create false \ 25 | && poetry install $(test "$YOUR_ENV" == production && echo "--no-dev") --no-interaction --no-ansi --no-root 26 | 27 | # Creating folders, and files for a project: 28 | COPY . /code 29 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | transmission: 4 | build: 5 | context: .. 6 | dockerfile: docker/transmission.df 7 | testbed: 8 | build: 9 | context: .. 10 | dockerfile: docker/clutch.df 11 | depends_on: 12 | - transmission 13 | start_dependencies: 14 | build: 15 | context: . 16 | dockerfile: integration-wait.df 17 | depends_on: 18 | - transmission 19 | command: transmission:9091 20 | -------------------------------------------------------------------------------- /docker/integration-test-wait.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | : "${SLEEP_LENGTH:=2}" 4 | : "${TIMEOUT_LENGTH:=300}" 5 | 6 | wait_for() { 7 | START=$(date +%s) 8 | echo "Waiting for $1 to listen on $2..." 9 | while ! nc -z "$1" "$2"; 10 | do 11 | if [ $(($(date +%s) - START)) -gt $TIMEOUT_LENGTH ]; then 12 | echo "Service $1:$2 did not start within $TIMEOUT_LENGTH seconds. Aborting..." 13 | exit 1 14 | fi 15 | echo "sleeping" 16 | sleep $SLEEP_LENGTH 17 | done 18 | } 19 | 20 | for var in "$@" 21 | do 22 | host="${var%:*}" 23 | port="${var#*:}" 24 | wait_for "$host" "$port" 25 | done 26 | -------------------------------------------------------------------------------- /docker/integration-wait.df: -------------------------------------------------------------------------------- 1 | FROM alpine:3.6 2 | 3 | ADD integration-test-wait.sh /usr/local/bin/entrypoint.sh 4 | 5 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 6 | -------------------------------------------------------------------------------- /docker/integration_resources/client_setup.py: -------------------------------------------------------------------------------- 1 | from clutch import Client 2 | 3 | client = Client(host="transmission") 4 | -------------------------------------------------------------------------------- /docker/integration_resources/watch/ion.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/docker/integration_resources/watch/ion.torrent -------------------------------------------------------------------------------- /docker/integration_resources/watch/little_women.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/docker/integration_resources/watch/little_women.torrent -------------------------------------------------------------------------------- /docker/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rpc-enabled" : "true", 3 | "rpc-host-whitelist-enabled": true, 4 | "rpc-host-whitelist": "transmission", 5 | "rpc-whitelist-enabled": false, 6 | "watch-dir-enabled": true, 7 | "watch-dir": "/" 8 | } 9 | -------------------------------------------------------------------------------- /docker/transmission.df: -------------------------------------------------------------------------------- 1 | FROM archlinux:latest 2 | RUN pacman -Syu --noconfirm 3 | 4 | ARG APPROOT=/app 5 | ENV APPROOT $APPROOT 6 | 7 | # Install transmission: 8 | RUN pacman -S --noconfirm transmission-cli 9 | 10 | WORKDIR $APPROOT 11 | COPY ./docker/settings.json $APPROOT 12 | COPY ./docker/integration_resources $APPROOT/integration_resources 13 | # exposes RPC port 14 | EXPOSE 9091 15 | # 51413/tcp 51413/udp 16 | ENTRYPOINT transmission-daemon --log-debug -f -c $APPROOT/integration_resources/watch -w $APPROOT/integration_resources/data -g $APPROOT 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/clutch.method.rst: -------------------------------------------------------------------------------- 1 | clutch.method package 2 | ===================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.method.method module 8 | --------------------------- 9 | 10 | .. automodule:: clutch.method.method 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.method.misc module 16 | ------------------------- 17 | 18 | .. automodule:: clutch.method.misc 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.method.queue module 24 | -------------------------- 25 | 26 | .. automodule:: clutch.method.queue 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | clutch.method.session module 32 | ---------------------------- 33 | 34 | .. automodule:: clutch.method.session 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | clutch.method.shared module 40 | --------------------------- 41 | 42 | .. automodule:: clutch.method.shared 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | clutch.method.torrent module 48 | ---------------------------- 49 | 50 | .. automodule:: clutch.method.torrent 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | Module contents 56 | --------------- 57 | 58 | .. automodule:: clutch.method 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | -------------------------------------------------------------------------------- /docs/source/clutch.network.rpc.rst: -------------------------------------------------------------------------------- 1 | clutch.network.rpc package 2 | ========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.network.rpc.convert module 8 | --------------------------------- 9 | 10 | .. automodule:: clutch.network.rpc.convert 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.network.rpc.message module 16 | --------------------------------- 17 | 18 | .. automodule:: clutch.network.rpc.message 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: clutch.network.rpc 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/source/clutch.network.rst: -------------------------------------------------------------------------------- 1 | clutch.network package 2 | ====================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.network.rpc 11 | 12 | Submodules 13 | ---------- 14 | 15 | clutch.network.connection module 16 | -------------------------------- 17 | 18 | .. automodule:: clutch.network.connection 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.network.session module 24 | ----------------------------- 25 | 26 | .. automodule:: clutch.network.session 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | clutch.network.utility module 32 | ----------------------------- 33 | 34 | .. automodule:: clutch.network.utility 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: clutch.network 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /docs/source/clutch.rst: -------------------------------------------------------------------------------- 1 | clutch package 2 | ============== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.method 11 | clutch.network 12 | clutch.schema 13 | 14 | Submodules 15 | ---------- 16 | 17 | clutch.client module 18 | -------------------- 19 | 20 | .. automodule:: clutch.client 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | 25 | Module contents 26 | --------------- 27 | 28 | .. automodule:: clutch 29 | :members: 30 | :undoc-members: 31 | :show-inheritance: 32 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.request.misc.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.request.misc package 2 | ================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.request.misc.bandwidth\_group module 8 | -------------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.request.misc.bandwidth_group 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.request.misc.port\_test module 16 | -------------------------------------------- 17 | 18 | .. automodule:: clutch.schema.request.misc.port_test 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: clutch.schema.request.misc 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.request.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.request package 2 | ============================= 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.schema.request.misc 11 | clutch.schema.request.session 12 | clutch.schema.request.torrent 13 | 14 | Module contents 15 | --------------- 16 | 17 | .. automodule:: clutch.schema.request 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.request.session.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.request.session package 2 | ===================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.request.session.accessor module 8 | --------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.request.session.accessor 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.request.session.mutator module 16 | -------------------------------------------- 17 | 18 | .. automodule:: clutch.schema.request.session.mutator 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.schema.request.session.shared module 24 | ------------------------------------------- 25 | 26 | .. automodule:: clutch.schema.request.session.shared 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | Module contents 32 | --------------- 33 | 34 | .. automodule:: clutch.schema.request.session 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.request.torrent.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.request.torrent package 2 | ===================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.request.torrent.accessor module 8 | --------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.request.torrent.accessor 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.request.torrent.add module 16 | ---------------------------------------- 17 | 18 | .. automodule:: clutch.schema.request.torrent.add 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.schema.request.torrent.mutator module 24 | -------------------------------------------- 25 | 26 | .. automodule:: clutch.schema.request.torrent.mutator 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | Module contents 32 | --------------- 33 | 34 | .. automodule:: clutch.schema.request.torrent 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.rst: -------------------------------------------------------------------------------- 1 | clutch.schema package 2 | ===================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.schema.request 11 | clutch.schema.user 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: clutch.schema 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.method.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user.method package 2 | ================================= 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.schema.user.method.session 11 | clutch.schema.user.method.torrent 12 | 13 | Submodules 14 | ---------- 15 | 16 | clutch.schema.user.method.misc module 17 | ------------------------------------- 18 | 19 | .. automodule:: clutch.schema.user.method.misc 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | clutch.schema.user.method.shared module 25 | --------------------------------------- 26 | 27 | .. automodule:: clutch.schema.user.method.shared 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: clutch.schema.user.method 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.method.session.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user.method.session package 2 | ========================================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.user.method.session.accessor module 8 | ------------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.user.method.session.accessor 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.user.method.session.mutator module 16 | ------------------------------------------------ 17 | 18 | .. automodule:: clutch.schema.user.method.session.mutator 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.schema.user.method.session.shared module 24 | ----------------------------------------------- 25 | 26 | .. automodule:: clutch.schema.user.method.session.shared 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | Module contents 32 | --------------- 33 | 34 | .. automodule:: clutch.schema.user.method.session 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.method.torrent.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user.method.torrent package 2 | ========================================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.user.method.torrent.accessor module 8 | ------------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.user.method.torrent.accessor 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.user.method.torrent.action module 16 | ----------------------------------------------- 17 | 18 | .. automodule:: clutch.schema.user.method.torrent.action 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.schema.user.method.torrent.add module 24 | -------------------------------------------- 25 | 26 | .. automodule:: clutch.schema.user.method.torrent.add 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | clutch.schema.user.method.torrent.move module 32 | --------------------------------------------- 33 | 34 | .. automodule:: clutch.schema.user.method.torrent.move 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | clutch.schema.user.method.torrent.mutator module 40 | ------------------------------------------------ 41 | 42 | .. automodule:: clutch.schema.user.method.torrent.mutator 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | clutch.schema.user.method.torrent.remove module 48 | ----------------------------------------------- 49 | 50 | .. automodule:: clutch.schema.user.method.torrent.remove 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | clutch.schema.user.method.torrent.rename module 56 | ----------------------------------------------- 57 | 58 | .. automodule:: clutch.schema.user.method.torrent.rename 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | Module contents 64 | --------------- 65 | 66 | .. automodule:: clutch.schema.user.method.torrent 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.response.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user.response package 2 | =================================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.schema.user.response.session 11 | clutch.schema.user.response.torrent 12 | 13 | Submodules 14 | ---------- 15 | 16 | clutch.schema.user.response.misc module 17 | --------------------------------------- 18 | 19 | .. automodule:: clutch.schema.user.response.misc 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: clutch.schema.user.response 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.response.session.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user.response.session package 2 | =========================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.user.response.session.accessor module 8 | --------------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.user.response.session.accessor 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.user.response.session.stats module 16 | ------------------------------------------------ 17 | 18 | .. automodule:: clutch.schema.user.response.session.stats 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: clutch.schema.user.response.session 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.response.torrent.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user.response.torrent package 2 | =========================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | clutch.schema.user.response.torrent.accessor module 8 | --------------------------------------------------- 9 | 10 | .. automodule:: clutch.schema.user.response.torrent.accessor 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | clutch.schema.user.response.torrent.add module 16 | ---------------------------------------------- 17 | 18 | .. automodule:: clutch.schema.user.response.torrent.add 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | clutch.schema.user.response.torrent.rename module 24 | ------------------------------------------------- 25 | 26 | .. automodule:: clutch.schema.user.response.torrent.rename 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | Module contents 32 | --------------- 33 | 34 | .. automodule:: clutch.schema.user.response.torrent 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/source/clutch.schema.user.rst: -------------------------------------------------------------------------------- 1 | clutch.schema.user package 2 | ========================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | clutch.schema.user.method 11 | clutch.schema.user.response 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: clutch.schema.user 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /docs/source/commands.rst: -------------------------------------------------------------------------------- 1 | Commands 2 | ======== 3 | 4 | Transmission RPC specifies a variety of methods to call. 5 | 6 | In Clutch, those methods are implemented on :class:`~clutch.client.Client`. 7 | 8 | Groups 9 | ------ 10 | 11 | Client methods are organized into groups: :attr:`~clutch.client.Client.torrent`, :attr:`~clutch.client.Client.session`, :attr:`~clutch.client.Client.queue`, :attr:`~clutch.client.Client.misc`. 12 | 13 | Torrent 14 | ....... 15 | All torrent methods are found in :class:`~clutch.method.torrent.TorrentMethods`. 16 | 17 | Session 18 | ....... 19 | All session methods are found in :class:`~clutch.method.session.SessionMethods`. 20 | 21 | Queue 22 | ..... 23 | All queue methods are found in :class:`~clutch.method.queue.QueueMethods`. 24 | 25 | Misc 26 | .... 27 | All misc methods are found in :class:`~clutch.method.misc.MiscellaneousMethods`. 28 | 29 | Responses 30 | --------- 31 | Methods return a :class:`~clutch.network.rpc.message.Response` object that has three fields: ``result``, ``arguments`` and ``tag``. 32 | 33 | The :class:`~clutch.network.rpc.message.Response` object and :attr:`~clutch.network.rpc.message.Response.arguments` field are both `pydantic models`_. 34 | 35 | So they have some `useful methods for converting`_ into simple data formats like ``dict``: 36 | 37 | .. code-block:: python 38 | 39 | model.dict(...) 40 | 41 | Or a JSON string: 42 | 43 | .. code-block:: python 44 | 45 | model.json(...) 46 | 47 | To make a model with many fields more manageable, remove the clutter of empty fields using the option ``exclude_none``: 48 | 49 | .. code-block:: python 50 | 51 | model.dict(exclude_none=True) 52 | 53 | .. _pydantic models: https://pydantic-docs.helpmanual.io/usage/models/ 54 | .. _useful methods for converting: https://pydantic-docs.helpmanual.io/usage/exporting_models/ 55 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | import os 6 | import sys 7 | 8 | # -- Project information ----------------------------------------------------- 9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 10 | 11 | 12 | sys.path.insert(0, os.path.abspath("../../")) 13 | 14 | 15 | project = 'Clutch' 16 | copyright = '2025, Michael Hadam' 17 | author = 'Michael Hadam' 18 | release = '7.0.3' 19 | 20 | # -- General configuration --------------------------------------------------- 21 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 22 | 23 | extensions = [ 24 | "sphinx.ext.autodoc", 25 | "sphinx_autodoc_typehints", 26 | ] 27 | 28 | always_document_param_types = True 29 | set_type_checking_flag = True 30 | 31 | templates_path = ['_templates'] 32 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 33 | 34 | 35 | 36 | # -- Options for HTML output ------------------------------------------------- 37 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 38 | 39 | html_theme = 'alabaster' 40 | html_static_path = ['_static'] 41 | -------------------------------------------------------------------------------- /docs/source/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | -------- 3 | 4 | Create a client 5 | =============== 6 | .. code-block:: python 7 | 8 | client = Client() 9 | 10 | List all fields of current torrents 11 | =================================== 12 | 13 | .. code-block:: python 14 | 15 | response: Response[TorrentAccessorResponse] = client.torrent.accessor(all_fields=True) 16 | torrents: Sequence[TorrentAccessorObject] = response.arguments.torrents 17 | print(torrents[0].dict(exclude_none=True)) 18 | 19 | Get specific fields of torrents 20 | =============================== 21 | 22 | .. code-block:: python 23 | 24 | fields: Set[TorrentAccessorField] = {"id", "status", "name"} 25 | response: Response[TorrentAccessorResponse] = client.torrent.accessor(fields) 26 | torrents: Sequence[TorrentAccessorObject] = response.arguments.torrents 27 | torrent = torrents[0] 28 | torrent_id, torrent_status, torrent_name = torrent.id, torrent.status, torrent.name 29 | 30 | Remove torrents 31 | =============== 32 | 33 | .. code-block:: python 34 | 35 | response: Response[TorrentAccessorResponse] = client.torrent.remove( 36 | torrent_id, delete_local_data=False 37 | ) 38 | 39 | Add torrents 40 | =============== 41 | 42 | .. code-block:: python 43 | 44 | arguments: TorrentAddArguments = { 45 | "filename": "/path/to/file", 46 | "paused": True, 47 | } 48 | response: Response[TorrentAdd] = client.torrent.add(arguments) 49 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. meta:: 2 | :description: Transmission RPC library used to control the Transmission BitTorrent client. 3 | 4 | .. title:: Transmission RPC Library: Clutch 5 | 6 | Clutch - Transmission RPC for Python 7 | ==================================== 8 | 9 | Version |release|. 10 | 11 | .. image:: https://img.shields.io/github/stars/mhadam/clutch?style=social 12 | :target: https://github.com/mhadam/clutch 13 | :alt: GitHub stars badge 14 | 15 | .. image:: https://img.shields.io/pypi/v/transmission-clutch.svg 16 | :target: https://pypi.org/project/transmission-clutch 17 | :alt: PyPI badge 18 | 19 | ----- 20 | 21 | .. toctree:: 22 | :hidden: 23 | :maxdepth: 2 24 | :caption: Contents: 25 | 26 | intro 27 | examples 28 | server_config 29 | commands 30 | modules 31 | 32 | Clutch is an open-source Python library for talking to the `Transmission BitTorrent client`_ over RPC_. 33 | 34 | It's handy for doing things like: 35 | 36 | * automating work when managing torrents 37 | 38 | * user interfaces and clients 39 | 40 | * collecting torrent stats 41 | 42 | Installation 43 | ------------ 44 | 45 | Pip 46 | *** 47 | .. code-block:: console 48 | 49 | $ pip install transmission-clutch 50 | 51 | Poetry 52 | ****** 53 | .. code-block:: console 54 | 55 | $ poetry add transmission-clutch 56 | 57 | Get started 58 | ------------- 59 | 60 | Import the package and make a client: 61 | 62 | .. code-block:: python 63 | 64 | from clutch import Client 65 | client = Client(address="http://localhost:9091/transmission/rpc") 66 | 67 | Now issue a command to Transmission: 68 | 69 | .. code-block:: python 70 | 71 | >>> client.torrent.accessor(fields=['id', 'files'], ids=[1]).dict(exclude_none=True) 72 | {'result': 'success', 'arguments': {'torrents': [{'files': [{'bytes_completed': 1053440, 'length': 1053440, 'name': 'little_women/little_women.txt'}], 'id': 1}]}} 73 | >>> client.torrent.accessor(fields=['id', 'files'], ids=[1]).json(exclude_none=True) 74 | '{"result": "success", "arguments": {"torrents": [{"files": [{"bytes_completed": 1053440, "length": 1053440, "name": "little_women/little_women.txt"}], "id": 1}]}}' 75 | 76 | .. _RPC: https://en.wikipedia.org/wiki/Remote_procedure_call 77 | .. _`Transmission BitTorrent client`: https://transmissionbt.com 78 | -------------------------------------------------------------------------------- /docs/source/intro.rst: -------------------------------------------------------------------------------- 1 | Intro 2 | ===== 3 | 4 | Transmission comes with an `RPC server`_ with this `RPC protocol specification`_. 5 | 6 | When it receives a request, the server performs the requested action and sends back a response. 7 | 8 | RPC format 9 | ---------- 10 | 11 | To illustrate the form of request and response messages, the following parts contain some pseudo-JSON schema. 12 | 13 | Request 14 | ******* 15 | 16 | A request requires ``method`` be specified, ``arguments`` and ``tag`` are optional. 17 | 18 | ``tag`` can be provided for matching requests and responses. 19 | 20 | When the server responds, it includes the same ``tag`` that was provided in the request: 21 | 22 | .. code-block:: none 23 | 24 | { 25 | "method": str 26 | "arguments": Dict[str, object] 27 | "tag": int 28 | } 29 | 30 | Response 31 | ******** 32 | 33 | Likewise, ``result`` is the only required part. 34 | 35 | .. code-block:: none 36 | 37 | { 38 | "result": str ("success" on success otherwise an error message) 39 | "arguments": Dict[str, object] 40 | "tag": int 41 | } 42 | 43 | .. _`RPC server`: https://en.wikipedia.org/wiki/Remote_procedure_call 44 | .. _`RPC protocol specification`: https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md 45 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | clutch 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | clutch 8 | -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --only-group=docs --no-hashes 3 | alabaster==0.7.16 4 | anyio==4.8.0 5 | babel==2.16.0 6 | certifi==2024.12.14 7 | charset-normalizer==3.4.0 8 | click==8.1.7 9 | colorama==0.4.6 10 | docutils==0.21.2 11 | exceptiongroup==1.2.2 12 | h11==0.14.0 13 | idna==3.10 14 | imagesize==1.4.1 15 | jinja2==3.1.4 16 | markupsafe==3.0.2 17 | packaging==24.2 18 | pygments==2.18.0 19 | requests==2.32.3 20 | sniffio==1.3.1 21 | snowballstemmer==2.2.0 22 | sphinx==7.4.7 23 | sphinx-autobuild==2024.10.3 24 | sphinx-autodoc-typehints==2.3.0 25 | sphinxcontrib-applehelp==2.0.0 26 | sphinxcontrib-devhelp==2.0.0 27 | sphinxcontrib-htmlhelp==2.1.0 28 | sphinxcontrib-jsmath==1.0.1 29 | sphinxcontrib-qthelp==2.0.0 30 | sphinxcontrib-serializinghtml==2.0.0 31 | starlette==0.45.2 32 | tomli==2.2.1 33 | typing-extensions==4.12.2 34 | urllib3==2.2.3 35 | uvicorn==0.34.0 36 | watchfiles==1.0.4 37 | websockets==14.2 38 | -------------------------------------------------------------------------------- /docs/source/server_config.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ------------- 3 | By default, the RPC server listens at:: 4 | 5 | http://localhost:9091/transmission/rpc 6 | 7 | But this can be changed through configuration settings like ``rpc-bind-address`` and ``port``. 8 | 9 | Other useful settings include ``rpc-whitelist`` (for whitelisting IP addresses), and ``rpc-host-whitelist`` (for whitelisting domain names). 10 | 11 | GUI configuration 12 | ***************** 13 | 14 | MacOS client 15 | ............ 16 | In the MacOS desktop client, these settings are accessed through the system menu bar: 17 | 18 | :menuselection:`Transmission --> Preferences --> Remote` 19 | 20 | File configuration 21 | ****************** 22 | Settings can be manually specified in a platform-specific file, as well. 23 | 24 | Reference `Transmission's configuration file documentation`_ for all available settings, their default values, as well as where the configuration file is located for each platform. 25 | 26 | .. _`Transmission's configuration file documentation`: https://github.com/transmission/transmission/wiki/Editing-Configuration-Files 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "transmission-clutch" 3 | version = "7.0.3" 4 | description = "An RPC client library for the Transmission BitTorrent client" 5 | authors = [ 6 | { name = "Michael Hadam", email = "michael@hadam.us" } 7 | ] 8 | maintainers = [ 9 | { name = "Michael Hadam", email = "michael@hadam.us" } 10 | ] 11 | readme = "README.rst" 12 | license = "MIT" 13 | license-files = ["LICEN[CS]E*"] 14 | packages = [{ include = "clutch" }] 15 | requires-python = ">=3.10" 16 | dependencies = [ 17 | "requests~=2.32", 18 | "pydantic~=2.10" 19 | ] 20 | 21 | [project.urls] 22 | Documentation = "https://clutch.readthedocs.io/en/latest/" 23 | Repository = "https://github.com/mhadam/clutch" 24 | Issues = "https://github.com/mhadam/clutch/issues" 25 | 26 | [dependency-groups] 27 | test = [ 28 | "pytest-httpserver>=1.1.0", 29 | "pytest~=8.3", 30 | "deepdiff>=8.1.1", 31 | "dirty-equals>=0.9.0", 32 | ] 33 | dev = [ 34 | "ruff", 35 | "mypy", 36 | "hatchling>=1.27.0", 37 | "tomlscript", 38 | "types-requests>=2.32.0.20241016", 39 | ] 40 | docs = [ 41 | "pydantic", 42 | "sphinx", 43 | "sphinx-autodoc-typehints", 44 | "sphinx-autobuild", 45 | ] 46 | 47 | [build-system] 48 | requires = ["hatchling"] 49 | build-backend = "hatchling.build" 50 | 51 | [tool.hatch.build.targets.wheel] 52 | packages = ["clutch"] 53 | 54 | [tool.pytest.ini_options] 55 | log_cli = true 56 | log_cli_level = "INFO" 57 | log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" 58 | log_cli_date_format = "%Y-%m-%d %H:%M:%S" 59 | 60 | [tool.ruff] 61 | target-version = "py310" 62 | lint.extend-select = ["RUF013", "F401"] 63 | extend-include = ["clutch/", "tests/"] 64 | 65 | [tool.mypy] 66 | plugins = ["pydantic.mypy"] 67 | 68 | [tool.tomlscript] 69 | docs-autodoc = "uv run sphinx-apidoc -o source/ ../clutch" 70 | docs-autogen = "uv run sphinx-autogen source/index.rst -o source/" 71 | docs-reqs = "uv export --only-group=docs --no-hashes | awk '{print $1}' FS=' ;' > docs/requirements.txt" 72 | mypy = "uv run mypy clutch/ tests/" 73 | ruff = "ruff format" 74 | unit = """ 75 | docker build -f docker/clutch.df -t clutch-test . 76 | docker run --rm --entrypoint "/bin/sh" clutch-test -c "mypy .; pytest tests/unit" 77 | """ 78 | docs-gen = "uv run sphinx-autogen docs/index.rst -o docs/" 79 | docs-live = "uv run sphinx-autobuild docs docs/_build/html" 80 | clean-containers = """ 81 | docker stop $(docker ps -a -q) 82 | docker rm $(docker ps -a -q) 83 | """ 84 | integration-shell = """ 85 | docker-compose -f ./docker/docker-compose.yml up -d --force-recreate --no-deps --build testbed transmission 86 | docker-compose -f ./docker/docker-compose.yml run --rm start_dependencies 87 | docker-compose -f ./docker/docker-compose.yml run --rm testbed sh -c "python -i client_setup.py" 88 | """ 89 | end-to-end = """ 90 | docker-compose -f ./docker/docker-compose.yml up -d --force-recreate --no-deps --build testbed transmission 91 | docker-compose -f ./docker/docker-compose.yml run --rm start_dependencies 92 | docker-compose -f ./docker/docker-compose.yml run --rm testbed sh -c "pytest tests/endtoend" 93 | """ 94 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml -o requirements.txt 3 | annotated-types==0.7.0 4 | # via pydantic 5 | certifi==2024.12.14 6 | # via requests 7 | charset-normalizer==3.4.1 8 | # via requests 9 | idna==3.10 10 | # via requests 11 | pydantic==2.10.5 12 | # via transmission-clutch (pyproject.toml) 13 | pydantic-core==2.27.2 14 | # via pydantic 15 | requests==2.32.3 16 | # via transmission-clutch (pyproject.toml) 17 | typing-extensions==4.12.2 18 | # via 19 | # pydantic 20 | # pydantic-core 21 | urllib3==2.3.0 22 | # via requests 23 | -------------------------------------------------------------------------------- /rpc-spec.md: -------------------------------------------------------------------------------- 1 | # Transmission's RPC specification 2 | This document describes a protocol for interacting with Transmission sessions remotely. 3 | 4 | ### 1.1 Terminology 5 | The [JSON](https://www.json.org/) terminology in [RFC 4627](https://datatracker.ietf.org/doc/html/rfc4627) is used. 6 | RPC requests and responses are formatted in JSON. 7 | 8 | ### 1.2 Tools 9 | If `transmission-remote` is called with a `--debug` argument, its RPC traffic to the Transmission server will be dumped to the terminal. This can be useful when you want to compare requests in your application to another for reference. 10 | 11 | If `transmission-qt` is run with an environment variable `TR_RPC_VERBOSE` set, it too will dump the RPC requests and responses to the terminal for inspection. 12 | 13 | Lastly, using the browser's developer tools in the Transmission web client is always an option. 14 | 15 | ### 1.3 Libraries of ready-made wrappers 16 | Some people outside of the Transmission project have written libraries that wrap this RPC API. These aren't supported by the Transmission project, but are listed here in the hope that they may be useful: 17 | 18 | | Language | Link 19 | |:---|:--- 20 | | C# | https://www.nuget.org/packages/Transmission.API.RPC 21 | | Go | https://github.com/hekmon/transmissionrpc 22 | | Python | https://github.com/Trim21/transmission-rpc 23 | | Rust | https://crates.io/crates/transmission-rpc 24 | 25 | 26 | ## 2 Message format 27 | Messages are formatted as objects. There are two types: requests (described in [section 2.1](#21-requests)) and responses (described in [section 2.2](#22-responses)). 28 | 29 | All text **must** be UTF-8 encoded. 30 | 31 | ### 2.1 Requests 32 | Requests support three keys: 33 | 34 | 1. A required `method` string telling the name of the method to invoke 35 | 2. An optional `arguments` object of key/value pairs. The keys allowed are defined by the `method`. 36 | 3. An optional `tag` number used by clients to track responses. If provided by a request, the response MUST include the same tag. 37 | 38 | ```json 39 | { 40 | "arguments": { 41 | "fields": [ 42 | "version" 43 | ] 44 | }, 45 | "method": "session-get", 46 | "tag": 912313 47 | } 48 | ``` 49 | 50 | 51 | ### 2.2 Responses 52 | Responses to a request will include: 53 | 54 | 1. A required `result` string whose value MUST be `success` on success, or an error string on failure. 55 | 2. An optional `arguments` object of key/value pairs. Its keys contents are defined by the `method` and `arguments` of the original request. 56 | 3. An optional `tag` number as described in 2.1. 57 | 58 | ```json 59 | { 60 | "arguments": { 61 | "version": "2.93 (3c5870d4f5)" 62 | }, 63 | "result": "success", 64 | "tag": 912313 65 | } 66 | ``` 67 | 68 | ### 2.3 Transport mechanism 69 | HTTP POSTing a JSON-encoded request is the preferred way of communicating 70 | with a Transmission RPC server. The current Transmission implementation 71 | has the default URL as `http://host:9091/transmission/rpc`. Clients 72 | may use this as a default, but should allow the URL to be reconfigured, 73 | since the port and path may be changed to allow mapping and/or multiple 74 | daemons to run on a single server. 75 | 76 | #### 2.3.1 CSRF protection 77 | Most Transmission RPC servers require a `X-Transmission-Session-Id` 78 | header to be sent with requests, to prevent CSRF attacks. 79 | 80 | When your request has the wrong id -- such as when you send your first 81 | request, or when the server expires the CSRF token -- the 82 | Transmission RPC server will return an HTTP 409 error with the 83 | right `X-Transmission-Session-Id` in its own headers. 84 | 85 | So, the correct way to handle a 409 response is to update your 86 | `X-Transmission-Session-Id` and to resend the previous request. 87 | 88 | #### 2.3.2 DNS rebinding protection 89 | Additional check is being made on each RPC request to make sure that the 90 | client sending the request does so using one of the allowed hostnames by 91 | which RPC server is meant to be available. 92 | 93 | If host whitelisting is enabled (which is true by default), Transmission 94 | inspects the `Host:` HTTP header value (with port stripped, if any) and 95 | matches it to one of the whitelisted names. Regardless of host whitelist 96 | content, `localhost` and `localhost.` domain names as well as all the IP 97 | addresses are always implicitly allowed. 98 | 99 | For more information on configuration, see settings.json documentation for 100 | `rpc-host-whitelist-enabled` and `rpc-host-whitelist` keys. 101 | 102 | #### 2.3.3 Authentication 103 | Enabling authentication is an optional security feature that can be enabled 104 | on Transmission RPC servers. Authentication occurs by method of HTTP Basic 105 | Access Authentication. 106 | 107 | If authentication is enabled, Transmission inspects the `Authorization:` 108 | HTTP header value to validate the credentials of the request. The value 109 | of this HTTP header is expected to be [`Basic `](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization#basic), 110 | where is equal to a base64 encoded string of the 111 | username and password (respectively), separated by a colon. 112 | 113 | ## 3 Torrent requests 114 | ### 3.1 Torrent action requests 115 | | Method name | libtransmission function | Description 116 | |:--|:--|:-- 117 | | `torrent-start` | tr_torrentStart | start torrent 118 | | `torrent-start-now` | tr_torrentStartNow | start torrent disregarding queue position 119 | | `torrent-stop` | tr_torrentStop | stop torrent 120 | | `torrent-verify` | tr_torrentVerify | verify torrent 121 | | `torrent-reannounce` | tr_torrentManualUpdate | re-announce to trackers now 122 | 123 | Request arguments: `ids`, which specifies which torrents to use. 124 | All torrents are used if the `ids` argument is omitted. 125 | 126 | `ids` should be one of the following: 127 | 128 | 1. an integer referring to a torrent id 129 | 2. a list of torrent id numbers, SHA1 hash strings, or both 130 | 3. a string, `recently-active`, for recently-active torrents 131 | 132 | Note that integer torrent ids are not stable across Transmission daemon 133 | restarts. Use torrent hashes if you need stable ids. 134 | 135 | Response arguments: none 136 | 137 | ### 3.2 Torrent mutator: `torrent-set` 138 | Method name: `torrent-set` 139 | 140 | Request arguments: 141 | 142 | | Key | Value Type | Value Description 143 | |:--|:--|:-- 144 | | `bandwidthPriority` | number | this torrent's bandwidth tr_priority_t 145 | | `downloadLimit` | number | maximum download speed (KBps) 146 | | `downloadLimited` | boolean | true if `downloadLimit` is honored 147 | | `files-unwanted` | array | indices of file(s) to not download 148 | | `files-wanted` | array | indices of file(s) to download 149 | | `group` | string | The name of this torrent's bandwidth group 150 | | `honorsSessionLimits` | boolean | true if session upload limits are honored 151 | | `ids` | array | torrent list, as described in 3.1 152 | | `labels` | array | array of string labels 153 | | `location` | string | new location of the torrent's content 154 | | `peer-limit` | number | maximum number of peers 155 | | `priority-high` | array | indices of high-priority file(s) 156 | | `priority-low` | array | indices of low-priority file(s) 157 | | `priority-normal` | array | indices of normal-priority file(s) 158 | | `queuePosition` | number | position of this torrent in its queue [0...n) 159 | | `seedIdleLimit` | number | torrent-level number of minutes of seeding inactivity 160 | | `seedIdleMode` | number | which seeding inactivity to use. See tr_idlelimit 161 | | `seedRatioLimit` | double | torrent-level seeding ratio 162 | | `seedRatioMode` | number | which ratio to use. See tr_ratiolimit 163 | | `sequentialDownload` | boolean | download torrent pieces sequentially 164 | | `trackerAdd` | array | **DEPRECATED** use trackerList instead 165 | | `trackerList` | string | string of announce URLs, one per line, and a blank line between [tiers](https://www.bittorrent.org/beps/bep_0012.html). 166 | | `trackerRemove` | array | **DEPRECATED** use trackerList instead 167 | | `trackerReplace` | array | **DEPRECATED** use trackerList instead 168 | | `uploadLimit` | number | maximum upload speed (KBps) 169 | | `uploadLimited` | boolean | true if `uploadLimit` is honored 170 | 171 | Just as an empty `ids` value is shorthand for "all ids", using an empty array 172 | for `files-wanted`, `files-unwanted`, `priority-high`, `priority-low`, or 173 | `priority-normal` is shorthand for saying "all files". 174 | 175 | Response arguments: none 176 | 177 | ### 3.3 Torrent accessor: `torrent-get` 178 | Method name: `torrent-get`. 179 | 180 | Request arguments: 181 | 182 | 1. An optional `ids` array as described in 3.1. 183 | 2. A required `fields` array of keys. (see list below) 184 | 3. An optional `format` string specifying how to format the 185 | `torrents` response field. Allowed values are `objects` 186 | (default) and `table`. (see "Response arguments" below) 187 | 188 | Response arguments: 189 | 190 | 1. A `torrents` array. 191 | 192 | If the `format` request was `objects` (default), `torrents` will 193 | be an array of objects, each of which contains the key/value 194 | pairs matching the request's `fields` arg. This was the only 195 | format before Transmission 3 and has some obvious programmer 196 | conveniences, such as parsing directly into Javascript objects. 197 | 198 | If the format was `table`, then `torrents` will be an array of 199 | arrays. The first row holds the keys and each remaining row holds 200 | a torrent's values for those keys. This format is more efficient 201 | in terms of JSON generation and JSON parsing. 202 | 203 | 2. If the request's `ids` field was `recently-active`, 204 | a `removed` array of torrent-id numbers of recently-removed 205 | torrents. 206 | 207 | Note: For more information on what these fields mean, see the comments 208 | in [libtransmission/transmission.h](../libtransmission/transmission.h). 209 | The 'source' column here corresponds to the data structure there. 210 | 211 | | Key | Value Type | transmission.h source 212 | |:--|:--|:-- 213 | | `activityDate` | number | tr_stat 214 | | `addedDate` | number | tr_stat 215 | | `availability` | array (see below)| tr_torrentAvailability() 216 | | `bandwidthPriority` | number | tr_priority_t 217 | | `comment` | string | tr_torrent_view 218 | | `corruptEver`| number | tr_stat 219 | | `creator`| string | tr_torrent_view 220 | | `dateCreated`| number| tr_torrent_view 221 | | `desiredAvailable`| number| tr_stat 222 | | `doneDate`| number | tr_stat 223 | | `downloadDir` | string | tr_torrent 224 | | `downloadedEver` | number | tr_stat 225 | | `downloadLimit` | number | tr_torrent 226 | | `downloadLimited` | boolean | tr_torrent 227 | | `editDate` | number | tr_stat 228 | | `error` | number | tr_stat 229 | | `errorString` | string | tr_stat 230 | | `eta` | number | tr_stat 231 | | `etaIdle` | number | tr_stat 232 | | `file-count` | number | tr_info 233 | | `files`| array (see below)| n/a 234 | | `fileStats`| array (see below)| n/a 235 | | `group`| string| n/a 236 | | `hashString`| string| tr_torrent_view 237 | | `haveUnchecked`| number| tr_stat 238 | | `haveValid`| number| tr_stat 239 | | `honorsSessionLimits`| boolean| tr_torrent 240 | | `id` | number | tr_torrent 241 | | `isFinished` | boolean| tr_stat 242 | | `isPrivate` | boolean| tr_torrent 243 | | `isStalled` | boolean| tr_stat 244 | | `labels` | array of strings | tr_torrent 245 | | `leftUntilDone` | number| tr_stat 246 | | `magnetLink` | string| n/a 247 | | `manualAnnounceTime` | number| tr_stat 248 | | `maxConnectedPeers` | number| tr_torrent 249 | | `metadataPercentComplete` | double| tr_stat 250 | | `name` | string| tr_torrent_view 251 | | `peer-limit` | number| tr_torrent 252 | | `peers` | array (see below)| n/a 253 | | `peersConnected` | number| tr_stat 254 | | `peersFrom` | object (see below)| n/a 255 | | `peersGettingFromUs` | number| tr_stat 256 | | `peersSendingToUs` | number| tr_stat 257 | | `percentComplete` | double | tr_stat 258 | | `percentDone` | double | tr_stat 259 | | `pieces` | string (see below)| tr_torrent 260 | | `pieceCount`| number| tr_torrent_view 261 | | `pieceSize`| number| tr_torrent_view 262 | | `priorities`| array (see below)| n/a 263 | | `primary-mime-type`| string| tr_torrent 264 | | `queuePosition`| number| tr_stat 265 | | `rateDownload` (B/s)| number| tr_stat 266 | | `rateUpload` (B/s)| number| tr_stat 267 | | `recheckProgress`| double| tr_stat 268 | | `secondsDownloading`| number| tr_stat 269 | | `secondsSeeding`| number| tr_stat 270 | | `seedIdleLimit`| number| tr_torrent 271 | | `seedIdleMode`| number| tr_inactivelimit 272 | | `seedRatioLimit`| double| tr_torrent 273 | | `seedRatioMode`| number| tr_ratiolimit 274 | | `sequentialDownload`| boolean| tr_torrent 275 | | `sizeWhenDone`| number| tr_stat 276 | | `startDate`| number| tr_stat 277 | | `status`| number (see below)| tr_stat 278 | | `trackers`| array (see below)| n/a 279 | | `trackerList` | string | string of announce URLs, one per line, with a blank line between tiers 280 | | `trackerStats`| array (see below)| n/a 281 | | `totalSize`| number| tr_torrent_view 282 | | `torrentFile`| string| tr_info 283 | | `uploadedEver`| number| tr_stat 284 | | `uploadLimit`| number| tr_torrent 285 | | `uploadLimited`| boolean| tr_torrent 286 | | `uploadRatio`| double| tr_stat 287 | | `wanted`| array (see below)| n/a 288 | | `webseeds`| array of strings | tr_tracker_view 289 | | `webseedsSendingToUs`| number| tr_stat 290 | 291 | `availability`: An array of `pieceCount` numbers representing the number of connected peers that have each piece, or -1 if we already have the piece ourselves. 292 | 293 | `files`: array of objects, each containing: 294 | 295 | | Key | Value Type | transmission.h source 296 | |:--|:--|:-- 297 | | `bytesCompleted` | number | tr_file_view 298 | | `length` | number | tr_file_view 299 | | `name` | string | tr_file_view 300 | | `beginPiece` | number | tr_file_view 301 | | `endPiece` | number | tr_file_view 302 | 303 | Files are returned in the order they are laid out in the torrent. References to "file indices" throughout this specification should be interpreted as the position of the file within this ordering, with the first file bearing index 0. 304 | 305 | `fileStats`: a file's non-constant properties. An array of `tr_info.filecount` objects, in the same order as `files`, each containing: 306 | 307 | | Key | Value Type | transmission.h source 308 | |:--|:--|:-- 309 | | `bytesCompleted` | number | tr_file_view 310 | | `wanted` | boolean | tr_file_view (**Note:** Not to be confused with `torrent-get.wanted`, which is an array of 0/1 instead of boolean) 311 | | `priority` | number | tr_file_view 312 | 313 | `peers`: an array of objects, each containing: 314 | 315 | | Key | Value Type | transmission.h source 316 | |:--|:--|:-- 317 | | `address` | string | tr_peer_stat 318 | | `clientName` | string | tr_peer_stat 319 | | `clientIsChoked` | boolean | tr_peer_stat 320 | | `clientIsInterested` | boolean | tr_peer_stat 321 | | `flagStr` | string | tr_peer_stat 322 | | `isDownloadingFrom` | boolean | tr_peer_stat 323 | | `isEncrypted` | boolean | tr_peer_stat 324 | | `isIncoming` | boolean | tr_peer_stat 325 | | `isUploadingTo` | boolean | tr_peer_stat 326 | | `isUTP` | boolean | tr_peer_stat 327 | | `peerIsChoked` | boolean | tr_peer_stat 328 | | `peerIsInterested` | boolean | tr_peer_stat 329 | | `port` | number | tr_peer_stat 330 | | `progress` | double | tr_peer_stat 331 | | `rateToClient` (B/s) | number | tr_peer_stat 332 | | `rateToPeer` (B/s) | number | tr_peer_stat 333 | 334 | `peersFrom`: an object containing: 335 | 336 | | Key | Value Type | transmission.h source 337 | |:--|:--|:-- 338 | | `fromCache` | number | tr_stat 339 | | `fromDht` | number | tr_stat 340 | | `fromIncoming` | number | tr_stat 341 | | `fromLpd` | number | tr_stat 342 | | `fromLtep` | number | tr_stat 343 | | `fromPex` | number | tr_stat 344 | | `fromTracker` | number | tr_stat 345 | 346 | 347 | `pieces`: A bitfield holding pieceCount flags which are set to 'true' if we have the piece matching that position. JSON doesn't allow raw binary data, so this is a base64-encoded string. (Source: tr_torrent) 348 | 349 | `priorities`: An array of `tr_torrentFileCount()` numbers. Each is the `tr_priority_t` mode for the corresponding file. 350 | 351 | `status`: A number between 0 and 6, where: 352 | 353 | | Value | Meaning 354 | |:--|:-- 355 | | 0 | Torrent is stopped 356 | | 1 | Torrent is queued to verify local data 357 | | 2 | Torrent is verifying local data 358 | | 3 | Torrent is queued to download 359 | | 4 | Torrent is downloading 360 | | 5 | Torrent is queued to seed 361 | | 6 | Torrent is seeding 362 | 363 | 364 | `trackers`: array of objects, each containing: 365 | 366 | | Key | Value Type | transmission.h source 367 | |:--|:--|:-- 368 | | `announce` | string | tr_tracker_view 369 | | `id` | number | tr_tracker_view 370 | | `scrape` | string | tr_tracker_view 371 | | `sitename` | string | tr_tracker_view 372 | | `tier` | number | tr_tracker_view 373 | 374 | `trackerStats`: array of objects, each containing: 375 | 376 | | Key | Value Type | transmission.h source 377 | |:--|:--|:-- 378 | | `announce` | string | tr_tracker_view 379 | | `announceState` | number | tr_tracker_view 380 | | `downloadCount` | number | tr_tracker_view 381 | | `hasAnnounced` | boolean | tr_tracker_view 382 | | `hasScraped` | boolean | tr_tracker_view 383 | | `host` | string | tr_tracker_view 384 | | `id` | number | tr_tracker_view 385 | | `isBackup` | boolean | tr_tracker_view 386 | | `lastAnnouncePeerCount` | number | tr_tracker_view 387 | | `lastAnnounceResult` | string | tr_tracker_view 388 | | `lastAnnounceStartTime` | number | tr_tracker_view 389 | | `lastAnnounceSucceeded` | boolean | tr_tracker_view 390 | | `lastAnnounceTime` | number | tr_tracker_view 391 | | `lastAnnounceTimedOut` | boolean | tr_tracker_view 392 | | `lastScrapeResult` | string | tr_tracker_view 393 | | `lastScrapeStartTime` | number | tr_tracker_view 394 | | `lastScrapeSucceeded` | boolean | tr_tracker_view 395 | | `lastScrapeTime` | number | tr_tracker_view 396 | | `lastScrapeTimedOut` | boolean | tr_tracker_view 397 | | `leecherCount` | number | tr_tracker_view 398 | | `nextAnnounceTime` | number | tr_tracker_view 399 | | `nextScrapeTime` | number | tr_tracker_view 400 | | `scrape` | string | tr_tracker_view 401 | | `scrapeState` | number | tr_tracker_view 402 | | `seederCount` | number | tr_tracker_view 403 | | `sitename` | string | tr_tracker_view 404 | | `tier` | number | tr_tracker_view 405 | 406 | 407 | `wanted`: An array of `tr_torrentFileCount()` 0/1, 1 (true) if the corresponding file is to be downloaded. (Source: `tr_file_view`) 408 | 409 | **Note:** For backwards compatibility, in `4.x.x`, `wanted` is serialized as an array of `0` or `1` that should be treated as booleans. 410 | This will be fixed in `5.0.0` to return an array of booleans. 411 | 412 | Example: 413 | 414 | Say we want to get the name and total size of torrents #7 and #10. 415 | 416 | Request: 417 | 418 | ```json 419 | { 420 | "arguments": { 421 | "fields": [ "id", "name", "totalSize" ], 422 | "ids": [ 7, 10 ] 423 | }, 424 | "method": "torrent-get", 425 | "tag": 39693 426 | } 427 | ``` 428 | 429 | Response: 430 | 431 | ```json 432 | { 433 | "arguments": { 434 | "torrents": [ 435 | { 436 | "id": 10, 437 | "name": "Fedora x86_64 DVD", 438 | "totalSize": 34983493932 439 | }, 440 | { 441 | "id": 7, 442 | "name": "Ubuntu x86_64 DVD", 443 | "totalSize": 9923890123 444 | } 445 | ] 446 | }, 447 | "result": "success", 448 | "tag": 39693 449 | } 450 | ``` 451 | 452 | ### 3.4 Adding a torrent 453 | Method name: `torrent-add` 454 | 455 | Request arguments: 456 | 457 | | Key | Value Type | Description 458 | |:--|:--|:-- 459 | | `cookies` | string | pointer to a string of one or more cookies. 460 | | `download-dir` | string | path to download the torrent to 461 | | `filename` | string | filename or URL of the .torrent file 462 | | `labels` | array | array of string labels 463 | | `metainfo` | string | base64-encoded .torrent content 464 | | `paused` | boolean | if true, don't start the torrent 465 | | `peer-limit` | number | maximum number of peers 466 | | `bandwidthPriority` | number | torrent's bandwidth tr_priority_t 467 | | `files-wanted` | array | indices of file(s) to download 468 | | `files-unwanted` | array | indices of file(s) to not download 469 | | `priority-high` | array | indices of high-priority file(s) 470 | | `priority-low` | array | indices of low-priority file(s) 471 | | `priority-normal` | array | indices of normal-priority file(s) 472 | 473 | Either `filename` **or** `metainfo` **must** be included. All other arguments are optional. 474 | 475 | The format of the `cookies` should be `NAME=CONTENTS`, where `NAME` is the cookie name and `CONTENTS` is what the cookie should contain. Set multiple cookies like this: `name1=content1; name2=content2;` etc. See [libcurl documentation](http://curl.haxx.se/libcurl/c/curl_easy_setopt.html#CURLOPTCOOKIE) for more information. 476 | 477 | Response arguments: 478 | 479 | * On success, a `torrent-added` object in the form of one of 3.3's torrent objects with the fields for `id`, `name`, and `hashString`. 480 | 481 | * When attempting to add a duplicate torrent, a `torrent-duplicate` object in the same form is returned, but the response's `result` value is still `success`. 482 | 483 | ### 3.5 Removing a torrent 484 | Method name: `torrent-remove` 485 | 486 | | Key | Value Type | Description 487 | |:--|:--|:-- 488 | | `ids` | array | torrent list, as described in 3.1 489 | | `delete-local-data` | boolean | delete local data. (default: false) 490 | 491 | Response arguments: none 492 | 493 | ### 3.6 Moving a torrent 494 | Method name: `torrent-set-location` 495 | 496 | Request arguments: 497 | 498 | | Key | Value Type | Description 499 | |:--|:--|:-- 500 | | `ids` | array | torrent list, as described in 3.1 501 | | `location` | string | the new torrent location 502 | | `move` | boolean | if true, move from previous location. otherwise, search "location" for files (default: false) 503 | 504 | Response arguments: none 505 | 506 | ### 3.7 Renaming a torrent's path 507 | Method name: `torrent-rename-path` 508 | 509 | For more information on the use of this function, see the transmission.h 510 | documentation of `tr_torrentRenamePath()`. In particular, note that if this 511 | call succeeds you'll want to update the torrent's `files` and `name` field 512 | with `torrent-get`. 513 | 514 | Request arguments: 515 | 516 | | Key | Value Type | Description 517 | |:--|:--|:-- 518 | | `ids` | array | the torrent list, as described in 3.1 (must only be 1 torrent) 519 | | `path` | string | the path to the file or folder that will be renamed 520 | | `name` | string | the file or folder's new name 521 | 522 | Response arguments: `path`, `name`, and `id`, holding the torrent ID integer 523 | 524 | ## 4 Session requests 525 | ### 4.1 Session arguments 526 | | Key | Value Type | Description 527 | |:--|:--|:-- 528 | | `alt-speed-down` | number | max global download speed (KBps) 529 | | `alt-speed-enabled` | boolean | true means use the alt speeds 530 | | `alt-speed-time-begin` | number | when to turn on alt speeds (units: minutes after midnight) 531 | | `alt-speed-time-day` | number | what day(s) to turn on alt speeds (look at tr_sched_day) 532 | | `alt-speed-time-enabled` | boolean | true means the scheduled on/off times are used 533 | | `alt-speed-time-end` | number | when to turn off alt speeds (units: same) 534 | | `alt-speed-up` | number | max global upload speed (KBps) 535 | | `blocklist-enabled` | boolean | true means enabled 536 | | `blocklist-size` | number | number of rules in the blocklist 537 | | `blocklist-url` | string | location of the blocklist to use for `blocklist-update` 538 | | `cache-size-mb` | number | maximum size of the disk cache (MB) 539 | | `config-dir` | string | location of transmission's configuration directory 540 | | `default-trackers` | string | announce URLs, one per line, and a blank line between [tiers](https://www.bittorrent.org/beps/bep_0012.html). 541 | | `dht-enabled` | boolean | true means allow DHT in public torrents 542 | | `download-dir` | string | default path to download torrents 543 | | `download-dir-free-space` | number | **DEPRECATED** Use the `free-space` method instead. 544 | | `download-queue-enabled` | boolean | if true, limit how many torrents can be downloaded at once 545 | | `download-queue-size` | number | max number of torrents to download at once (see download-queue-enabled) 546 | | `encryption` | string | `required`, `preferred`, `tolerated` 547 | | `idle-seeding-limit-enabled` | boolean | true if the seeding inactivity limit is honored by default 548 | | `idle-seeding-limit` | number | torrents we're seeding will be stopped if they're idle for this long 549 | | `incomplete-dir-enabled` | boolean | true means keep torrents in incomplete-dir until done 550 | | `incomplete-dir` | string | path for incomplete torrents, when enabled 551 | | `lpd-enabled` | boolean | true means allow Local Peer Discovery in public torrents 552 | | `peer-limit-global` | number | maximum global number of peers 553 | | `peer-limit-per-torrent` | number | maximum global number of peers 554 | | `peer-port-random-on-start` | boolean | true means pick a random peer port on launch 555 | | `peer-port` | number | port number 556 | | `pex-enabled` | boolean | true means allow PEX in public torrents 557 | | `port-forwarding-enabled` | boolean | true means ask upstream router to forward the configured peer port to transmission using UPnP or NAT-PMP 558 | | `queue-stalled-enabled` | boolean | whether or not to consider idle torrents as stalled 559 | | `queue-stalled-minutes` | number | torrents that are idle for N minuets aren't counted toward seed-queue-size or download-queue-size 560 | | `rename-partial-files` | boolean | true means append `.part` to incomplete files 561 | | `reqq` | number | the number of outstanding block requests a peer is allowed to queue in the client 562 | | `rpc-version-minimum` | number | the minimum RPC API version supported 563 | | `rpc-version-semver` | string | the current RPC API version in a [semver](https://semver.org)-compatible string 564 | | `rpc-version` | number | the current RPC API version 565 | | `script-torrent-added-enabled` | boolean | whether or not to call the `added` script 566 | | `script-torrent-added-filename` | string | filename of the script to run 567 | | `script-torrent-done-enabled` | boolean | whether or not to call the `done` script 568 | | `script-torrent-done-filename` | string | filename of the script to run 569 | | `script-torrent-done-seeding-enabled` | boolean | whether or not to call the `seeding-done` script 570 | | `script-torrent-done-seeding-filename` | string | filename of the script to run 571 | | `seed-queue-enabled` | boolean | if true, limit how many torrents can be uploaded at once 572 | | `seed-queue-size` | number | max number of torrents to uploaded at once (see seed-queue-enabled) 573 | | `seedRatioLimit` | double | the default seed ratio for torrents to use 574 | | `seedRatioLimited` | boolean | true if seedRatioLimit is honored by default 575 | | `session-id` | string | the current `X-Transmission-Session-Id` value 576 | | `speed-limit-down-enabled` | boolean | true means enabled 577 | | `speed-limit-down` | number | max global download speed (KBps) 578 | | `speed-limit-up-enabled` | boolean | true means enabled 579 | | `speed-limit-up` | number | max global upload speed (KBps) 580 | | `start-added-torrents` | boolean | true means added torrents will be started right away 581 | | `trash-original-torrent-files` | boolean | true means the .torrent file of added torrents will be deleted 582 | | `units` | object | see below 583 | | `utp-enabled` | boolean | true means allow UTP 584 | | `version` | string | long version string `$version ($revision)` 585 | 586 | 587 | `units`: an object containing: 588 | 589 | | Key | Value Type | transmission.h source 590 | |:--|:--|:-- 591 | | `speed-units` | array | 4 strings: KB/s, MB/s, GB/s, TB/s 592 | | `speed-bytes` | number | number of bytes in a KB (1000 for kB; 1024 for KiB) 593 | | `size-units` | array | 4 strings: KB/s, MB/s, GB/s, TB/s 594 | | `size-bytes` | number | number of bytes in a KB (1000 for kB; 1024 for KiB) 595 | | `memory-units` | array | 4 strings: KB/s, MB/s, GB/s, TB/s 596 | | `memory-bytes` | number | number of bytes in a KB (1000 for kB; 1024 for KiB) 597 | 598 | `rpc-version` indicates the RPC interface version supported by the RPC server. 599 | It is incremented when a new version of Transmission changes the RPC interface. 600 | 601 | `rpc-version-minimum` indicates the oldest API supported by the RPC server. 602 | It is changes when a new version of Transmission changes the RPC interface 603 | in a way that is not backwards compatible. There are no plans for this 604 | to be common behavior. 605 | 606 | #### 4.1.1 Mutators 607 | Method name: `session-set` 608 | 609 | Request arguments: the mutable properties from 4.1's arguments, i.e. all of them 610 | except: 611 | 612 | * `blocklist-size` 613 | * `config-dir` 614 | * `rpc-version-minimum`, 615 | * `rpc-version-semver` 616 | * `rpc-version` 617 | * `session-id` 618 | * `units` 619 | * `version` 620 | 621 | Response arguments: none 622 | 623 | #### 4.1.2 Accessors 624 | Method name: `session-get` 625 | 626 | Request arguments: an optional `fields` array of keys (see 4.1) 627 | 628 | Response arguments: key/value pairs matching the request's `fields` 629 | argument if present, or all supported fields (see 4.1) otherwise. 630 | 631 | ### 4.2 Session statistics 632 | Method name: `session-stats` 633 | 634 | Request arguments: none 635 | 636 | Response arguments: 637 | 638 | | Key | Value Type | Description 639 | |:--|:--|:-- 640 | | `activeTorrentCount` | number 641 | | `downloadSpeed` | number 642 | | `pausedTorrentCount` | number 643 | | `torrentCount` | number 644 | | `uploadSpeed` | number 645 | | `cumulative-stats` | stats object (see below) 646 | | `current-stats` | stats object (see below) 647 | 648 | A stats object contains: 649 | 650 | | Key | Value Type | transmission.h source 651 | |:--|:--|:-- 652 | | `uploadedBytes` | number | tr_session_stats 653 | | `downloadedBytes` | number | tr_session_stats 654 | | `filesAdded` | number | tr_session_stats 655 | | `sessionCount` | number | tr_session_stats 656 | | `secondsActive` | number | tr_session_stats 657 | 658 | ### 4.3 Blocklist 659 | Method name: `blocklist-update` 660 | 661 | Request arguments: none 662 | 663 | Response arguments: a number `blocklist-size` 664 | 665 | ### 4.4 Port checking 666 | This method tests to see if your incoming peer port is accessible 667 | from the outside world. 668 | 669 | Method name: `port-test` 670 | 671 | Request arguments: an optional argument `ipProtocol`. 672 | `ipProtocol` is a string specifying the IP protocol version to be used for the port test. 673 | Set to `ipv4` to check IPv4, or set to `ipv6` to check IPv6. 674 | For backwards compatibility, it is allowed to omit this argument to get the behaviour before Transmission `4.1.0`, 675 | which is to check whichever IP protocol the OS happened to use to connect to our port test service, 676 | frankly not very useful. 677 | 678 | Response arguments: 679 | 680 | | Key | Value Type | Description 681 | | :-- | :-- | :-- 682 | | `port-is-open` | boolean | true if port is open, false if port is closed 683 | | `ipProtocol` | string | `ipv4` if the test was carried out on IPv4, `ipv6` if the test was carried out on IPv6, unset if it cannot be determined 684 | 685 | ### 4.5 Session shutdown 686 | This method tells the transmission session to shut down. 687 | 688 | Method name: `session-close` 689 | 690 | Request arguments: none 691 | 692 | Response arguments: none 693 | 694 | ### 4.6 Queue movement requests 695 | | Method name | transmission.h source 696 | |:--|:-- 697 | | `queue-move-top` | tr_torrentQueueMoveTop() 698 | | `queue-move-up` | tr_torrentQueueMoveUp() 699 | | `queue-move-down` | tr_torrentQueueMoveDown() 700 | | `queue-move-bottom` | tr_torrentQueueMoveBottom() 701 | 702 | Request arguments: 703 | 704 | | Key | Value Type | Description 705 | |:--|:--|:-- 706 | | `ids` | array | torrent list, as described in 3.1. 707 | 708 | Response arguments: none 709 | 710 | ### 4.7 Free space 711 | This method tests how much free space is available in a 712 | client-specified folder. 713 | 714 | Method name: `free-space` 715 | 716 | Request arguments: 717 | 718 | | Key | Value type | Description 719 | |:--|:--|:-- 720 | | `path` | string | the directory to query 721 | 722 | Response arguments: 723 | 724 | | Key | Value type | Description 725 | |:--|:--|:-- 726 | | `path` | string | same as the Request argument 727 | | `size-bytes` | number | the size, in bytes, of the free space in that directory 728 | | `total_size` | number | the total capacity, in bytes, of that directory 729 | 730 | ### 4.8 Bandwidth groups 731 | #### 4.8.1 Bandwidth group mutator: `group-set` 732 | Method name: `group-set` 733 | 734 | Request parameters: 735 | 736 | | Key | Value type | Description 737 | |:--|:--|:-- 738 | | `honorsSessionLimits` | boolean | true if session upload limits are honored 739 | | `name` | string | Bandwidth group name 740 | | `speed-limit-down-enabled` | boolean | true means enabled 741 | | `speed-limit-down` | number | max global download speed (KBps) 742 | | `speed-limit-up-enabled` | boolean | true means enabled 743 | | `speed-limit-up` | number | max global upload speed (KBps) 744 | 745 | Response arguments: none 746 | 747 | #### 4.8.2 Bandwidth group accessor: `group-get` 748 | Method name: `group-get` 749 | 750 | Request arguments: An optional argument `group`. 751 | `group` is either a string naming the bandwidth group, 752 | or a list of such strings. 753 | If `group` is omitted, all bandwidth groups are used. 754 | 755 | Response arguments: 756 | 757 | | Key | Value type | Description 758 | |:--|:--|:-- 759 | |`group`| array | A list of bandwidth group description objects 760 | 761 | A bandwidth group description object has: 762 | 763 | | Key | Value type | Description 764 | |:--|:--|:-- 765 | | `honorsSessionLimits` | boolean | true if session upload limits are honored 766 | | `name` | string | Bandwidth group name 767 | | `speed-limit-down-enabled` | boolean | true means enabled 768 | | `speed-limit-down` | number | max global download speed (KBps) 769 | | `speed-limit-up-enabled` | boolean | true means enabled 770 | | `speed-limit-up` | number | max global upload speed (KBps) 771 | 772 | ## 5 Protocol versions 773 | This section lists the changes that have been made to the RPC protocol. 774 | 775 | There are two ways to check for API compatibility. Since most developers know 776 | [semver](https://semver.org/), session-get's `rpc-version-semver` is the 777 | recommended way. That value is a semver-compatible string of the RPC protocol 778 | version number. 779 | 780 | Since Transmission predates the semver 1.0 spec, the previous scheme was for 781 | the RPC version to be a whole number and to increment it whenever a change was 782 | made. That is session-get's `rpc-version`. `rpc-version-minimum` lists the 783 | oldest version that is compatible with the current version; i.e. an app coded 784 | to use `rpc-version-minimum` would still work on a Transmission release running 785 | `rpc-version`. 786 | 787 | Breaking changes are denoted with a :bomb: emoji. 788 | 789 | Transmission 1.30 (`rpc-version-semver` 1.0.0, `rpc-version`: 1) 790 | 791 | Initial revision. 792 | 793 | Transmission 1.40 (`rpc-version-semver` 1.1.0, `rpc-version`: 2) 794 | 795 | | Method | Description 796 | |:---|:--- 797 | | `torrent-get` | new `port` to `peers` 798 | 799 | Transmission 1.41 (`rpc-version-semver` 1.2.0, `rpc-version`: 3) 800 | 801 | | Method | Description 802 | |:---|:--- 803 | | `session-get` | new arg `version` 804 | | `torrent-get` | new arg `downloaders` 805 | | `torrent-remove` | new method 806 | 807 | Transmission 1.50 (`rpc-version-semver` 1.3.0, `rpc-version`: 4) 808 | 809 | | Method | Description 810 | |:---|:--- 811 | |`session-get` | new arg `rpc-version-minimum` 812 | |`session-get` | new arg `rpc-version` 813 | |`session-stats` | added `cumulative-stats` 814 | |`session-stats` | added `current-stats` 815 | |`torrent-get` | new arg `downloadDir` 816 | 817 | Transmission 1.60 (`rpc-version-semver` 2.0.0, `rpc-version`: 5) 818 | 819 | | Method | Description 820 | |:---|:--- 821 | | `session-get` | :bomb: renamed `peer-limit` to `peer-limit-global` 822 | | `session-get` | :bomb: renamed `pex-allowed` to `pex-enabled` 823 | | `session-get` | :bomb: renamed `port` to `peer-port` 824 | | `torrent-get` | :bomb: removed arg `downloadLimitMode` 825 | | `torrent-get` | :bomb: removed arg `uploadLimitMode` 826 | | `torrent-set` | :bomb: renamed `speed-limit-down-enabled` to `downloadLimited` 827 | | `torrent-set` | :bomb: renamed `speed-limit-down` to `downloadLimit` 828 | | `torrent-set` | :bomb: renamed `speed-limit-up-enabled` to `uploadLimited` 829 | | `torrent-set` | :bomb: renamed `speed-limit-up` to `uploadLimit` 830 | | `blocklist-update` | new method 831 | | `port-test` | new method 832 | | `session-get` | new arg `alt-speed-begin` 833 | | `session-get` | new arg `alt-speed-down` 834 | | `session-get` | new arg `alt-speed-enabled` 835 | | `session-get` | new arg `alt-speed-end` 836 | | `session-get` | new arg `alt-speed-time-enabled` 837 | | `session-get` | new arg `alt-speed-up` 838 | | `session-get` | new arg `blocklist-enabled` 839 | | `session-get` | new arg `blocklist-size` 840 | | `session-get` | new arg `peer-limit-per-torrent` 841 | | `session-get` | new arg `seedRatioLimit` 842 | | `session-get` | new arg `seedRatioLimited` 843 | | `torrent-add` | new arg `files-unwanted` 844 | | `torrent-add` | new arg `files-wanted` 845 | | `torrent-add` | new arg `priority-high` 846 | | `torrent-add` | new arg `priority-low` 847 | | `torrent-add` | new arg `priority-normal` 848 | | `torrent-get` | new arg `bandwidthPriority` 849 | | `torrent-get` | new arg `fileStats` 850 | | `torrent-get` | new arg `honorsSessionLimits` 851 | | `torrent-get` | new arg `percentDone` 852 | | `torrent-get` | new arg `pieces` 853 | | `torrent-get` | new arg `seedRatioLimit` 854 | | `torrent-get` | new arg `seedRatioMode` 855 | | `torrent-get` | new arg `torrentFile` 856 | | `torrent-get` | new ids option `recently-active` 857 | | `torrent-reannounce` | new method 858 | | `torrent-set` | new arg `bandwidthPriority` 859 | | `torrent-set` | new arg `honorsSessionLimits` 860 | | `torrent-set` | new arg `seedRatioLimit` 861 | | `torrent-set` | new arg `seedRatioLimited` 862 | 863 | Transmission 1.70 (`rpc-version-semver` 2.1.0, `rpc-version`: 6) 864 | 865 | | Method | Description 866 | |:---|:--- 867 | | `method torrent-set-location` | new method 868 | 869 | Transmission 1.80 (`rpc-version-semver` 3.0.0, `rpc-version`: 7) 870 | 871 | | Method | Description 872 | |:---|:--- 873 | | `torrent-get` | :bomb: removed arg `announceResponse` (use `trackerStats instead`) 874 | | `torrent-get` | :bomb: removed arg `announceURL` (use `trackerStats instead`) 875 | | `torrent-get` | :bomb: removed arg `downloaders` (use `trackerStats instead`) 876 | | `torrent-get` | :bomb: removed arg `lastAnnounceTime` (use `trackerStats instead`) 877 | | `torrent-get` | :bomb: removed arg `lastScrapeTime` (use `trackerStats instead`) 878 | | `torrent-get` | :bomb: removed arg `leechers` (use `trackerStats instead`) 879 | | `torrent-get` | :bomb: removed arg `nextAnnounceTime` (use `trackerStats instead`) 880 | | `torrent-get` | :bomb: removed arg `nextScrapeTime` (use `trackerStats instead`) 881 | | `torrent-get` | :bomb: removed arg `scrapeResponse` (use `trackerStats instead`) 882 | | `torrent-get` | :bomb: removed arg `scrapeURL` (use `trackerStats instead`) 883 | | `torrent-get` | :bomb: removed arg `seeders` (use `trackerStats instead`) 884 | | `torrent-get` | :bomb: removed arg `swarmSpeed` 885 | | `torrent-get` | :bomb: removed arg `timesCompleted` (use `trackerStats instead`) 886 | | `session-set` | new arg `incomplete-dir-enabled` 887 | | `session-set` | new arg `incomplete-dir` 888 | | `torrent-get` | new arg `magnetLink` 889 | | `torrent-get` | new arg `metadataPercentComplete` 890 | | `torrent-get` | new arg `trackerStats` 891 | 892 | Transmission 1.90 (`rpc-version-semver` 3.1.0, `rpc-version`: 8) 893 | 894 | | Method | Description 895 | |:---|:--- 896 | | `session-set` | new arg `rename-partial-files` 897 | | `session-get` | new arg `rename-partial-files` 898 | | `session-get` | new arg `config-dir` 899 | | `torrent-add` | new arg `bandwidthPriority` 900 | | `torrent-get` | new trackerStats arg `lastAnnounceTimedOut` 901 | 902 | Transmission 1.92 (`rpc-version-semver` 3.2.0, `rpc-version`: 8) 903 | 904 | Note: `rpc-version` was not bumped in this release due to an oversight. 905 | 906 | | Method | Description 907 | |:---|:--- 908 | | `torrent-get` | new trackerStats arg `lastScrapeTimedOut` 909 | 910 | Transmission 2.00 (`rpc-version-semver` 3.3.0, `rpc-version`: 9) 911 | 912 | | Method | Description 913 | |:---|:--- 914 | | `session-set` | new arg `start-added-torrents` 915 | | `session-set` | new arg `trash-original-torrent-files` 916 | | `session-get` | new arg `start-added-torrents` 917 | | `session-get` | new arg `trash-original-torrent-files` 918 | | `torrent-get` | new arg `isFinished` 919 | 920 | Transmission 2.10 (`rpc-version-semver` 3.4.0, `rpc-version`: 10) 921 | 922 | | Method | Description 923 | |:---|:--- 924 | | `session-get` | new arg `cache-size-mb` 925 | | `session-get` | new arg `units` 926 | | `session-set` | new arg `idle-seeding-limit-enabled` 927 | | `session-set` | new arg `idle-seeding-limit` 928 | | `torrent-set` | new arg `seedIdleLimit` 929 | | `torrent-set` | new arg `seedIdleMode` 930 | | `torrent-set` | new arg `trackerAdd` 931 | | `torrent-set` | new arg `trackerRemove` 932 | | `torrent-set` | new arg `trackerReplace` 933 | 934 | Transmission 2.12 (`rpc-version-semver` 3.5.0, `rpc-version`: 11) 935 | 936 | | Method | Description 937 | |:---|:--- 938 | | `session-get` | new arg `blocklist-url` 939 | | `session-set` | new arg `blocklist-url` 940 | 941 | Transmission 2.20 (`rpc-version-semver` 3.6.0, `rpc-version`: 12) 942 | 943 | | Method | Description 944 | |:---|:--- 945 | | `session-get` | new arg `download-dir-free-space` 946 | | `session-close` | new method 947 | 948 | Transmission 2.30 (`rpc-version-semver` 4.0.0, `rpc-version`: 13) 949 | 950 | | Method | Description 951 | |:---|:--- 952 | | `torrent-get` | :bomb: removed arg `peersKnown` 953 | | `session-get` | new arg `isUTP` to the `peers` list 954 | | `torrent-add` | new arg `cookies` 955 | 956 | Transmission 2.40 (`rpc-version-semver` 5.0.0, `rpc-version`: 14) 957 | 958 | | Method | Description 959 | |:---|:--- 960 | | `torrent-get` | :bomb: values of `status` field changed 961 | | `queue-move-bottom` | new method 962 | | `queue-move-down` | new method 963 | | `queue-move-top` | new method 964 | | `session-set` | new arg `download-queue-enabled` 965 | | `session-set` | new arg `download-queue-size` 966 | | `session-set` | new arg `queue-stalled-enabled` 967 | | `session-set` | new arg `queue-stalled-minutes` 968 | | `session-set` | new arg `seed-queue-enabled` 969 | | `session-set` | new arg `seed-queue-size` 970 | | `torrent-get` | new arg `fromLpd` in peersFrom 971 | | `torrent-get` | new arg `isStalled` 972 | | `torrent-get` | new arg `queuePosition` 973 | | `torrent-set` | new arg `queuePosition` 974 | | `torrent-start-now` | new method 975 | 976 | Transmission 2.80 (`rpc-version-semver` 5.1.0, `rpc-version`: 15) 977 | 978 | | Method | Description 979 | |:---|:--- 980 | | `torrent-get` | new arg `etaIdle` 981 | | `torrent-rename-path` | new method 982 | | `free-space` | new method 983 | | `torrent-add` | new return arg `torrent-duplicate` 984 | 985 | Transmission 3.00 (`rpc-version-semver` 5.2.0, `rpc-version`: 16) 986 | 987 | | Method | Description 988 | |:---|:--- 989 | | `session-get` | new request arg `fields` 990 | | `session-get` | new arg `session-id` 991 | | `torrent-get` | new arg `labels` 992 | | `torrent-set` | new arg `labels` 993 | | `torrent-get` | new arg `editDate` 994 | | `torrent-get` | new request arg `format` 995 | 996 | Transmission 4.0.0 (`rpc-version-semver` 5.3.0, `rpc-version`: 17) 997 | 998 | | Method | Description 999 | |:---|:--- 1000 | | `/upload` | :warning: undocumented `/upload` endpoint removed 1001 | | `session-get` | :warning: **DEPRECATED** `download-dir-free-space`. Use `free-space` instead. 1002 | | `free-space` | new return arg `total_size` 1003 | | `session-get` | new arg `default-trackers` 1004 | | `session-get` | new arg `rpc-version-semver` 1005 | | `session-get` | new arg `script-torrent-added-enabled` 1006 | | `session-get` | new arg `script-torrent-added-filename` 1007 | | `session-get` | new arg `script-torrent-done-seeding-enabled` 1008 | | `session-get` | new arg `script-torrent-done-seeding-filename` 1009 | | `torrent-add` | new arg `labels` 1010 | | `torrent-get` | new arg `availability` 1011 | | `torrent-get` | new arg `file-count` 1012 | | `torrent-get` | new arg `group` 1013 | | `torrent-get` | new arg `percentComplete` 1014 | | `torrent-get` | new arg `primary-mime-type` 1015 | | `torrent-get` | new arg `tracker.sitename` 1016 | | `torrent-get` | new arg `trackerStats.sitename` 1017 | | `torrent-get` | new arg `trackerList` 1018 | | `torrent-set` | new arg `group` 1019 | | `torrent-set` | new arg `trackerList` 1020 | | `torrent-set` | :warning: **DEPRECATED** `trackerAdd`. Use `trackerList` instead. 1021 | | `torrent-set` | :warning: **DEPRECATED** `trackerRemove`. Use `trackerList` instead. 1022 | | `torrent-set` | :warning: **DEPRECATED** `trackerReplace`. Use `trackerList` instead. 1023 | | `group-set` | new method 1024 | | `group-get` | new method 1025 | | `torrent-get` | :warning: old arg `wanted` was implemented as an array of `0` or `1` in Transmission 3.00 and older, despite being documented as an array of booleans. Transmission 4.0.0 and 4.0.1 "fixed" this by returning an array of booleans; but in practical terms, this change caused an unannounced breaking change for any 3rd party code that expected `0` or `1`. For this reason, 4.0.2 restored the 3.00 behavior and updated this spec to match the code. 1026 | 1027 | Transmission 4.1.0 (`rpc-version-semver` 5.4.0, `rpc-version`: 18) 1028 | | Method | Description 1029 | |:---|:--- 1030 | | `torrent-get` | new arg `sequentialDownload` 1031 | | `torrent-set` | new arg `sequentialDownload` 1032 | | `torrent-get` | new arg `files.beginPiece` 1033 | | `torrent-get` | new arg `files.endPiece` 1034 | | `port-test` | new arg `ipProtocol` -------------------------------------------------------------------------------- /scripts/attach-shell.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker exec -it $1 bash 4 | -------------------------------------------------------------------------------- /scripts/build-images.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker image build -t mhadam/transmission -f ../docker/transmission.df .. 4 | docker image build -t mhadam/clutch -f ../docker/clutch.df .. 5 | -------------------------------------------------------------------------------- /scripts/run-containers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker-compose -f ../docker/docker-compose.yml up -d 4 | -------------------------------------------------------------------------------- /scripts/run-transmission.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker run --rm mhadam/transmission 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/__init__.py -------------------------------------------------------------------------------- /tests/endtoend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/endtoend/__init__.py -------------------------------------------------------------------------------- /tests/endtoend/test_accessor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Enabling debugging at http.client level (requests->urllib3->http.client) 4 | # you will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. 5 | # the only thing missing will be the response.body which is not logged. 6 | from http.client import HTTPConnection 7 | 8 | from clutch.client import Client 9 | from clutch.network.rpc.message import Response 10 | 11 | HTTPConnection.debuglevel = 1 # type: ignore 12 | 13 | logging.basicConfig() # you need to initialize logging, otherwise you will not see anything from requests 14 | logging.getLogger().setLevel(logging.DEBUG) 15 | requests_log = logging.getLogger("urllib3") 16 | requests_log.setLevel(logging.DEBUG) 17 | requests_log.propagate = True 18 | 19 | 20 | def test_retrieve_all_fields(): 21 | tag = 16 22 | client = Client(host="transmission") 23 | 24 | response: Response = client.torrent.accessor(all_fields=True, tag=tag) 25 | 26 | assert response.result == "success" 27 | assert response.tag == tag 28 | assert "little_women" in { 29 | x["name"] 30 | for x in response.model_dump(exclude_none=True)["arguments"]["torrents"] 31 | } 32 | assert "ion" in { 33 | x["name"] 34 | for x in response.model_dump(exclude_none=True)["arguments"]["torrents"] 35 | } 36 | 37 | 38 | def test_retrieve_two_fields(): 39 | tag = 16 40 | client = Client(host="transmission") 41 | 42 | response: Response = client.torrent.accessor(fields=["id", "name"], tag=tag) 43 | 44 | assert response.result == "success" 45 | assert response.tag == tag 46 | assert "id" in response.model_dump(exclude_none=True)["arguments"]["torrents"][0] 47 | assert "name" in response.model_dump(exclude_none=True)["arguments"]["torrents"][0] 48 | -------------------------------------------------------------------------------- /tests/endtoend/test_action.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/endtoend/test_action.py -------------------------------------------------------------------------------- /tests/endtoend/test_mutator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Enabling debugging at http.client level (requests->urllib3->http.client) 4 | # you will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. 5 | # the only thing missing will be the response.body which is not logged. 6 | from http.client import HTTPConnection 7 | 8 | HTTPConnection.debuglevel = 1 # type: ignore 9 | 10 | logging.basicConfig() # you need to initialize logging, otherwise you will not see anything from requests 11 | logging.getLogger().setLevel(logging.DEBUG) 12 | requests_log = logging.getLogger("urllib3") 13 | requests_log.setLevel(logging.DEBUG) 14 | requests_log.propagate = True 15 | 16 | 17 | def test_server_returns_same_tag(): 18 | pass 19 | # tag = 15 20 | # mutator_args: TorrentMutatorArguments = {} 21 | # client = Client(host="transmission") 22 | # 23 | # response: Response = client.torrent.mutator(mutator_args, tag) 24 | # 25 | # assert response["tag"] == tag 26 | 27 | 28 | def test_conversion_torrent_replace(): 29 | pass 30 | -------------------------------------------------------------------------------- /tests/mock/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/mock/__init__.py -------------------------------------------------------------------------------- /tests/mock/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | from typing import Any, Callable 5 | 6 | import pytest 7 | import pytest_httpserver 8 | from deepdiff import DeepDiff 9 | from dirty_equals import FunctionCheck 10 | 11 | from clutch.client import Client 12 | 13 | 14 | @pytest.fixture(scope="function") 15 | def client(httpserver): 16 | client = Client(port=httpserver.port) 17 | return client 18 | 19 | 20 | def custom_compare(expect) -> Callable[[Any], bool]: 21 | def inner(actual): 22 | diff = DeepDiff(actual, expect, ignore_order=True) 23 | is_same = len(diff) == 0 24 | if not is_same: 25 | msg = json.dumps( 26 | { 27 | "expect": expect, 28 | "actual": actual, 29 | "diff": json.loads(diff.to_json()), 30 | }, 31 | indent=2, 32 | ) 33 | logging.getLogger().warn(msg=msg) 34 | return is_same 35 | 36 | return inner 37 | 38 | 39 | @pytest.fixture(scope="function") 40 | def mocker(monkeypatch, httpserver): 41 | def set_mock(req=None, res=None): 42 | r = {k: v for k, v in res.items() if v is not None} 43 | if req is None: 44 | httpserver.expect_request( 45 | "/transmission/rpc", method="POST" 46 | ).respond_with_json(r) 47 | else: 48 | httpserver.expect_request( 49 | "/transmission/rpc", 50 | method="POST", 51 | json=FunctionCheck(custom_compare(req)), 52 | ).respond_with_json(r) 53 | 54 | return set_mock 55 | -------------------------------------------------------------------------------- /tests/mock/test_bandwidth_groups.py: -------------------------------------------------------------------------------- 1 | def test_bandwidth_groups(httpserver, mocker, client): 2 | mocker( 3 | req={"method": "group-get", "arguments": {"group": "abc"}}, 4 | res={ 5 | "result": "success", 6 | "arguments": { 7 | "group": [ 8 | { 9 | "honorsSessionLimits": True, 10 | "name": "", 11 | "speed-limit-down-enabled": True, 12 | "speed-limit-down": 1, 13 | "speed-limit-up-enabled": True, 14 | "speed-limit-up": 1, 15 | } 16 | ] 17 | }, 18 | }, 19 | ) 20 | _ = client.misc.bandwidth_groups(group="abc") 21 | httpserver.check_assertions() 22 | -------------------------------------------------------------------------------- /tests/mock/test_cases.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, Any 3 | 4 | from clutch.schema.user.method.torrent.action import TorrentActionMethod 5 | 6 | from clutch import Client 7 | 8 | test_cases: list[tuple[str, dict, dict, Callable[[Client], Any]]] = [ 9 | ( 10 | "torrent add", 11 | { 12 | "method": "torrent-add", 13 | "arguments": {"filename": "example.torrent"}, 14 | "tag": 1, 15 | }, 16 | { 17 | "result": "success", 18 | "arguments": { 19 | "torrent-added": {"id": 1, "name": "example", "hashString": "abc"} 20 | }, 21 | }, 22 | lambda c: c.torrent.add({"filename": "example.torrent"}, tag=1), 23 | ), 24 | ( 25 | "torrent add with magnet link", 26 | { 27 | "method": "torrent-add", 28 | "arguments": {"filename": "magnet:?xt=urn:btih:examplehash"}, 29 | }, 30 | { 31 | "result": "success", 32 | "arguments": { 33 | "torrent-added": { 34 | "id": 2, 35 | "name": "magnet-example", 36 | "hashString": "abc", 37 | } 38 | }, 39 | }, 40 | lambda c: c.torrent.add({"filename": "magnet:?xt=urn:btih:examplehash"}), 41 | ), 42 | ( 43 | "torrent get with objects format", 44 | { 45 | "method": "torrent-get", 46 | "arguments": {"format": "objects", "fields": ["status", "name", "id"]}, 47 | "tag": 2, 48 | }, 49 | { 50 | "result": "success", 51 | "arguments": { 52 | "torrents": [ 53 | {"id": 1, "name": "example", "status": "downloading"}, 54 | {"id": 2, "name": "magnet-example", "status": "stopped"}, 55 | ] 56 | }, 57 | }, 58 | lambda c: c.torrent.accessor(fields={"id", "name", "status"}, tag=2), 59 | ), 60 | ( 61 | "torrent set location", 62 | { 63 | "method": "torrent-set-location", 64 | "arguments": {"ids": [1], "location": "/new/path", "move": True}, 65 | "tag": 3, 66 | }, 67 | {"result": "success"}, 68 | lambda c: c.torrent.move([1], "/new/path", move=True, tag=3), 69 | ), 70 | ( 71 | "torrent action - stop", 72 | {"method": "torrent-stop", "arguments": {"ids": [1]}}, 73 | {"result": "success"}, 74 | lambda c: c.torrent.action(TorrentActionMethod.STOP, ids=[1]), 75 | ), 76 | ( 77 | "torrent start", 78 | {"method": "torrent-start", "arguments": {"ids": [2]}}, 79 | {"result": "success"}, 80 | lambda c: c.torrent.action(TorrentActionMethod.START, ids=[2]), 81 | ), 82 | ( 83 | "torrent remove", 84 | { 85 | "method": "torrent-remove", 86 | "arguments": {"ids": [1], "delete-local-data": True}, 87 | "tag": 4, 88 | }, 89 | {"result": "success"}, 90 | lambda c: c.torrent.remove(tag=4, delete_local_data=True, ids=[1]), 91 | ), 92 | ( 93 | "session stats", 94 | {"method": "session-stats", "tag": 1}, 95 | { 96 | "result": "success", 97 | "arguments": { 98 | "activeTorrentCount": 1, 99 | "downloadSpeed": 1024, 100 | "uploadSpeed": 512, 101 | }, 102 | }, 103 | lambda c: c.session.stats(tag=1), 104 | ), 105 | ( 106 | "session set", 107 | { 108 | "method": "session-set", 109 | "arguments": {"download-dir": "/new/download/path"}, 110 | "tag": 5, 111 | }, 112 | {"result": "success"}, 113 | lambda c: c.session.mutator( 114 | arguments={"download_dir": "/new/download/path"}, tag=5 115 | ), 116 | ), 117 | ( 118 | "session get", 119 | { 120 | "method": "session-get", 121 | "arguments": {"fields": ["version", "rpc-version", "download-dir"]}, 122 | }, 123 | { 124 | "result": "success", 125 | "arguments": { 126 | "version": "3.00", 127 | "rpc-version": 17, 128 | "download-dir": "/new/download/path", 129 | }, 130 | }, 131 | lambda c: c.session.accessor(fields={"version", "rpc_version", "download_dir"}), 132 | ), 133 | ( 134 | "free space", 135 | {"method": "free-space", "arguments": {"path": "/test/dir"}, "tag": 5}, 136 | { 137 | "result": "success", 138 | "arguments": { 139 | "path": "/test/dir", 140 | "size-bytes": 1024 * 1024 * 1024, 141 | }, 142 | }, 143 | lambda c: c.misc.free_space(path="/test/dir", tag=5), 144 | ), 145 | ] 146 | 147 | 148 | def test_all(client, httpserver, mocker): 149 | for i, case in enumerate(test_cases): 150 | desc, req, res, call = case 151 | logging.getLogger().info(f"running {desc}") 152 | mocker(req=req, res=res) 153 | try: 154 | call(client) 155 | except Exception as e: 156 | logging.getLogger().info(e) 157 | httpserver.check_assertions() 158 | httpserver.clear() 159 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/client/__init__.py -------------------------------------------------------------------------------- /tests/unit/client/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import create_autospec 2 | 3 | import pytest 4 | 5 | from clutch.client import Client 6 | from clutch.network.connection import Connection 7 | from clutch.network.session import TransmissionSession 8 | 9 | 10 | @pytest.fixture(scope="function") 11 | def client(): 12 | session = create_autospec(TransmissionSession) 13 | connection = Connection("127.0.0.1", session) 14 | client = Client() 15 | client._connection = connection 16 | return client 17 | -------------------------------------------------------------------------------- /tests/unit/client/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/client/session/__init__.py -------------------------------------------------------------------------------- /tests/unit/client/session/test_accessor.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | from dirty_equals import IsList 4 | 5 | 6 | def test_session_accessor(client): 7 | tag = 5 8 | try: 9 | client.session.accessor(fields={"rpc_version", "seed_ratio_limit"}, tag=tag) 10 | except Exception as e: 11 | print(e) 12 | 13 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 14 | assert request_data["method"] == "session-get" 15 | assert request_data["arguments"] == { 16 | "fields": IsList("rpc-version", "seedRatioLimit", check_order=False) 17 | } 18 | assert request_data["tag"] == tag 19 | -------------------------------------------------------------------------------- /tests/unit/client/session/test_mutator.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_session_mutator_arguments(client): 5 | try: 6 | client.session.mutator( 7 | arguments={"seed_ratio_limited": True, "seed_queue_size": 10} 8 | ) 9 | except Exception as e: 10 | pass 11 | 12 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 13 | assert request_data["method"] == "session-set" 14 | assert request_data["arguments"] == { 15 | "seed-queue-size": 10, 16 | "seedRatioLimited": True, 17 | } 18 | -------------------------------------------------------------------------------- /tests/unit/client/test_queue.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | from clutch.method.queue import QueueMovement 4 | 5 | 6 | def test_queue_move(client): 7 | try: 8 | client.queue.move(movement=QueueMovement.TOP, ids=[1, 2]) 9 | except Exception as e: 10 | pass 11 | 12 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 13 | assert request_data["method"] == QueueMovement.TOP.value 14 | assert request_data["arguments"] == {"ids": [1, 2]} 15 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/client/torrent/__init__.py -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_accessor.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_torrent_accessor_fields(client): 5 | try: 6 | client.torrent.accessor(fields=["peer_limit", "tracker_stats"]) 7 | except: 8 | pass 9 | 10 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 11 | assert request_data["method"] == "torrent-get" 12 | assert set(request_data["arguments"]["fields"]) == {"peer-limit", "trackerStats"} 13 | assert request_data["arguments"]["format"] == "objects" 14 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_action.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | from clutch.schema.user.method.torrent.action import TorrentActionMethod 4 | 5 | 6 | def test_torrent_action(client): 7 | try: 8 | client.torrent.action(TorrentActionMethod.START_NOW, ids=[1, 2]) 9 | except Exception: 10 | pass 11 | 12 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 13 | assert request_data["method"] == TorrentActionMethod.START_NOW.value 14 | assert request_data["arguments"] == {"ids": [1, 2]} 15 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_add.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_torrent_add_filename(client): 5 | file = "some_filename" 6 | try: 7 | client.torrent.add({"filename": file, "priority_low": []}) 8 | except Exception as e: 9 | pass 10 | 11 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 12 | assert request_data["method"] == "torrent-add" 13 | assert request_data["arguments"] == {"filename": file, "priority-low": []} 14 | 15 | 16 | def test_torrent_add_metainfo(client): 17 | metainfo = "some_metainfo" 18 | try: 19 | client.torrent.add({"metainfo": metainfo}) 20 | except Exception: 21 | pass 22 | 23 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 24 | assert request_data["method"] == "torrent-add" 25 | assert request_data["arguments"] == {"metainfo": metainfo} 26 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_move.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_torrent_move(client): 5 | location = "some_location" 6 | try: 7 | client.torrent.move(ids=[1, 2], location=location, move=True) 8 | except Exception as e: 9 | pass 10 | 11 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 12 | assert request_data["method"] == "torrent-set-location" 13 | assert request_data["arguments"] == { 14 | "ids": [1, 2], 15 | "location": location, 16 | "move": True, 17 | } 18 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_mutator.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_torrent_mutator(client): 5 | try: 6 | client.torrent.mutator() 7 | except Exception: 8 | pass 9 | 10 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 11 | assert request_data["method"] == "torrent-set" 12 | assert request_data["arguments"] == {} 13 | 14 | 15 | def test_torrent_mutator_arguments(client): 16 | try: 17 | client.torrent.mutator( 18 | ids=["hi", 2], 19 | arguments={"priority_high": [], "download_limited": True, "ids": [3, 4]}, 20 | ) 21 | except Exception as e: 22 | pass 23 | 24 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 25 | assert request_data["method"] == "torrent-set" 26 | # turn ids into a set since pydantic seems to serialize as a set 27 | # and original ids order differs from assertion 28 | assert len(set(request_data["arguments"]["ids"])) == len( 29 | request_data["arguments"]["ids"] 30 | ) 31 | request_data["arguments"]["ids"] = set(request_data["arguments"]["ids"]) 32 | assert request_data["arguments"] == { 33 | "ids": {"hi", 2}, 34 | "downloadLimited": True, 35 | "priority-high": [], 36 | } 37 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_remove.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_torrent_remove(client): 5 | try: 6 | client.torrent.remove(ids=[1, 2], delete_local_data=True) 7 | except Exception as e: 8 | pass 9 | 10 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 11 | assert request_data["method"] == "torrent-remove" 12 | assert request_data["arguments"] == {"ids": [1, 2], "delete-local-data": True} 13 | -------------------------------------------------------------------------------- /tests/unit/client/torrent/test_rename.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | 4 | def test_torrent_rename(client): 5 | path = "some_path" 6 | name = "some_name" 7 | try: 8 | client.torrent.rename(ids=[1, 2], path=path, name=name) 9 | except Exception as e: 10 | pass 11 | 12 | request_data = loads(client._connection.session.post.call_args.kwargs["data"]) 13 | assert request_data["method"] == "torrent-rename-path" 14 | assert request_data["arguments"] == {"ids": [1, 2], "name": name, "path": path} 15 | -------------------------------------------------------------------------------- /tests/unit/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/schema/__init__.py -------------------------------------------------------------------------------- /tests/unit/schema/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/schema/user/__init__.py -------------------------------------------------------------------------------- /tests/unit/schema/user/response/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/schema/user/response/__init__.py -------------------------------------------------------------------------------- /tests/unit/schema/user/response/torrent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhadam/clutch/c4d30187f33ba8b1ebb87b573511bc0b400ee8ca/tests/unit/schema/user/response/torrent/__init__.py -------------------------------------------------------------------------------- /tests/unit/schema/user/response/torrent/test_accessor.py: -------------------------------------------------------------------------------- 1 | from clutch.schema.user.response.torrent.accessor import TorrentAccessorObject 2 | 3 | 4 | def test_optional_fields(): 5 | data = {"error": "some string"} 6 | 7 | result = TorrentAccessorObject.construct(**data) 8 | 9 | # as of writing now, construct creates a model without any of the missing fields 10 | assert result.error == "some string" 11 | -------------------------------------------------------------------------------- /tests/unit/test_networking.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from unittest.mock import create_autospec 3 | 4 | from clutch.network.connection import Connection 5 | from clutch.network.rpc.message import Request, Response 6 | from clutch.network.session import TransmissionSession 7 | from clutch.schema.user.response.torrent.accessor import TorrentAccessorResponse 8 | 9 | 10 | def test_connection_marshals_requests_and_responses(): 11 | session = create_autospec(TransmissionSession) 12 | session.post.return_value.text = ( 13 | '{"result":"success","arguments":{"test":"response"},"tag":5}' 14 | ) 15 | connection = Connection("127.0.0.1", session) 16 | request = Request(method="torrent-get", arguments={"test": "request"}, tag=5) 17 | 18 | response: Optional[Response] = connection.send(request) 19 | 20 | assert response is not None 21 | assert response.arguments["test"] == "response" 22 | assert response.tag == 5 23 | assert response.result == "success" 24 | 25 | 26 | def test_connection_marshals_types(): 27 | session = create_autospec(TransmissionSession) 28 | session.post.return_value.text = ( 29 | '{"result":"success","arguments":{"torrents":[{"id":1},{"id":2}]},"tag":5}' 30 | ) 31 | connection = Connection("127.0.0.1", session) 32 | request = Request(method="torrent-get", arguments={"fields": ["id"]}, tag=5) 33 | 34 | response = connection.send(request, TorrentAccessorResponse) 35 | 36 | assert response is not None 37 | assert isinstance(response.arguments, TorrentAccessorResponse) 38 | assert response.arguments.model_dump(exclude_none=True)["torrents"] == [ 39 | {"id": 1}, 40 | {"id": 2}, 41 | ] 42 | assert response.tag == 5 43 | assert response.result == "success" 44 | --------------------------------------------------------------------------------