├── .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 |
--------------------------------------------------------------------------------