├── .gitignore ├── .project ├── .pydevproject ├── BUGS ├── Jenkinsfile ├── MANIFEST.in ├── Makefile ├── README.md ├── bin ├── configleecher ├── leechtorrents └── uploadtorrents ├── build.parameters ├── default └── leechtorrents ├── desktop └── uploadtorrents.desktop ├── doc └── bitcoin.png ├── pyproject.toml ├── seedboxtools.spec ├── service └── leechtorrents@.service ├── setup.cfg └── src └── seedboxtools ├── __init__.py ├── cli.py ├── clients.py ├── config.py ├── leecher.py ├── test_util.py ├── uploader.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | dist 4 | seedboxtools-* 5 | seedboxtools.egg-info 6 | build 7 | .settings 8 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | seedboxtools 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Default 6 | python 2.7 7 | 8 | /seedboxtools/src 9 | 10 | 11 | -------------------------------------------------------------------------------- /BUGS: -------------------------------------------------------------------------------- 1 | FIXME: check that the required commands are available and work properly, both at startup of the leecher and during config time 2 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | // https://github.com/Rudd-O/shared-jenkins-libraries 2 | @Library('shared-jenkins-libraries@master') _ 3 | 4 | genericFedoraRPMPipeline() 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include BUGS 2 | include README.md 3 | include doc/bitcoin.png 4 | include uploadtorrents.desktop 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 2 | 3 | .PHONY: clean dist rpm srpm 4 | 5 | clean: 6 | cd $(ROOT_DIR) && find -name '*~' -print0 | xargs -0r rm -fv && rm -fr build dist *.tar.gz *.rpm 7 | 8 | dist: clean 9 | python -m build -s 10 | 11 | srpm: dist 12 | @which rpmbuild || { echo 'rpmbuild is not available. Please install the rpm-build package with the command `dnf install rpm-build` to continue, then rerun this step.' ; exit 1 ; } 13 | cd $(ROOT_DIR) || exit $$? ; rpmbuild --define "_srcrpmdir ." --define "_sourcedir dist" -bs *spec 14 | 15 | rpm: srpm 16 | @which rpmbuild || { echo 'rpmbuild is not available. Please install the rpm-build package with the command `dnf install rpm-build` to continue, then rerun this step.' ; exit 1 ; } 17 | cd $(ROOT_DIR) || exit $$? ; rpmbuild --define "_srcrpmdir ." --define "_rpmdir dist" --rebuild *.src.rpm 18 | cd $(ROOT_DIR) ; mv -f dist/*/* . && rm -rf dist/*/* 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Seedbox tools (seedboxtools) 2 | 3 | | Donate to support this free software | 4 | |:------------------------------------:| 5 | | | 6 | | [12cXnYY3wabbPmLpS7ZgACh4mBawrXdndk](bitcoin:12cXnYY3wabbPmLpS7ZgACh4mBawrXdndk) | 7 | 8 | The seedbox tools will help you download all those Linux ISOs that you 9 | downloaded on your remote seedbox (whether it's a Transmission Web, or 10 | TorrentFlux-b4rt, or a PulsedMedia seedbox) 100% automatically, without any 11 | manual intervention on your part. 12 | 13 | With this program installed on your home computer, all you need to do is 14 | simply start a torrent in your seedbox, from anywhere you are; then, when 15 | you get back home, all your downloads will be fully downloaded at home, 16 | ready to use and enjoy. 17 | 18 | ## Tools included in this set 19 | 20 | This package contains several tools: 21 | 22 | 1. leechtorrents: a tool that leeches finished downloads from a torrent 23 | seedbox to your local computer. 24 | 2. configleecher: a configuration wizard to set up the clients to work 25 | properly against your seedbox. 26 | 3. uploadtorrents: a tool that lets you queue up a torrent or magnet link 27 | for download on your seedbox. 28 | 29 | ## What you need to have before using this package 30 | 31 | * Python 3.7 on your local machine 32 | * Python iniparse installed there 33 | * Python requests installed there, version 0.11.1 or higher (with SSL support) 34 | * a seedbox running TorrentFlux-b4rt or Transmission Web + API, or 35 | a PulsedMedia ruTorrent seedbox from PulsedMedia.com 36 | * an SSH server on your seedbox 37 | * an SSH client on your local machine 38 | * a public key-authenticated user account in the seedbox, so that your user 39 | can log in without passwords and can read the torrents and downloads 40 | directories in the seedbox 41 | * rsync installed on both machines 42 | * if you are using TorrentFlux-b4rt on your seedbox: 43 | * the command torrentinfo-console from the BitTorrent package, installed 44 | on the seedbox 45 | * the command fluxcli installed and operational on the seedbox 46 | * if you are using Transmission on your seedbox: 47 | * the command transmission-remote from the Transmission package, 48 | installed on your local machine 49 | * the API server port open so that transmission-remote can query it 50 | * if you are using a PulsedMedia seedbox, you don't need to do anything 51 | 52 | ## Installation 53 | 54 | You will need to install this package on your local machine. 55 | 56 | You can install this package directly from PyPI using pip:: 57 | 58 | ``` 59 | pip install seedboxtools 60 | ``` 61 | 62 | If you are on an RPM-based distribution, build an RPM from the source package 63 | and install the resulting RPM:: 64 | 65 | ``` 66 | make rpm 67 | ``` 68 | 69 | Otherwise, just use the standard Python installation system:: 70 | 71 | ``` 72 | python -m build -s 73 | pip install dist/*.tar.gz 74 | ``` 75 | 76 | You can also run it directly from the unpacked source directory:: 77 | 78 | ``` 79 | export PYTHONPATH=src 80 | bin/leechtorrents --help 81 | ``` 82 | 83 | ## Configuration 84 | 85 | The tools require some configuration after installation. There is a nifty 86 | configuration wizard that will set the configuration file up. Run it and 87 | answer a few questions:: 88 | 89 | ``` 90 | configleecher 91 | ``` 92 | 93 | The script will ask you for the necessary configuration values before you can 94 | run the tools here. You should run this wizard on the machine where you'll 95 | be running `leechtorrents` (see below). 96 | 97 | Note: Both TorrentFlux and Transmission protect their download and torrent 98 | directories using permissions. You should become part of the UNIX group 99 | they use to protect those directories, and change the permissions 100 | accordingly so you have at least read and list permissions (rx). 101 | 102 | ## Downloading finished torrents with the leecher tool 103 | 104 | The leecher tool will contact your seedbox and ask for a listing of finished 105 | torrents, then download them locally to the directory you chose during 106 | configuration. There are various ways to run the script: 107 | 108 | * manually on a terminal window 109 | * with cron 110 | * in a systemd unit file as a service 111 | 112 | In all cases, the leecher tool will figure out finished torrents, download 113 | them to the download folder you configured during the `configleecher` stage, 114 | then create a file named `..done` within the download folder, 115 | to indicate that the torrent has finished downloading. This marker helps the 116 | leecher tool remember which torrents were fully downloaded, so that it doesn't 117 | attempt to download them yet again. 118 | 119 | ### Manually 120 | 121 | In your terminal program of choice, just run the command:: 122 | 123 | ``` 124 | leechtorrents 125 | ``` 126 | 127 | There are various options you can supply to the program to change its 128 | behavior, such as enabling periodic checks and logging to a file. Run 129 | `leechtorrents -h` to see the options. 130 | 131 | ### With cron 132 | 133 | Put this in your crontab to run it every minute:: 134 | 135 | ``` 136 | * * * * * leechtorrents -Dql 137 | ``` 138 | 139 | `leechtorrents` will daemonize itself, write to its default log file (which 140 | you could change with another command line option), and be quiet if no work 141 | needs to be done. Locking prevents multiple `leechtorrents` processes from 142 | running simultaneously. 143 | 144 | ### With systemd 145 | 146 | Enable the respective unit file for your user: 147 | 148 | ``` 149 | # $USER contains the user that will run leechtorrents. 150 | # Only run this after configuring the torrent leecher! 151 | sudo systemctl enable --now leechtorrents@$USER 152 | ``` 153 | 154 | You can configure command line options in `/etc/default/leechtorrents` as well 155 | as with `~/.config/leechtorrents-environment`. The environment variable 156 | `$LEECHTORRENTS_OPTS` is defined in either of those files, and carries the 157 | command-line options that will be used by the program. 158 | 159 | You can verify if there are any errors using: 160 | 161 | ``` 162 | sudo systemctl status leechtorrents@$USER 163 | # and 164 | sudo journalctl -b -u leechtorrents@$USER 165 | ``` 166 | 167 | # Removing completed torrents once they have been fully downloaded 168 | 169 | The leecher tool has the ability to remove completed downloads that aren't 170 | seeding from your seedbox. Just pass the command line option `-r` to the 171 | leecher tool `leechtorrents`, and it will automatically remove from the 172 | seedbox each torrent it successfully downloads, so long as the torrent 173 | is not seeding anymore. This feature helps conserve disk space in your 174 | seedbox. Note that, once a torrent has been removed from the seedbox, 175 | its corresponding `..done` file on the download folder 176 | will be eliminated, to clear up clutter in the download folder. 177 | 178 | Example:: 179 | 180 | ``` 181 | leechtorrents -r 182 | ``` 183 | 184 | # Running a program after a torrent is finished downloading 185 | 186 | The leecher tool has the capacity to run a program (non-interactively) right 187 | after a download is completed, and will also pass the full path to the file 188 | or directory that was downloaded to the program. This program will be run 189 | right after the download is done, and (if you have enabled said option) 190 | before the torrent is removed from the seedbox, and its marker file removed 191 | from the download folder. 192 | 193 | To activate the running of the post-download program, pass the option `-s` 194 | followed by the path to the program you want to run. 195 | 196 | Here is an example that runs a particular program to process downloads:: 197 | 198 | ``` 199 | leechtorrents -s /usr/local/bin/blend-linux-distributions 200 | ``` 201 | 202 | In this example, right after your favorite Linux distribution torrent 203 | (which surely is `Fedora-22.iso`) is done and saved to your download folder 204 | `/srv/seedbox`, `leechtorrents` will execute the following command line:: 205 | 206 | ``` 207 | /usr/local/bin/blend-linux-distributions /srv/seedbox/Fedora-22.iso 208 | ``` 209 | 210 | The standard output and standard error of the program are passed to the 211 | standard output and standard error of `leechtorrents`, which may be your 212 | terminal, a logging service, or the log file set aside for logging purposes 213 | by the `leechtorrents` command line parameter `-l`. Standard input will 214 | be nullified, so no option for interacting with the program will exist. 215 | 216 | Note that your program will only ever execute once per downloaded torrent. 217 | Also note that the return value of your program will be ignored. Finally, 218 | please note that if your program doesn't finish, this will block further 219 | downloads, so make sure to equip your program with a timeout (perhaps using 220 | `SIGALRM` or such mechanisms). 221 | 222 | If you want to run a shell or other language script against the downloaded 223 | file or directory, you are advised to write a script file and pass that as 224 | the argument to `-s`, then use the first argument to the script file as 225 | the path to the downloaded file (it's usually `$1` in sh-like languages, 226 | like it is `sys.argv[1]` in Python). 227 | 228 | How to upload torrents to your seedbox 229 | -------------------------------------- 230 | 231 | The `uploadtorrents` command-line tool included in this package will upload the 232 | provided torrent files or magnet links to your seedbox:: 233 | 234 | ``` 235 | uploadtorrents TORRENT [TORRENT ...] 236 | ``` 237 | 238 | This tool currently only supports PulsedMedia clients. 239 | -------------------------------------------------------------------------------- /bin/configleecher: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | from seedboxtools.config import wizard 8 | 9 | if __name__ == "__main__": 10 | sys.exit(wizard()) 11 | -------------------------------------------------------------------------------- /bin/leechtorrents: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | from seedboxtools.leecher import mainloop 8 | 9 | if __name__ == "__main__": 10 | sys.exit(mainloop()) 11 | -------------------------------------------------------------------------------- /bin/uploadtorrents: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | 6 | from seedboxtools.uploader import main 7 | 8 | if __name__ == "__main__": 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /build.parameters: -------------------------------------------------------------------------------- 1 | ["RELEASE": "37 38"] 2 | -------------------------------------------------------------------------------- /default/leechtorrents: -------------------------------------------------------------------------------- 1 | # Default leechtorrents command line options for the service. 2 | # You can also override this in $HOME/.config/leechtorrents-environment 3 | # for the specific user this unit is enabled. 4 | # 5 | # The defaults are -q for quiet and -t 60 for checking the torrent server 6 | # every 60 seconds. 7 | 8 | LEECHTORRENTS_OPTS="-q -t 60" 9 | -------------------------------------------------------------------------------- /desktop/uploadtorrents.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Upload torrents to seedbox 3 | Exec=uploadtorrents %U 4 | Icon=application-x-executable 5 | Terminal=false 6 | TryExec=uploadtorrents 7 | Type=Application 8 | MimeType=application/x-bittorrent;x-scheme-handler/magnet; 9 | Categories=Network;FileTransfer;P2P; 10 | X-AppInstall-Keywords=torrent 11 | -------------------------------------------------------------------------------- /doc/bitcoin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/seedboxtools/bb1776be074f24728b0d0434a4e91e25e26a6ede/doc/bitcoin.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /seedboxtools.spec: -------------------------------------------------------------------------------- 1 | # See https://docs.fedoraproject.org/en-US/packaging-guidelines/Python/#_example_spec_file 2 | 3 | %define debug_package %{nil} 4 | 5 | %define mybuildnumber %{?build_number}%{?!build_number:1} 6 | 7 | Name: seedboxtools 8 | Version: 1.6.7 9 | Release: %{mybuildnumber}%{?dist} 10 | Summary: A tool to automate downloading finished torrents from a seedbox 11 | 12 | License: LGPLv2.1 13 | URL: https://github.com/Rudd-O/%{name} 14 | Source: %{url}/archive/v%{version}/%{name}-%{version}.tar.gz 15 | 16 | BuildArch: noarch 17 | BuildRequires: python3-devel 18 | BuildRequires: systemd-rpm-macros 19 | Requires: python3-requests 20 | Requires: python3-iniparse 21 | 22 | %description 23 | The seedbox tools will help you download all those Linux ISOs that you 24 | downloaded on your remote seedbox (whether it's a Transmission Web, or 25 | TorrentFlux-b4rt, or a PulsedMedia seedbox) 100% automatically, without any 26 | manual intervention on your part. 27 | 28 | %prep 29 | %autosetup -p1 -n %{name}-%{version} 30 | 31 | %generate_buildrequires 32 | %pyproject_buildrequires 33 | 34 | 35 | %build 36 | %pyproject_wheel 37 | 38 | 39 | %install 40 | %pyproject_install 41 | 42 | %pyproject_save_files %{name} 43 | 44 | %post 45 | %systemd_post 'leechtorrents@.service' 46 | 47 | %preun 48 | %systemd_preun 'leechtorrents@*.service' 49 | 50 | %postun 51 | %systemd_postun_with_restart 'leechtorrents@*.service' 52 | 53 | 54 | %files -f %{pyproject_files} 55 | %doc README.md BUGS 56 | %{_bindir}/configleecher 57 | %{_bindir}/leechtorrents 58 | %{_bindir}/uploadtorrents 59 | %{_unitdir}/leechtorrents@.service 60 | %{_datadir}/applications/uploadtorrents.desktop 61 | %{_prefix}/etc/default/leechtorrents 62 | 63 | %changelog 64 | * Wed Jul 27 2022 Manuel Amador 1.5.0-1 65 | - First spec-based RPM packaging release 66 | 67 | -------------------------------------------------------------------------------- /service/leechtorrents@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Leech torrents as user %i 3 | After=network.target network-online.target 4 | Wants=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=%i 9 | EnvironmentFile=-/usr/etc/default/leechtorrents 10 | EnvironmentFile=-/etc/default/leechtorrents 11 | ExecStart=/usr/bin/bash -c 'if test -f ~/.config/leechtorrents-environment ; then source ~/.config/leechtorrents-environment ; fi ; exec leechtorrents $LEECHTORRENTS_OPTS' 12 | Restart=on-failure 13 | RestartSec=15s 14 | RestartPreventExitStatus=2 4 6 200 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = seedboxtools 3 | version = attr: seedboxtools.__version__ 4 | author = Manuel Amador (Rudd-O) 5 | author_email = rudd-o@rudd-o.com 6 | description = A tool to automate downloading finished torrents from a seedbox 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/Rudd-O/seedboxtools 10 | classifiers = 11 | Programming Language :: Python :: 3 :: Only 12 | Development Status :: 5 - Production/Stable 13 | Environment :: Console 14 | Environment :: No Input/Output (Daemon) 15 | Intended Audience :: End Users/Desktop 16 | Intended Audience :: System Administrators 17 | License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2) 18 | License :: OSI Approved :: GNU General Public License (GPL) 19 | Operating System :: POSIX :: Linux 20 | Topic :: Communications :: File Sharing 21 | Topic :: Utilities 22 | keywords = 23 | seedbox 24 | TorrentFlux 25 | Transmission 26 | PulsedMedia 27 | torrents 28 | download 29 | 30 | [options] 31 | install_requires = 32 | iniparse 33 | requests 34 | package_dir = 35 | = src 36 | packages = find: 37 | scripts = 38 | bin/configleecher 39 | bin/leechtorrents 40 | bin/uploadtorrents 41 | 42 | [options.data_files] 43 | lib/systemd/system = service/leechtorrents@.service 44 | share/applications = desktop/uploadtorrents.desktop 45 | etc/default = default/leechtorrents 46 | share/doc/seedboxtools = BUGS 47 | 48 | [options.packages.find] 49 | where = src 50 | -------------------------------------------------------------------------------- /src/seedboxtools/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.6.7' 2 | -------------------------------------------------------------------------------- /src/seedboxtools/cli.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Command - line helpers for seedboxtools 3 | ''' 4 | 5 | import argparse 6 | import optparse 7 | 8 | def get_parser(): 9 | '''returns the parser for the command line options''' 10 | parser = optparse.OptionParser() 11 | parser.add_option( 12 | "-g", '--logfile', 13 | help="redirect standard output and standard error to log file (relative to download directory; default %default)", 14 | action='store', dest='logfile', default=None, 15 | ) 16 | parser.add_option( 17 | "-D", '--daemon', 18 | help="daemonize after start; useful for cron executions (combined with --lock); implies option -g .torrentleecher.log unless specified otherwise", 19 | action='store_true', dest='daemonize', default=False, 20 | ) 21 | parser.add_option( 22 | "-t", '--run-every', 23 | help="start up and run forever, looping every X seconds; useful for systemd executions", 24 | action='store', dest='run_every', default=False 25 | ) 26 | parser.add_option( 27 | "-r", '--remove-finished', 28 | help="remove downloaded torrents that are not seeding anymore", 29 | action='store_true', dest='remove_finished', default=False 30 | ) 31 | parser.add_option( 32 | "-s", '--run-processor-program', 33 | help="run program after completing download, passing path to download as first argument", 34 | action='store', dest='run_processor_program', default=None 35 | ) 36 | parser.add_option( 37 | "-l", '--lock', 38 | help="lock working directory; useful for cron executions (combine with --daemon to prevent cron from jamming until downloads are finished)", 39 | action='store_true', dest='lock', default=False 40 | ) 41 | parser.add_option( 42 | "-H", '--lock-homedir', 43 | help="lock home directory; alternative (mutually exclusive) to --lock", 44 | action='store_true', dest='lock_homedir', default=False 45 | ) 46 | parser.add_option( 47 | "-q", '--quiet', 48 | help="do not print anything, except for errors", 49 | action='store_true', dest='quiet', default=False 50 | ) 51 | return parser 52 | 53 | def get_uploader_parser(): 54 | '''returns argument parser for uploader tool''' 55 | parser = argparse.ArgumentParser(description='Upload torrents and magnet links to seedbox.') 56 | parser.add_argument('-d', '--debug', action="store_true", default=False, 57 | help='enable tracebacks for errors') 58 | parser.add_argument('torrents', metavar='TORRENT', nargs='+', 59 | help='torrent file or magnet link') 60 | return parser 61 | -------------------------------------------------------------------------------- /src/seedboxtools/clients.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client classes for seedboxtools 3 | """ 4 | 5 | import seedboxtools.util as util 6 | import re 7 | import os 8 | import requests 9 | import json 10 | import subprocess 11 | import xmlrpc.client 12 | 13 | from functools import partial 14 | from urllib.parse import quote 15 | 16 | # We must present some form of timeout or else the request can hang forever. 17 | # The documentation insists production code must specify it. 18 | def post(*args, **kwargs): 19 | if "timeout" not in kwargs: 20 | kwargs = dict(kwargs) 21 | kwargs["timeout"] = 15 22 | return requests.post(*args, **kwargs) 23 | 24 | 25 | def remote_test_minus_e(passthru, path): 26 | cmd = ["test", "-e", path] 27 | returncode = passthru(cmd) 28 | if returncode == 1: 29 | return False 30 | elif returncode == 0: 31 | return True 32 | elif returncode == -2: 33 | raise IOError(4, "exists_on_server interrupted") 34 | else: 35 | raise subprocess.CalledProcessError(returncode, ["ssh", ""] + cmd) 36 | 37 | 38 | class SeedboxClientException(Exception): 39 | pass 40 | 41 | 42 | class TemporaryMalfunction(SeedboxClientException): 43 | pass 44 | 45 | 46 | class Misconfiguration(SeedboxClientException): 47 | pass 48 | 49 | 50 | class InvalidTorrent(SeedboxClientException): 51 | def __init__(self, message): 52 | self.message = message 53 | 54 | def __str__(self): 55 | return "invalid torrent file or magnet link: %r" % self.message 56 | 57 | 58 | class SeedboxClient: 59 | def __init__(self, local_download_dir): 60 | self.local_download_dir = local_download_dir 61 | 62 | def get_finished_torrents(self): 63 | """ 64 | Returns a series of tuples (torrentdescriptor, "Done") 65 | for every torrent that is done. 66 | If there is a temporary error, it raises TemporaryMalfunction. 67 | """ 68 | raise NotImplementedError 69 | 70 | def get_file_name(self, torrentname): 71 | """ 72 | Returns the file or path name to the torrent given a 73 | torrentdescriptor. 74 | """ 75 | raise NotImplementedError 76 | 77 | def transfer(self, filename): 78 | raise NotImplementedError 79 | 80 | def exists_on_server(self, filename): 81 | raise NotImplementedError 82 | 83 | def remove_remote_download(self, filename): 84 | raise NotImplementedError 85 | 86 | def get_files_to_download(self): 87 | """Returns iterator with get_finished_torrents result.""" 88 | torrents = self.get_finished_torrents() 89 | for name, status in torrents: 90 | yield (name, status, self.get_file_name(name)) 91 | 92 | def upload_magnet_link(self, magnet_link): 93 | raise NotImplementedError 94 | 95 | def upload_torrent(self, torrent_path): 96 | raise NotImplementedError 97 | 98 | 99 | class TorrentFluxClient(SeedboxClient): 100 | def __init__( 101 | self, 102 | local_download_dir, 103 | hostname, 104 | base_dir, 105 | incoming_dir, 106 | torrentinfo_path, 107 | fluxcli_path, 108 | ssh_hostname="", 109 | ): 110 | SeedboxClient.__init__(self, local_download_dir) 111 | self.hostname = hostname 112 | self.ssh_hostname = ssh_hostname or hostname 113 | self.base_dir = base_dir 114 | self.incoming_dir = incoming_dir 115 | self.fluxcli_path = fluxcli_path 116 | self.torrentinfo_path = torrentinfo_path 117 | 118 | self.getssh = partial(util.ssh_getstdout, self.ssh_hostname) 119 | self.passthru = partial(util.ssh_passthru, self.ssh_hostname) 120 | 121 | def get_finished_torrents(self): 122 | stdout = self.getssh([self.fluxcli, "transfers"]) 123 | stdout = stdout.splitlines()[2:-5] 124 | stdout.reverse() 125 | stdout = [ 126 | re.match("^- (.+) - [0123456789.]+ [KMG]B - (Seeding|Done)", line) 127 | for line in stdout 128 | ] 129 | pairs = [(match.group(1), match.group(2)) for match in stdout if match] 130 | return pairs 131 | 132 | def get_file_name(self, torrentname): 133 | fullpath = os.path.join(self.base_dir, ".transfers", torrentname) 134 | stdout = self.getssh( 135 | ["env", "LANG=C", self.torrentinfo_path, fullpath] 136 | ).splitlines() 137 | 138 | def isf(x): 139 | return x.startswith("file name...........: ") 140 | 141 | def isd(x): 142 | return x.startswith("directory name......: ") 143 | 144 | filenames = [f[22:] for f in stdout if isf(f)] 145 | if not len(filenames): 146 | _ = stdout.index("files...............:") 147 | # we disregard the actual filenames, we now want the dir name 148 | filenames = [f[22:] for f in stdout if isd(f)] 149 | assert len(filenames) == 1, "Wrong length of filenames: %r" % filenames 150 | return filenames[0] 151 | 152 | def transfer(self, filename): 153 | path = os.path.join(self.incoming_dir, filename) 154 | path = "%s:%s" % (self.ssh_hostname, path) 155 | return util.rsync(path, self.local_download_dir) 156 | 157 | def exists_on_server(self, filename): 158 | path = os.path.join(self.incoming_dir, filename) 159 | return remote_test_minus_e(self.passthru, path) 160 | 161 | def remove_remote_download(self, filename): 162 | returncode = self.passthru( 163 | ["rm", "-rf", os.path.join(self.incoming_dir, filename)] 164 | ) 165 | if returncode == 0: 166 | return 167 | elif returncode == -2: 168 | raise IOError(4, "remove_remote_download interrupted") 169 | else: 170 | raise AssertionError("remove dirs only returned %s" % returncode) 171 | 172 | 173 | class TransmissionClient(SeedboxClient): 174 | def __init__( 175 | self, 176 | local_download_dir, 177 | hostname, 178 | torrents_dir, 179 | incoming_dir, 180 | torrentinfo_path, 181 | transmission_remote_path, 182 | transmission_remote_user, 183 | transmission_remote_password, 184 | ssh_hostname="", 185 | ): 186 | SeedboxClient.__init__(self, local_download_dir) 187 | self.hostname = hostname 188 | self.torrents_dir = torrents_dir 189 | self.incoming_dir = incoming_dir 190 | self.transmission_remote_path = transmission_remote_path 191 | self.transmission_remote_user = transmission_remote_user 192 | self.transmission_remote_password = transmission_remote_password 193 | self.ssh_hostname = ssh_hostname or hostname 194 | 195 | self.getssh = partial(util.ssh_getstdout, self.ssh_hostname) 196 | self.passthru = partial(util.ssh_passthru, self.ssh_hostname) 197 | 198 | def get_finished_torrents(self): 199 | u, p = ( 200 | self.transmission_remote_user, 201 | self.transmission_remote_password, 202 | ) 203 | stdout = util.getstdout( 204 | [ 205 | self.transmission_remote_path, 206 | self.hostname, 207 | f"--auth={u}:{p}", 208 | "-l", 209 | ] 210 | ) 211 | stdout = stdout.splitlines()[1:-1] 212 | stdout.reverse() 213 | stdout = [x.split() + [x[70:]] for x in stdout] 214 | 215 | def donetoseeding(t): 216 | return "Seeding" if t != "Stopped" else t 217 | 218 | stdout = [ 219 | ( 220 | x[0], 221 | donetoseeding(x[8]), 222 | x[-1], 223 | ) 224 | for x in stdout 225 | if x[4] in "Done" 226 | ] 227 | self.torrent_to_id_map = dict((x[2], x[0]) for x in stdout) 228 | pairs = [(x[2], x[1]) for x in stdout] 229 | return pairs 230 | 231 | def get_file_name(self, torrentname): 232 | # first, cache the torrent names to IDs 233 | if not hasattr(self, "torrent_to_id_map"): 234 | self.get_finished_torrents() 235 | torrent_id = self.torrent_to_id_map[torrentname] 236 | u, p = ( 237 | self.transmission_remote_user, 238 | self.transmission_remote_password, 239 | ) 240 | stdout = util.getstdout( 241 | [ 242 | "env", 243 | "LANG=C", 244 | self.transmission_remote_path, 245 | self.hostname, 246 | f"--auth={u}:{p}", 247 | "-t", 248 | torrent_id, 249 | "-f", 250 | ] 251 | ).splitlines() 252 | filename = util.firstcomponent(stdout[2][34:]) 253 | return filename 254 | 255 | def transfer(self, filename): 256 | path = os.path.join(self.incoming_dir, filename) 257 | path = "%s:%s" % (self.ssh_hostname, path) 258 | return util.rsync(path, self.local_download_dir) 259 | 260 | def exists_on_server(self, filename): 261 | path = os.path.join(self.incoming_dir, filename) 262 | return remote_test_minus_e(self.passthru, path) 263 | 264 | def remove_remote_download(self, filename): 265 | if not hasattr(self, "torrent_to_id_map"): 266 | self.get_finished_torrents() 267 | if not hasattr(self, "filename_to_torrent_map"): 268 | self.filename_to_torrent_map = dict( 269 | (self.get_file_name(torrentname), torrentname) 270 | for torrentname, _ in self.get_finished_torrents() 271 | ) 272 | torrent = self.filename_to_torrent_map[filename] 273 | torrent_id = self.torrent_to_id_map[torrent] 274 | u, p = ( 275 | self.transmission_remote_user, 276 | self.transmission_remote_password, 277 | ) 278 | returncode = util.passthru( 279 | [ 280 | "env", 281 | "LANG=C", 282 | self.transmission_remote_path, 283 | self.hostname, 284 | f"--auth={u}:{p}", 285 | "-t", 286 | torrent_id, 287 | "--remove-and-delete", 288 | ] 289 | ) 290 | if returncode == 0: 291 | return 292 | elif returncode == -2: 293 | raise IOError(4, "remove_remote_download interrupted") 294 | else: 295 | raise AssertionError("remove dirs only returned %s" % returncode) 296 | 297 | 298 | class PulsedMediaClient(SeedboxClient): 299 | def __init__( 300 | self, 301 | local_download_dir, 302 | hostname, 303 | login, 304 | password, 305 | ssh_hostname="", 306 | label="", 307 | ): 308 | """Client for ruTorrent servers default in PulsedMedia seedboxes.""" 309 | SeedboxClient.__init__(self, local_download_dir) 310 | self.hostname = hostname 311 | self.ssh_hostname = ssh_hostname or hostname 312 | self.login = login 313 | self.password = password 314 | self.label = label.strip() 315 | 316 | self.getssh = partial( 317 | util.ssh_getstdout, 318 | "%s@%s" % (login, self.ssh_hostname), 319 | ) 320 | self.passthru = partial( 321 | util.ssh_passthru, 322 | "%s@%s" % (login, self.ssh_hostname), 323 | ) 324 | 325 | # Here we disable the certificate warnings that take place with 326 | # PulsedMedia's less-than-nice SSL certificates. Tragic, but the 327 | # alternative is to keep spamming the log forever. 328 | try: 329 | import requests.packages.urllib3 330 | 331 | requests.packages.urllib3.disable_warnings() 332 | except (ImportError, Exception): 333 | pass 334 | 335 | def get_finished_torrents(self): 336 | r = post( 337 | "https://%s/user-%s/rutorrent/plugins/httprpc/action.php" 338 | % (self.hostname, self.login), 339 | auth=(self.login, self.password), 340 | data="mode=list", 341 | ) 342 | if r.status_code == 500: 343 | raise TemporaryMalfunction( 344 | "Server returned a temporary 500 status code: %s" % r.content 345 | ) 346 | if r.status_code == 404: 347 | raise Misconfiguration( 348 | "Server address (%s) may be misconfigured: %s" % self.hostname 349 | ) 350 | assert r.status_code == 200, ( 351 | "Non-OK status code while retrieving get_finished_torrents: %r" 352 | % r.status_code 353 | ) 354 | data = json.loads(r.content) 355 | torrents = data["t"] 356 | if not torrents: 357 | # There are no torrents to download, or so the server says. 358 | return [] 359 | self.torrents_cache = torrents 360 | try: 361 | self.path_for_filename_cache = dict( 362 | [ 363 | (os.path.basename(torrent[25]), torrent[25]) 364 | for torrent in list(torrents.values()) 365 | ] 366 | ) 367 | self.hash_for_filename_cache = dict( 368 | [ 369 | (os.path.basename(torrent[25]), thehash) 370 | for thehash, torrent in list(torrents.items()) 371 | ] 372 | ) 373 | except AttributeError as e: 374 | raise AttributeError( 375 | "normally this would be a 'list' object has no attribute 'values', but in reality something went wrong with the unserialization of JSON values, which were serialized from %r and were supposed to come from the 't' bag of JSON data -- this happens when PulsedMedia's server fucks up (%s)" 376 | % (r.content, e) 377 | ) 378 | done_torrents = [] 379 | for key, torrent in list(torrents.items()): 380 | # filename = torrent[25] 381 | completed_chunks = int(torrent[6]) 382 | size_chunks = int(torrent[7]) 383 | done = completed_chunks / size_chunks 384 | if self.label and self.label != torrent[14]: 385 | # If it does not match the label, the torrent is 386 | # never "done". 387 | done = 0 388 | if done == 1: 389 | done_torrents.append( 390 | (key, "Done" if int(torrent[0]) == 0 else "Seeding") 391 | ) 392 | return done_torrents 393 | 394 | def get_file_name(self, torrentname): 395 | # in this implementation, get_finished_torrents MUST BE called first 396 | # or else this will bomb out with an attribute error 397 | torrent = self.torrents_cache[torrentname] 398 | return os.path.basename(torrent[25]) 399 | 400 | def transfer(self, filename): 401 | # in this implementation, get_finished_torrents MUST BE called first 402 | # or else this will bomb out with an attribute error 403 | path = self.path_for_filename_cache[filename] 404 | path = "%s@%s:%s" % (self.login, self.ssh_hostname, path) 405 | return util.rsync(path, self.local_download_dir) 406 | 407 | def exists_on_server(self, filename): 408 | # in this implementation, get_finished_torrents MUST BE called first 409 | # or else this will bomb out with an attribute error 410 | path = self.path_for_filename_cache[filename] 411 | return remote_test_minus_e(self.passthru, path) 412 | 413 | def upload_magnet_link(self, magnet_link): 414 | return self._upload(data={"url": magnet_link}) 415 | 416 | def upload_torrent(self, torrent_path): 417 | n = os.path.basename(torrent_path) 418 | with open(torrent_path, "rb") as tf: 419 | return self._upload(files={"torrent_file": (n, tf)}) 420 | 421 | def _upload(self, **params): 422 | r = post( 423 | "https://%s/user-%s/rutorrent/php/addtorrent.php" 424 | % (self.hostname, self.login), 425 | auth=(self.login, self.password), 426 | **params, 427 | ) 428 | if r.status_code == 500: 429 | raise TemporaryMalfunction( 430 | "Server returned a temporary 500 status code: %s" % r.text 431 | ) 432 | if r.status_code == 404: 433 | raise Misconfiguration( 434 | "Server address (%s) may be misconfigured: %s" 435 | % (self.hostname, r.status_code) 436 | ) 437 | 438 | if "addTorrentSuccess" in r.text: 439 | return 440 | 441 | if "addTorrentFailed" in r.text: 442 | if "data" in params: 443 | raise InvalidTorrent(params["data"]["url"]) 444 | raise InvalidTorrent(params["files"]["torrent_file"][0]) 445 | 446 | assert 0, (r.status_code, r.text) 447 | 448 | def remove_remote_download(self, filename): 449 | # in this implementation, get_finished_torrents MUST BE called first 450 | # or else this will bomb out with an attribute error 451 | login = quote(self.login, safe="") 452 | passw = quote(self.password, safe="") 453 | url = ( 454 | f"https://{login}:{passw}@{self.hostname}/user-{login}" 455 | + "/rutorrent/plugins/httprpc/action.php" 456 | ) 457 | client = xmlrpc.client.ServerProxy(url) 458 | infohash = self.hash_for_filename_cache[filename] 459 | mcall = xmlrpc.client.MultiCall(client) 460 | mcall.d.custom5.set(infohash, "1") 461 | mcall.d.delete_tied(infohash) 462 | mcall.d.erase(infohash) 463 | try: 464 | _, delete_tied_result, erase_result = list(mcall()) 465 | except xmlrpc.client.ProtocolError as exc: 466 | raise Misconfiguration( 467 | f"Server address ({self.hostname}) may be misconfigured" 468 | ) from exc 469 | except xmlrpc.client.Fault as exc: 470 | raise TemporaryMalfunction("Server returned a fault.") from exc 471 | 472 | assert delete_tied_result == 0, f"Delete tied result {delete_tied_result}" 473 | assert erase_result == 0, f"Erase result {erase_result}" 474 | 475 | 476 | clients = { 477 | "TransmissionClient": TransmissionClient, 478 | "TorrentFluxClient": TorrentFluxClient, 479 | "PulsedMedia": PulsedMediaClient, 480 | } 481 | 482 | 483 | def lookup_client(name): 484 | return clients[name] 485 | -------------------------------------------------------------------------------- /src/seedboxtools/config.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Configuration for the seedbox downloader 3 | ''' 4 | 5 | import os 6 | from iniparse import INIConfig 7 | from iniparse.config import Undefined 8 | from seedboxtools import clients 9 | 10 | default_filename = os.path.expanduser("~/.torrentleecher.cfg") 11 | 12 | def get_default_config(): 13 | cfg = INIConfig() 14 | cfg.general.client = 'TransmissionClient' 15 | cfg.general.local_download_dir = os.path.join(os.path.expanduser("~"), "Downloads") 16 | cfg[cfg.general.client].hostname = '' 17 | cfg[cfg.general.client].ssh_hostname = '' 18 | cfg[cfg.general.client].torrents_dir = '/var/lib/transmission/torrents' 19 | cfg[cfg.general.client].incoming_dir = '/var/lib/transmission/Downloads' 20 | cfg[cfg.general.client].transmission_remote_path = 'transmission-remote' 21 | cfg[cfg.general.client].transmission_remote_user = 'admin' 22 | cfg[cfg.general.client].transmission_remote_password = '' 23 | cfg[cfg.general.client].torrentinfo_path = 'torrentinfo-console' 24 | return cfg 25 | 26 | def load_config(fobject): 27 | cfg = INIConfig(fobject) 28 | return cfg 29 | 30 | def save_config(cfgobject, fobject): 31 | text = str(cfgobject) 32 | fobject.write(text) 33 | fobject.flush() 34 | 35 | def get_client(config): 36 | client_constructor = clients.lookup_client(config.general.client) 37 | args = {"local_download_dir":config.general.local_download_dir} 38 | client_props = getattr(config, config.general.client) 39 | args.update(set([ (x, getattr(client_props, x)) for x in client_props ])) 40 | args = dict(args) 41 | return client_constructor(**args) 42 | 43 | def raw_input_default(prompt, default, choices=None): 44 | if callable(default): 45 | try: default = default() 46 | except Exception: default = '' 47 | if isinstance(default,Undefined): 48 | default = '' 49 | if choices: 50 | choices_str = " or ".join(choices) + ", default " 51 | else: 52 | choices_str = '' 53 | prompt = "Hint: leave empty to select the default, enter a period to select an empty value\n" + prompt 54 | string = prompt + " (%s%s): " % (choices_str, default) 55 | _ = input(string) 56 | choice = _ if _ else default 57 | if _ == ".": 58 | choice = "" 59 | if choices and choice not in choices: 60 | print(choice, "is not in the choices, please try again") 61 | return raw_input_default(prompt, default, choices) 62 | return choice 63 | 64 | def wizard(): 65 | try: import readline 66 | except ImportError: pass 67 | cfg = get_default_config() 68 | try: 69 | f = open(default_filename) 70 | cfg = load_config(f) 71 | except (IOError, OSError): 72 | print("Couldn't load configuration. Creating a new configuration file.") 73 | cfg.general.local_download_dir = raw_input_default( 74 | "Local download directory", 75 | cfg.general.local_download_dir, 76 | ) 77 | cfg.general.client = raw_input_default( 78 | "Torrent server type", 79 | cfg.general.client, 80 | ["TorrentFluxClient", "TransmissionClient", "PulsedMedia"], 81 | ) 82 | if cfg.general.client == 'TransmissionClient': 83 | cfg[cfg.general.client].hostname = raw_input_default( 84 | "Torrent server host name", 85 | cfg[cfg.general.client].hostname, 86 | ) 87 | cfg[cfg.general.client].ssh_hostname = raw_input_default( 88 | "Server SSH host name (leave empty if is the same as the torrent server host name)", 89 | cfg[cfg.general.client].ssh_hostname, 90 | ) 91 | cfg[cfg.general.client].torrents_dir = raw_input_default( 92 | "Directory where the torrent server stores torrent files", 93 | cfg[cfg.general.client].torrents_dir, 94 | ) 95 | cfg[cfg.general.client].incoming_dir = raw_input_default( 96 | "Directory where the torrent server stores downloaded files", 97 | cfg[cfg.general.client].incoming_dir, 98 | ) 99 | cfg[cfg.general.client].transmission_remote_path = raw_input_default( 100 | "Command to run transmission-remote locally", 101 | cfg[cfg.general.client].transmission_remote_path, 102 | ) 103 | cfg[cfg.general.client].transmission_remote_user = raw_input_default( 104 | "User name for transmission-remote", 105 | cfg[cfg.general.client].transmission_remote_user, 106 | ) 107 | cfg[cfg.general.client].transmission_remote_password = raw_input_default( 108 | "Password for transmission-remote", 109 | cfg[cfg.general.client].transmission_remote_password, 110 | ) 111 | cfg[cfg.general.client].torrentinfo_path = raw_input_default( 112 | "Command to run torrentinfo-console in the server", 113 | cfg[cfg.general.client].torrentinfo_path, 114 | ) 115 | elif cfg.general.client == 'TorrentFluxClient': 116 | cfg[cfg.general.client].hostname = raw_input_default( 117 | "Torrent server host name", 118 | cfg[cfg.general.client].hostname, 119 | ) 120 | cfg[cfg.general.client].ssh_hostname = raw_input_default( 121 | "Server SSH host name (leave empty if is the same as the torrent server host name)", 122 | cfg[cfg.general.client].ssh_hostname, 123 | ) 124 | cfg[cfg.general.client].base_dir = raw_input_default( 125 | "Base directory where TorrentFlux stores its .transfers directory", 126 | cfg[cfg.general.client].base_dir, 127 | ) 128 | cfg[cfg.general.client].incoming_dir = raw_input_default( 129 | "Directory where where TorrentFlux stores downloaded files", 130 | cfg[cfg.general.client].incoming_dir, 131 | ) 132 | cfg[cfg.general.client].fluxcli_path = raw_input_default( 133 | "Command to run fluxcli in the server", 134 | cfg[cfg.general.client].fluxcli_path, 135 | ) 136 | cfg[cfg.general.client].torrentinfo_path = raw_input_default( 137 | "Command to run torrentinfo-console in the server", 138 | cfg[cfg.general.client].torrentinfo_path, 139 | ) 140 | elif cfg.general.client == 'PulsedMedia': 141 | cfg[cfg.general.client].hostname = raw_input_default( 142 | "Hostname (x.pulsedmedia.com)", 143 | lambda: cfg[cfg.general.client].hostname, 144 | ) 145 | cfg[cfg.general.client].ssh_hostname = raw_input_default( 146 | "Server SSH host name (leave empty if is the same as the torrent server host name)", 147 | cfg[cfg.general.client].ssh_hostname, 148 | ) 149 | cfg[cfg.general.client].label = raw_input_default( 150 | "Label to download", 151 | cfg[cfg.general.client].label, 152 | ) 153 | cfg[cfg.general.client].login = raw_input_default( 154 | "Login", 155 | lambda: cfg[cfg.general.client].login, 156 | ) 157 | cfg[cfg.general.client].password = raw_input_default( 158 | "Password", 159 | lambda: cfg[cfg.general.client].password, 160 | ) 161 | else: 162 | assert 0, "Not reached" 163 | print("Writing this configuration to %s" % default_filename) 164 | print("===============8<================") 165 | print(cfg) 166 | print("===============>8================") 167 | oldumask = os.umask(0o077) 168 | save_config(cfg, open(default_filename, "w")) 169 | os.umask(oldumask) 170 | print(""" 171 | The configuration wizard is done. Next steps: 172 | 173 | * If your seedbox provider requires you to SSH into the seedbox in order to 174 | download files (most do), you must now set up SSH public key authentication. 175 | While logged in as the user that will be using the leechtorrents command, 176 | create a (passwordless) SSH keypair using the command `ssh-keygen`, then 177 | make sure to deploy the public SSH key to the seedbox with the command 178 | `ssh-copy-id `. You will know that the deed is 179 | done once you can type `ssh ` and the command 180 | logs you into the seedbox without prompting you for a password. 181 | * If your seedbox provider requires you to SSH into the seedbox in order to 182 | download files, then your seedbox must have rsync installed, and you must 183 | also have rsync installed locally. 184 | 185 | Having done these steps, you are ready to run leechtorrents. Fire it up, 186 | and never have to worry about manually downloading your latest torrents from 187 | your seedbox! Make sure to read the README file of this program to get tips 188 | on how to run it periodically or permanently in your computer. 189 | 190 | Enjoy!""") 191 | -------------------------------------------------------------------------------- /src/seedboxtools/leecher.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the code in charge of downloading proper 3 | """ 4 | 5 | import errno, os, signal, sys, subprocess, time, traceback 6 | from seedboxtools import util, cli, config 7 | from seedboxtools.clients import TemporaryMalfunction, Misconfiguration 8 | from requests.exceptions import ConnectionError 9 | 10 | EXIT_NOTCONFIGURED = 6 11 | EXIT_NOPERMISSION = 4 12 | EXIT_INVALIDARGUMENT = 2 13 | EXIT_CHDIR = 200 14 | 15 | # start execution here 16 | def download(client, remove_finished=False, run_processor_program=None): 17 | for torrent, status, filename in client.get_files_to_download(): 18 | # Set loop vars up 19 | download_lockfile = ".%s.done" % filename 20 | fully_downloaded = os.path.exists(download_lockfile) 21 | seeding = status == "Seeding" 22 | 23 | # If the file is completely downloaded but not to be remotely removed, skip 24 | if fully_downloaded and not remove_finished: 25 | util.report_message( 26 | "%s from %s is fully downloaded, continuing to next torrent" 27 | % (filename, torrent) 28 | ) 29 | continue 30 | 31 | # If the remote files don't exist, skip 32 | util.report_message( 33 | "Checking if %s from torrent %s exists on server" % (filename, torrent) 34 | ) 35 | if not client.exists_on_server(filename): 36 | util.report_message( 37 | "%s from %s is no longer available on server, continuing to next torrent" 38 | % (filename, torrent) 39 | ) 40 | continue 41 | 42 | if not fully_downloaded: 43 | 44 | # Start download. 45 | util.report_message("Downloading %s from torrent %s" % (filename, torrent)) 46 | util.mark_dir_downloading_when_it_appears(filename) 47 | retvalue = client.transfer(filename) 48 | if retvalue != 0: 49 | # rsync failed 50 | util.mark_dir_error(filename) 51 | if retvalue == 20: 52 | util.report_error( 53 | "Download of %s stopped -- rsync process interrupted" 54 | % (filename,) 55 | ) 56 | util.report_message("Finishing by user request") 57 | return 2 58 | elif retvalue < 0: 59 | util.report_error( 60 | "Download of %s failed -- rsync process killed with signal %s" 61 | % (filename, -retvalue) 62 | ) 63 | util.report_message("Aborting") 64 | return 1 65 | else: 66 | util.report_error( 67 | "Download of %s failed -- rsync process exited with return status %s" 68 | % (filename, retvalue) 69 | ) 70 | util.report_message("Aborting") 71 | return 1 72 | # Rsync successful 73 | # mark file as downloaded 74 | try: 75 | open(download_lockfile, "w").write("Done") 76 | except OSError as e: 77 | if e.errno != 17: 78 | raise 79 | # report successful download 80 | fully_downloaded = True 81 | util.mark_dir_complete(filename) 82 | util.report_message("Download of %s complete" % filename) 83 | 84 | if run_processor_program is not None: 85 | try: 86 | retval = subprocess.call( 87 | [run_processor_program, filename], stdin=open(os.devnull) 88 | ) 89 | util.report_message( 90 | "Execution of %s %s exited with return value%s" 91 | % ( 92 | run_processor_program, 93 | filename, 94 | retval, 95 | ) 96 | ) 97 | except OSError as e: 98 | util.report_error( 99 | "Program %r is not executable: %s" % (run_processor_program, e) 100 | ) 101 | 102 | if remove_finished: 103 | if seeding: 104 | util.report_message( 105 | "%s from %s is complete but still seeding, not removing" 106 | % (filename, torrent) 107 | ) 108 | else: 109 | client.remove_remote_download(filename) 110 | try: 111 | os.unlink(download_lockfile) 112 | except OSError as e: 113 | if e.errno != errno.ENOENT: 114 | raise 115 | util.report_message("Removal of %s complete" % filename) 116 | 117 | 118 | sighandled = False 119 | 120 | 121 | def sighandler(signum, frame): 122 | global sighandled 123 | if not sighandled: 124 | util.report_message("Received signal %s" % signum) 125 | # temporarily immunize from signals 126 | oldhandler = signal.signal(signum, signal.SIG_IGN) 127 | os.killpg(0, signum) 128 | signal.signal(signum, oldhandler) 129 | sighandled = True 130 | 131 | 132 | def do_guarded(client, remove_finished, run_processor_program): 133 | global sighandled 134 | try: 135 | return download( 136 | client=client, 137 | remove_finished=remove_finished, 138 | run_processor_program=run_processor_program, 139 | ) 140 | except IOError as e: 141 | if e.errno == 4: 142 | pass 143 | else: 144 | traceback.print_exc() 145 | return 8 146 | except Misconfiguration as e: 147 | util.report_error(str(e)) 148 | traceback.print_exc() 149 | return 16 150 | except TemporaryMalfunction as e: 151 | util.report_error(str(e)) 152 | except ConnectionError as e: 153 | util.report_error(str(e)) 154 | except subprocess.CalledProcessError as e: 155 | if not sighandled: 156 | raise 157 | except Exception as e: 158 | raise 159 | 160 | 161 | def mainloop(): 162 | global sighandled 163 | 164 | parser = cli.get_parser() 165 | opts, args = parser.parse_args() 166 | util.set_verbose(not opts.quiet) 167 | 168 | # command line parameter checks 169 | if args: 170 | parser.error("This command accepts no arguments") 171 | 172 | if opts.lock and opts.lock_homedir: 173 | parser.error("--lock and --lock-homedir are mutually exclusive") 174 | 175 | if opts.run_every is not False: 176 | try: 177 | opts.run_every = int(opts.run_every) 178 | if opts.run_every < 1: 179 | raise ValueError 180 | except ValueError as e: 181 | parser.error("option --run-every must be a positive integer") 182 | 183 | # check config availability and load configuration 184 | try: 185 | config_fobject = open(config.default_filename) 186 | except (IOError, OSError) as e: 187 | util.report_error( 188 | "Cannot load configuration (%s) -- run configleecher first" % (e) 189 | ) 190 | sys.exit(EXIT_NOTCONFIGURED) 191 | cfg = config.load_config(config_fobject) 192 | local_download_dir = cfg.general.local_download_dir 193 | client = config.get_client(cfg) 194 | 195 | # check download dir and log file availability 196 | try: 197 | os.chdir(local_download_dir) 198 | except (IOError, OSError) as e: 199 | util.report_error( 200 | "Cannot change to download directory %r: %s" % (local_download_dir, e) 201 | ) 202 | sys.exit(EXIT_CHDIR) 203 | 204 | # check processor program availability 205 | if opts.run_processor_program is not None: 206 | program = util.executable_exists(opts.run_processor_program) 207 | if program is None: 208 | util.report_error( 209 | "Program %r is not executable" % opts.run_processor_program 210 | ) 211 | sys.exit(EXIT_INVALIDARGUMENT) 212 | 213 | if opts.logfile: 214 | try: 215 | open(opts.logfile, "a") 216 | except (IOError, OSError) as e: 217 | util.report_error("Cannot open log file %r: %s" % (opts.logfile, e)) 218 | sys.exit(EXIT_NOPERMISSION) 219 | 220 | # daemonization and preparation 221 | if opts.daemonize: 222 | logfile = opts.logfile if opts.logfile else ".torrentleecher.log" 223 | util.daemonize(logfile) 224 | # everything else depends on the local_download_dir being the cwd 225 | os.chdir(local_download_dir) 226 | elif opts.logfile: 227 | # non-daemonizing version of the above block 228 | os.close(1) 229 | os.close(2) 230 | sys.stdout = open(opts.logfile, "a") 231 | sys.stderr = sys.stderr 232 | os.dup2(1, 2) 233 | 234 | signal.signal(signal.SIGTERM, sighandler) 235 | signal.signal(signal.SIGINT, sighandler) 236 | 237 | # lockfile check 238 | if opts.lock or opts.lock_homedir: 239 | if opts.lock: 240 | torrentleecher_lockfile = ".torrentleecher.lock" 241 | else: 242 | torrentleecher_lockfile = os.path.join( 243 | os.path.expanduser("~"), ".torrentleecher.lock" 244 | ) 245 | result = util.lock(torrentleecher_lockfile) 246 | if not result: 247 | util.report_error("Another process has a lock on the download directory") 248 | sys.exit(0) 249 | 250 | dg = lambda: do_guarded( 251 | client, 252 | remove_finished=opts.remove_finished, 253 | run_processor_program=opts.run_processor_program, 254 | ) 255 | 256 | retvalue = 0 257 | if opts.run_every is False: 258 | util.report_message("Starting download of finished torrents") 259 | retvalue = dg() 260 | util.report_message("Download of finished torrents complete") 261 | else: 262 | util.report_message("Starting daemon for download of finished torrents") 263 | while not sighandled: 264 | retvalue = dg() 265 | if not sighandled: 266 | util.report_message("Sleeping %s seconds" % opts.run_every) 267 | for _ in range(opts.run_every): 268 | if not sighandled: 269 | time.sleep(1) 270 | util.report_message("Download of finished torrents complete") 271 | if sighandled: 272 | return 0 273 | return retvalue 274 | -------------------------------------------------------------------------------- /src/seedboxtools/test_util.py: -------------------------------------------------------------------------------- 1 | import seedboxtools.util as m 2 | 3 | def test_which(): 4 | # Should be true on most Unices. 5 | assert m.which("true").endswith("true") 6 | assert m.which("narostnaironstio") == None 7 | -------------------------------------------------------------------------------- /src/seedboxtools/uploader.py: -------------------------------------------------------------------------------- 1 | from seedboxtools import cli, config, util 2 | from requests.exceptions import ConnectionError 3 | import os 4 | import sys 5 | 6 | def main(): 7 | parser = cli.get_uploader_parser() 8 | args = parser.parse_args() 9 | 10 | # check config availability and load configuration 11 | try: 12 | config_fobject = open(config.default_filename) 13 | except (IOError, OSError) as e: 14 | util.report_error("Cannot load configuration (%s) -- run configleecher first" % (e)) 15 | sys.exit(7) 16 | cfg = config.load_config(config_fobject) 17 | client = config.get_client(cfg) 18 | 19 | # separate the wheat from the chaff 20 | # and when I say 'wheat' and 'chaff', I mean 'torrent files' and 'magnet links' 21 | is_magnet = lambda _: _.startswith("magnet:") 22 | is_torrent = lambda _: not is_magnet(_) 23 | 24 | # give all the torrents/magnets to the client 25 | failed = False 26 | for uploadable in args.torrents: 27 | try: 28 | if type(uploadable) is str: 29 | try: 30 | uploadable = uploadable.decode(sys.getfilesystemencoding()) 31 | except AttributeError: 32 | pass 33 | if is_magnet(uploadable): 34 | client.upload_magnet_link(uploadable) 35 | util.report_message("%s submitted to seedbox" % uploadable) 36 | elif is_torrent(uploadable): 37 | client.upload_torrent(uploadable) 38 | util.report_message("%s submitted to seedbox" % os.path.basename(uploadable)) 39 | else: 40 | raise ValueError("%s is not a torrent or a magnet link" % uploadable) 41 | except Exception as e: 42 | if args.debug: 43 | raise 44 | extramessage = "" 45 | if isinstance(e, ConnectionError): 46 | if e.args[0].errno == -2: 47 | extramessage = "\nCheck the hostname in your seedboxtools configuration." 48 | util.report_error("error while uploading %s: %s%s" % (uploadable, e, extramessage)) 49 | failed = True 50 | 51 | if failed: 52 | return 4 53 | else: 54 | return 0 55 | -------------------------------------------------------------------------------- /src/seedboxtools/util.py: -------------------------------------------------------------------------------- 1 | """Subprocess utilities for seedboxtools""" 2 | 3 | 4 | # subprocess utilities 5 | 6 | from subprocess import Popen, PIPE, STDOUT, call, check_call 7 | import os 8 | import sys 9 | import fcntl 10 | from threading import Thread 11 | import time 12 | 13 | 14 | def shell_quote(shellarg): 15 | return "'%s'" % shellarg.replace("'", r"'\''") 16 | 17 | 18 | def getstdout(cmdline): 19 | p = Popen(cmdline, stdout=PIPE) 20 | output = p.communicate()[0].decode("utf-8") 21 | if p.returncode != 0: 22 | raise Exception("Command %s return code %s" % (cmdline, p.returncode)) 23 | return output 24 | 25 | 26 | def getstdoutstderr( 27 | cmdline, inp=None 28 | ): # return stoud and stderr in a single string object 29 | p = Popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=STDOUT) 30 | output = p.communicate(inp)[0].decode("utf-8") 31 | if p.returncode != 0: 32 | raise Exception("Command %s return code %s" % (cmdline, p.returncode)) 33 | return output 34 | 35 | 36 | def passthru(cmdline: list[str]) -> int: 37 | return call(cmdline) # return status code, pass the outputs thru 38 | 39 | 40 | def rsync(source: str, destination: str) -> int: 41 | RSYNC_OPTS = ["-rtlDvzP", "--chmod=go+rX", "--chmod=u+rwX", "--executability"] 42 | cmdline = ["rsync"] + RSYNC_OPTS + ["--", source, destination] 43 | return passthru(cmdline) 44 | 45 | 46 | def quote_cmdline(cmdline): 47 | """Quote a command line in list form for SSH usage""" 48 | return " ".join(shell_quote(x) for x in cmdline) 49 | 50 | 51 | def ssh_getstdout(hostname, cmdline): 52 | cmd = quote_cmdline(cmdline) 53 | return getstdout( 54 | ["ssh", "-o", "BatchMode yes", "-o", "ForwardX11 no", hostname, cmd] 55 | ) 56 | 57 | 58 | def ssh_passthru(hostname, cmdline): 59 | cmd = quote_cmdline(cmdline) 60 | return passthru( 61 | ["ssh", "-o", "BatchMode yes", "-o", "ForwardX11 no", hostname, cmd] 62 | ) 63 | 64 | 65 | def firstcomponent(path): 66 | if not path: 67 | raise ValueError("path cannot be empty: %r" % path) 68 | oldpath = path 69 | while True: 70 | path = os.path.dirname(path) 71 | if not path or os.path.dirname(path) == path: 72 | break 73 | oldpath = path 74 | return oldpath 75 | 76 | 77 | # unix process utilities 78 | 79 | 80 | def daemonize(logfile=os.devnull): 81 | """Detach a process from the controlling terminal and run it in the 82 | background as a daemon. 83 | """ 84 | 85 | pwd = os.getcwd() 86 | logfile = os.path.join(pwd, logfile) 87 | 88 | try: 89 | pid = os.fork() 90 | except OSError as e: 91 | raise Exception("%s [%d]" % (e.strerror, e.errno)) 92 | 93 | if pid == 0: # The first child. 94 | os.setsid() 95 | try: 96 | pid = os.fork() # Fork a second child. 97 | except OSError as e: 98 | raise Exception("%s [%d]" % (e.strerror, e.errno)) 99 | 100 | if pid == 0: # The second child. 101 | os.chdir("/") 102 | else: 103 | # exit() or _exit()? See below. 104 | os._exit(0) # Exit parent (the first child) of the second child. 105 | else: 106 | os._exit(0) # Exit parent of the first child. 107 | 108 | import resource # Resource usage information. 109 | 110 | maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1] 111 | if maxfd == resource.RLIM_INFINITY: 112 | maxfd = 1024 113 | 114 | # Iterate through and close all file descriptors. 115 | for f in [sys.stderr, sys.stdout, sys.stdin]: 116 | try: 117 | f.flush() 118 | except BaseException: 119 | pass 120 | 121 | for fd in range(0, 2): 122 | try: 123 | os.close(fd) 124 | except OSError: 125 | pass 126 | 127 | for f in [sys.stderr, sys.stdout, sys.stdin]: 128 | try: 129 | f.close() 130 | except BaseException: 131 | pass 132 | 133 | sys.stdin = open("/dev/null", "r") 134 | sys.stdout = open(logfile, "a") 135 | sys.stderr = open(logfile, "a") 136 | os.dup2(1, 2) 137 | 138 | return 0 139 | 140 | 141 | def lock(lockfile): 142 | global f 143 | try: 144 | fcntl.lockf(f.fileno(), fcntl.LOCK_UN) 145 | f.close() 146 | except BaseException: 147 | pass 148 | try: 149 | f = open(lockfile, "w") 150 | fcntl.lockf(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) 151 | except IOError as e: 152 | if e.errno == 11: 153 | return False 154 | else: 155 | raise 156 | return True 157 | 158 | 159 | # icon-setting utilities 160 | 161 | 162 | def set_dir_icon(filename, iconname): 163 | text = ( 164 | """[Desktop Entry] 165 | Icon = % s 166 | """ 167 | % iconname 168 | ) 169 | try: 170 | open(os.path.join(filename, ".directory"), "w").write(text) 171 | except BaseException: 172 | pass 173 | 174 | 175 | def mark_dir_complete(filename): 176 | set_dir_icon(filename, "dialog-ok-apply.png") 177 | 178 | 179 | def mark_dir_downloading(filename): 180 | set_dir_icon(filename, "document-open-remote.png") 181 | 182 | 183 | def mark_dir_error(filename): 184 | set_dir_icon(filename, "dialog-cancel.png") 185 | 186 | 187 | def mark_dir_downloading_when_it_appears(filename): 188 | isdir = os.path.isdir 189 | tiem = time.time 190 | sleep = time.sleep 191 | starttime = tiem() 192 | 193 | def dowatch(): 194 | while not isdir(filename) and tiem() - starttime < 600: 195 | sleep(0.1) 196 | if isdir(filename): 197 | mark_dir_downloading(filename) 198 | 199 | tehrd = Thread(target=dowatch) 200 | tehrd.setDaemon(True) 201 | tehrd.start() 202 | 203 | 204 | # message reporting utilities 205 | 206 | 207 | def which(program): 208 | import os 209 | 210 | def is_exe(fpath): 211 | return os.path.isfile(fpath) and os.access(fpath, os.X_OK) 212 | 213 | fpath, fname = os.path.split(program) 214 | if fpath: 215 | if is_exe(program): 216 | return program 217 | else: 218 | for path in os.environ["PATH"].split(os.pathsep): 219 | path = path.strip('"') 220 | exe_file = os.path.join(path, program) 221 | if is_exe(exe_file): 222 | return exe_file 223 | 224 | return None 225 | 226 | 227 | def notify_send(message, transient=True): 228 | cmd = [ 229 | "notify-send", 230 | "-a", 231 | os.path.basename(sys.argv[0]), 232 | "Seedbox tools", 233 | message, 234 | ] 235 | if transient: 236 | cmd.append("--hint=int:transient:1") 237 | return check_call(cmd) 238 | 239 | 240 | _use_linux_gui = None 241 | 242 | 243 | def use_linux_gui(): 244 | global _use_linux_gui 245 | if _use_linux_gui is None: 246 | if os.environ.get("DISPLAY", None) and which("notify-send"): 247 | _use_linux_gui = True 248 | else: 249 | _use_linux_gui = False 250 | return _use_linux_gui 251 | 252 | 253 | verbose = True 254 | 255 | 256 | def set_verbose(v): 257 | global verbose 258 | verbose = v 259 | 260 | 261 | def report_message(text): 262 | global verbose 263 | if verbose: 264 | if use_linux_gui(): 265 | notify_send(text.capitalize()) 266 | print(text, file=sys.stderr) 267 | 268 | 269 | def report_error(text): 270 | if use_linux_gui(): 271 | notify_send(text.capitalize(), transient=False) 272 | print(text, file=sys.stderr) 273 | 274 | 275 | def executable_exists(path): 276 | """Checks that an executable is executable, along PATH 277 | or, if specified as relative or absolute path name, directly.""" 278 | envpath = os.getenv("PATH", "/usr/local/bin:/usr/bin:/bin") 279 | PATH = envpath.split(os.path.pathsep) 280 | for d in PATH: 281 | if os.path.sep in path: 282 | fullname = path 283 | else: 284 | fullname = path.join(d, path) 285 | if os.access(fullname, os.X_OK): 286 | return fullname 287 | return None 288 | --------------------------------------------------------------------------------