├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .isort.cfg ├── CHANGELOG.rst ├── Dockerfile.2004 ├── Dockerfile.2204 ├── Dockerfile.2204.deluge-2.1.1 ├── LICENSE ├── README.rst ├── libtc ├── __init__.py ├── __main__.py ├── baseclient.py ├── bencode.py ├── clients │ ├── __init__.py │ ├── deluge.py │ ├── fakeclient.py │ ├── liltorrent.py │ ├── qbittorrent.py │ ├── rtorrent.py │ ├── tests │ │ ├── __init__.py │ │ ├── basetest.py │ │ ├── test_deluge.py │ │ ├── test_liltorrent.py │ │ ├── test_management.py │ │ ├── test_qbittorrent.py │ │ ├── test_rtorrent.py │ │ ├── test_transmission.py │ │ ├── testfiles │ │ │ ├── My-Bluray.torrent │ │ │ ├── My-Bluray │ │ │ │ └── BDMV │ │ │ │ │ ├── BACKUP │ │ │ │ │ ├── MovieObject.bdmv │ │ │ │ │ ├── PLAYLIST │ │ │ │ │ │ └── 00000.mpls │ │ │ │ │ └── index.bdmv │ │ │ │ │ ├── MovieObject.bdmv │ │ │ │ │ ├── PLAYLIST │ │ │ │ │ └── 00000.mpls │ │ │ │ │ ├── STREAM │ │ │ │ │ └── 00000.m2ts │ │ │ │ │ └── index.bdmv │ │ │ ├── My-DVD.torrent │ │ │ ├── My-DVD │ │ │ │ └── VIDEO_TS │ │ │ │ │ ├── VIDEO_TS.BUP │ │ │ │ │ ├── VIDEO_TS.IFO │ │ │ │ │ ├── VTS_01_0.BUP │ │ │ │ │ ├── VTS_01_0.IFO │ │ │ │ │ ├── VTS_01_0.VOB │ │ │ │ │ └── VTS_01_1.VOB │ │ │ ├── Some-CD-Release.torrent │ │ │ ├── Some-CD-Release │ │ │ │ ├── CD1 │ │ │ │ │ ├── somestuff-1.r00 │ │ │ │ │ ├── somestuff-1.r01 │ │ │ │ │ ├── somestuff-1.r02 │ │ │ │ │ ├── somestuff-1.r03 │ │ │ │ │ ├── somestuff-1.r04 │ │ │ │ │ ├── somestuff-1.r05 │ │ │ │ │ ├── somestuff-1.r06 │ │ │ │ │ ├── somestuff-1.rar │ │ │ │ │ └── somestuff-1.sfv │ │ │ │ ├── CD2 │ │ │ │ │ ├── somestuff-2.r00 │ │ │ │ │ ├── somestuff-2.r01 │ │ │ │ │ ├── somestuff-2.r02 │ │ │ │ │ ├── somestuff-2.r03 │ │ │ │ │ ├── somestuff-2.r04 │ │ │ │ │ ├── somestuff-2.r05 │ │ │ │ │ ├── somestuff-2.r06 │ │ │ │ │ ├── somestuff-2.r07 │ │ │ │ │ ├── somestuff-2.rar │ │ │ │ │ └── somestuff-2.sfv │ │ │ │ ├── Sample │ │ │ │ │ └── some-rls.mkv │ │ │ │ ├── Subs │ │ │ │ │ ├── somestuff-subs.r00 │ │ │ │ │ ├── somestuff-subs.rar │ │ │ │ │ └── somestuff-subs.sfv │ │ │ │ └── crap.nfo │ │ │ ├── Some-Release.torrent │ │ │ ├── Some-Release │ │ │ │ ├── Sample │ │ │ │ │ └── some-rls.mkv │ │ │ │ ├── Subs │ │ │ │ │ ├── some-subs.rar │ │ │ │ │ └── some-subs.sfv │ │ │ │ ├── some-rls.nfo │ │ │ │ ├── some-rls.r00 │ │ │ │ ├── some-rls.r01 │ │ │ │ ├── some-rls.r02 │ │ │ │ ├── some-rls.r03 │ │ │ │ ├── some-rls.r04 │ │ │ │ ├── some-rls.r05 │ │ │ │ ├── some-rls.r06 │ │ │ │ ├── some-rls.rar │ │ │ │ └── some-rls.sfv │ │ │ ├── file_a.txt │ │ │ ├── file_b.txt │ │ │ ├── file_c.txt │ │ │ ├── hashalignment │ │ │ │ ├── file_a │ │ │ │ └── file_b │ │ │ ├── hashalignment_multifile.torrent │ │ │ ├── hashalignment_singlefile.torrent │ │ │ ├── test.torrent │ │ │ └── test_single.torrent │ │ └── utils_testclient.py │ └── transmission.py ├── exceptions.py ├── liltorrent.py ├── management.py ├── parse_clients.py ├── scgitransport.py ├── tests │ ├── __init__.py │ └── test_liltorrent.py ├── torrent.py └── utils.py ├── setup.py └── test-requirements.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .env* 2 | dist 3 | .git 4 | .* 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Tests 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "build" 17 | build: 18 | # The type of runner that the job will run on 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | dockerimage: 24 | - johndoee/test-libtc:20.04 25 | - johndoee/test-libtc:22.04 26 | - johndoee/test-libtc:22.04-deluge-2.1.1 27 | 28 | # Steps represent a sequence of tasks that will be executed as part of the job 29 | steps: 30 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 31 | - uses: actions/checkout@v2 32 | 33 | - name: Pull docker image 34 | run: docker pull ${{ matrix.dockerimage }} 35 | 36 | - name: Run tests in image 37 | run: docker run --rm -v ${GITHUB_WORKSPACE}/libtc:/libtc ${{ matrix.dockerimage }} pytest -p no:cacheprovider "/libtc/" 38 | 39 | -------------------------------------------------------------------------------- /.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 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Local 132 | dropin.cache 133 | 134 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=3 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | use_parentheses=True 6 | line_length=88 7 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Changelog 3 | ================================ 4 | 5 | Version 1.3.4 (13-08-2022) 6 | -------------------------------- 7 | 8 | * Added: Label support for qbittorrent 9 | 10 | Version 1.3.3 (18-06-2022) 11 | -------------------------------- 12 | 13 | * Change: rtorrent shares more information about failures to add torrents 14 | 15 | Version 1.3.2 (15-06-2022) 16 | -------------------------------- 17 | 18 | * Bugfix: symlinks should not be resolved 19 | 20 | Version 1.3.1 (03-06-2022) 21 | -------------------------------- 22 | 23 | * Bugfix: Pypi broke 24 | 25 | Version 1.3.0 (03-06-2022) 26 | -------------------------------- 27 | 28 | * Added: Test matrix for multiple ubuntu releases 29 | 30 | * Change: Bumped and unbounded version of some requirements 31 | 32 | * Bugfix: Changed how qbittorrent handles subfolders 33 | * Bugfix: Transmission 3 torrent location 34 | * Bugfix: Fixed qbittorrent torrent file retrival problem in newer versions 35 | 36 | Version 1.2.3 (30-05-2022) 37 | -------------------------------- 38 | 39 | * Added: Label support to rtorrent and deluge 40 | 41 | Version 1.2.2 (22-05-2022) 42 | -------------------------------- 43 | 44 | * Added: Parse function from config to map of clients 45 | 46 | * Bugfix: Added missing implementations for the test client 47 | 48 | Version 1.2.1 (08-04-2022) 49 | -------------------------------- 50 | 51 | * Change: Bumped click version 52 | 53 | * Bugfix: Added missing implementations for the fake client 54 | 55 | Version 1.2.0 (08-04-2022) 56 | -------------------------------- 57 | 58 | * Added: move_torrent support 59 | 60 | * Change: Session usage with transmission to reuse conneciton 61 | 62 | Version 1.1.1 (03-11-2021) 63 | -------------------------------- 64 | 65 | * Change: Transmission supports basic auth 66 | 67 | * Bugfix: Ensuring Automatic Torrent Management Mode is disabled when using qBittorrent 68 | * Bugfix: Deluge 1 bug with download_location 69 | * Bugfix: Transmission bug with progress percentage on files in reverse 70 | 71 | Version 1.1.0 (22-06-2020) 72 | -------------------------------- 73 | 74 | * Added: get_files to list files in a given torrent 75 | 76 | * Bugfix: Problem with start/stop of deluge 1 torrents 77 | 78 | Version 1.0.0 (10-05-2020) 79 | -------------------------------- 80 | 81 | * Initial release -------------------------------------------------------------------------------- /Dockerfile.2004: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y deluged \ 7 | transmission-daemon \ 8 | qbittorrent-nox \ 9 | rtorrent \ 10 | python3 \ 11 | python3-pip \ 12 | && \ 13 | rm -rf /var/lib/apt/lists/* 14 | 15 | RUN pip3 install -U setuptools pip wheel 16 | ADD test-requirements.txt / 17 | RUN pip3 install -r /test-requirements.txt 18 | -------------------------------------------------------------------------------- /Dockerfile.2204: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y deluged \ 7 | transmission-daemon \ 8 | qbittorrent-nox \ 9 | rtorrent \ 10 | python3 \ 11 | python3-pip \ 12 | && \ 13 | rm -rf /var/lib/apt/lists/* 14 | 15 | RUN sed -i 's/def findCaller(self, stack_info=False):/def findCaller(self, *args, **kwargs):/g' /usr/lib/python3/dist-packages/deluge/log.py 16 | 17 | RUN pip3 install -U setuptools pip wheel 18 | ADD test-requirements.txt / 19 | RUN pip3 install -r /test-requirements.txt 20 | -------------------------------------------------------------------------------- /Dockerfile.2204.deluge-2.1.1: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y software-properties-common && \ 7 | add-apt-repository ppa:deluge-team/stable && \ 8 | apt-get update && \ 9 | apt-get install -y deluged \ 10 | transmission-daemon \ 11 | qbittorrent-nox \ 12 | rtorrent \ 13 | python3 \ 14 | python3-pip \ 15 | && \ 16 | rm -rf /var/lib/apt/lists/* 17 | 18 | RUN sed -i 's/def findCaller(self, stack_info=False):/def findCaller(self, *args, **kwargs):/g' /usr/lib/python3/dist-packages/deluge/log.py 19 | 20 | RUN pip3 install -U setuptools pip wheel 21 | ADD test-requirements.txt / 22 | RUN pip3 install -r /test-requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 Anders Jensen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Lib Torrent Client 3 | ================================ 4 | 5 | This is a library to interface with a variety of torrent clients, 6 | abstracting away the need to understand a specific client api. 7 | 8 | .. image:: https://github.com/JohnDoee/libtc/actions/workflows/main.yml/badge.svg?branch=master 9 | 10 | Requirements 11 | -------------------------------- 12 | 13 | * Python 3.6 or higher 14 | 15 | 16 | Installation 17 | -------------------------------- 18 | 19 | .. code-block:: bash 20 | 21 | pip install libtc 22 | 23 | 24 | Features 25 | -------------------------------- 26 | 27 | Clients: 28 | 29 | * rtorrent 30 | * Deluge 31 | * Transmission 32 | * qBittorrent 33 | * LilTorrent (local-to-remote interface for other clients) 34 | 35 | Methods: 36 | 37 | * List torrents 38 | * Stop/start torrents 39 | * Add/remove torrents 40 | * Retrieve the actual .torrent file 41 | 42 | Other: 43 | 44 | * Verify local content exist 45 | * Discover client config to autoconfigure clients 46 | * Move torrents between clients 47 | 48 | Commandline interface 49 | --------------------------------- 50 | 51 | The commandline interface allows for basic operations on torrents: 52 | 53 | .. code-block:: bash 54 | 55 | # See available commands 56 | libtc --help 57 | 58 | # See help for individual command 59 | libtc "transmission+http://127.0.0.1:9091/transmission/rpc?session_path=%7E/.config/transmission" list --help 60 | 61 | # Execute a command 62 | libtc "transmission+http://127.0.0.1:9091/transmission/rpc?session_path=%7E/.config/transmission" list 63 | 64 | # Move torrent with infohash da39a3ee5e6b4b0d3255bfef95601890afd80709 from transmission to deluge 65 | libtc "transmission+http://127.0.0.1:9091/transmission/rpc?session_path=%7E/.config/transmission" move \ 66 | "da39a3ee5e6b4b0d3255bfef95601890afd80709" \ 67 | "deluge://localclient:da39a3ee5e6b4b0d3255bfef95601890afd80709@127.0.0.1:58846?session_path=%7E/.config/deluge" 68 | 69 | Session path & fetching torrents 70 | --------------------------------- 71 | 72 | This library can find and use the actual torrent files but this is generally not possible to the APIs. 73 | Therefore it must know where the torrents are stored locally. 74 | 75 | These folders must contain the actual `.torrent` files. 76 | 77 | A list of relative torrent paths can be found here: 78 | 79 | Deluge 80 | /state/ 81 | 82 | qBittorrent 83 | /data/BT_backup/ 84 | 85 | rtorrent 86 | / 87 | 88 | Transmission 89 | /torrents/ 90 | 91 | An example could be transmission configured with `session_path=/tmp/transmission/` then the actual torrent files would 92 | be store in `/tmp/transmission/torrents/`. 93 | 94 | These are subject to change depending on how it really works out with different client versions. 95 | 96 | URL Syntax 97 | --------------------------------- 98 | 99 | The query part of urls are generally optional 100 | 101 | Deluge 102 | ============================== 103 | 104 | Syntax: :code:`deluge://:@:?session_path=` 105 | 106 | Example: :code:`deluge://localclient:da39a3ee5e6b4b0d3255bfef95601890afd80709@127.0.0.1:58846?session_path=%7E/.config/deluge` 107 | 108 | LilTorrent 109 | ============================== 110 | 111 | Multiple path mappings can be added, they are joined by a `;` - apikey is mandatory. 112 | 113 | Syntax: :code:`liltorrent+://:?apikey=&path_mapping=:;:` 114 | 115 | Example: :code:`liltorrent+http://localhost:10977?apikey=secret&path_mapping=/a/%3A/b/%3B/s/t/%3A/f/` 116 | 117 | This example changes :code:`/a/horse.gif` to :code:`/b/horse.gif` 118 | 119 | qBittorrent 120 | ============================== 121 | 122 | Syntax: :code:`qbittorrent+://:@:?session_path=` 123 | 124 | Example: :code:`qbittorrent+http://admin:adminadmin@localhost:8080?session_path=%7E/.config/qBittorrent` 125 | 126 | rtorrent 127 | ============================== 128 | 129 | Syntax: :code:`rtorrent+://:?session_path=&torrent_temp_path=` 130 | 131 | Example: :code:`rtorrent+scgi:///path/to/scgi.socket?session_path=%7E/.rtorrent/&torrent_temp_path=%7E/.rtorrent/tmp-libtc` 132 | 133 | Example: :code:`rtorrent+scgi://127.0.0.1:5000?session_path=%7E/.rtorrent/&torrent_temp_path=%7E/.rtorrent/tmp-libtc` 134 | 135 | Example: :code:`rtorrent+http://127.0.0.1:8000/SCGI?session_path=%7E/.rtorrent/&torrent_temp_path=%7E/.rtorrent/tmp-libtc` 136 | 137 | Transmission 138 | ============================== 139 | 140 | Syntax: :code:`transmission+://:?session_path=` 141 | 142 | Example: :code:`transmission+http://127.0.0.1:9091/transmission/rpc?session_path=%7E/.config/transmission` 143 | 144 | LilTorrent usage 145 | --------------------------------- 146 | 147 | This layer can work as an abstraction layer between local clients in different environments, 148 | e.g. in a docker container. 149 | 150 | .. code-block:: bash 151 | 152 | pip install libtc[liltorrent] 153 | 154 | LILTORRENT_APIKEY=secretapikey LILTORRENT_CLIENT=rtorrent:///path/to/scgi.socket liltorrent 155 | 156 | * `LILTORRENT_APIKEY` is the apikey that the server is accessible through 157 | * `LILTORRENT_CLIENT` is a client URL 158 | 159 | Config file syntax 160 | --------------------------------- 161 | 162 | These examples use .toml format, while the actual parsing logic is agnostic to on-disk format, it's the recommended one. 163 | 164 | The display_name is the name shown when client is used. If it is not set, then the config file key is used, 165 | e.g. `[clients.another-transmission]` is called `another-transmission` if no display_name is set. 166 | 167 | The URL config as described above can also be used and is seen in the last example as `deluge-url`. 168 | 169 | Each key must be unique, e.g. you cannot have two clients with the same key, e.g. two `[clients.the-transmission]` 170 | 171 | .. code-block:: toml 172 | 173 | [clients] 174 | 175 | [clients.deluge] 176 | display_name = "A Deluge" 177 | client_type = "deluge" 178 | host = "127.0.0.1" 179 | port = 58846 180 | username = "localclient" 181 | password = "secretpassword" 182 | session_path = "~/.config/deluge/" 183 | 184 | [clients.the-transmission] 185 | display_name = "Some transmission" 186 | client_type = "transmission" 187 | url = "http://127.0.0.1:9091/transmission/rpc" 188 | session_path = "~/.config/transmission-daemon/" 189 | 190 | [clients.another-transmission] 191 | display_name = "Horse transmission" 192 | client_type = "transmission" 193 | url = "http://127.0.0.1:9092/transmission/rpc" 194 | session_path = "~/.config/transmission-daemon2/" 195 | 196 | [clients.rtorrent] 197 | display_name = "rtorrent" 198 | client_type = "rtorrent" 199 | url = "scgi://127.0.0.1:5000" 200 | session_path = "~/.rtorrent/" 201 | 202 | [clients.another-qbittorrent] 203 | display_name = "qBittorrent 1" 204 | client_type = "qbittorrent" 205 | url = "http://localhost:8080/" 206 | username = "admin" 207 | password = "adminadmin" 208 | session_path = "~/.config/qbittorrent/" 209 | 210 | # This is an example of using the url syntax 211 | [clients.deluge-url] 212 | display_name = "Deluge url" 213 | client_url = "deluge://localclient:da39a3ee5e6b4b0d3255bfef95601890afd80709@127.0.0.1:58846?session_path=%7E/.config/deluge" 214 | 215 | [clients.rtorrent-with-label] 216 | display_name = "rtorrent" 217 | client_type = "rtorrent" 218 | url = "scgi://127.0.0.1:5000" 219 | session_path = "~/.rtorrent/" 220 | label = "alabel" 221 | 222 | [clients.deluge-with-label] 223 | display_name = "A Deluge" 224 | client_type = "deluge" 225 | host = "127.0.0.1" 226 | port = 58846 227 | username = "localclient" 228 | password = "secretpassword" 229 | session_path = "~/.config/deluge/" 230 | label = "alabel" 231 | 232 | License 233 | --------------------------------- 234 | 235 | MIT -------------------------------------------------------------------------------- /libtc/__init__.py: -------------------------------------------------------------------------------- 1 | from .bencode import BTFailure, bdecode, bencode 2 | from .clients import * 3 | from .exceptions import FailedToExecuteException, LibTorrentClientException 4 | from .management import move_torrent 5 | from .parse_clients import parse_clients_from_toml_dict 6 | from .torrent import * 7 | from .utils import TorrentProblems 8 | 9 | __version__ = "1.3.4" 10 | 11 | __all__ = [ 12 | "DelugeClient", 13 | "RTorrentClient", 14 | "TransmissionClient", 15 | "QBittorrentClient", 16 | "LilTorrentClient", 17 | "FakeClient", 18 | "TORRENT_CLIENT_MAPPING", 19 | "TorrentData", 20 | "TorrentState", 21 | "TorrentFile", 22 | "bencode", 23 | "bdecode", 24 | "LibTorrentClientException", 25 | "FailedToExecuteException", 26 | "move_torrent", 27 | "parse_libtc_url", 28 | "TorrentProblems", 29 | "parse_clients_from_toml_dict", 30 | "BTFailure", 31 | ] 32 | -------------------------------------------------------------------------------- /libtc/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | from tabulate import tabulate 3 | 4 | from libtc import move_torrent, parse_libtc_url 5 | 6 | 7 | @click.group() 8 | @click.argument("client_url") 9 | @click.pass_context 10 | def cli(ctx, client_url): 11 | ctx.ensure_object(dict) 12 | ctx.obj["client"] = parse_libtc_url(client_url) 13 | 14 | 15 | @cli.command() 16 | @click.option("--active", is_flag=True, help="Show only active torrents") 17 | @click.pass_context 18 | def list(ctx, active): 19 | client = ctx.obj["client"] 20 | if active: 21 | torrents = client.list_active() 22 | else: 23 | torrents = client.list() 24 | torrents = sorted( 25 | [(t.infohash, t.name) for t in torrents], key=lambda x: x[1].lower() 26 | ) 27 | print(tabulate(torrents, headers=["Infohash", "Name"], tablefmt="presto")) 28 | 29 | 30 | @cli.command() 31 | @click.argument("infohash") 32 | @click.pass_context 33 | def start(ctx, infohash): 34 | client = ctx.obj["client"] 35 | client.start(infohash) 36 | print(f"Started {infohash}") 37 | 38 | 39 | @cli.command() 40 | @click.argument("infohash") 41 | @click.pass_context 42 | def stop(ctx, infohash): 43 | client = ctx.obj["client"] 44 | client.stop(infohash) 45 | print(f"Stopped {infohash}") 46 | 47 | 48 | @cli.command() 49 | @click.argument("infohash") 50 | @click.pass_context 51 | def remove(ctx, infohash): 52 | client = ctx.obj["client"] 53 | client.remove(infohash) 54 | print(f"Removed {infohash}") 55 | 56 | 57 | @cli.command() 58 | @click.pass_context 59 | def test_connection(ctx): 60 | client = ctx.obj["client"] 61 | if client.test_connection(): 62 | print("Connected to client successfully") 63 | else: 64 | print("Failed to connect") 65 | 66 | 67 | @cli.command() 68 | @click.argument("infohash") 69 | @click.argument("target_client_url") 70 | @click.pass_context 71 | def move(ctx, infohash, target_client_url): 72 | source_client = ctx.obj["client"] 73 | target_client = parse_libtc_url(target_client_url) 74 | move_torrent(infohash, source_client, target_client) 75 | print(f"Moved {infohash}") 76 | 77 | 78 | if __name__ == "__main__": 79 | cli() 80 | -------------------------------------------------------------------------------- /libtc/baseclient.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod, abstractproperty 2 | 3 | 4 | class BaseClient(metaclass=ABCMeta): 5 | @abstractproperty 6 | def identifier(): 7 | """ 8 | Text string used to identify this client 9 | """ 10 | 11 | @abstractproperty 12 | def display_name(): 13 | """ 14 | Human readable name for this client 15 | """ 16 | 17 | @abstractmethod 18 | def list(): 19 | """ 20 | Return a list of `TorrentData` 21 | """ 22 | 23 | @abstractmethod 24 | def list_active(): 25 | """ 26 | Return a list of `TorrentData` with active torrents 27 | """ 28 | 29 | @abstractmethod 30 | def start(infohash): 31 | """ 32 | Start a torrent with a given infohash 33 | """ 34 | 35 | @abstractmethod 36 | def stop(infohash): 37 | """ 38 | Stop a torrent with a given infohash 39 | """ 40 | 41 | @abstractmethod 42 | def test_connection(): 43 | """ 44 | Test if the client is reachable. 45 | """ 46 | 47 | @abstractmethod 48 | def add( 49 | torrent, 50 | destination_path, 51 | fast_resume=False, 52 | add_name_to_folder=True, 53 | minimum_expected_data="none", 54 | stopped=False, 55 | ): 56 | """ 57 | Add a new torrent, 58 | 59 | torrent: decoded torrentfile 60 | destination_path: path where to store the data 61 | fast_resume: Try to fast-resume 62 | add_name_to_folder: add name from torrent to the folder, only multifile torrent 63 | minimum_expected_data: check local data and make sure minimum is there. 64 | Choices are: none, partial, full 65 | stopped: add torrent in stopped state 66 | """ 67 | 68 | @abstractmethod 69 | def remove(infohash): 70 | """ 71 | Remove a torrent with a given infohash 72 | """ 73 | 74 | @abstractmethod 75 | def retrieve_torrentfile(infohash): 76 | """ 77 | Retrieve the torrent file and returns it content 78 | """ 79 | 80 | @abstractmethod 81 | def get_download_path(infohash): 82 | """ 83 | Find the path where the files are actually stored. 84 | 85 | This path is expected to contain the actual files. 86 | """ 87 | 88 | @abstractmethod 89 | def move_torrent(infohash, destination_path): 90 | """ 91 | Move torrent to destination_path. 92 | """ 93 | 94 | @abstractmethod 95 | def get_files(infohash): 96 | """ 97 | Returns a list of `TorrentFile` in a given infohash. 98 | """ 99 | 100 | @abstractmethod 101 | def serialize_configuration(): 102 | """ 103 | Serializes the current configuration and returns it as a url. 104 | """ 105 | 106 | @classmethod 107 | @abstractmethod 108 | def auto_configure(): 109 | """ 110 | Tries to auto-configure an instance of this client and return it. 111 | """ 112 | -------------------------------------------------------------------------------- /libtc/bencode.py: -------------------------------------------------------------------------------- 1 | # The contents of this file are subject to the BitTorrent Open Source License 2 | # Version 1.1 (the License). You may not copy or use this file, in either 3 | # source code or executable form, except in compliance with the License. You 4 | # may obtain a copy of the License at http://www.bittorrent.com/license/. 5 | # 6 | # Software distributed under the License is distributed on an AS IS basis, 7 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License 8 | # for the specific language governing rights and limitations under the 9 | # License. 10 | 11 | # Written by Petru Paler 12 | # Modified to have Python 3 support by Anders Jensen 13 | 14 | 15 | class BTFailure(Exception): 16 | pass 17 | 18 | 19 | def decode_int(x, f): 20 | f += 1 21 | newf = x.find(b"e", f) 22 | n = int(x[f:newf]) 23 | if x[f] == 45: 24 | if x[f + 1] == 48: 25 | raise ValueError 26 | elif x[f] == 48 and newf != f + 1: 27 | raise ValueError 28 | return (n, newf + 1) 29 | 30 | 31 | def decode_string(x, f): 32 | colon = x.find(b":", f) 33 | n = int(x[f:colon]) 34 | if x[f] == 48 and colon != f + 1: 35 | raise ValueError 36 | colon += 1 37 | return (x[colon : colon + n], colon + n) 38 | 39 | 40 | def decode_list(x, f): 41 | r, f = [], f + 1 42 | while x[f] != 101: 43 | v, f = decode_func[x[f]](x, f) 44 | r.append(v) 45 | return (r, f + 1) 46 | 47 | 48 | def decode_dict(x, f): 49 | r, f = {}, f + 1 50 | while x[f] != 101: 51 | k, f = decode_string(x, f) 52 | r[k], f = decode_func[x[f]](x, f) 53 | return (r, f + 1) 54 | 55 | 56 | decode_func = {} 57 | decode_func[108] = decode_list 58 | decode_func[100] = decode_dict 59 | decode_func[105] = decode_int 60 | 61 | for i in range(48, 59): 62 | decode_func[i] = decode_string 63 | 64 | 65 | def bdecode(x): 66 | try: 67 | r, l = decode_func[x[0]](x, 0) 68 | except (IndexError, KeyError, ValueError): 69 | raise BTFailure("not a valid bencoded string") 70 | if l != len(x): 71 | raise BTFailure("invalid bencoded value (data after valid prefix)") 72 | return r 73 | 74 | 75 | class Bencached(object): 76 | __slots__ = ["bencoded"] 77 | 78 | def __init__(self, s): 79 | self.bencoded = s 80 | 81 | 82 | def encode_bencached(x, r): 83 | r.append(x.bencoded) 84 | 85 | 86 | def encode_int(x, r): 87 | r.extend((b"i", str(x).encode(), b"e")) 88 | 89 | 90 | def encode_bool(x, r): 91 | if x: 92 | encode_int(1, r) 93 | else: 94 | encode_int(0, r) 95 | 96 | 97 | def encode_string(x, r): 98 | r.extend((str(len(x)).encode(), b":", x)) 99 | 100 | 101 | def encode_list(x, r): 102 | r.append(b"l") 103 | for i in x: 104 | encode_func[type(i)](i, r) 105 | r.append(b"e") 106 | 107 | 108 | def encode_dict(x, r): 109 | r.append(b"d") 110 | for k, v in sorted(x.items()): 111 | r.extend((str(len(k)).encode(), b":", k)) 112 | encode_func[type(v)](v, r) 113 | r.append(b"e") 114 | 115 | 116 | encode_func = {} 117 | encode_func[Bencached] = encode_bencached 118 | encode_func[int] = encode_int 119 | encode_func[str] = encode_string 120 | encode_func[bytes] = encode_string 121 | encode_func[list] = encode_list 122 | encode_func[tuple] = encode_list 123 | encode_func[dict] = encode_dict 124 | 125 | try: 126 | from types import BooleanType 127 | 128 | encode_func[BooleanType] = encode_bool 129 | except ImportError: 130 | pass 131 | 132 | 133 | def bencode(x): 134 | r = [] 135 | encode_func[type(x)](x, r) 136 | return b"".join(r) 137 | -------------------------------------------------------------------------------- /libtc/clients/__init__.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qsl, urlparse 2 | 3 | from .deluge import DelugeClient 4 | from .fakeclient import FakeClient 5 | from .liltorrent import LilTorrentClient 6 | from .qbittorrent import QBittorrentClient 7 | from .rtorrent import RTorrentClient 8 | from .transmission import TransmissionClient 9 | 10 | __all__ = [ 11 | "DelugeClient", 12 | "FakeClient", 13 | "LilTorrentClient", 14 | "QBittorrentClient", 15 | "RTorrentClient", 16 | "TransmissionClient", 17 | "TORRENT_CLIENT_MAPPING", 18 | "parse_libtc_url", 19 | ] 20 | 21 | TORRENT_CLIENT_MAPPING = { 22 | DelugeClient.identifier: DelugeClient, 23 | RTorrentClient.identifier: RTorrentClient, 24 | TransmissionClient.identifier: TransmissionClient, 25 | FakeClient.identifier: FakeClient, 26 | QBittorrentClient.identifier: QBittorrentClient, 27 | LilTorrentClient.identifier: LilTorrentClient, 28 | } 29 | 30 | 31 | def parse_libtc_url(url): 32 | # transmission+http://127.0.0.1:9091/?session_path=/session/path/ 33 | # rtorrent+scgi:///path/to/socket.scgi?session_path=/session/path/ 34 | # deluge://username:password@127.0.0.1:58664/?session_path=/session/path/ 35 | # qbittorrent+http://username:password@127.0.0.1:8080/?session_path=/session/path/ 36 | 37 | if url in TORRENT_CLIENT_MAPPING: 38 | return TORRENT_CLIENT_MAPPING[url].auto_config() 39 | 40 | kwargs = {} 41 | parsed = urlparse(url) 42 | scheme = parsed.scheme.split("+") 43 | netloc = parsed.netloc 44 | if "@" in netloc: 45 | auth, netloc = netloc.split("@") 46 | username, password = auth.split(":") 47 | kwargs["username"] = username 48 | kwargs["password"] = password 49 | 50 | client = scheme[0] 51 | if len(scheme) == 2: 52 | kwargs["url"] = f"{scheme[1]}://{netloc}{parsed.path}" 53 | else: 54 | kwargs["host"], kwargs["port"] = netloc.split(":") 55 | kwargs["port"] = int(kwargs["port"]) 56 | 57 | kwargs.update(dict(parse_qsl(parsed.query))) 58 | return TORRENT_CLIENT_MAPPING[client](**kwargs) 59 | -------------------------------------------------------------------------------- /libtc/clients/deluge.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import os 4 | from datetime import datetime 5 | from pathlib import Path 6 | from urllib.parse import urlencode 7 | 8 | import pytz 9 | from deluge_client import DelugeRPCClient, LocalDelugeRPCClient 10 | from deluge_client.client import DelugeClientException 11 | 12 | from ..baseclient import BaseClient 13 | from ..bencode import bencode 14 | from ..exceptions import FailedToExecuteException 15 | from ..torrent import TorrentData, TorrentFile, TorrentState 16 | from ..utils import ( 17 | calculate_minimum_expected_data, 18 | has_minimum_expected_data, 19 | map_existing_files, 20 | ) 21 | 22 | 23 | class DelugeClient(BaseClient): 24 | identifier = "deluge" 25 | display_name = "Deluge" 26 | 27 | keys = [ 28 | "name", 29 | "progress", 30 | "state", 31 | "total_size", 32 | "time_added", 33 | "total_uploaded", 34 | "tracker_host", 35 | "upload_payload_rate", 36 | "download_payload_rate", 37 | "label", 38 | ] 39 | 40 | def __init__(self, host, port, username, password, session_path=None, label=None): 41 | self.host = host 42 | self.port = port 43 | self.username = username 44 | self.password = password 45 | self.session_path = session_path and Path(session_path) 46 | self.label = label 47 | 48 | @property 49 | def client(self): 50 | return DelugeRPCClient( 51 | host=self.host, 52 | port=self.port, 53 | username=self.username, 54 | password=self.password, 55 | decode_utf8=True, 56 | ) 57 | 58 | def _fetch_list_result(self, filter): 59 | result = [] 60 | try: 61 | with self.client as client: 62 | torrents = client.core.get_torrents_status(filter, self.keys) 63 | except (DelugeClientException, ConnectionError, OSError): 64 | raise FailedToExecuteException() 65 | for infohash, torrent_data in torrents.items(): 66 | if torrent_data["state"] in ["Seeding", "Downloading"]: 67 | state = TorrentState.ACTIVE 68 | elif torrent_data["state"] in ["Error"]: 69 | state = TorrentState.ERROR 70 | else: 71 | state = TorrentState.STOPPED 72 | 73 | result.append( 74 | TorrentData( 75 | infohash, 76 | torrent_data["name"], 77 | torrent_data["total_size"], 78 | state, 79 | torrent_data["progress"], 80 | torrent_data["total_uploaded"], 81 | datetime.utcfromtimestamp(torrent_data["time_added"]).astimezone( 82 | pytz.UTC 83 | ), 84 | torrent_data["tracker_host"], 85 | torrent_data["upload_payload_rate"], 86 | torrent_data["download_payload_rate"], 87 | torrent_data.get("label", ""), 88 | ) 89 | ) 90 | return result 91 | 92 | def list(self): 93 | return self._fetch_list_result({}) 94 | 95 | def list_active(self): 96 | return self._fetch_list_result({"state": "Active"}) 97 | 98 | def start(self, infohash): 99 | try: 100 | with self.client as client: 101 | client.core.resume_torrent([infohash]) 102 | except (DelugeClientException, ConnectionError, OSError): 103 | raise FailedToExecuteException() 104 | 105 | def stop(self, infohash): 106 | try: 107 | with self.client as client: 108 | client.core.pause_torrent([infohash]) 109 | except (DelugeClientException, ConnectionError, OSError): 110 | raise FailedToExecuteException() 111 | 112 | def test_connection(self): 113 | try: 114 | with self.client as client: 115 | return client.core.get_free_space() is not None 116 | except (DelugeClientException, ConnectionError, OSError): 117 | return False 118 | 119 | def add( 120 | self, 121 | torrent, 122 | destination_path, 123 | fast_resume=False, 124 | add_name_to_folder=True, 125 | minimum_expected_data="none", 126 | stopped=False, 127 | ): 128 | current_expected_data = calculate_minimum_expected_data( 129 | torrent, destination_path, add_name_to_folder 130 | ) 131 | if not has_minimum_expected_data(minimum_expected_data, current_expected_data): 132 | raise FailedToExecuteException( 133 | f"Minimum expected data not reached, wanted {minimum_expected_data} actual {current_expected_data}" 134 | ) 135 | destination_path = Path(os.path.abspath(destination_path)) 136 | encoded_torrent = base64.b64encode(bencode(torrent)) 137 | infohash = hashlib.sha1(bencode(torrent[b"info"])).hexdigest() 138 | options = {"download_location": str(destination_path), "seed_mode": fast_resume} 139 | if stopped: 140 | options["add_paused"] = True 141 | if not add_name_to_folder: 142 | files = map_existing_files( 143 | torrent, destination_path, add_name_to_folder=False 144 | ) 145 | mapped_files = {} 146 | for i, (fp, f, size, exists) in enumerate(files): 147 | mapped_files[i] = str(f) 148 | options["mapped_files"] = mapped_files 149 | 150 | try: 151 | with self.client as client: 152 | result = client.core.add_torrent_file( 153 | "torrent.torrent", encoded_torrent, options 154 | ) 155 | if self.label: 156 | if self.label not in client.label.get_labels(): 157 | client.label.add(self.label) 158 | client.label.set_torrent(infohash, self.label) 159 | except (DelugeClientException, ConnectionError, OSError): 160 | raise FailedToExecuteException() 161 | 162 | if result != infohash: 163 | raise FailedToExecuteException() 164 | 165 | def remove(self, infohash): 166 | try: 167 | with self.client as client: 168 | client.core.remove_torrent(infohash, False) 169 | except (DelugeClientException, ConnectionError, OSError): 170 | raise FailedToExecuteException() 171 | 172 | def retrieve_torrentfile(self, infohash): 173 | if not self.session_path: 174 | raise FailedToExecuteException("Session path is not configured") 175 | torrent_path = self.session_path / "state" / f"{infohash}.torrent" 176 | if not torrent_path.is_file(): 177 | raise FailedToExecuteException("Torrent file does not exist") 178 | return torrent_path.read_bytes() 179 | 180 | def get_download_path(self, infohash): 181 | # Deluge has a download place and an internal mapping relative to the files 182 | # which makes it a bit of a guesswork to figure out the download folder. 183 | # The algorithm we will be using is, multifile and a single shared prefix (also single folder max). 184 | try: 185 | with self.client as client: 186 | torrents = client.core.get_torrents_status( 187 | {"id": [infohash]}, 188 | ["name", "download_location", "save_path", "files"], 189 | ) 190 | except (DelugeClientException, ConnectionError, OSError): 191 | raise FailedToExecuteException( 192 | "Failed to fetch download_location from Deluge" 193 | ) 194 | 195 | if not torrents: 196 | raise FailedToExecuteException("Empty result from deluge") 197 | 198 | torrent_data = torrents[infohash] 199 | download_location = torrent_data.get( 200 | "download_location", torrent_data.get("save_path") 201 | ) 202 | if not download_location: 203 | raise FailedToExecuteException( 204 | "Unable to retrieve a valid download_location" 205 | ) 206 | if ( 207 | len(torrent_data["files"]) == 1 208 | and "/" not in torrent_data["files"][0]["path"] 209 | ): 210 | return Path(download_location) 211 | 212 | prefixes = set(f["path"].split("/")[0] for f in torrent_data["files"]) 213 | if len(prefixes) == 1: 214 | return Path(download_location) / list(prefixes)[0] 215 | else: 216 | return Path(download_location) 217 | 218 | def move_torrent(self, infohash, destination_path): 219 | try: 220 | with self.client as client: 221 | client.core.move_storage([infohash], os.path.abspath(destination_path)) 222 | except (DelugeClientException, ConnectionError, OSError): 223 | raise FailedToExecuteException("Failed to move torrent") 224 | 225 | def get_files(self, infohash): 226 | try: 227 | with self.client as client: 228 | torrents = client.core.get_torrents_status( 229 | {"id": [infohash]}, 230 | ["name", "files", "file_progress"], 231 | ) 232 | except (DelugeClientException, ConnectionError, OSError): 233 | raise FailedToExecuteException("Failed to fetch files from Deluge") 234 | 235 | torrent_data = torrents[infohash] 236 | files = torrent_data["files"] 237 | file_progress = torrent_data["file_progress"] 238 | is_singlefile = len(files) == 1 and "/" not in files[0]["path"] 239 | result = [] 240 | for f, p in zip(files, file_progress): 241 | name = f["path"] 242 | if not is_singlefile: 243 | name = name.split("/", 1)[1] 244 | result.append(TorrentFile(name, f["size"], p * 100)) 245 | return result 246 | 247 | def serialize_configuration(self): 248 | url = f"{self.identifier}://{self.username}:{self.password}@{self.host}:{self.port}" 249 | query = {} 250 | if self.session_path: 251 | query["session_path"] = str(self.session_path) 252 | 253 | if query: 254 | url += f"?{urlencode(query)}" 255 | 256 | return url 257 | 258 | @classmethod 259 | def auto_configure(cls): 260 | client = LocalDelugeRPCClient() 261 | return cls(client.host, client.port, client.username, client.password) 262 | -------------------------------------------------------------------------------- /libtc/clients/fakeclient.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from datetime import datetime 4 | 5 | import pytz 6 | 7 | from ..baseclient import BaseClient 8 | from ..exceptions import FailedToExecuteException 9 | from ..torrent import TorrentData, TorrentState 10 | 11 | TORRENTS = {} 12 | 13 | 14 | def randomString(rng, letters, stringLength): 15 | return "".join(rng.choice(letters) for i in range(stringLength)) 16 | 17 | 18 | def generate_torrent(rng): 19 | size = rng.randint(100000, 7000000000) 20 | return TorrentData( 21 | randomString(rng, "abcdef0123456789", 40), 22 | randomString( 23 | rng, string.ascii_lowercase + " " + "0123456789", rng.randint(10, 26) 24 | ), 25 | size, 26 | TorrentState.ACTIVE, 27 | 100, 28 | rng.randint(size // 10, size * 20), 29 | datetime.utcfromtimestamp(rng.randint(1500000000, 1590000000)).astimezone( 30 | pytz.UTC 31 | ), 32 | "example.com", 33 | rng.randint(0, 500) == 0 and rng.randint(100, 1000000), 34 | 0, 35 | "", 36 | ) 37 | 38 | 39 | def touch_torrents(rng, torrents): 40 | for t in torrents: 41 | if t.upload_rate > 0: 42 | t.upload_rate = rng.randint(100, 1000000) 43 | t.uploaded += t.upload_rate * 10 44 | 45 | 46 | class FakeClient(BaseClient): 47 | identifier = "fakeclient" 48 | display_name = "FakeClient" 49 | 50 | def __init__(self, seed, num_torrents): 51 | if seed not in TORRENTS: 52 | rng = random.Random(seed) 53 | TORRENTS[seed] = { 54 | "rng": rng, 55 | "torrents": [generate_torrent(rng) for _ in range(num_torrents)], 56 | } 57 | self._torrents = TORRENTS[seed] 58 | 59 | def list(self): 60 | touch_torrents(self._torrents["rng"], self._torrents["torrents"]) 61 | return self._torrents["torrents"] 62 | 63 | def list_active(self): 64 | touch_torrents(self._torrents["rng"], self._torrents["torrents"]) 65 | return [t for t in self._torrents["torrents"] if t.upload_rate > 0] 66 | 67 | def start(self, infohash): 68 | pass 69 | 70 | def stop(self, infohash): 71 | pass 72 | 73 | def test_connection(self): 74 | return True 75 | 76 | def add( 77 | self, 78 | torrent, 79 | destination_path, 80 | fast_resume=False, 81 | add_name_to_folder=True, 82 | minimum_expected_data="none", 83 | stopped=False, 84 | ): 85 | pass 86 | 87 | def remove(self, infohash): 88 | pass 89 | 90 | def retrieve_torrentfile(self, infohash): 91 | raise FailedToExecuteException("Dummy client does not retrieve torrents") 92 | 93 | def get_download_path(self, infohash): 94 | raise FailedToExecuteException("No data exist") 95 | 96 | def move_torrent(self, infohash, destination_path): 97 | raise FailedToExecuteException("Unable to move torrent") 98 | 99 | def get_files(self): 100 | raise FailedToExecuteException("Unable to get files") 101 | 102 | def move_torrent(self, infohash, destination_path): 103 | raise FailedToExecuteException("Failed to set path") 104 | 105 | def serialize_configuration(self): 106 | raise FailedToExecuteException("Unserializable") 107 | 108 | def auto_configure(self): 109 | raise FailedToExecuteException("Auto configure not available") 110 | -------------------------------------------------------------------------------- /libtc/clients/liltorrent.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlencode, urljoin 3 | 4 | import requests 5 | from requests.exceptions import RequestException 6 | 7 | from ..baseclient import BaseClient 8 | from ..bencode import bencode 9 | from ..exceptions import FailedToExecuteException 10 | from ..torrent import TorrentData, TorrentFile 11 | from ..utils import rewrite_path 12 | 13 | 14 | class LilTorrentClient(BaseClient): 15 | identifier = "liltorrent" 16 | display_name = "LilTorrent" 17 | 18 | def __init__(self, apikey, url, path_mapping=None): 19 | self.url = url 20 | self.apikey = apikey 21 | self.headers = { 22 | "Accept": "application/json", 23 | "Authorization": f"Bearer {apikey}", 24 | } 25 | if path_mapping: 26 | self.path_mapping = dict( 27 | [Path(p) for p in pm.split(":")] for pm in path_mapping.split(";") 28 | ) 29 | else: 30 | self.path_mapping = {} 31 | 32 | self.reverse_path_mapping = {v: k for (k, v) in self.path_mapping.items()} 33 | 34 | def _call(self, _method, url, *args, **kwargs): 35 | url = urljoin(self.url, url) 36 | kwargs["headers"] = kwargs.get("headers", {}) 37 | kwargs["headers"].update(self.headers) 38 | try: 39 | r = getattr(requests, _method)(url, *args, **kwargs) 40 | if r.status_code == 500: 41 | raise FailedToExecuteException(*r.json()) 42 | else: 43 | return r 44 | except RequestException: 45 | raise FailedToExecuteException("Unable to contact liltorrent instance") 46 | 47 | def _fetch_list_result(self, url): 48 | return [ 49 | TorrentData.unserialize(torrent) 50 | for torrent in self._call("get", url).json() 51 | ] 52 | 53 | def list(self): 54 | return self._fetch_list_result("list") 55 | 56 | def list_active(self): 57 | return self._fetch_list_result("list_active") 58 | 59 | def start(self, infohash): 60 | return self._call("post", "start", params={"infohash": infohash}).json() 61 | 62 | def stop(self, infohash): 63 | return self._call("post", "stop", params={"infohash": infohash}).json() 64 | 65 | def test_connection(self): 66 | try: 67 | return self._call("get", "test_connection").json() 68 | except FailedToExecuteException: 69 | return False 70 | 71 | def add( 72 | self, 73 | torrent, 74 | destination_path, 75 | fast_resume=False, 76 | add_name_to_folder=True, 77 | minimum_expected_data="none", 78 | stopped=False, 79 | ): 80 | destination_path = rewrite_path(destination_path, self.path_mapping) 81 | return self._call( 82 | "post", 83 | "add", 84 | params={ 85 | "destination_path": str(destination_path), 86 | "fast_resume": fast_resume and "true" or "false", 87 | "add_name_to_folder": add_name_to_folder and "true" or "false", 88 | "minimum_expected_data": minimum_expected_data, 89 | "stopped": stopped and "true" or "false", 90 | }, 91 | files={"torrent": bencode(torrent)}, 92 | ).json() 93 | 94 | def remove(self, infohash): 95 | return self._call("post", "remove", params={"infohash": infohash}).json() 96 | 97 | def retrieve_torrentfile(self, infohash): 98 | return self._call( 99 | "get", "retrieve_torrentfile", params={"infohash": infohash} 100 | ).content 101 | 102 | def get_download_path(self, infohash): 103 | path = self._call( 104 | "get", "get_download_path", params={"infohash": infohash} 105 | ).json() 106 | return rewrite_path(Path(path), self.reverse_path_mapping) 107 | 108 | def move_torrent(self, infohash, destination_path): 109 | return self._call( 110 | "post", 111 | "move_torrent", 112 | params={ 113 | "infohash": infohash, 114 | "destination_path": str( 115 | rewrite_path(Path(destination_path), self.reverse_path_mapping) 116 | ), 117 | }, 118 | ).json() 119 | 120 | def get_files(self, infohash): 121 | return [ 122 | TorrentFile.unserialize(torrent) 123 | for torrent in self._call( 124 | "get", "get_files", params={"infohash": infohash} 125 | ).json() 126 | ] 127 | 128 | def serialize_configuration(self): 129 | url = f"{self.identifier}+{self.url}" 130 | query = {} 131 | if self.apikey: 132 | query["apikey"] = str(self.apikey) 133 | 134 | if self.path_mapping: 135 | query["path_mapping"] = ";".join( 136 | [f"{k!s}:{v!s}" for (k, v) in self.path_mapping.items()] 137 | ) 138 | 139 | if query: 140 | url += f"?{urlencode(query)}" 141 | 142 | return url 143 | 144 | @classmethod 145 | def auto_configure(): 146 | raise FailedToExecuteException("Cannot auto-configure this type") 147 | 148 | def horse(self): 149 | return "horse" 150 | -------------------------------------------------------------------------------- /libtc/clients/qbittorrent.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from urllib.parse import urlencode, urljoin, urlparse 4 | 5 | import requests 6 | from requests.exceptions import RequestException 7 | 8 | from ..baseclient import BaseClient 9 | from ..bencode import bdecode, bencode 10 | from ..exceptions import FailedToExecuteException 11 | from ..torrent import TorrentData, TorrentFile, TorrentState 12 | from ..utils import ( 13 | calculate_minimum_expected_data, 14 | get_tracker_domain, 15 | has_minimum_expected_data, 16 | move_files, 17 | ) 18 | 19 | 20 | class QBittorrentClient(BaseClient): 21 | identifier = "qbittorrent" 22 | display_name = "qBittorrent" 23 | 24 | def __init__(self, url, username, password, session_path=None, label=None): 25 | self.url = url 26 | self.username = username 27 | self.password = password 28 | self.session_path = session_path and Path(session_path) 29 | self.label = label 30 | self._session = requests.Session() 31 | 32 | def _call(self, _method, url, *args, **kwargs): 33 | return getattr(self._session, _method)(urljoin(self.url, url), *args, **kwargs) 34 | 35 | def _login(self): 36 | r = self._call( 37 | "post", 38 | urljoin(self.url, "/api/v2/auth/login"), 39 | headers={"Referer": self.url}, 40 | data={"username": self.username, "password": self.password}, 41 | ) 42 | 43 | return r.status_code == 200 44 | 45 | def call(self, method, url, *args, **kwargs): 46 | try: 47 | r = self._call(method, url, *args, **kwargs) 48 | except RequestException: 49 | raise FailedToExecuteException() 50 | 51 | if r.status_code > 400: 52 | if not self._login(): 53 | raise FailedToExecuteException() 54 | 55 | r = self._call(method, url, *args, **kwargs) 56 | 57 | if r.status_code > 400: 58 | raise FailedToExecuteException() 59 | 60 | return r 61 | 62 | def _fetch_list_result(self, filter): 63 | result = [] 64 | torrents = self.call( 65 | "get", "/api/v2/torrents/info", params={"filter": filter} 66 | ).json() 67 | for torrent in torrents: 68 | if torrent["state"] == "error": 69 | state = TorrentState.ERROR 70 | if torrent["state"].startswith("paused") or torrent["state"].startswith( 71 | "queued" 72 | ): 73 | state = TorrentState.STOPPED 74 | else: 75 | state = TorrentState.ACTIVE 76 | 77 | tracker = "" 78 | if torrent["tracker"]: 79 | tracker = get_tracker_domain(torrent["tracker"]) 80 | 81 | result.append( 82 | TorrentData( 83 | torrent["hash"], 84 | torrent["name"], 85 | torrent["size"], 86 | state, 87 | torrent["progress"] * 100.0, 88 | torrent["uploaded"], 89 | torrent["added_on"], 90 | tracker, 91 | torrent["upspeed"], 92 | torrent["dlspeed"], 93 | torrent["category"], 94 | ) 95 | ) 96 | 97 | return result 98 | 99 | def list(self): 100 | return self._fetch_list_result("all") 101 | 102 | def list_active(self): 103 | return self._fetch_list_result("active") 104 | 105 | def start(self, infohash): 106 | self.call("get", "/api/v2/torrents/resume", params={"hashes": infohash}) 107 | 108 | def stop(self, infohash): 109 | self.call("get", "/api/v2/torrents/pause", params={"hashes": infohash}) 110 | 111 | def test_connection(self): 112 | try: 113 | return len(self.call("get", "/api/v2/app/version").text) > 0 114 | except FailedToExecuteException: 115 | return False 116 | 117 | def add( 118 | self, 119 | torrent, 120 | destination_path, 121 | fast_resume=False, 122 | add_name_to_folder=True, 123 | minimum_expected_data="none", 124 | stopped=False, 125 | ): 126 | current_expected_data = calculate_minimum_expected_data( 127 | torrent, destination_path, add_name_to_folder 128 | ) 129 | if not has_minimum_expected_data(minimum_expected_data, current_expected_data): 130 | raise FailedToExecuteException( 131 | f"Minimum expected data not reached, wanted {minimum_expected_data} actual {current_expected_data}" 132 | ) 133 | encoded_torrent = bencode(torrent) 134 | data = { 135 | "savepath": str(destination_path), 136 | "skip_checking": (fast_resume and "true" or "false"), 137 | "autoTMM": "false", 138 | "root_folder": add_name_to_folder, 139 | "contentLayout": (add_name_to_folder and "Original" or "NoSubfolder"), 140 | } 141 | if stopped: 142 | data["paused"] = "true" 143 | if self.label: 144 | data["tags"] = self.label 145 | 146 | self.call( 147 | "post", 148 | "/api/v2/torrents/add", 149 | files={"torrents": encoded_torrent}, 150 | data=data, 151 | ) 152 | 153 | def remove(self, infohash): 154 | self.call( 155 | "get", 156 | "/api/v2/torrents/delete", 157 | params={"hashes": infohash, "deleteFiles": "false"}, 158 | ) 159 | 160 | def retrieve_torrentfile(self, infohash): 161 | if not self.session_path: 162 | raise FailedToExecuteException("Session path is not configured") 163 | torrent_path = self.session_path / "data" / "BT_backup" / f"{infohash}.torrent" 164 | torrent_resume_path = ( 165 | self.session_path / "data" / "BT_backup" / f"{infohash}.fastresume" 166 | ) 167 | 168 | if not torrent_path.is_file(): 169 | raise FailedToExecuteException("Torrent file does not exist") 170 | torrent_data = bdecode(torrent_path.read_bytes()) 171 | if b"announce" not in torrent_data: 172 | if not torrent_resume_path.is_file(): 173 | raise FailedToExecuteException("Torrent resume file does not exist") 174 | torrent_resume_data = bdecode(torrent_resume_path.read_bytes()) 175 | trackers = torrent_resume_data.get(b"trackers") 176 | if not trackers: 177 | raise FailedToExecuteException("No trackers found in torrent file") 178 | torrent_data[b"announce"] = trackers.pop(0)[0] 179 | if trackers: 180 | torrent_data[b"announce-list"] = trackers 181 | 182 | return bencode(torrent_data) 183 | 184 | def get_download_path(self, infohash): 185 | return self._get_download_path(infohash)[0] 186 | 187 | def _get_download_path(self, infohash): 188 | torrents = self.call( 189 | "get", "/api/v2/torrents/info", params={"hashes": infohash} 190 | ).json() 191 | torrent_files = self.call( 192 | "get", "/api/v2/torrents/files", params={"hash": infohash} 193 | ).json() 194 | if not torrents or not torrent_files: 195 | raise FailedToExecuteException("Failed to retrieve download path") 196 | 197 | torrent = torrents[0] 198 | 199 | if len(torrent_files) == 1 and torrent_files[0]["name"] == torrent["name"]: 200 | return Path(torrent["save_path"]), False 201 | 202 | prefixes = set(f["name"].split("/")[0] for f in torrent_files) 203 | if len(prefixes) == 1 and list(prefixes)[0] == torrent["name"]: 204 | return Path(torrent["save_path"]) / torrent["name"], True 205 | elif len(prefixes) > 1: 206 | return Path(torrent["save_path"]), True 207 | else: 208 | return Path(torrent["save_path"]), False 209 | 210 | def move_torrent(self, infohash, destination_path): 211 | self.stop(infohash) 212 | current_download_path, contains_folder_name = self._get_download_path(infohash) 213 | files = self.get_files(infohash) 214 | 215 | move_files(current_download_path, destination_path, files) 216 | if contains_folder_name: 217 | current_download_path = current_download_path.parent 218 | self.call( 219 | "post", 220 | "/api/v2/torrents/setLocation", 221 | data={"hashes": infohash, "location": str(destination_path)}, 222 | ) 223 | for _ in range(20): 224 | import time 225 | 226 | time.sleep(0.3) 227 | print(self._get_download_path(infohash)) 228 | self.start(infohash) 229 | 230 | def get_files(self, infohash): 231 | torrents = self.call( 232 | "get", "/api/v2/torrents/info", params={"hashes": infohash} 233 | ).json() 234 | torrent_files = self.call( 235 | "get", "/api/v2/torrents/files", params={"hash": infohash} 236 | ).json() 237 | torrent = torrents[0] 238 | prefixes = set(f["name"].split("/")[0] for f in torrent_files) 239 | trim_prefix = len(prefixes) == 1 and list(prefixes)[0] == torrent["name"] 240 | result = [] 241 | for f in torrent_files: 242 | if trim_prefix and "/" in f["name"]: 243 | name = f["name"].split("/", 1)[1] 244 | else: 245 | name = f["name"] 246 | result.append( 247 | TorrentFile( 248 | name, 249 | f["size"], 250 | f["progress"] * 100, 251 | ) 252 | ) 253 | return result 254 | 255 | def serialize_configuration(self): 256 | parsed = urlparse(self.url) 257 | url = f"{self.identifier}+{parsed.scheme}://{self.username}:{self.password}@{parsed.netloc}{parsed.path}" 258 | query = {} 259 | if self.session_path: 260 | query["session_path"] = str(self.session_path) 261 | 262 | if query: 263 | url += f"?{urlencode(query)}" 264 | 265 | return url 266 | 267 | @classmethod 268 | def auto_configure(cls): 269 | raise FailedToExecuteException("Unable to auto configure this client type") 270 | -------------------------------------------------------------------------------- /libtc/clients/rtorrent.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from datetime import datetime 5 | from pathlib import Path 6 | from urllib.parse import quote, urlencode, urlsplit 7 | from xml.parsers.expat import ExpatError 8 | from xmlrpc.client import Error as XMLRPCError 9 | from xmlrpc.client import ServerProxy 10 | 11 | import pytz 12 | 13 | from ..baseclient import BaseClient 14 | from ..bencode import bencode 15 | from ..exceptions import FailedToExecuteException 16 | from ..scgitransport import SCGITransport 17 | from ..torrent import TorrentData, TorrentFile, TorrentState 18 | from ..utils import ( 19 | calculate_minimum_expected_data, 20 | get_tracker_domain, 21 | has_minimum_expected_data, 22 | map_existing_files, 23 | move_files, 24 | ) 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def create_proxy(url): 30 | parsed = urlsplit(url) 31 | proto = url.split(":")[0].lower() 32 | if proto == "scgi": 33 | if parsed.netloc: 34 | url = f"http://{parsed.netloc}" 35 | logger.debug(f"Creating SCGI XMLRPC Proxy with url {url}") 36 | return ServerProxy(url, transport=SCGITransport()) 37 | else: 38 | path = parsed.path 39 | logger.debug(f"Creating SCGI XMLRPC Socket Proxy with socket file {path}") 40 | return ServerProxy("http://1", transport=SCGITransport(socket_path=path)) 41 | else: 42 | logger.debug(f"Creating Normal XMLRPC Proxy with url {url}") 43 | return ServerProxy(url) 44 | 45 | 46 | def bitfield_to_string(bitfield): 47 | """ 48 | Converts a list of booleans into a bitfield 49 | """ 50 | retval = bytearray((len(bitfield) + 7) // 8) 51 | 52 | for piece, bit in enumerate(bitfield): 53 | if bit: 54 | retval[piece // 8] |= 1 << (7 - piece % 8) 55 | 56 | return bytes(retval) 57 | 58 | 59 | class RTorrentClient(BaseClient): 60 | identifier = "rtorrent" 61 | display_name = "rtorrent" 62 | _methods = None 63 | 64 | def __init__(self, url, session_path=None, torrent_temp_path=None, label=None): 65 | self.url = url 66 | self.proxy = create_proxy(url) 67 | self.session_path = session_path and Path(session_path) 68 | self.torrent_temp_path = torrent_temp_path and Path(torrent_temp_path) 69 | self.label = label 70 | 71 | def _fetch_list_result(self, view): 72 | result = [] 73 | try: 74 | torrents = self.proxy.d.multicall2( 75 | "", 76 | view, 77 | "d.hash=", 78 | "d.name=", 79 | "d.is_active=", 80 | "d.message=", 81 | "d.size_bytes=", 82 | "d.completed_bytes=", 83 | "d.up.total=", 84 | "d.up.rate=", 85 | "d.down.rate=", 86 | "d.timestamp.finished=", 87 | "t.multicall=,t.url=", 88 | "d.custom1=", 89 | ) 90 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 91 | raise FailedToExecuteException() 92 | for torrent in torrents: 93 | if torrent[3]: 94 | state = TorrentState.ERROR 95 | elif torrent[2] == 0: 96 | state = TorrentState.STOPPED 97 | else: 98 | state = TorrentState.ACTIVE 99 | 100 | progress = (torrent[5] / torrent[4]) * 100 101 | if torrent[10]: 102 | tracker = get_tracker_domain(torrent[10][0][0]) 103 | else: 104 | tracker = "None" 105 | 106 | result.append( 107 | TorrentData( 108 | torrent[0].lower(), 109 | torrent[1], 110 | torrent[4], 111 | state, 112 | progress, 113 | torrent[6], 114 | datetime.utcfromtimestamp(torrent[9]).astimezone(pytz.UTC), 115 | tracker, 116 | torrent[7], 117 | torrent[8], 118 | torrent[11], 119 | ) 120 | ) 121 | 122 | return result 123 | 124 | def get_methods(self): 125 | if self._methods is None: 126 | self._methods = self.proxy.system.listMethods() 127 | 128 | return self._methods 129 | 130 | def list(self): 131 | return self._fetch_list_result("main") 132 | 133 | def list_active(self): 134 | try: 135 | if "spreadsheet_active" not in self.proxy.view.list(): 136 | self.proxy.view.add("", "spreadsheet_active") 137 | self.proxy.view.filter( 138 | "", "spreadsheet_active", "or={d.up.rate=,d.down.rate=}" 139 | ) 140 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 141 | raise FailedToExecuteException() 142 | return self._fetch_list_result("spreadsheet_active") 143 | 144 | def start(self, infohash): 145 | try: 146 | self.proxy.d.start(infohash) 147 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 148 | raise FailedToExecuteException() 149 | 150 | def stop(self, infohash): 151 | try: 152 | self.proxy.d.stop(infohash) 153 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 154 | raise FailedToExecuteException() 155 | 156 | def test_connection(self): 157 | try: 158 | return self.proxy.system.pid() is not None 159 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 160 | return False 161 | 162 | def add( 163 | self, 164 | torrent, 165 | destination_path, 166 | fast_resume=False, 167 | add_name_to_folder=True, 168 | minimum_expected_data="none", 169 | stopped=False, 170 | ): 171 | current_expected_data = calculate_minimum_expected_data( 172 | torrent, destination_path, add_name_to_folder 173 | ) 174 | if not has_minimum_expected_data(minimum_expected_data, current_expected_data): 175 | raise FailedToExecuteException( 176 | f"Minimum expected data not reached, wanted {minimum_expected_data} actual {current_expected_data}" 177 | ) 178 | destination_path = Path(os.path.abspath(destination_path)) 179 | 180 | if fast_resume: 181 | logger.info("Adding fast resume data") 182 | 183 | psize = torrent[b"info"][b"piece length"] 184 | pieces = len(torrent[b"info"][b"pieces"]) // 20 185 | bitfield = [True] * pieces 186 | 187 | torrent[b"libtorrent_resume"] = {b"files": []} 188 | 189 | files = map_existing_files(torrent, destination_path) 190 | current_position = 0 191 | for fp, f, size, exists in files: 192 | logger.debug(f"Handling file {fp!r}") 193 | 194 | result = {b"priority": 1, b"completed": int(exists)} 195 | if exists: 196 | result[b"mtime"] = int(fp.stat().st_mtime) 197 | torrent[b"libtorrent_resume"][b"files"].append(result) 198 | 199 | last_position = current_position + size 200 | 201 | first_piece = current_position // psize 202 | last_piece = (last_position + psize - 1) // psize 203 | 204 | for piece in range(first_piece, last_piece): 205 | logger.debug(f"Setting piece {piece} to {exists}") 206 | bitfield[piece] *= exists 207 | 208 | current_position = last_position 209 | 210 | if all(bitfield): 211 | logger.info("This torrent is complete, setting bitfield to chunk count") 212 | torrent[b"libtorrent_resume"][ 213 | b"bitfield" 214 | ] = pieces # rtorrent wants the number of pieces when torrent is complete 215 | else: 216 | logger.info("This torrent is incomplete, setting bitfield") 217 | torrent[b"libtorrent_resume"][b"bitfield"] = bitfield_to_string( 218 | bitfield 219 | ) 220 | 221 | encoded_torrent = bencode(torrent) 222 | cmd = [encoded_torrent] 223 | if add_name_to_folder: 224 | cmd.append(f'd.directory.set="{destination_path!s}"') 225 | else: 226 | cmd.append(f'd.directory_base.set="{destination_path!s}"') 227 | if self.label: 228 | cmd.append(f"d.custom1.set={quote(self.label)}") 229 | logger.info(f"Sending to rtorrent: {cmd!r}") 230 | try: # TODO: use torrent_temp_path if payload is too big 231 | if stopped: 232 | self.proxy.load.raw("", *cmd) 233 | else: 234 | self.proxy.load.raw_start("", *cmd) 235 | except (XMLRPCError, ConnectionError, OSError, ExpatError) as e: 236 | raise FailedToExecuteException(f"Failed to add torrent: {e!r}") 237 | 238 | def remove(self, infohash): 239 | try: 240 | self.proxy.d.erase(infohash) 241 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 242 | raise FailedToExecuteException() 243 | 244 | def retrieve_torrentfile(self, infohash): 245 | if not self.session_path: 246 | raise FailedToExecuteException("Session path is not configured") 247 | torrent_path = self.session_path / f"{infohash.upper()}.torrent" 248 | if not torrent_path.is_file(): 249 | raise FailedToExecuteException("Torrent file does not exist") 250 | return torrent_path.read_bytes() 251 | 252 | def get_download_path(self, infohash): 253 | try: 254 | return Path(self.proxy.d.directory(infohash)) 255 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 256 | raise FailedToExecuteException("Failed to retrieve download path") 257 | 258 | def move_torrent(self, infohash, destination_path): 259 | files = self.get_files(infohash) 260 | current_download_path = self.get_download_path(infohash) 261 | is_multi_file = self.proxy.d.is_multi_file(infohash) 262 | 263 | self.stop(infohash) 264 | self.proxy.d.directory.set(infohash, str(destination_path)) 265 | if is_multi_file: 266 | move_files( 267 | current_download_path, 268 | destination_path / current_download_path.name, 269 | files, 270 | ) 271 | else: 272 | move_files( 273 | current_download_path, 274 | destination_path, 275 | files, 276 | preserve_parent_folder=True, 277 | ) 278 | 279 | self.start(infohash) 280 | 281 | def get_files(self, infohash): 282 | result = [] 283 | try: 284 | files = self.proxy.f.multicall( 285 | infohash, 286 | "", 287 | "f.path=", 288 | "f.size_bytes=", 289 | "f.completed_chunks=", 290 | "f.size_chunks=", 291 | ) 292 | for f in files: 293 | path, size, completed_chunks, size_chunks = f 294 | if completed_chunks > size_chunks: 295 | completed_chunks = size_chunks 296 | 297 | if size_chunks == 0: 298 | progress = 0.0 299 | else: 300 | progress = (completed_chunks / size_chunks) * 100 301 | result.append(TorrentFile(path, size, progress)) 302 | except (XMLRPCError, ConnectionError, OSError, ExpatError): 303 | raise FailedToExecuteException("Failed to retrieve files") 304 | 305 | return result 306 | 307 | def serialize_configuration(self): 308 | url = f"{self.identifier}+{self.url}" 309 | query = {} 310 | if self.session_path: 311 | query["session_path"] = str(self.session_path) 312 | 313 | if self.label: 314 | query["label"] = self.label 315 | 316 | if query: 317 | url += f"?{urlencode(query)}" 318 | 319 | return url 320 | 321 | @classmethod 322 | def auto_configure(cls, path="~/.rtorrent.rc"): 323 | # Does not work with latest rtorrent config 324 | config_path = Path(path).expanduser() 325 | if not config_path.is_file(): 326 | raise FailedToExecuteException("Unable to find config file") 327 | 328 | try: 329 | config_data = config_path.read_text() 330 | except PermissionError: 331 | raise FailedToExecuteException("Config file not accessible") 332 | 333 | scgi_info = re.findall( 334 | r"^\s*scgi_(port|local)\s*=\s*(.+)\s*$", str(config_data), re.MULTILINE 335 | ) 336 | if not scgi_info: 337 | raise FailedToExecuteException("No scgi info found in configuration file") 338 | 339 | scgi_method, scgi_url = scgi_info[0] 340 | 341 | if scgi_method == "port": 342 | scgi_url = scgi_url.strip() 343 | else: 344 | scgi_url = Path(os.path.abspath(Path(scgi_url.strip()).expanduser())) 345 | 346 | client = cls(f"scgi://{scgi_url}") 347 | session_path = Path(os.path.abspath(Path(client.proxy.session.path()))) 348 | if session_path.is_dir(): 349 | client.session_path = session_path 350 | return client 351 | -------------------------------------------------------------------------------- /libtc/clients/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/__init__.py -------------------------------------------------------------------------------- /libtc/clients/tests/basetest.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import shutil 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from libtc import TorrentData, TorrentState, bdecode, bencode 10 | 11 | 12 | @pytest.fixture 13 | def testfiles(): 14 | with tempfile.TemporaryDirectory() as tmp_path: 15 | tmp_path = Path(tmp_path) 16 | testfiles = Path(__file__).parent / "testfiles" 17 | shutil.copytree(testfiles, tmp_path / "testfiles") 18 | yield tmp_path / "testfiles" 19 | 20 | 21 | @pytest.fixture 22 | def tempdir(): 23 | with tempfile.TemporaryDirectory() as tmp_path: 24 | yield Path(tmp_path) 25 | 26 | 27 | def test_test_connection(client): 28 | assert client.test_connection() 29 | 30 | 31 | def test_list(client): 32 | assert client.list() == [] 33 | 34 | 35 | def verify_torrent_state(client, states, do_not_fail=False): 36 | hard_states = set( 37 | [ 38 | "infohash", 39 | "name", 40 | "data_location", 41 | ] 42 | ) 43 | for _ in range(50): 44 | found_invalid_state = False 45 | time.sleep(0.1) 46 | 47 | torrent_list = client.list() 48 | if len(torrent_list) != len(states): 49 | continue 50 | 51 | for td, state in zip(torrent_list, states): 52 | assert isinstance(td, TorrentData) 53 | for k, v in state.items(): 54 | td_v = getattr(td, k) 55 | if v != td_v: 56 | print(f"Invalid state {k} is {td_v} should be {v}") 57 | if k in hard_states: 58 | assert v == td_v 59 | else: 60 | print(f"Invalid state {k} is {td_v} should be {v}") 61 | found_invalid_state = True 62 | break 63 | if not found_invalid_state: 64 | return 65 | else: 66 | if not do_not_fail: 67 | pytest.fail("Torrent states was never correctly added") 68 | 69 | 70 | def test_add_torrent_multifile(client, testfiles): 71 | torrent = testfiles / "Some-Release.torrent" 72 | torrent_data = bdecode(torrent.read_bytes()) 73 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 74 | client.add(torrent_data, testfiles, fast_resume=False) 75 | 76 | verify_torrent_state( 77 | client, 78 | [ 79 | { 80 | "infohash": infohash, 81 | "name": "Some-Release", 82 | "state": TorrentState.ACTIVE, 83 | "progress": 100.0, 84 | } 85 | ], 86 | ) 87 | assert client.get_download_path(infohash) == testfiles / "Some-Release" 88 | 89 | client.remove(infohash) 90 | verify_torrent_state(client, []) 91 | assert (testfiles / "Some-Release").exists() 92 | 93 | 94 | def test_add_torrent_singlefile(client, testfiles): 95 | torrent = testfiles / "test_single.torrent" 96 | torrent_data = bdecode(torrent.read_bytes()) 97 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 98 | client.add(torrent_data, testfiles, fast_resume=False) 99 | 100 | verify_torrent_state( 101 | client, 102 | [ 103 | { 104 | "infohash": infohash, 105 | "name": "file_a.txt", 106 | "state": TorrentState.ACTIVE, 107 | "progress": 100.0, 108 | } 109 | ], 110 | ) 111 | assert client.get_download_path(infohash) == testfiles 112 | 113 | client.remove(infohash) 114 | verify_torrent_state(client, []) 115 | assert (testfiles / "file_a.txt").exists() 116 | 117 | 118 | def test_add_torrent_multifile_no_add_name_to_folder(client, testfiles): 119 | torrent = testfiles / "Some-Release.torrent" 120 | torrent_data = bdecode(torrent.read_bytes()) 121 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 122 | client.add( 123 | torrent_data, 124 | testfiles / "Some-Release", 125 | fast_resume=False, 126 | add_name_to_folder=False, 127 | ) 128 | 129 | verify_torrent_state( 130 | client, 131 | [{"infohash": infohash, "state": TorrentState.ACTIVE, "progress": 100.0}], 132 | ) 133 | assert client.get_download_path(infohash) == testfiles / "Some-Release" 134 | client.remove(infohash) 135 | verify_torrent_state(client, []) 136 | assert (testfiles / "Some-Release").exists() 137 | 138 | 139 | def test_add_torrent_multifile_no_add_name_to_folder_different_name(client, testfiles): 140 | new_path = Path(testfiles) / "New-Some-Release" 141 | (testfiles / "Some-Release").rename(new_path) 142 | torrent = testfiles / "Some-Release.torrent" 143 | torrent_data = bdecode(torrent.read_bytes()) 144 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 145 | client.add( 146 | torrent_data, 147 | new_path, 148 | fast_resume=False, 149 | add_name_to_folder=False, 150 | minimum_expected_data="full", 151 | ) 152 | 153 | verify_torrent_state( 154 | client, 155 | [{"infohash": infohash, "state": TorrentState.ACTIVE, "progress": 100.0}], 156 | ) 157 | assert client.get_download_path(infohash) == testfiles / "New-Some-Release" 158 | 159 | client.remove(infohash) 160 | verify_torrent_state(client, []) 161 | assert (Path(testfiles) / "New-Some-Release").exists() 162 | 163 | 164 | def test_add_torrent_singlefile_no_add_name_to_folder(client, testfiles): 165 | torrent = testfiles / "test_single.torrent" 166 | torrent_data = bdecode(torrent.read_bytes()) 167 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 168 | client.add(torrent_data, testfiles, fast_resume=False, add_name_to_folder=False) 169 | 170 | verify_torrent_state( 171 | client, 172 | [{"infohash": infohash, "state": TorrentState.ACTIVE, "progress": 100.0}], 173 | ) 174 | assert client.get_download_path(infohash) == testfiles 175 | 176 | client.remove(infohash) 177 | verify_torrent_state(client, []) 178 | assert (testfiles / "file_a.txt").exists() 179 | 180 | 181 | def test_add_torrent_singlefile_no_data(client, testfiles, tmp_path): 182 | torrent = testfiles / "test_single.torrent" 183 | torrent_data = bdecode(torrent.read_bytes()) 184 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 185 | client.add(torrent_data, tmp_path, fast_resume=False, add_name_to_folder=False) 186 | 187 | verify_torrent_state( 188 | client, 189 | [ 190 | { 191 | "infohash": infohash, 192 | "state": TorrentState.ACTIVE, 193 | "progress": 0.0, 194 | } 195 | ], 196 | ) 197 | assert client.get_download_path(infohash) == Path(tmp_path) 198 | 199 | client.remove(infohash) 200 | verify_torrent_state(client, []) 201 | assert (testfiles / "file_a.txt").exists() 202 | 203 | 204 | def test_retrieve_torrent(client, testfiles): 205 | torrent = testfiles / "test_single.torrent" 206 | torrent_data = bdecode(torrent.read_bytes()) 207 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 208 | client.add(torrent_data, testfiles, fast_resume=False) 209 | 210 | verify_torrent_state( 211 | client, 212 | [ 213 | { 214 | "infohash": infohash, 215 | } 216 | ], 217 | ) 218 | time.sleep(2) # qBittorrent has a delay before it saves trackers 219 | retrieved_torrent_data = bdecode(client.retrieve_torrentfile(infohash)) 220 | assert ( 221 | hashlib.sha1(bencode(retrieved_torrent_data[b"info"])).hexdigest() == infohash 222 | ) 223 | assert retrieved_torrent_data.get(b"announce") == torrent_data.get(b"announce") 224 | assert retrieved_torrent_data.get(b"announce-list") == torrent_data.get( 225 | b"announce-list" 226 | ) 227 | 228 | client.remove(infohash) 229 | 230 | 231 | def test_add_torrent_multifile_stopped(client, testfiles): 232 | torrent = testfiles / "Some-Release.torrent" 233 | torrent_data = bdecode(torrent.read_bytes()) 234 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 235 | client.add(torrent_data, testfiles, fast_resume=False, stopped=True) 236 | 237 | verify_torrent_state( 238 | client, 239 | [ 240 | { 241 | "infohash": infohash, 242 | "name": "Some-Release", 243 | "progress": 100.0, 244 | } 245 | ], 246 | do_not_fail=True, 247 | ) 248 | 249 | verify_torrent_state( 250 | client, 251 | [ 252 | { 253 | "infohash": infohash, 254 | "state": TorrentState.STOPPED, 255 | } 256 | ], 257 | ) 258 | 259 | client.start(infohash) 260 | 261 | verify_torrent_state( 262 | client, 263 | [ 264 | { 265 | "infohash": infohash, 266 | "state": TorrentState.ACTIVE, 267 | } 268 | ], 269 | ) 270 | 271 | assert client.get_download_path(infohash) == testfiles / "Some-Release" 272 | 273 | client.remove(infohash) 274 | verify_torrent_state(client, []) 275 | assert (testfiles / "Some-Release").exists() 276 | 277 | 278 | def test_start_stop(client, testfiles): 279 | torrent = testfiles / "Some-Release.torrent" 280 | torrent_data = bdecode(torrent.read_bytes()) 281 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 282 | client.add(torrent_data, testfiles, fast_resume=False) 283 | time.sleep(2) # Weird bug with Deluge 284 | 285 | verify_torrent_state( 286 | client, 287 | [ 288 | { 289 | "infohash": infohash, 290 | "name": "Some-Release", 291 | "state": TorrentState.ACTIVE, 292 | "progress": 100.0, 293 | } 294 | ], 295 | ) 296 | 297 | client.stop(infohash) 298 | 299 | verify_torrent_state( 300 | client, 301 | [ 302 | { 303 | "infohash": infohash, 304 | "name": "Some-Release", 305 | "state": TorrentState.STOPPED, 306 | "progress": 100.0, 307 | } 308 | ], 309 | ) 310 | 311 | client.start(infohash) 312 | 313 | verify_torrent_state( 314 | client, 315 | [ 316 | { 317 | "infohash": infohash, 318 | "name": "Some-Release", 319 | "state": TorrentState.ACTIVE, 320 | "progress": 100.0, 321 | } 322 | ], 323 | ) 324 | 325 | client.remove(infohash) 326 | verify_torrent_state(client, []) 327 | assert (testfiles / "Some-Release").exists() 328 | 329 | 330 | def test_get_files_multifile(client, testfiles): 331 | torrent = testfiles / "Some-Release.torrent" 332 | torrent_data = bdecode(torrent.read_bytes()) 333 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 334 | client.add(torrent_data, testfiles, fast_resume=False) 335 | 336 | verify_torrent_state( 337 | client, 338 | [ 339 | { 340 | "infohash": infohash, 341 | "name": "Some-Release", 342 | "state": TorrentState.ACTIVE, 343 | "progress": 100.0, 344 | } 345 | ], 346 | ) 347 | assert client.get_download_path(infohash) == testfiles / "Some-Release" 348 | 349 | files = sorted(client.get_files(infohash), key=lambda x: x.path) 350 | expected_filenames = sorted( 351 | [ 352 | "Sample/some-rls.mkv", 353 | "Subs/some-subs.rar", 354 | "Subs/some-subs.sfv", 355 | "some-rls.nfo", 356 | "some-rls.r00", 357 | "some-rls.r01", 358 | "some-rls.r02", 359 | "some-rls.r03", 360 | "some-rls.r04", 361 | "some-rls.r05", 362 | "some-rls.r06", 363 | "some-rls.rar", 364 | "some-rls.sfv", 365 | ] 366 | ) 367 | assert len(files) == len(expected_filenames) 368 | for f, name in zip(files, expected_filenames): 369 | assert f.path == name 370 | assert f.progress == 100.0 371 | assert f.size == 12 372 | 373 | client.remove(infohash) 374 | verify_torrent_state(client, []) 375 | assert (testfiles / "Some-Release").exists() 376 | 377 | 378 | def test_get_files_singlefile(client, testfiles): 379 | torrent = testfiles / "test_single.torrent" 380 | torrent_data = bdecode(torrent.read_bytes()) 381 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 382 | client.add(torrent_data, testfiles, fast_resume=False) 383 | 384 | verify_torrent_state( 385 | client, 386 | [ 387 | { 388 | "infohash": infohash, 389 | "name": "file_a.txt", 390 | "state": TorrentState.ACTIVE, 391 | "progress": 100.0, 392 | } 393 | ], 394 | ) 395 | assert client.get_download_path(infohash) == testfiles 396 | 397 | files = sorted(client.get_files(infohash), key=lambda x: x.path) 398 | expected_filenames = sorted( 399 | [ 400 | "file_a.txt", 401 | ] 402 | ) 403 | assert len(files) == len(expected_filenames) 404 | for f, name in zip(files, expected_filenames): 405 | assert f.path == name 406 | assert f.progress == 100.0 407 | assert f.size == 11 408 | 409 | client.remove(infohash) 410 | verify_torrent_state(client, []) 411 | assert (testfiles / "file_a.txt").exists() 412 | 413 | 414 | def test_move_torrent_singlefile(client, testfiles, tempdir): 415 | torrent = testfiles / "test_single.torrent" 416 | torrent_data = bdecode(torrent.read_bytes()) 417 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 418 | client.add(torrent_data, testfiles, fast_resume=False) 419 | 420 | verify_torrent_state( 421 | client, 422 | [ 423 | { 424 | "infohash": infohash, 425 | "name": "file_a.txt", 426 | "state": TorrentState.ACTIVE, 427 | "progress": 100.0, 428 | } 429 | ], 430 | ) 431 | assert client.get_download_path(infohash) == testfiles 432 | client.move_torrent(infohash, tempdir) 433 | verify_torrent_state( 434 | client, 435 | [ 436 | { 437 | "infohash": infohash, 438 | "name": "file_a.txt", 439 | "state": TorrentState.ACTIVE, 440 | "progress": 100.0, 441 | } 442 | ], 443 | ) 444 | assert client.get_download_path(infohash) == tempdir 445 | 446 | client.remove(infohash) 447 | verify_torrent_state(client, []) 448 | 449 | 450 | def test_move_torrent_multifile(client, testfiles, tempdir): 451 | torrent = testfiles / "Some-Release.torrent" 452 | torrent_data = bdecode(torrent.read_bytes()) 453 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 454 | client.add(torrent_data, testfiles, fast_resume=False) 455 | remove_folder = testfiles / "Some-Release" / "Sample" 456 | preserve_file = testfiles / "Some-Release" / "do-not-move.txt" 457 | preserve_file.write_text("keep this file") 458 | preserve_folder = testfiles / "Some-Release" / "do-not-remove" 459 | preserve_folder.mkdir() 460 | 461 | verify_torrent_state( 462 | client, 463 | [ 464 | { 465 | "infohash": infohash, 466 | "name": "Some-Release", 467 | "state": TorrentState.ACTIVE, 468 | "progress": 100.0, 469 | } 470 | ], 471 | ) 472 | assert client.get_download_path(infohash) == testfiles / "Some-Release" 473 | assert preserve_file.exists() 474 | assert remove_folder.exists() 475 | # assert preserve_folder.exists() 476 | 477 | client.move_torrent(infohash, tempdir) 478 | verify_torrent_state( 479 | client, 480 | [ 481 | { 482 | "infohash": infohash, 483 | "name": "Some-Release", 484 | "state": TorrentState.ACTIVE, 485 | "progress": 100.0, 486 | } 487 | ], 488 | ) 489 | assert client.get_download_path(infohash) == tempdir / "Some-Release" 490 | assert not (tempdir / "Some-Release" / "do-not-move.txt").exists() 491 | assert not (tempdir / "Some-Release" / "do-not-move").exists() 492 | assert preserve_file.exists() 493 | assert not remove_folder.exists() 494 | # assert preserve_folder.exists() # Broken with transmission 495 | 496 | client.remove(infohash) 497 | verify_torrent_state(client, []) 498 | -------------------------------------------------------------------------------- /libtc/clients/tests/test_deluge.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from libtc import DelugeClient 10 | 11 | from .basetest import * 12 | 13 | 14 | @pytest.fixture( 15 | scope="module", 16 | params=[ 17 | True, 18 | False, 19 | ], 20 | ) 21 | def client(request): 22 | with tempfile.TemporaryDirectory() as tmp_path: 23 | tmp_path = Path(tmp_path) 24 | auth_path = tmp_path / "auth" 25 | p = subprocess.Popen(["deluged", "-c", str(tmp_path), "-d", "-L", "debug"]) 26 | for _ in range(30): 27 | if auth_path.exists() and auth_path.stat().st_size > 0: 28 | break 29 | time.sleep(0.1) 30 | else: 31 | p.kill() 32 | pytest.fail("Unable to get deluge auth") 33 | 34 | with auth_path.open() as f: 35 | username, password = f.read().split("\n")[0].split(":")[:2] 36 | 37 | client = DelugeClient("127.0.0.1", 58846, username, password, tmp_path) 38 | if request.param: 39 | with client.client as c: 40 | c.core.enable_plugin("Label") 41 | client.label = "testlabel" 42 | for _ in range(30): 43 | if client.test_connection(): 44 | break 45 | time.sleep(0.1) 46 | else: 47 | p.kill() 48 | pytest.fail("Unable to start deluge") 49 | yield client 50 | p.kill() 51 | 52 | 53 | def test_serialize_configuration(client): 54 | url = client.serialize_configuration() 55 | url, query = url.split("?") 56 | assert re.match(r"deluge://localclient:[a-f0-9]{40}@127.0.0.1:58846", url) 57 | assert query.startswith("session_path=") 58 | -------------------------------------------------------------------------------- /libtc/clients/tests/test_liltorrent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import time 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from libtc import LilTorrentClient 10 | 11 | from .basetest import * 12 | from .test_transmission import client as transmission_client 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def client(transmission_client): 17 | env = os.environ.copy() 18 | env["LILTORRENT_CLIENT"] = transmission_client.serialize_configuration() 19 | env["LILTORRENT_APIKEY"] = "secretkey" 20 | 21 | p = subprocess.Popen(["python3", "-m", "libtc.liltorrent"], env=env) 22 | 23 | path_mapping = f"{str(transmission_client.session_path / 'from_path')}:{str(transmission_client.session_path / 'to_path')}" 24 | client = LilTorrentClient( 25 | "secretkey", "http://127.0.0.1:10977/", path_mapping=path_mapping 26 | ) 27 | for _ in range(30): 28 | if client.test_connection(): 29 | break 30 | time.sleep(0.1) 31 | else: 32 | p.kill() 33 | pytest.fail("Unable to start liltorrent") 34 | 35 | yield client 36 | p.kill() 37 | 38 | 39 | def test_serialize_configuration(client): 40 | url = client.serialize_configuration() 41 | url, query = url.split("?") 42 | assert re.match(r"liltorrent\+http://127.0.0.1:10977/", url) 43 | assert "apikey=secretkey" in query 44 | assert re.match(r".*path_mapping=.*from_path%3A.*to_path.*", query) 45 | 46 | 47 | def test_path_mapping(client, testfiles): 48 | from_path, to_path = list(client.path_mapping.items())[0] 49 | 50 | torrent = testfiles / "Some-Release.torrent" 51 | torrent_data = bdecode(torrent.read_bytes()) 52 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 53 | full_to_path = testfiles / to_path 54 | full_to_path.mkdir() 55 | (testfiles / "Some-Release").rename(full_to_path / "Some-Release") 56 | client.add(torrent_data, testfiles / from_path, fast_resume=False) 57 | 58 | verify_torrent_state( 59 | client, 60 | [ 61 | { 62 | "infohash": infohash, 63 | "name": "Some-Release", 64 | "state": TorrentState.ACTIVE, 65 | "progress": 100.0, 66 | } 67 | ], 68 | ) 69 | assert client.get_download_path(infohash) == testfiles / from_path / "Some-Release" 70 | # TODO: test get_files 71 | 72 | client.remove(infohash) 73 | verify_torrent_state(client, []) 74 | -------------------------------------------------------------------------------- /libtc/clients/tests/test_management.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from libtc import TorrentState, bdecode, bencode, move_torrent 8 | 9 | from .basetest import testfiles, verify_torrent_state 10 | from .test_deluge import client as deluge_client 11 | from .test_qbittorrent import client as qbittorrent_client 12 | from .test_rtorrent import client as rtorrent_client 13 | from .test_transmission import client as transmission_client 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "source_client_name,target_client_name", 18 | [ 19 | ("deluge", "qbittorrent"), 20 | ("qbittorrent", "rtorrent"), 21 | ("rtorrent", "transmission"), 22 | ("transmission", "deluge"), 23 | ], 24 | ) 25 | def test_move_multifile( 26 | source_client_name, 27 | target_client_name, 28 | testfiles, 29 | deluge_client, 30 | qbittorrent_client, 31 | rtorrent_client, 32 | transmission_client, 33 | ): 34 | clients = { 35 | "deluge": deluge_client, 36 | "qbittorrent": qbittorrent_client, 37 | "rtorrent": rtorrent_client, 38 | "transmission": transmission_client, 39 | } 40 | 41 | source_client = clients[source_client_name] 42 | target_client = clients[target_client_name] 43 | 44 | torrent = testfiles / "Some-Release.torrent" 45 | torrent_data = bdecode(torrent.read_bytes()) 46 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 47 | source_client.add(torrent_data, testfiles, fast_resume=False) 48 | 49 | verify_torrent_state( 50 | source_client, 51 | [ 52 | { 53 | "infohash": infohash, 54 | "name": "Some-Release", 55 | "state": TorrentState.ACTIVE, 56 | "progress": 100.0, 57 | } 58 | ], 59 | ) 60 | 61 | move_torrent(infohash, source_client, target_client) 62 | verify_torrent_state( 63 | target_client, 64 | [ 65 | { 66 | "infohash": infohash, 67 | "name": "Some-Release", 68 | "state": TorrentState.ACTIVE, 69 | "progress": 100.0, 70 | } 71 | ], 72 | ) 73 | 74 | verify_torrent_state( 75 | source_client, 76 | [], 77 | ) 78 | target_client.remove(infohash) 79 | verify_torrent_state( 80 | target_client, 81 | [], 82 | ) 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "source_client_name,target_client_name", 87 | [ 88 | ("deluge", "qbittorrent"), 89 | ("qbittorrent", "rtorrent"), 90 | ("rtorrent", "transmission"), 91 | ("transmission", "deluge"), 92 | ], 93 | ) 94 | def test_move_singlefile( 95 | source_client_name, 96 | target_client_name, 97 | testfiles, 98 | deluge_client, 99 | qbittorrent_client, 100 | rtorrent_client, 101 | transmission_client, 102 | ): 103 | clients = { 104 | "deluge": deluge_client, 105 | "qbittorrent": qbittorrent_client, 106 | "rtorrent": rtorrent_client, 107 | "transmission": transmission_client, 108 | } 109 | 110 | source_client = clients[source_client_name] 111 | target_client = clients[target_client_name] 112 | 113 | torrent = testfiles / "test_single.torrent" 114 | torrent_data = bdecode(torrent.read_bytes()) 115 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 116 | source_client.add(torrent_data, testfiles, fast_resume=False) 117 | 118 | verify_torrent_state( 119 | source_client, 120 | [ 121 | { 122 | "infohash": infohash, 123 | "name": "file_a.txt", 124 | "state": TorrentState.ACTIVE, 125 | "progress": 100.0, 126 | } 127 | ], 128 | ) 129 | 130 | move_torrent(infohash, source_client, target_client) 131 | verify_torrent_state( 132 | target_client, 133 | [ 134 | { 135 | "infohash": infohash, 136 | "name": "file_a.txt", 137 | "state": TorrentState.ACTIVE, 138 | "progress": 100.0, 139 | } 140 | ], 141 | ) 142 | 143 | verify_torrent_state( 144 | source_client, 145 | [], 146 | ) 147 | target_client.remove(infohash) 148 | verify_torrent_state( 149 | target_client, 150 | [], 151 | ) 152 | 153 | 154 | @pytest.mark.parametrize( 155 | "source_client_name,target_client_name", 156 | [ 157 | ("deluge", "qbittorrent"), 158 | ("qbittorrent", "rtorrent"), 159 | ("rtorrent", "transmission"), 160 | ("transmission", "deluge"), 161 | ], 162 | ) 163 | def test_move_multifile_no_add_name_to_folder( 164 | source_client_name, 165 | target_client_name, 166 | testfiles, 167 | deluge_client, 168 | qbittorrent_client, 169 | rtorrent_client, 170 | transmission_client, 171 | ): 172 | clients = { 173 | "deluge": deluge_client, 174 | "qbittorrent": qbittorrent_client, 175 | "rtorrent": rtorrent_client, 176 | "transmission": transmission_client, 177 | } 178 | 179 | source_client = clients[source_client_name] 180 | target_client = clients[target_client_name] 181 | 182 | new_path = Path(testfiles) / "New-Some-Release" 183 | (testfiles / "Some-Release").rename(new_path) 184 | torrent = testfiles / "Some-Release.torrent" 185 | torrent_data = bdecode(torrent.read_bytes()) 186 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 187 | source_client.add( 188 | torrent_data, 189 | new_path, 190 | fast_resume=False, 191 | add_name_to_folder=False, 192 | minimum_expected_data="full", 193 | ) 194 | 195 | verify_torrent_state( 196 | source_client, 197 | [ 198 | { 199 | "infohash": infohash, 200 | "state": TorrentState.ACTIVE, 201 | "progress": 100.0, 202 | } 203 | ], 204 | ) 205 | 206 | move_torrent(infohash, source_client, target_client) 207 | verify_torrent_state( 208 | target_client, 209 | [ 210 | { 211 | "infohash": infohash, 212 | "state": TorrentState.ACTIVE, 213 | "progress": 100.0, 214 | } 215 | ], 216 | ) 217 | 218 | verify_torrent_state( 219 | source_client, 220 | [], 221 | ) 222 | target_client.remove(infohash) 223 | verify_torrent_state( 224 | target_client, 225 | [], 226 | ) 227 | 228 | 229 | @pytest.mark.parametrize( 230 | "source_client_name,target_client_name", 231 | [ 232 | ("deluge", "qbittorrent"), 233 | ("qbittorrent", "rtorrent"), 234 | ("rtorrent", "transmission"), 235 | ("transmission", "deluge"), 236 | ], 237 | ) 238 | def test_move_multifile_stopped( 239 | source_client_name, 240 | target_client_name, 241 | testfiles, 242 | deluge_client, 243 | qbittorrent_client, 244 | rtorrent_client, 245 | transmission_client, 246 | ): 247 | clients = { 248 | "deluge": deluge_client, 249 | "qbittorrent": qbittorrent_client, 250 | "rtorrent": rtorrent_client, 251 | "transmission": transmission_client, 252 | } 253 | 254 | source_client = clients[source_client_name] 255 | target_client = clients[target_client_name] 256 | 257 | torrent = testfiles / "Some-Release.torrent" 258 | torrent_data = bdecode(torrent.read_bytes()) 259 | infohash = hashlib.sha1(bencode(torrent_data[b"info"])).hexdigest() 260 | source_client.add(torrent_data, testfiles, fast_resume=False) 261 | time.sleep(2) # Weird bug with Deluge 262 | 263 | verify_torrent_state( 264 | source_client, 265 | [ 266 | { 267 | "infohash": infohash, 268 | "name": "Some-Release", 269 | "state": TorrentState.ACTIVE, 270 | "progress": 100.0, 271 | } 272 | ], 273 | ) 274 | 275 | source_client.stop(infohash) 276 | 277 | verify_torrent_state( 278 | source_client, 279 | [ 280 | { 281 | "infohash": infohash, 282 | "name": "Some-Release", 283 | "state": TorrentState.STOPPED, 284 | "progress": 100.0, 285 | } 286 | ], 287 | ) 288 | 289 | move_torrent(infohash, source_client, target_client) 290 | verify_torrent_state( 291 | target_client, 292 | [ 293 | { 294 | "infohash": infohash, 295 | "name": "Some-Release", 296 | "state": TorrentState.STOPPED, 297 | } 298 | ], 299 | ) 300 | 301 | target_client.start(infohash) 302 | 303 | verify_torrent_state( 304 | target_client, 305 | [ 306 | { 307 | "infohash": infohash, 308 | "name": "Some-Release", 309 | "state": TorrentState.ACTIVE, 310 | "progress": 100.0, 311 | } 312 | ], 313 | ) 314 | 315 | verify_torrent_state( 316 | source_client, 317 | [], 318 | ) 319 | target_client.remove(infohash) 320 | verify_torrent_state( 321 | target_client, 322 | [], 323 | ) 324 | -------------------------------------------------------------------------------- /libtc/clients/tests/test_qbittorrent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from libtc import QBittorrentClient 10 | 11 | from .basetest import * 12 | 13 | QBITTORRENT_CONFIG = r"""[LegalNotice] 14 | Accepted=true 15 | 16 | [Network] 17 | Cookies=@Invalid() 18 | """ 19 | 20 | 21 | @pytest.fixture( 22 | scope="module", 23 | params=[ 24 | True, 25 | False, 26 | ], 27 | ) 28 | def client(request): 29 | with tempfile.TemporaryDirectory() as tmp_path: 30 | tmp_path = Path(tmp_path) 31 | tmp_config_path = tmp_path / "qBittorrent" / "config" / "qBittorrent_new.conf" 32 | tmp_config_path.parent.mkdir(parents=True) 33 | with open(tmp_config_path, "w") as f: 34 | f.write(QBITTORRENT_CONFIG) 35 | 36 | p = subprocess.Popen(["qbittorrent-nox", f"--profile={tmp_path!s}"]) 37 | client = QBittorrentClient( 38 | "http://localhost:8080/", 39 | "admin", 40 | "adminadmin", 41 | str(tmp_path / "qBittorrent"), 42 | ) 43 | for _ in range(30): 44 | if client.test_connection(): 45 | break 46 | time.sleep(0.1) 47 | else: 48 | p.kill() 49 | pytest.fail("Unable to start qbittorrent") 50 | if request.param: 51 | client.label = "testlabel" 52 | if ( 53 | "create_subfolder_enabled" 54 | in client.call("get", "/api/v2/app/preferences").json() 55 | ): 56 | client.call( 57 | "post", 58 | "/api/v2/app/setPreferences", 59 | data={"json": json.dumps({"create_subfolder_enabled": request.param})}, 60 | ) 61 | yield client 62 | if ( 63 | client.call("get", "/api/v2/app/preferences").json()[ 64 | "create_subfolder_enabled" 65 | ] 66 | != request.param 67 | ): 68 | pytest.fail("Settings were modified when they should not have been") 69 | else: 70 | if request.param: 71 | torrent_content_layout = "Original" 72 | else: 73 | torrent_content_layout = "NoSubfolder" 74 | client.call( 75 | "post", 76 | "/api/v2/app/setPreferences", 77 | data={ 78 | "json": json.dumps( 79 | {"torrent_content_layout": torrent_content_layout} 80 | ) 81 | }, 82 | ) 83 | yield client 84 | p.kill() 85 | 86 | 87 | def test_serialize_configuration(client): 88 | url = client.serialize_configuration() 89 | url, query = url.split("?") 90 | assert url == "qbittorrent+http://admin:adminadmin@localhost:8080/" 91 | assert query.startswith("session_path=") 92 | -------------------------------------------------------------------------------- /libtc/clients/tests/test_rtorrent.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from libtc import RTorrentClient 10 | 11 | from .basetest import * 12 | 13 | RTORRENT_CONFIG = r"""############################################################################# 14 | # A minimal rTorrent configuration that provides the basic features 15 | # you want to have in addition to the built-in defaults. 16 | # 17 | # See https://github.com/rakshasa/rtorrent/wiki/CONFIG-Template 18 | # for an up-to-date version. 19 | ############################################################################# 20 | 21 | 22 | ## Instance layout (base paths) 23 | method.insert = cfg.basedir, private|const|string, (cat,"%%TMPDIR%%/") 24 | method.insert = cfg.download, private|const|string, (cat,(cfg.basedir),"download/") 25 | method.insert = cfg.logs, private|const|string, (cat,(cfg.basedir),"log/") 26 | method.insert = cfg.logfile, private|const|string, (cat,(cfg.logs),"rtorrent-",(system.time),".log") 27 | method.insert = cfg.session, private|const|string, (cat,(cfg.basedir),".session/") 28 | method.insert = cfg.watch, private|const|string, (cat,(cfg.basedir),"watch/") 29 | 30 | ## Create instance directories 31 | execute.throw = sh, -c, (cat,\ 32 | "mkdir -p \"",(cfg.download),"\" ",\ 33 | "\"",(cfg.logs),"\" ",\ 34 | "\"",(cfg.session),"\" ",\ 35 | "\"",(cfg.watch),"/load\" ",\ 36 | "\"",(cfg.watch),"/start\" ") 37 | 38 | ## Listening port for incoming peer traffic (fixed; you can also randomize it) 39 | network.port_range.set = 50000-50000 40 | network.port_random.set = no 41 | 42 | 43 | ## Tracker-less torrent and UDP tracker support 44 | ## (conservative settings for 'private' trackers, change for 'public') 45 | dht.mode.set = disable 46 | protocol.pex.set = no 47 | 48 | trackers.use_udp.set = no 49 | 50 | 51 | ## Peer settings 52 | throttle.max_uploads.set = 100 53 | throttle.max_uploads.global.set = 250 54 | 55 | throttle.min_peers.normal.set = 20 56 | throttle.max_peers.normal.set = 60 57 | throttle.min_peers.seed.set = 30 58 | throttle.max_peers.seed.set = 80 59 | trackers.numwant.set = 80 60 | 61 | protocol.encryption.set = allow_incoming,try_outgoing,enable_retry 62 | 63 | 64 | ## Limits for file handle resources, this is optimized for 65 | ## an `ulimit` of 1024 (a common default). You MUST leave 66 | ## a ceiling of handles reserved for rTorrent's internal needs! 67 | network.http.max_open.set = 50 68 | network.max_open_files.set = 600 69 | network.max_open_sockets.set = 300 70 | 71 | 72 | ## Memory resource usage (increase if you have a large number of items loaded, 73 | ## and/or the available resources to spend) 74 | pieces.memory.max.set = 1800M 75 | network.xmlrpc.size_limit.set = 4M 76 | 77 | 78 | ## Basic operational settings (no need to change these) 79 | session.path.set = (cat, (cfg.session)) 80 | directory.default.set = (cat, (cfg.download)) 81 | log.execute = (cat, (cfg.logs), "execute.log") 82 | #log.xmlrpc = (cat, (cfg.logs), "xmlrpc.log") 83 | execute.nothrow = sh, -c, (cat, "echo >",\ 84 | (session.path), "rtorrent.pid", " ",(system.pid)) 85 | 86 | 87 | ## Other operational settings (check & adapt) 88 | encoding.add = utf8 89 | system.umask.set = 0027 90 | system.cwd.set = (directory.default) 91 | network.http.dns_cache_timeout.set = 25 92 | 93 | method.insert = system.startup_time, value|const, (system.time) 94 | method.insert = d.data_path, simple,\ 95 | "if=(d.is_multi_file),\ 96 | (cat, (d.directory), /),\ 97 | (cat, (d.directory), /, (d.name))" 98 | method.insert = d.session_file, simple, "cat=(session.path), (d.hash), .torrent" 99 | 100 | ## Run the rTorrent process as a daemon in the background 101 | ## (and control via XMLRPC sockets) 102 | system.daemon.set = true 103 | network.scgi.open_local = (cat,(session.path),rpc.socket) 104 | execute.nothrow = chmod,770,(cat,(session.path),rpc.socket) 105 | 106 | 107 | ## Logging: 108 | ## Levels = critical error warn notice info debug 109 | ## Groups = connection_* dht_* peer_* rpc_* storage_* thread_* tracker_* torrent_* 110 | print = (cat, "Logging to ", (cfg.logfile)) 111 | log.open_file = "log", (cfg.logfile) 112 | log.add_output = "info", "log" 113 | #log.add_output = "tracker_debug", "log" 114 | 115 | ### END of rtorrent.rc ###""" 116 | 117 | 118 | @pytest.fixture( 119 | scope="module", 120 | params=[ 121 | True, 122 | False, 123 | ], 124 | ) 125 | def client(request): 126 | with tempfile.TemporaryDirectory() as tmp_path: 127 | tmp_path = Path(tmp_path) 128 | config_content = RTORRENT_CONFIG.replace("%%TMPDIR%%", str(tmp_path)) 129 | config_file = tmp_path / ".rtorrent.rc" 130 | with open(config_file, "w") as f: 131 | f.write(config_content) 132 | 133 | p = subprocess.Popen(["rtorrent", "-n", "-o", f"import={config_file!s}"]) 134 | client = RTorrentClient( 135 | f"scgi://{tmp_path!s}/.session/rpc.socket", tmp_path / ".session" 136 | ) 137 | if request.param: 138 | client.label = "testlabel" 139 | for _ in range(30): 140 | if client.test_connection(): 141 | break 142 | time.sleep(0.1) 143 | else: 144 | p.kill() 145 | pytest.fail("Unable to start rtorrent") 146 | yield client 147 | p.kill() 148 | 149 | 150 | def test_serialize_configuration(client): 151 | url = client.serialize_configuration() 152 | url, query = url.split("?") 153 | assert re.match(r"rtorrent\+scgi:///.+/.session/rpc.socket", url) 154 | assert query.startswith("session_path=") 155 | -------------------------------------------------------------------------------- /libtc/clients/tests/test_transmission.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tempfile 3 | import time 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from libtc import TransmissionClient 9 | 10 | from .basetest import * 11 | 12 | 13 | @pytest.fixture(scope="module") 14 | def client(): 15 | with tempfile.TemporaryDirectory() as tmp_path: 16 | tmp_path = Path(tmp_path) 17 | p = subprocess.Popen( 18 | [ 19 | "transmission-daemon", 20 | "--config-dir", 21 | tmp_path, 22 | "--download-dir", 23 | tmp_path / "downloads", 24 | "-T", 25 | "-f", 26 | ] 27 | ) 28 | 29 | client = TransmissionClient( 30 | "http://localhost:9091/transmission/rpc", session_path=str(tmp_path) 31 | ) 32 | for _ in range(30): 33 | if client.test_connection(): 34 | break 35 | time.sleep(0.1) 36 | else: 37 | p.kill() 38 | pytest.fail("Unable to start transmission") 39 | yield client 40 | p.kill() 41 | 42 | 43 | def test_serialize_configuration(client): 44 | url = client.serialize_configuration() 45 | url, query = url.split("?") 46 | assert url == "transmission+http://localhost:9091/transmission/rpc" 47 | assert query.startswith("session_path=") 48 | 49 | 50 | def test_auto_configure(client): 51 | config_path = Path(client.session_path) / "settings.json" 52 | auto_client = TransmissionClient.auto_configure(config_path) 53 | assert auto_client.serialize_configuration().replace( 54 | "localhost", "127.0.0.1" 55 | ) == client.serialize_configuration().replace("localhost", "127.0.0.1") 56 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/My-Bluray.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/BACKUP/MovieObject.bdmv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/BACKUP/PLAYLIST/00000.mpls: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/BACKUP/index.bdmv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/MovieObject.bdmv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/PLAYLIST/00000.mpls: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/STREAM/00000.m2ts: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-Bluray/BDMV/index.bdmv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/My-DVD.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD/VIDEO_TS/VIDEO_TS.BUP: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD/VIDEO_TS/VIDEO_TS.IFO: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD/VIDEO_TS/VTS_01_0.BUP: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD/VIDEO_TS/VTS_01_0.IFO: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD/VIDEO_TS/VTS_01_0.VOB: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/My-DVD/VIDEO_TS/VTS_01_1.VOB: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/Some-CD-Release.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r00: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r01: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r02: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r03: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r04: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r05: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.r06: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.rar: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD1/somestuff-1.sfv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r00: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r01: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r02: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r03: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r04: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r05: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r06: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.r07: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.rar: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/CD2/somestuff-2.sfv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/Sample/some-rls.mkv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/Subs/somestuff-subs.r00: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/Subs/somestuff-subs.rar: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/Subs/somestuff-subs.sfv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-CD-Release/crap.nfo: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/Some-Release.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/Sample/some-rls.mkv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/Subs/some-subs.rar: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/Subs/some-subs.sfv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaaa -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.nfo: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r00: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r01: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r02: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r03: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r04: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r05: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.r06: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.rar: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/Some-Release/some-rls.sfv: -------------------------------------------------------------------------------- 1 | aaaaaaaaaaa 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/file_a.txt: -------------------------------------------------------------------------------- 1 | 1111111111 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/file_b.txt: -------------------------------------------------------------------------------- 1 | 1111111111 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/file_c.txt: -------------------------------------------------------------------------------- 1 | 1111111111 2 | -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/hashalignment/file_a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/hashalignment/file_a -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/hashalignment/file_b: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/hashalignment/file_b -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/hashalignment_multifile.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/hashalignment_multifile.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/hashalignment_singlefile.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/hashalignment_singlefile.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/test.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/test.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/testfiles/test_single.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/clients/tests/testfiles/test_single.torrent -------------------------------------------------------------------------------- /libtc/clients/tests/utils_testclient.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from ...baseclient import BaseClient 4 | from ...exceptions import FailedToExecuteException 5 | 6 | TestTorrent = namedtuple( 7 | "TestTorrent", 8 | [ 9 | "torrent_data", 10 | "torrent_files", 11 | "download_path", 12 | "torrent_file_data", 13 | "is_active", 14 | ], 15 | ) 16 | 17 | 18 | class TestClient(BaseClient): 19 | identifier = "testclient" 20 | display_name = "TestClient" 21 | 22 | def __init__(self): 23 | self._action_queue = [] 24 | self._test_connection = True 25 | self._torrents = {} 26 | 27 | def list(self): 28 | return [t.torrent_data for t in self._torrents.values()] 29 | 30 | def list_active(self): 31 | return [t.torrent_data for t in self._torrents.values() if t.is_active] 32 | 33 | def start(self, infohash): 34 | if infohash not in self._torrents: 35 | raise FailedToExecuteException("Torrent does not exist") 36 | self._action_queue.append(("start", {"infohash": infohash})) 37 | 38 | def stop(self, infohash): 39 | if infohash not in self._torrents: 40 | raise FailedToExecuteException("Torrent does not exist") 41 | self._action_queue.append(("stop", {"infohash": infohash})) 42 | 43 | def test_connection(self): 44 | return self._test_connection 45 | 46 | def add( 47 | self, 48 | torrent, 49 | destination_path, 50 | fast_resume=False, 51 | add_name_to_folder=True, 52 | minimum_expected_data="none", 53 | stopped=False, 54 | ): 55 | self._action_queue.append( 56 | ( 57 | "add", 58 | { 59 | "torrent": torrent, 60 | "destination_path": destination_path, 61 | "fast_resume": fast_resume, 62 | "add_name_to_folder": add_name_to_folder, 63 | "minimum_expected_data": minimum_expected_data, 64 | "stopped": stopped, 65 | }, 66 | ) 67 | ) 68 | 69 | def remove(self, infohash): 70 | if infohash not in self._torrents: 71 | raise FailedToExecuteException("Torrent does not exist") 72 | self._action_queue.append(("remove", {"infohash": infohash})) 73 | 74 | def retrieve_torrentfile(self, infohash): 75 | if infohash not in self._torrents: 76 | raise FailedToExecuteException("Torrent does not exist") 77 | return self._torrents[infohash].torrent_file_data 78 | 79 | def get_download_path(self, infohash): 80 | if infohash not in self._torrents: 81 | raise FailedToExecuteException("Torrent does not exist") 82 | return self._torrents[infohash].download_path 83 | 84 | def move_torrent(self, infohash, destination_path): 85 | self._action_queue.append( 86 | ( 87 | "move_torrent", 88 | { 89 | "infohash": infohash, 90 | "destination_path": destination_path, 91 | }, 92 | ) 93 | ) 94 | 95 | def get_files(self, infohash): 96 | if infohash not in self._torrents: 97 | raise FailedToExecuteException("Torrent does not exist") 98 | return self._torrents[infohash].torrent_files 99 | 100 | def serialize_configuration(self): 101 | return f"{self.identifier}://" 102 | 103 | def auto_configure(cls): 104 | raise FailedToExecuteException("Cannot autoconfigure") 105 | 106 | def _inject_torrent( 107 | self, 108 | torrent_data, 109 | torrent_files, 110 | download_path, 111 | torrent_file_data=None, 112 | is_active=True, 113 | ): 114 | self._torrents[torrent_data.infohash] = TestTorrent( 115 | torrent_data, torrent_files, download_path, torrent_file_data, is_active 116 | ) 117 | -------------------------------------------------------------------------------- /libtc/clients/transmission.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import os 5 | from datetime import datetime 6 | from pathlib import Path 7 | from urllib.parse import urlencode 8 | 9 | import pytz 10 | import requests 11 | from requests.exceptions import RequestException 12 | 13 | from ..baseclient import BaseClient 14 | from ..bencode import bencode 15 | from ..exceptions import FailedToExecuteException 16 | from ..torrent import TorrentData, TorrentFile, TorrentState 17 | from ..utils import ( 18 | calculate_minimum_expected_data, 19 | get_tracker_domain, 20 | has_minimum_expected_data, 21 | ) 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class TransmissionClient(BaseClient): 27 | identifier = "transmission" 28 | display_name = "Transmission" 29 | 30 | _session_id = "" 31 | 32 | def __init__(self, url, session_path=None, username=None, password=None): 33 | self.url = url 34 | self.session_path = session_path and Path(session_path) 35 | self.username = username 36 | self.password = password 37 | self.session = requests.Session() 38 | 39 | def _call(self, method, **kwargs): 40 | logger.debug(f"Calling {method!r} args {kwargs!r}") 41 | auth = None 42 | if self.username and self.password: 43 | auth = (self.username, self.password) 44 | return self.session.post( 45 | self.url, 46 | data=json.dumps({"method": method, "arguments": kwargs}), 47 | headers={"X-Transmission-Session-Id": self._session_id}, 48 | auth=auth, 49 | ) 50 | 51 | def call(self, method, **kwargs): 52 | try: 53 | r = self._call(method, **kwargs) 54 | except RequestException: 55 | raise FailedToExecuteException() 56 | if r.status_code == 409: 57 | self._session_id = r.headers["X-Transmission-Session-Id"] 58 | r = self._call(method, **kwargs) 59 | 60 | if r.status_code != 200: 61 | raise FailedToExecuteException() 62 | 63 | r = r.json() 64 | logger.debug("Got transmission reply") 65 | if r["result"] != "success": 66 | raise FailedToExecuteException() 67 | 68 | return r["arguments"] 69 | 70 | def _fetch_list_result(self, only_active): 71 | result = [] 72 | fields = [ 73 | "hashString", 74 | "name", 75 | "sizeWhenDone", 76 | "status", 77 | "error", 78 | "percentDone", 79 | "uploadedEver", 80 | "addedDate", 81 | "trackers", 82 | "rateUpload", 83 | "rateDownload", 84 | ] 85 | if only_active: 86 | call_result = self.call("torrent-get", ids="recently-active", fields=fields) 87 | else: 88 | call_result = self.call("torrent-get", fields=fields) 89 | for torrent in call_result["torrents"]: 90 | if torrent["error"] > 0: 91 | state = TorrentState.ERROR 92 | elif torrent["status"] > 0: 93 | state = TorrentState.ACTIVE 94 | else: 95 | state = TorrentState.STOPPED 96 | 97 | if torrent["trackers"]: 98 | tracker = get_tracker_domain(torrent["trackers"][0]["announce"]) 99 | else: 100 | tracker = "None" 101 | 102 | result.append( 103 | TorrentData( 104 | torrent["hashString"], 105 | torrent["name"], 106 | torrent["sizeWhenDone"], 107 | state, 108 | torrent["percentDone"] * 100, 109 | torrent["uploadedEver"], 110 | datetime.utcfromtimestamp(torrent["addedDate"]).astimezone( 111 | pytz.UTC 112 | ), 113 | tracker, 114 | torrent["rateUpload"], 115 | torrent["rateDownload"], 116 | "", 117 | ) 118 | ) 119 | return result 120 | 121 | def get_download_path(self, infohash): 122 | # It is impossible to determine the actual location of a file in transmission due to the 123 | # inability to determine if a torrent is a single-file or multi-file torrent without checking 124 | # This is best effort that will work in almost every case. 125 | call_result = self.call( 126 | "torrent-get", ids=[infohash], fields=["downloadDir", "name", "files"] 127 | ) 128 | if not call_result["torrents"]: 129 | raise FailedToExecuteException("Torrent not found") 130 | 131 | torrent = call_result["torrents"][0] 132 | if len(torrent["files"]) == 1 and "/" not in torrent["files"][0]["name"]: 133 | return Path(torrent["downloadDir"]) 134 | else: 135 | return ( 136 | Path(torrent["downloadDir"]) / torrent["files"][0]["name"].split("/")[0] 137 | ) 138 | 139 | def move_torrent(self, infohash, destination_path): 140 | call_result = self.call("torrent-get", ids=[infohash], fields=["name"]) 141 | if not call_result["torrents"]: 142 | raise FailedToExecuteException("Torrent not found") 143 | 144 | self.call( 145 | "torrent-set-location", 146 | ids=[infohash], 147 | location=os.path.abspath(destination_path), 148 | move=True, 149 | ) 150 | 151 | def list(self): 152 | return self._fetch_list_result(False) 153 | 154 | def list_active(self): 155 | return self._fetch_list_result(True) 156 | 157 | def start(self, infohash): 158 | self.call("torrent-start", ids=[infohash]) 159 | 160 | def stop(self, infohash): 161 | self.call("torrent-stop", ids=[infohash]) 162 | 163 | def test_connection(self): 164 | try: 165 | session_data = self.call("session-get") 166 | except FailedToExecuteException: 167 | return False 168 | else: 169 | if session_data["rpc-version"] < 15: 170 | raise FailedToExecuteException( 171 | "You need to update to a newer version of Transmission" 172 | ) 173 | 174 | return True 175 | 176 | def add( 177 | self, 178 | torrent, 179 | destination_path, 180 | fast_resume=False, 181 | add_name_to_folder=True, 182 | minimum_expected_data="none", 183 | stopped=False, 184 | ): 185 | current_expected_data = calculate_minimum_expected_data( 186 | torrent, destination_path, add_name_to_folder 187 | ) 188 | if not has_minimum_expected_data(minimum_expected_data, current_expected_data): 189 | raise FailedToExecuteException( 190 | f"Minimum expected data not reached, wanted {minimum_expected_data} actual {current_expected_data}" 191 | ) 192 | if current_expected_data != "full": 193 | fast_resume = False 194 | destination_path = Path(os.path.abspath(destination_path)) 195 | encoded_torrent = base64.b64encode(bencode(torrent)).decode() 196 | 197 | name = torrent[b"info"][b"name"].decode() 198 | if add_name_to_folder: 199 | download_dir = destination_path 200 | else: 201 | if b"files" in torrent[b"info"]: 202 | download_dir = destination_path.parent 203 | display_name = destination_path.name 204 | else: 205 | download_dir = destination_path 206 | display_name = name 207 | 208 | kwargs = { 209 | "download-dir": str(download_dir), 210 | "metainfo": encoded_torrent, 211 | "paused": True, 212 | } 213 | result = self.call("torrent-add", **kwargs) 214 | tid = result["torrent-added"]["id"] 215 | 216 | if not add_name_to_folder: 217 | self.call( 218 | "torrent-rename-path", ids=[tid], path=str(name), name=str(display_name) 219 | ) 220 | self.call("torrent-verify", ids=[tid]) 221 | if not stopped: 222 | self.call("torrent-start", ids=[tid]) 223 | 224 | def remove(self, infohash): 225 | self.call("torrent-remove", ids=[infohash]) 226 | 227 | def retrieve_torrentfile(self, infohash): 228 | if not self.session_path: 229 | raise FailedToExecuteException("Session path is not configured") 230 | torrent_path = self.session_path / "torrents" 231 | for f in torrent_path.iterdir(): 232 | if ( 233 | f.name.endswith(f".{infohash[:16]}.torrent") 234 | or f.name == f"{infohash}.torrent" 235 | ): 236 | return f.read_bytes() 237 | raise FailedToExecuteException("Torrent file does not exist") 238 | 239 | def get_files(self, infohash): 240 | call_result = self.call("torrent-get", ids=[infohash], fields=["name", "files"]) 241 | torrent = call_result["torrents"][0] 242 | files = torrent["files"] 243 | is_singlefile = len(files) == 1 and "/" not in files[0]["name"] 244 | 245 | result = [] 246 | for f in files: 247 | name = f["name"] 248 | if not is_singlefile: 249 | name = name.split("/", 1)[1] 250 | if f["length"] > 0: 251 | progress = (f["bytesCompleted"] / f["length"]) * 100 252 | else: 253 | progress = 100.0 254 | result.append(TorrentFile(name, f["length"], progress)) 255 | 256 | return result 257 | 258 | def serialize_configuration(self): 259 | url = f"{self.identifier}+{self.url}" 260 | query = {} 261 | if self.session_path: 262 | query["session_path"] = str(self.session_path) 263 | 264 | if query: 265 | url += f"?{urlencode(query)}" 266 | 267 | return url 268 | 269 | @classmethod 270 | def auto_configure(cls, path="~/.config/transmission-daemon/settings.json"): 271 | config_path = Path(path).expanduser() 272 | if not config_path.is_file(): 273 | raise FailedToExecuteException("Unable to find config file") 274 | 275 | try: 276 | config_data = json.loads(config_path.read_text()) 277 | except PermissionError: 278 | raise FailedToExecuteException("Config file not accessible") 279 | 280 | ip = config_data.get("rpc-bind-address") 281 | port = config_data.get("rpc-port") 282 | if ip == "0.0.0.0": 283 | ip = "127.0.0.1" 284 | 285 | if not ip: 286 | raise FailedToExecuteException("Unable to find a bind ip") 287 | 288 | if not port: 289 | raise FailedToExecuteException("Unable to find port") 290 | 291 | return cls( 292 | f"http://{ip}:{port}/transmission/rpc", session_path=config_path.parent 293 | ) 294 | -------------------------------------------------------------------------------- /libtc/exceptions.py: -------------------------------------------------------------------------------- 1 | class LibTorrentClientException(Exception): 2 | """Base exception""" 3 | 4 | 5 | class FailedToExecuteException(LibTorrentClientException): 6 | """Failed to execute command on torrent client""" 7 | -------------------------------------------------------------------------------- /libtc/liltorrent.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from functools import wraps 4 | from io import BytesIO 5 | from pathlib import Path 6 | 7 | from flask import Flask, abort, jsonify, request, send_file 8 | 9 | from .bencode import bdecode 10 | from .clients import parse_libtc_url 11 | from .exceptions import FailedToExecuteException 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | app = Flask(__name__) 16 | 17 | 18 | def get_client(): 19 | return parse_libtc_url(os.environ["LILTORRENT_CLIENT"]) 20 | 21 | 22 | def require_apikey(view_function): 23 | @wraps(view_function) 24 | def decorated_function(*args, **kwargs): 25 | apikey = os.environ["LILTORRENT_APIKEY"] 26 | if apikey and request.headers.get("authorization") == f"Bearer {apikey}": 27 | return view_function(*args, **kwargs) 28 | else: 29 | abort(401) 30 | 31 | return decorated_function 32 | 33 | 34 | def handle_exception(view_function): 35 | @wraps(view_function) 36 | def decorated_function(*args, **kwargs): 37 | try: 38 | return view_function(*args, **kwargs) 39 | except FailedToExecuteException as e: 40 | logger.exception("Failed to handle request") 41 | return jsonify(e.args), 500 42 | 43 | return decorated_function 44 | 45 | 46 | @app.route("/list") 47 | @require_apikey 48 | def list(): 49 | client = get_client() 50 | return jsonify([t.serialize() for t in client.list()]) 51 | 52 | 53 | @app.route("/list_active") 54 | @require_apikey 55 | def list_active(): 56 | client = get_client() 57 | return jsonify([t.serialize() for t in client.list_active()]) 58 | 59 | 60 | @app.route("/start", methods=["POST"]) 61 | @require_apikey 62 | def start(): 63 | client = get_client() 64 | client.start(request.args.get("infohash")) 65 | return jsonify({}) 66 | 67 | 68 | @app.route("/stop", methods=["POST"]) 69 | @require_apikey 70 | def stop(): 71 | client = get_client() 72 | client.stop(request.args.get("infohash")) 73 | return jsonify({}) 74 | 75 | 76 | @app.route("/test_connection") 77 | @require_apikey 78 | def test_connection(): 79 | client = get_client() 80 | return jsonify(client.test_connection()) 81 | 82 | 83 | @app.route("/add", methods=["POST"]) 84 | @require_apikey 85 | def add(): 86 | client = get_client() 87 | destination_path = Path(request.args.get("destination_path")) 88 | fast_resume = request.args.get("fast_resume") == "true" 89 | add_name_to_folder = request.args.get("add_name_to_folder") == "true" 90 | stopped = request.args.get("stopped") == "true" 91 | torrent = bdecode(request.files["torrent"].read()) 92 | client.add( 93 | torrent, 94 | destination_path, 95 | fast_resume=fast_resume, 96 | add_name_to_folder=add_name_to_folder, 97 | minimum_expected_data=request.args.get("minimum_expected_data"), 98 | stopped=stopped, 99 | ) 100 | return jsonify({}) 101 | 102 | 103 | @app.route("/remove", methods=["POST"]) 104 | @require_apikey 105 | def remove(): 106 | client = get_client() 107 | client.remove(request.args.get("infohash")) 108 | return jsonify({}) 109 | 110 | 111 | @app.route("/retrieve_torrentfile") 112 | @require_apikey 113 | def retrieve_torrentfile(): 114 | client = get_client() 115 | infohash = request.args.get("infohash") 116 | torrent_file = BytesIO(client.retrieve_torrentfile(infohash)) 117 | return send_file( 118 | torrent_file, 119 | mimetype="application/x-bittorrent", 120 | as_attachment=True, 121 | attachment_filename=f"{infohash}.torrent", 122 | ) 123 | 124 | 125 | @app.route("/get_download_path") 126 | @require_apikey 127 | def get_download_path(): 128 | client = get_client() 129 | return jsonify(str(client.get_download_path(request.args.get("infohash")))) 130 | 131 | 132 | @app.route("/move_torrent", methods=["POST"]) 133 | @require_apikey 134 | def move_torrent(): 135 | client = get_client() 136 | infohash = request.args.get("infohash") 137 | destination_path = Path(request.args.get("destination_path")) 138 | client.move_torrent(infohash, destination_path) 139 | return jsonify({}) 140 | 141 | 142 | @app.route("/get_files") 143 | @require_apikey 144 | def get_files(): 145 | client = get_client() 146 | return jsonify( 147 | [t.serialize() for t in client.get_files(request.args.get("infohash"))] 148 | ) 149 | 150 | 151 | def cli(): 152 | try: 153 | port = int(os.environ.get("LILTORRENT_PORT")) 154 | except (ValueError, TypeError): 155 | port = 10977 156 | import waitress 157 | 158 | waitress.serve(app, port=port, url_scheme="http") 159 | 160 | 161 | if __name__ == "__main__": 162 | cli() 163 | -------------------------------------------------------------------------------- /libtc/management.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .bencode import BTFailure, bdecode 4 | from .exceptions import FailedToExecuteException 5 | from .torrent import TorrentState 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def move_torrent( 11 | infohash, source_client, target_client, fast_resume=False 12 | ): # TODO: preserver start/stop 13 | source_client.test_connection() 14 | target_client.test_connection() 15 | 16 | for source_torrent in source_client.list(): 17 | if source_torrent.infohash == infohash: 18 | break 19 | else: 20 | raise FailedToExecuteException(f"Infohash {infohash} was not found on source") 21 | 22 | target_torrents = target_client.list() 23 | if any(t for t in target_torrents if t.infohash == infohash): 24 | raise FailedToExecuteException(f"Infohash {infohash} was found on target") 25 | 26 | if source_torrent.state == TorrentState.ERROR: 27 | raise FailedToExecuteException("Cannot move a torrent in an error state") 28 | 29 | try: 30 | torrent_data = bdecode(source_client.retrieve_torrentfile(infohash)) 31 | except BTFailure: 32 | raise FailedToExecuteException("Unable to decode retrieved torrent") 33 | 34 | # if multifile and path ends with 'name', add without skip_name, otherwise add with name and path trimmed 35 | download_path = source_client.get_download_path(infohash) 36 | if ( 37 | b"files" in torrent_data[b"info"] 38 | and download_path.name == torrent_data[b"info"][b"name"].decode() 39 | ): 40 | download_path = download_path.parent 41 | add_name_to_folder = True 42 | else: 43 | add_name_to_folder = False 44 | 45 | if source_torrent.state == TorrentState.ACTIVE: 46 | source_client.stop(infohash) 47 | try: 48 | target_client.add( 49 | torrent_data, 50 | download_path, 51 | fast_resume=fast_resume, 52 | add_name_to_folder=add_name_to_folder, 53 | minimum_expected_data="full", 54 | stopped=source_torrent.state == TorrentState.STOPPED, 55 | ) 56 | except FailedToExecuteException: 57 | logger.exception("Failed to add torrent to the new client") 58 | if source_torrent.state == TorrentState.ACTIVE: 59 | source_client.start(infohash) 60 | raise FailedToExecuteException("Failed to add torrent to new client") 61 | 62 | source_client.remove(infohash) 63 | -------------------------------------------------------------------------------- /libtc/parse_clients.py: -------------------------------------------------------------------------------- 1 | from .clients import TORRENT_CLIENT_MAPPING, parse_libtc_url 2 | 3 | 4 | def parse_clients_from_toml_dict(toml_dict): 5 | clients = {} 6 | 7 | for name, config in toml_dict["clients"].items(): 8 | display_name = config.pop("display_name", name) 9 | client_url = config.pop("client_url", None) 10 | if client_url: 11 | client = parse_libtc_url(client_url) 12 | else: 13 | client_type = config.pop("client_type") 14 | client_cls = TORRENT_CLIENT_MAPPING[client_type] 15 | client = client_cls(**config) 16 | clients[name] = { 17 | "display_name": display_name, 18 | "client": client, 19 | } 20 | 21 | return clients 22 | -------------------------------------------------------------------------------- /libtc/scgitransport.py: -------------------------------------------------------------------------------- 1 | """SCGI XMLRPC Transport. 2 | 3 | XMLRPC in Python only supports HTTP(S). This module extends the transport to also support SCGI. 4 | 5 | SCGI is required by rTorrent if you want to communicate directly with an instance. 6 | 7 | Example: 8 | Small usage example 9 | 10 | literal blocks:: 11 | from xmlrpc.client import ServerProxy 12 | 13 | proxy = ServerProxy('http://127.0.0.1:8000/', transport=SCGITransport()) 14 | proxy.system.listMethods() 15 | 16 | License: 17 | Public Domain (no attribution needed). 18 | The license only applies to THIS file. 19 | """ 20 | 21 | import socket 22 | from io import BytesIO 23 | from xmlrpc.client import Transport 24 | 25 | 26 | def encode_netstring(input): 27 | return str(len(input)).encode() + b":" + input + b"," 28 | 29 | 30 | def encode_header(key, value): 31 | return key + b"\x00" + value + b"\x00" 32 | 33 | 34 | class SCGITransport(Transport): 35 | def __init__(self, *args, **kwargs): 36 | self.socket_path = kwargs.pop("socket_path", "") 37 | Transport.__init__(self, *args, **kwargs) 38 | 39 | def single_request(self, host, handler, request_body, verbose=False): 40 | self.verbose = verbose 41 | if self.socket_path: 42 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 43 | s.connect(self.socket_path) 44 | else: 45 | host, port = host.split(":") 46 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 47 | s.connect((host, int(port))) 48 | 49 | request = encode_header(b"CONTENT_LENGTH", str(len(request_body)).encode()) 50 | request += encode_header(b"SCGI", b"1") 51 | request += encode_header(b"REQUEST_METHOD", b"POST") 52 | request += encode_header(b"REQUEST_URI", handler.encode()) 53 | 54 | request = encode_netstring(request) 55 | request += request_body 56 | 57 | s.send(request) 58 | 59 | response = b"" 60 | while True: 61 | r = s.recv(1024) 62 | if not r: 63 | break 64 | response += r 65 | 66 | response_body = BytesIO(b"\r\n\r\n".join(response.split(b"\r\n\r\n")[1:])) 67 | 68 | return self.parse_response(response_body) 69 | 70 | 71 | if not hasattr(Transport, "single_request"): 72 | SCGITransport.request = SCGITransport.single_request 73 | -------------------------------------------------------------------------------- /libtc/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnDoee/libtc/1bbb8477a91df91f5f602e2ab94ab205f50696a8/libtc/tests/__init__.py -------------------------------------------------------------------------------- /libtc/tests/test_liltorrent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime 4 | from io import BytesIO 5 | from pathlib import Path 6 | 7 | import pytest 8 | import pytz 9 | 10 | from libtc import FailedToExecuteException, bdecode, liltorrent 11 | from libtc.baseclient import BaseClient 12 | from libtc.torrent import TorrentData, TorrentFile, TorrentState 13 | 14 | GLOBAL_CONFIG = { 15 | "headers": {"Authorization": f"Bearer testkey"}, 16 | } 17 | TORRENT_DATA = ( 18 | b"d8:announce42:udp://tracker.opentrackr.org:1337/announce10:creat" 19 | b"ed by13:mktorrent 1.04:infod6:lengthi11e4:name10:file_a.txt12:pi" 20 | b"ece lengthi262144e6:pieces20:Qy\xdb=Bc\xc9\xcbN\xcf\x0e\xdb\xc6S" 21 | b"\xcaF\x0e6x\xb77:privatei1eee" 22 | ) 23 | TORRENT_LIST = [ 24 | TorrentData( 25 | "a" * 40, 26 | "test 1", 27 | 1000, 28 | TorrentState.ACTIVE, 29 | 100.0, 30 | 10, 31 | datetime(2020, 1, 1, 0, 0, 0, tzinfo=pytz.UTC), 32 | "example.com", 33 | 10, 34 | 0, 35 | "", 36 | ), 37 | TorrentData( 38 | "b" * 40, 39 | "test 2", 40 | 2000, 41 | TorrentState.STOPPED, 42 | 0.0, 43 | 0, 44 | datetime(2020, 1, 2, 0, 0, 0, tzinfo=pytz.UTC), 45 | "example.com", 46 | 0, 47 | 10, 48 | "", 49 | ), 50 | ] 51 | TORRENT_FILE_LIST = [ 52 | TorrentFile("file1.txt", 12, 100.0), 53 | TorrentFile("file2.txt", 11, 88.0), 54 | ] 55 | 56 | 57 | class DummyClient(BaseClient): 58 | identifier = "dummyclient" 59 | display_name = "DummyClient" 60 | 61 | def __init__(self): 62 | self._call_log = [] 63 | 64 | def list(self): 65 | return TORRENT_LIST 66 | 67 | def list_active(self): 68 | return [TORRENT_LIST[0]] 69 | 70 | def start(self, infohash): 71 | self._call_log.append(("start", infohash)) 72 | 73 | def stop(self, infohash): 74 | self._call_log.append(("stop", infohash)) 75 | 76 | def test_connection(self): 77 | return True 78 | 79 | def add( 80 | self, 81 | torrent, 82 | destination_path, 83 | fast_resume=False, 84 | add_name_to_folder=True, 85 | minimum_expected_data="none", 86 | stopped=False, 87 | ): 88 | self._call_log.append( 89 | ( 90 | "add", 91 | torrent, 92 | destination_path, 93 | fast_resume, 94 | add_name_to_folder, 95 | minimum_expected_data, 96 | stopped, 97 | ) 98 | ) 99 | 100 | def remove(self, infohash): 101 | self._call_log.append(("remove", infohash)) 102 | 103 | def retrieve_torrentfile(self, infohash): 104 | return TORRENT_DATA 105 | 106 | def get_download_path(self, infohash): 107 | return Path("/download/path") 108 | 109 | def get_files(self, infohash): 110 | return TORRENT_FILE_LIST 111 | 112 | def move_torrent(self, infohash, destination_path): 113 | self._call_log.append(("move_torrent", infohash, destination_path)) 114 | 115 | def serialize_configuration(self): 116 | raise FailedToExecuteException("Not supported") 117 | 118 | def auto_configure(self): 119 | raise FailedToExecuteException("Not supported") 120 | 121 | 122 | def get_client(): 123 | return GLOBAL_CONFIG["client"] 124 | 125 | 126 | liltorrent.get_client = get_client 127 | 128 | 129 | @pytest.fixture 130 | def client(): 131 | os.environ["LILTORRENT_APIKEY"] = "testkey" 132 | GLOBAL_CONFIG["client"] = DummyClient() 133 | with liltorrent.app.test_client() as client: 134 | yield client 135 | 136 | 137 | def test_bad_apikey(client): 138 | r = client.post( 139 | "/start?infohash=0123456789abcdef", 140 | headers={"Authorization": "Bearer badkeyhere"}, 141 | ) 142 | assert r.status_code == 401 143 | assert len(GLOBAL_CONFIG["client"]._call_log) == 0 144 | 145 | 146 | def test_list(client): 147 | r = client.get("/list", headers=GLOBAL_CONFIG["headers"]) 148 | 149 | torrents = [TorrentData.unserialize(t) for t in json.loads(r.data)] 150 | assert len(torrents) == len(TORRENT_LIST) 151 | for t_1, t_2 in zip(torrents, TORRENT_LIST): 152 | for key in t_1.__slots__: 153 | assert getattr(t_1, key) == getattr(t_2, key) 154 | 155 | 156 | def test_list_active(client): 157 | r = client.get("/list_active", headers=GLOBAL_CONFIG["headers"]) 158 | torrents = [TorrentData.unserialize(t) for t in json.loads(r.data)] 159 | assert len(torrents) == len(TORRENT_LIST[:1]) 160 | for t_1, t_2 in zip(torrents, TORRENT_LIST[:1]): 161 | for key in t_1.__slots__: 162 | assert getattr(t_1, key) == getattr(t_2, key) 163 | 164 | 165 | def test_start(client): 166 | r = client.post( 167 | "/start?infohash=0123456789abcdef", headers=GLOBAL_CONFIG["headers"] 168 | ) 169 | assert GLOBAL_CONFIG["client"]._call_log[0] == ("start", "0123456789abcdef") 170 | 171 | 172 | def test_stop(client): 173 | r = client.post("/stop?infohash=0123456789abcdef", headers=GLOBAL_CONFIG["headers"]) 174 | assert GLOBAL_CONFIG["client"]._call_log[0] == ("stop", "0123456789abcdef") 175 | 176 | 177 | def test_test_connection(client): 178 | r = client.get("/test_connection", headers=GLOBAL_CONFIG["headers"]) 179 | assert json.loads(r.data) == True 180 | 181 | 182 | def test_add(client): 183 | r = client.post( 184 | "/add?destination_path=%2Ftmp%2Fhorse&fast_resume=true&add_name_to_folder=true&minimum_expected_data=full", 185 | content_type="multipart/form-data", 186 | headers=GLOBAL_CONFIG["headers"], 187 | data={"torrent": (BytesIO(TORRENT_DATA), "torrent")}, 188 | ) 189 | call_entry = GLOBAL_CONFIG["client"]._call_log[0] 190 | assert call_entry[0] == "add" 191 | assert call_entry[1] == bdecode(TORRENT_DATA) 192 | assert call_entry[2] == Path("/tmp/horse") 193 | assert call_entry[3] == True 194 | assert call_entry[4] == True 195 | assert call_entry[5] == "full" 196 | assert call_entry[6] == False 197 | 198 | 199 | def test_remove(client): 200 | r = client.post( 201 | "/remove?infohash=0123456789abcdef", headers=GLOBAL_CONFIG["headers"] 202 | ) 203 | assert GLOBAL_CONFIG["client"]._call_log[0] == ("remove", "0123456789abcdef") 204 | 205 | 206 | def test_retrieve_torrent_file(client): 207 | r = client.get( 208 | "/retrieve_torrentfile?infohash=0123456789abcdef", 209 | headers=GLOBAL_CONFIG["headers"], 210 | ) 211 | assert r.data == TORRENT_DATA 212 | assert "0123456789abcdef.torrent" in r.headers["Content-Disposition"] 213 | assert r.headers["Content-Type"] == "application/x-bittorrent" 214 | 215 | 216 | def test_get_download_path(client): 217 | r = client.get("/get_download_path", headers=GLOBAL_CONFIG["headers"]) 218 | assert json.loads(r.data) == "/download/path" 219 | 220 | 221 | def test_get_files(client): 222 | r = client.get("/get_files", headers=GLOBAL_CONFIG["headers"]) 223 | 224 | torrents = [TorrentFile.unserialize(t) for t in json.loads(r.data)] 225 | assert len(torrents) == len(TORRENT_FILE_LIST) 226 | for t_1, t_2 in zip(torrents, TORRENT_FILE_LIST): 227 | for key in t_1.__slots__: 228 | assert getattr(t_1, key) == getattr(t_2, key) 229 | -------------------------------------------------------------------------------- /libtc/torrent.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytz 4 | 5 | 6 | class TorrentData: 7 | __slots__ = ( 8 | "infohash", 9 | "name", 10 | "size", 11 | "state", 12 | "progress", 13 | "uploaded", 14 | "added", 15 | "tracker", 16 | "upload_rate", 17 | "download_rate", 18 | "label", 19 | ) 20 | 21 | def __init__( 22 | self, 23 | infohash, 24 | name, 25 | size, 26 | state, 27 | progress, 28 | uploaded, 29 | added, 30 | tracker, 31 | upload_rate, 32 | download_rate, 33 | label, 34 | ): 35 | self.infohash = infohash 36 | self.name = name 37 | self.size = size 38 | self.state = state 39 | self.progress = progress 40 | self.uploaded = uploaded 41 | self.added = added 42 | self.tracker = tracker 43 | self.upload_rate = upload_rate 44 | self.download_rate = download_rate 45 | self.label = label 46 | 47 | def __repr__(self): 48 | return f"TorrentData(infohash={self.infohash!r}, name={self.name!r})" 49 | 50 | def serialize(self): 51 | data = {k: getattr(self, k) for k in self.__slots__} 52 | data["added"] = data["added"].isoformat().split(".")[0].split("+")[0] 53 | return data 54 | 55 | @classmethod 56 | def unserialize(cls, data): 57 | data = dict(data) 58 | data["added"] = datetime.strptime(data["added"], "%Y-%m-%dT%H:%M:%S").replace( 59 | tzinfo=pytz.UTC 60 | ) 61 | return cls(**data) 62 | 63 | 64 | class TorrentState: 65 | ACTIVE = "active" 66 | STOPPED = "stopped" 67 | ERROR = "error" 68 | 69 | 70 | class TorrentFile: 71 | __slots__ = ( 72 | "path", 73 | "size", 74 | "progress", 75 | ) 76 | 77 | def __init__(self, path, size, progress): 78 | self.path = path 79 | self.size = size 80 | self.progress = progress 81 | 82 | def __repr__(self): 83 | return f"TorrentFile(path={self.path!r}, size={self.size!r}), progress={self.progress!r})" 84 | 85 | def serialize(self): 86 | return {k: getattr(self, k) for k in self.__slots__} 87 | 88 | @classmethod 89 | def unserialize(cls, data): 90 | return cls(**data) 91 | -------------------------------------------------------------------------------- /libtc/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | from urllib.parse import urlparse 5 | 6 | import publicsuffixlist 7 | 8 | 9 | def is_legal_path(path): 10 | for p in path: 11 | if p in [".", ".."] or "/" in p: 12 | return False 13 | return True 14 | 15 | 16 | def map_existing_files(torrent, path, add_name_to_folder=True): 17 | name = torrent[b"info"][b"name"].decode() 18 | 19 | files = [] 20 | if b"files" in torrent[b"info"]: 21 | for f in torrent[b"info"][b"files"]: 22 | file_path = Path(os.sep.join(os.fsdecode(p) for p in f[b"path"])) 23 | if add_name_to_folder: 24 | files.append((path / name / file_path, file_path, f[b"length"])) 25 | else: 26 | files.append((path / file_path, file_path, f[b"length"])) 27 | else: 28 | files.append((path / name, name, torrent[b"info"][b"length"])) 29 | 30 | result = [] 31 | 32 | for fp, f, size in files: 33 | result.append((fp, f, size, fp.is_file() and fp.stat().st_size == size)) 34 | 35 | return result 36 | 37 | 38 | def find_existing_files(torrent, path, add_name_to_folder=True): 39 | """ 40 | Checks if the files in a torrent exist, 41 | returns a tuple of found files, missing files, size found, size missing. 42 | """ 43 | 44 | found, missing, found_size, missing_size = 0, 0, 0, 0 45 | 46 | for fp, f, size, found in map_existing_files( 47 | torrent, path, add_name_to_folder=add_name_to_folder 48 | ): 49 | if found: 50 | found += 1 51 | found_size += size 52 | else: 53 | missing += 1 54 | missing_size += size 55 | 56 | return found, missing, found_size, missing_size 57 | 58 | 59 | def calculate_minimum_expected_data(torrent, path, add_name_to_folder=True): 60 | found, missing, found_size, missing_size = find_existing_files( 61 | torrent, path, add_name_to_folder=add_name_to_folder 62 | ) 63 | if not found_size: 64 | return "none" 65 | elif found_size and missing_size: 66 | return "partial" 67 | else: 68 | return "full" 69 | 70 | 71 | def has_minimum_expected_data(expected_data, actual_data): 72 | if expected_data == "none": 73 | return True 74 | elif expected_data == "partial" and actual_data in ["partial", "full"]: 75 | return True 76 | elif expected_data == actual_data == "full": 77 | return True 78 | return False 79 | 80 | 81 | def get_tracker_domain(tracker): 82 | url = urlparse(tracker) 83 | return get_tracker_domain.psl.privatesuffix(url.hostname) 84 | 85 | 86 | # Takes significant time to instantiate (~100ms), so only do it once 87 | get_tracker_domain.psl = publicsuffixlist.PublicSuffixList() 88 | 89 | 90 | def move_files(source_path, target_path, files, preserve_parent_folder=False): 91 | """Move a file mapping from source_path to target_path and preserve permission et.al.""" 92 | source_path = Path(source_path) 93 | target_path = Path(target_path) 94 | 95 | if not target_path.exists(): 96 | target_path.mkdir() 97 | shutil.copystat(source_path, target_path) 98 | 99 | potential_removal_folders = set() 100 | if not preserve_parent_folder: 101 | potential_removal_folders.add(source_path) 102 | 103 | for f in files: 104 | source_file = source_path / f.path 105 | target_file = target_path / f.path 106 | 107 | while not target_file.parent.exists(): 108 | source_file_parent = source_file.parent 109 | target_file_parent = target_file.parent 110 | 111 | while not ( 112 | target_file_parent.parent.exists() and not target_file_parent.exists() 113 | ): 114 | source_file_parent = source_file_parent.parent 115 | target_file_parent = target_file_parent.parent 116 | 117 | if target_path not in Path(os.path.abspath(target_file_parent)).parents: 118 | raise Exception() 119 | target_file_parent.mkdir() 120 | shutil.copystat(source_file_parent, target_file_parent) 121 | 122 | source_parent_folder = source_file.parent 123 | while ( 124 | source_path in source_parent_folder.parents 125 | and source_parent_folder not in potential_removal_folders 126 | ): 127 | potential_removal_folders.add(source_parent_folder) 128 | source_parent_folder = source_parent_folder.parent 129 | 130 | if target_path not in Path(os.path.abspath(target_file)).parents: 131 | raise Exception() 132 | 133 | source_file.rename(target_file) 134 | 135 | potential_removal_folders = sorted(potential_removal_folders, reverse=True) 136 | for folder in potential_removal_folders: 137 | if not list(folder.iterdir()): 138 | folder.rmdir() 139 | 140 | 141 | class TorrentProblems: 142 | INVALID_PATH_SEGMENT = [b"", b".", b"..", b"/", b"\\"] 143 | BAD_CHARACTER_SET = [ 144 | b"\x00", 145 | b"<", 146 | b">", 147 | b":", 148 | b"\\", 149 | b'"', 150 | b"/", 151 | b"\\", 152 | b"|", 153 | b"?", 154 | b"*", 155 | ] 156 | WINDOWS_RESERVED_NAMES = [ 157 | b"con", 158 | b"prn", 159 | b"aux", 160 | b"nul", 161 | b"com1", 162 | b"com2", 163 | b"com3", 164 | b"com4", 165 | b"com5", 166 | b"com6", 167 | b"com7", 168 | b"com8", 169 | b"com9", 170 | b"lpt1", 171 | b"lpt2", 172 | b"lpt3", 173 | b"lpt4", 174 | b"lpt5", 175 | b"lpt6", 176 | b"lpt7", 177 | b"lpt8", 178 | b"lpt9", 179 | ] 180 | STRIPPED_PREFIX_POSTFIX = [b" ", b"."] 181 | MAX_PATH_LENGTH = 260 182 | EMOJIS = [] # TODO: add emojis that e.g. transmission chokes on 183 | 184 | 185 | def rewrite_path(path, path_mapping): 186 | for k, v in path_mapping.items(): 187 | try: 188 | p = path.relative_to(k) 189 | return v / p 190 | except ValueError: 191 | pass 192 | return path 193 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | from libtc import __version__ 6 | 7 | 8 | def readme(): 9 | with open("README.rst") as f: 10 | return f.read() 11 | 12 | 13 | setup( 14 | name="libtc", 15 | version=__version__, 16 | url="https://github.com/JohnDoee/libtc", 17 | author="Anders Jensen", 18 | author_email="andersandjensen@gmail.com", 19 | description="Bittorrent client library", 20 | long_description=readme(), 21 | long_description_content_type="text/x-rst", 22 | license="MIT", 23 | packages=find_packages(), 24 | install_requires=[ 25 | "deluge-client~=1.9.0", 26 | "pytz", # pinning versions here causes problems 27 | "requests", # pinning versions here causes problems 28 | "click>=8.0,<9.0", 29 | "tabulate~=0.8.7", 30 | "publicsuffixlist~=0.7.3", 31 | ], 32 | tests_require=["pytest",], 33 | extras_require={"liltorrent": ["Flask~=1.1.2", "waitress~=1.4.3"]}, 34 | classifiers=[ 35 | "Development Status :: 4 - Beta", 36 | "Intended Audience :: Developers", 37 | "License :: OSI Approved :: MIT License", 38 | "Operating System :: OS Independent", 39 | "Programming Language :: Python", 40 | "Programming Language :: Python :: 3.6", 41 | "Programming Language :: Python :: 3.7", 42 | "Programming Language :: Python :: 3.8", 43 | "Programming Language :: Python :: 3.9", 44 | "Programming Language :: Python :: 3.10", 45 | ], 46 | entry_points={ 47 | "console_scripts": [ 48 | "libtc = libtc.__main__:cli", 49 | "liltorrent = libtc.liltorrent:cli", 50 | ] 51 | }, 52 | ) 53 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | deluge-client~=1.9.0 2 | pytz 3 | requests 4 | click>=8.0,<9.0 5 | tabulate~=0.8.7 6 | publicsuffixlist~=0.7.3 7 | pytest 8 | Flask~=2.1.2 9 | waitress~=2.1.2 --------------------------------------------------------------------------------