2 Commits

20 changed files with 439 additions and 828 deletions

View File

@@ -1,90 +0,0 @@
---
kind: pipeline
type: docker
name: Build and test amd64
platform:
os: linux
arch: amd64
steps:
- name: Build source
image: python:3.10
commands:
- make build
- name: Install
image: python:3.10
commands:
- make dist
- pip3 install -r requirements.txt
- pip3 install dist/*.whl
# - pipenv install pytest
# - name: Run tests
# image: python:3.10
# commands:
# pipenv run pytest
---
kind: pipeline
type: docker
name: Publish package to PyPi
platform:
os: linux
arch: amd64
steps:
- name: Newer version to publish?
image: python:3.10
commands:
- pip3 install delugeClient-kevin -q -q
- bash publish_version?.sh
- name: PyPi verify
image: python:3.10
commands:
- make dist
- pip3 install twine
- twine check dist/*
- name: PyPi test publish
image: python:3.10
environment:
TWINE_USERNAME:
from_secret: TWINE_USERNAME
TWINE_PASSWORD:
from_secret: TWINE_TEST_PASSWORD
commands:
- make dist
- pip3 install twine
- twine upload --repository-url https://test.pypi.org/legacy/ dist/*
- name: PyPi publish
image: python:3.10
environment:
TWINE_USERNAME:
from_secret: TWINE_USERNAME
TWINE_PASSWORD:
from_secret: TWINE_PASSWORD
commands:
- make dist
- pip3 install twine
- twine upload dist/*
depends_on:
- Build and test amd64
trigger:
branch:
- master
event:
exclude:
- pull_request
---
kind: signature
hmac: 60604a21f35e11d078d5d381bbea8e25b903175c018ba9e6f4a4379285e89883
...

2
.gitignore vendored
View File

@@ -1,5 +1,3 @@
deluge_cli.log
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

View File

@@ -1,22 +0,0 @@
.PHONY: clean
binaries=dist build
install:
python3 setup.py install
build:
python3 setup.py build
tarball:
python3 setup.py sdist
wheel:
python3 setup.py bdist_wheel
dist: tarball wheel
upload: clean dist
twine upload dist/*
clean:
rm -rf $(binaries)

123
README.md
View File

@@ -4,20 +4,23 @@
<h4 align="center"> A easy to use Deluge CLI that can connect to Deluge RPC (even over ssh) written entirely in python.</h4>
| Tested version | PyPi package | License |
|:--------|:------|:------|
| [![PyVersion](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) | [![PyPI](https://img.shields.io/pypi/v/delugeClient_kevin)](https://pypi.org/project/delugeClient_kevin/) |[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
| Drone CI | Known vulnerabilities |
|:--------|:------|
| [![Build Status](https://drone.schleppe.cloud/api/badges/KevinMidboe/delugeClient/status.svg)](https://drone.schleppe.cloud/KevinMidboe/delugeClient) | [![Known Vulnerabilities](https://snyk.io/test/github/kevinmidboe/delugeClient/badge.svg?targetFile=requirements.txt)](https://snyk.io/test/github/kevinmidboe/delugeClient?targetFile=requirements.txt)
<p align="center">
<a href="https://snyk.io/test/github/kevinmidboe/delugeclient?targetFile=requirements.txt">
<img src="https://snyk.io/test/github/kevinmidboe/delugeclient/badge.svg?targetFile=requirements.txt" alt="Known Vulnerabilities" data-canonical-src="https://snyk.io/test/github/kevinmidboe/delugeclient?targetFile=requirements.txt" style="max-width:100%;">
</a>
<a href="https://opensource.org/licenses/MIT">
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="">
</a>
</p>
<p align="center">
<a href="#abstract">Abstract</a>
<a href="#install">Install</a>
<a href="#usage">Usage</a>
<a href="#setup_virtualenv">Setup Virtual Environment</a>
<a href="#setup_virtualenv">Setup virtualenv</a>
<a href="#configure">Configure</a>
<a href="#installation">Install dependencies</a>
<a href="#usage">Usage</a>
<a href="#running">Running</a>
<a href="#contributing">Contributing</a>
</p>
@@ -25,51 +28,6 @@
## <a name="abstract"></a> Abstract
Create a deluge python client for interfacing with deluge for common tasks like listing, adding, removing and setting download directory for torrents.
## <a name="install"></a> Install
Install from source:
```bash
python3 setup.py install
```
Install from pip:
```bash
pip3 install delugeClient-kevin
```
## <a name="usage"></a> Usage
View delugeClient cli options with `delugeclient --help`:
```
Usage: python -m delugeclient [OPTIONS] COMMAND [ARGS]...
╭─ Options ───────────────────────────────────────────────────────────────╮
│ --debug Set log level to debug │
│ --info Set log level to info │
│ --warning Set log level to warning │
│ --error Set log level to error │
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell │
│ --help Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ Commands ──────────────────────────────────────────────────────────────╮
│ add Add magnet torrent │
│ disk Get free disk space │
│ get Get torrent by id or hash │
│ ls List all torrents │
│ remove Remove torrent by id or hash │
│ rm Remove torrent by name │
│ search Search for string segment in torrent name │
│ toggle Toggle torrent download state │
│ version Print package version │
╰─────────────────────────────────────────────────────────────────────────╯
```
### Running from source
Run from source for fun or during development using module flag:
```
python3 -m delugeClient --help
```
## <a name="setup_virtualenv"></a> Setup Virtual Environment
Virtual environment allows us to create a local environment for the requirements needed. Because pip does not download packages already downloaded to your system, we can use virtualenv to save our packages in the project folder.
@@ -83,7 +41,7 @@ To install virtualenv, simply run:
```
### Virtualenv setup
### Usage
After you have downloaded this project go to it in your terminal by going to the folder you downloaded and typing the following:
@@ -94,12 +52,14 @@ After you have downloaded this project go to it in your terminal by going to the
The to setup a virtual environment enter this:
```
$ virtualenv -p python3.10 env
$ virtualenv -p python3.6 env
```
> If you get an error now it might be because you don't have python3.10, please make sure you have python version 3.10 if else you can download it from [here](https://www.python.org/downloads/)
> If you get an error now it might be because you don't have python3.6, please make sure you have python version 3.6 if else you can download it from [here](https://www.python.org/downloads/)
First we navigate to the folder we downloaded.
Then we use the ```virtualenv``` command to create a ```env``` subdirectory in our project. This is where pip will download everything to and where we can add other specific python versions. Then we need to *activate* our virtual environment by doing:
```
@@ -136,9 +96,54 @@ Then you need to change the HOST and PORT to reflect the address for your deluge
$ cat /home/USER/.config/deluge/auth
```
## <a name="install"></a> Install Required Dependencies
Now that we have our virutalenv set up and activated we want to install all the necessary packages listed in `requirements.txt`. To install it's dependencies do the following:
```
$ pip install -r requirements.txt
```
Now we have our neccessary packages installed!
## <a name="usage"></a> Usage
```
Custom delugeRPC client
Usage:
deluge_cli add MAGNET [DIR] [--debug | --warning | --error]
deluge_cli get TORRENT
deluge_cli ls [--downloading | --seeding | --paused]
deluge_cli toggle TORRENT
deluge_cli rm TORRENT [--debug | --warning | --error]
deluge_cli (-h | --help)
deluge_cli --version
Arguments:
MAGNET Magnet link to add
DIR Directory to save to
TORRENT A selected torrent
Options:
-h --help Show this screen
--version Show version
--debug Print all debug log
--warning Print only logged warnings
--error Print error messages (Error/Warning)
```
### <a name="running"></a> Running
To interface with deluged :
```
$ ./deluge_cli.py ls
```
## <a name="contributing"></a> Contributing
- Fork it!
- Create your feature branch: git checkout -b my-new-feature
- Commit your changes: git commit -am 'Add some feature'
- Push to the branch: git push origin my-new-feature
- Submit a pull request
- Submit a pull request

10
config.ini Normal file
View File

@@ -0,0 +1,10 @@
[Deluge]
HOST = YOUR_DELUGE_HOST
PORT = YOUR_DELUGE_PORT
USER = YOUR_DELUGE_USER
PASSWORD = YOUR_DELUGE_PASSWORD
[ssh]
HOST = YOUR_DELUGE_SERVER_IP
USER = YOUR_SSH_USER
PKEY = YOUR_SSH_PRIVATE_KEY_DIRECTORY

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env python3.10
# -*- encoding: utf-8 -*-
from sys import path
from os.path import dirname, join, abspath
SCRIPT_DIR = dirname(abspath(__file__))
path.append(dirname(SCRIPT_DIR))
import logging
from delugeClient.utils import BASE_DIR
from delugeClient.deluge import Deluge
def addHandler(handler):
handler.setFormatter(formatter)
logger.addHandler(handler)
logger = logging.getLogger('deluge_cli')
logger.setLevel(logging.DEBUG)
fh = logging.FileHandler(join(BASE_DIR, 'deluge_cli.log'))
formatter = logging.Formatter('%(asctime)s| %(levelname)s | %(message)s')
addHandler(fh)

View File

@@ -1,176 +0,0 @@
#!/usr/bin/env python3.10
import os
import sys
import signal
import logging
import typer
from pprint import pprint
from delugeClient.deluge import Deluge
from delugeClient.utils import ColorizeFilter, BASE_DIR, validHash, convertFilesize
from delugeClient.__version__ import __version__
from delugeClient.__init__ import addHandler
ch = logging.StreamHandler()
ch.addFilter(ColorizeFilter())
addHandler(ch)
logger = logging.getLogger('deluge_cli')
app = typer.Typer()
deluge = None
def signal_handler(signal, frame):
"""
Handle exit by Keyboardinterrupt
"""
global deluge
del deluge
logger.info('\nGood bye!')
sys.exit(1)
def handleKeyboardInterrupt():
signal.signal(signal.SIGINT, signal_handler)
def printResponse(response, json=False):
try:
if json:
if isinstance(response, list):
print('[{}]'.format(','.join([t.toJSON() for t in response])))
else:
print(response.toJSON())
elif isinstance(response, list):
for el in response:
print(el)
elif response:
print(response)
except KeyError as error:
logger.error('Unexpected error while trying to print')
raise error
@app.command()
def add(magnet: str, json: bool = typer.Option(False, help="Print as json")):
'''
Add magnet torrent
'''
logger.info('Add command selected')
logger.debug(magnet)
response = deluge.add(magnet)
if validHash(response):
torrent = deluge.get(response)
printResponse(torrent, json)
else:
logger.info('Unable to add torrent')
@app.command()
def ls(json: bool = typer.Option(False, help="Print as json")):
'''
List all torrents
'''
logger.info('List command selected')
response = deluge.get_all()
if response is None:
logger.info('No torrents found')
return
printResponse(response, json)
@app.command()
def get(id: str, json: bool = typer.Option(False, help="Print as json")):
'''
Get torrent by id or hash
'''
logger.info('Get command selected for id: {}'.format(id))
if not validHash(id):
return logger.info("Id is not valid")
response = deluge.get(id)
printResponse(response, json)
@app.command()
def toggle(id: str):
'''
Toggle torrent download state
'''
logger.info('Toggle command selected for id: {}'.format(id))
if not validHash(id):
return logger.info("Id is not valid")
deluge.toggle(id)
torrent = deluge.get(id)
printResponse(torrent)
@app.command()
def search(query: str, json: bool = typer.Option(False, help="Print as json")):
'''
Search for string segment in torrent name
'''
logger.info('Search command selected with query: {}'.format(query))
response = deluge.search(query)
printResponse(response, json)
@app.command()
def rm(name: str, destroy: bool = typer.Option(False, help="Remove torrent by name")):
'''
Remove torrent by name
'''
logger.info('Removing torrent with name: {}, destroy flag: {}'.format(name, destroy))
response = deluge.removeByName(name, destroy)
@app.command()
def remove(id: str, destroy: bool = typer.Option(False, help="Remove torrent by id")):
'''
Remove torrent by id or hash
'''
logger.info('Removing torrent with id: {}, destroy flag: {}'.format(id, destroy))
if not validHash(id):
return logger.info("Id is not valid")
response = deluge.remove(id, destroy)
@app.command()
def disk():
'''
Get free disk space
'''
response = deluge.freeSpace()
if response == None or not isinstance(response, int):
logger.error("Unable to get available disk space")
return
print(convertFilesize(response))
@app.command()
def version():
'''
Print package version
'''
print(__version__)
# Runs before any command
@app.callback()
def defaultOptions(debug: bool = typer.Option(False, '--debug', help='Set log level to debug'), info: bool = typer.Option(False, '--info', help='Set log level to info'), warning: bool = typer.Option(False, '--warning', help='Set log level to warning'), error: bool = typer.Option(False, '--error', help='Set log level to error')):
ch.setLevel(logging.INFO)
if '--json' in sys.argv:
ch.setLevel(logging.CRITICAL)
elif error == True:
ch.setLevel(logging.ERROR)
elif warning == True:
ch.setLevel(logging.WARNING)
elif info == True:
ch.setLevel(logging.INFO)
elif debug == True:
ch.setLevel(logging.DEBUG)
# Initiate deluge
global deluge
deluge = Deluge()
def main():
app()
del deluge
if __name__ == '__main__':
handleKeyboardInterrupt()
main()

View File

@@ -1,4 +0,0 @@
__version__ = '0.3.5'
if __name__ == '__main__':
print(__version__)

View File

@@ -1,11 +0,0 @@
[deluge]
host=
port=58846
user=
password=
[ssh]
host=
user=
password=
pkey=

View File

@@ -1,220 +0,0 @@
#!/usr/bin/env python3.10
import os
import re
import sys
import logging
import requests
import logging.config
from deluge_client import DelugeRPCClient, FailedToReconnectException
from sshtunnel import SSHTunnelForwarder, BaseSSHTunnelForwarderError
from delugeClient.utils import getConfig, BASE_DIR
from delugeClient.torrent import Torrent
logger = logging.getLogger('deluge_cli')
def split_words(string):
logger.debug('Splitting input: {} (type: {}) with split_words'.format(string, type(string)))
return re.findall(r"[\w\d']+", string.lower())
def responseToString(response=None):
try:
response = response.decode('utf-8')
except (UnicodeDecodeError, AttributeError):
pass
return response
class Deluge(object):
"""docstring for ClassName"""
def __init__(self):
config = getConfig()
self.host = config['deluge']['host']
self.port = int(config['deluge']['port'])
self.user = config['deluge']['user']
self.password = config['deluge']['password']
self.ssh_host = config['ssh']['host']
self.ssh_user = config['ssh']['user']
self.ssh_pkey = config['ssh']['pkey']
self.ssh_password = config['ssh']['password']
try:
self._connect()
except FailedToReconnectException:
logger.error("Unable to connect to deluge, make sure it's running")
sys.exit(1)
except ConnectionRefusedError:
logger.error("Unable to connect to deluge, make sure it's running")
sys.exit(1)
except BaseException as error:
logger.error("Unable to connect to deluge, make sure it's running")
if 'nodename nor servname provided' in str(error):
sys.exit(1)
raise error
def freeSpace(self):
return self.client.call('core.get_free_space')
def parseResponse(self, response):
torrents = []
for key in response:
torrent = response[key]
torrents.append(Torrent.fromDeluge(torrent))
return torrents
def establishSSHTunnel(self):
logger.debug('Checking if script on same server as deluge RPC')
if self.password is not None:
self.tunnel = SSHTunnelForwarder(self.ssh_host, ssh_username=self.ssh_user, ssh_password=self.ssh_password,
local_bind_address=('localhost', self.port), remote_bind_address=('localhost', self.port))
elif self.pkey is not None:
self.tunnel = SSHTunnelForwarder(self.ssh_host, ssh_username=self.ssh_user, ssh_pkey=self.ssh_pkey,
local_bind_address=('localhost', self.port), remote_bind_address=('localhost', self.port))
else:
logger.error("Either password or private key path must be set in config.")
return
try:
self.tunnel.start()
except BaseSSHTunnelForwarderError as sshError:
logger.warning("SSH host {} online, check your connection".format(self.ssh_host))
return
def _call(self, command, *args):
try:
return self.client.call(command, *args)
except ConnectionRefusedError as error:
logger.error("Unable to run command, connection to deluge seems to be offline")
except FailedToReconnectException as error:
logger.error("Unable to run command, reconnection to deluge failed")
def _connect(self):
if self.host != 'localhost' and self.host is not None:
self.establishSSHTunnel()
self.client = DelugeRPCClient(self.host, self.port, self.user, self.password)
self.client.connect()
def add(self, url):
response = None
if (url.startswith('magnet')):
response = self._call('core.add_torrent_magnet', url, {})
elif url.startswith('http'):
magnet = self.getMagnetFromFile(url)
response = self._call('core.add_torrent_magnet', magnet, {})
return responseToString(response)
def get_all(self, _filter=None):
response = None
if (type(_filter) is list and len(_filter)):
if ('seeding' in _filter):
response = self._call('core.get_torrents_status', {'state': 'Seeding'}, [])
elif ('downloading' in _filter):
response = self._call('core.get_torrents_status', {'state': 'Downloading'}, [])
elif ('paused' in _filter):
response = self._call('core.get_torrents_status', {'paused': 'true'}, [])
else:
response = self.client.call('core.get_torrents_status', {}, [])
if response == {}:
return None
return self.parseResponse(response)
def search(self, query):
allTorrents = self.get_all()
torrentNamesMatchingQuery = []
if len(allTorrents):
for torrent in allTorrents:
if query in torrent.name.lower():
torrentNamesMatchingQuery.append(torrent)
allTorrents = torrentNamesMatchingQuery
return allTorrents
q_list = split_words(query)
return [ t for t in self.get_all() if (set(q_list) <= set(split_words(t.name))) ]
def get(self, id):
response = self._call('core.get_torrent_status', id, {})
if response == {}:
logger.warning('No torrent with id: {}'.format(id))
return None
return Torrent.fromDeluge(response)
def toggle(self, id):
torrent = self.get(id)
if torrent is None:
return
if (torrent.paused):
response = self._call('core.resume_torrent', [id])
else:
response = self._call('core.pause_torrent', [id])
return responseToString(response)
def removeByName(self, name, destroy=False):
matches = list(filter(lambda t: t.name == name, self.get_all()))
logger.info('Matches for {}: {}'.format(name, matches))
if len(matches) > 1:
raise ValueError('Multiple files found matching key. Unable to remove.')
elif len(matches) == 1:
torrent = matches[0]
response = self.remove(torrent.key, destroy)
logger.debug('Response rm: {}'.format(str(response)))
if response == False:
raise AttributeError('Unable to remove torrent.')
return responseToString(response)
else:
logger.error('ERROR. No torrent found with that name.')
def remove(self, id, destroy=False):
try:
response = self.client.call('core.remove_torrent', id, destroy)
logger.debug('Response from remove: {}'.format(str(response)))
return responseToString(response)
except BaseException as error:
if 'torrent_id not in session' in str(error):
logger.info('Unable to remove. No torrent with matching id')
return None
raise error
def filterOnValue(self, torrents, value):
filteredTorrents = []
for t in torrents:
value_template = {'key': None, 'name': None, value: None}
value_template['key'] = t.key
value_template['name'] = t.name
value_template[value] = getattr(t, value)
filteredTorrents.append(value_template)
return filteredTorrents
def __del__(self):
if hasattr(self, 'client') and self.client.connected:
logger.debug('Disconnected deluge rpc')
self.client.disconnect()
if hasattr(self, 'tunnel') and self.tunnel.is_active:
logger.debug('Closing ssh tunnel')
self.tunnel.stop(True)
def getMagnetFromFile(self, url):
logger.info('File url found, fetching magnet.')
r = requests.get(url, allow_redirects=False)
magnet = r.headers['Location']
logger.info('Found magnet: {}.'.format(magnet))
return magnet

View File

@@ -1,48 +0,0 @@
import json
import logging
from distutils.util import strtobool
from delugeClient.utils import convert
logger = logging.getLogger('deluge_cli')
class Torrent(object):
def __init__(self, key, name, progress, eta, save_path, state, paused, finished, files):
super(Torrent, self).__init__()
self.key = key
self.name = name
self.progress = "{0:.2f}".format(float(progress))
self.eta = eta
self.save_path = save_path
self.state = state
self.paused = paused
self.finished = finished
self.files = list(files)
def isFolder(self):
return len(self.files) > 1
def toBool(self, value):
return True if strtobool(value) else False
@classmethod
def fromDeluge(cls, d):
# Receive a dict with byte values, convert all elements to string values
d = convert(d)
toBool = lambda val: True if strtobool(val) else False
return cls(d['hash'], d['name'], d['progress'], d['eta'], d['save_path'], d['state'],
toBool(d['paused']), toBool(d['is_finished']), d['files'])
def toJSON(self, files=False):
torrentDict = {'key': self.key, 'name': self.name, 'progress': self.progress, 'eta': self.eta,
'save_path': self.save_path, 'state': self.state, 'paused': self.paused,
'finished': self.finished, 'files': self.files, 'is_folder': self.isFolder()}
if (files is False):
del torrentDict['files']
return json.dumps(torrentDict)
def __str__(self):
return "{} Progress: {}% ETA: {} State: {} Paused: {}".format(
self.name[:59].ljust(60), self.progress.rjust(5), self.eta.rjust(11), self.state.ljust(12), self.paused)

View File

@@ -1,98 +0,0 @@
#!/usr/bin/env python3.10
# -*- coding: utf-8 -*-
# @Author: kevinmidboe
# @Date: 2018-04-17 19:55:38
# @Last Modified by: KevinMidboe
# @Last Modified time: 2018-05-04 00:04:25
import os
import sys
import json
import shutil
import logging
import colored
import configparser
from pprint import pprint
from colored import stylize
__all__ = ('ColorizeFilter', )
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
logger = logging.getLogger('deluge_cli')
def checkConfigExists():
user_config_dir = os.path.expanduser("~") + "/.config/delugeClient"
config_dir = os.path.join(user_config_dir, 'config.ini')
def getConfig():
"""
Read path and get configuartion file with site settings
:return: config settings read from 'config.ini'
:rtype: configparser.ConfigParser
"""
config = configparser.ConfigParser()
user_config_dir = os.path.expanduser("~") + "/.config/delugeClient"
config_dir = os.path.join(user_config_dir, 'config.ini')
if not os.path.isfile(config_dir):
defaultConfig = os.path.join(BASE_DIR, 'default_config.ini')
logger.error('Missing config! Moved default.config.ini to {}.\nOpen this file and set all varaibles!'.format(config_dir))
os.makedirs(user_config_dir, exist_ok=True)
shutil.copyfile(defaultConfig, config_dir)
config.read(config_dir)
requiredParameters = [('deluge host', config['deluge']['host']), ('deluge port', config['deluge']['port']),
('deluge user', config['deluge']['user']), ('deluge password', config['deluge']['password']),
('ssh password', config['ssh']['user'])]
for key, value in requiredParameters:
if value == '':
logger.error('Missing value for variable: "{}" in config: \
"{}.'.format(key, user_config_dir))
exit(1)
return config
class ColorizeFilter(logging.Filter):
"""
Class for setting specific colors to levels of severity for log output
"""
color_by_level = {
10: 'cyan',
20: 'white',
30: 'orange_1',
40: 'red'
}
def filter(self, record):
record.raw_msg = record.msg
color = self.color_by_level.get(record.levelno)
if color:
record.msg = stylize(record.msg, colored.fg(color))
return True
def convert(data):
if isinstance(data, bytes): return data.decode('utf-8')
if isinstance(data, dict): return dict(map(convert, data.items()))
if isinstance(data, tuple): return map(convert, data)
json_data = json.dumps(data)
return json_data
def validHash(hash: str):
try:
return hash and len(hash) == 40 and int(hash, 16)
except ValueError:
return False
import math
def convertFilesize(size_bytes):
if size_bytes == None or size_bytes == 0:
return "0B"
size_name = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
i = int(math.floor(math.log(size_bytes, 1024)))
p = math.pow(1024, i)
s = round(size_bytes / p, 2)
return "%s %s" % (s, size_name[i])

1
deluge_cli.log Normal file
View File

@@ -0,0 +1 @@

308
deluge_cli.py Executable file
View File

@@ -0,0 +1,308 @@
#!/usr/bin/env python3.6
"""Custom delugeRPC client
Usage:
deluge_cli add MAGNET [DIR] [--debug | --warning | --error]
deluge_cli search NAME
deluge_cli get TORRENT
deluge_cli ls [--downloading | --seeding | --paused]
deluge_cli toggle TORRENT
deluge_cli progress
deluge_cli rm NAME [--destroy | --debug | --warning | --error]
deluge_cli (-h | --help)
deluge_cli --version
Arguments:
MAGNET Magnet link to add
DIR Directory to save to
TORRENT A selected torrent
Options:
-h --help Show this screen
--version Show version
--debug Print all debug log
--warning Print only logged warnings
--error Print error messages (Error/Warning)
--destroy When removing choose to remove file on disk
"""
import argparse
import os
import sys
import re
import signal
import socket
import logging
import logging.config
import configparser
from distutils.util import strtobool
from pprint import pprint
from deluge_client import DelugeRPCClient
from sshtunnel import SSHTunnelForwarder
from docopt import docopt
from utils import ColorizeFilter, convert
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
logger = logging.getLogger('deluge_cli')
logger.setLevel(logging.DEBUG)
fh = logging.FileHandler(os.path.join(BASE_DIR, 'deluge_cli.log'))
fh.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.ERROR)
formatter = logging.Formatter('%(asctime)s %(levelname)8s %(name)s | %(message)s')
fh.setFormatter(formatter)
logger.addHandler(fh)
logger.addHandler(ch)
logger.addFilter(ColorizeFilter())
def getConfig():
"""
Read path and get configuartion file with site settings
:return: config settings read from 'config.ini'
:rtype: configparser.ConfigParser
"""
config = configparser.ConfigParser()
config_dir = os.path.join(BASE_DIR, 'config.ini')
config.read(config_dir)
config_values = list(dict(config.items('Deluge')).values())
config_values.extend(list(dict(config.items('ssh')).values()))
if any(value.startswith('YOUR') for value in config_values):
raise ValueError('Please set variables in config.ini file.')
return config
def split_words(string):
logger.debug('Splitting input: {} (type: {}) with split_words'.format(string, type(string)))
return re.findall(r"[\w\d']+", string.lower())
class Deluge(object):
"""docstring for ClassName"""
def __init__(self):
config = getConfig()
self.host = config['Deluge']['HOST']
self.port = int(config['Deluge']['PORT'])
self.user = config['Deluge']['USER']
self.password = config['Deluge']['PASSWORD']
self.ssh_host = config['ssh']['HOST']
self.ssh_user = config['ssh']['USER']
self.ssh_pkey = config['ssh']['PKEY']
self._connect()
def parseResponse(self, response):
torrents = []
for key in response:
torrent = response[key]
torrents.append(Torrent.fromDeluge(torrent))
return torrents
def _connect(self):
logger.info('Checking if script on same server as deluge RPC')
if (socket.gethostbyname(socket.gethostname()) != self.host):
self.tunnel = SSHTunnelForwarder(self.ssh_host, ssh_username=self.ssh_user, ssh_pkey=self.ssh_pkey,
local_bind_address=('localhost', self.port), remote_bind_address=('localhost', self.port))
self.tunnel.start()
self.client = DelugeRPCClient(self.host, self.port, self.user, self.password)
self.client.connect()
def add(self, url):
if (url.startswith('magnet')):
return self.client.call('core.add_torrent_magnet', url, {})
def get_all(self, _filter=None):
if (type(_filter) is list and len(_filter)):
if ('seeding' in _filter):
response = self.client.call('core.get_torrents_status', {'state': 'Seeding'}, [])
elif ('downloading' in _filter):
response = self.client.call('core.get_torrents_status', {'state': 'Downloading'}, [])
elif ('paused' in _filter):
response = self.client.call('core.get_torrents_status', {'paused': 'true'}, [])
else:
response = self.client.call('core.get_torrents_status', {}, [])
return self.parseResponse(response)
def search(self, query):
q_list = split_words(query)
return [ t for t in self.get_all() if (set(q_list) <= set(split_words(t.name))) ]
def get(self, id):
response = self.client.call('core.get_torrent_status', id, {})
return Torrent.fromDeluge(response)
def togglePaused(self, id):
torrent = self.get(id)
if (torrent.paused):
response = self.client.call('core.resume_torrent', [id])
else:
response = self.client.call('core.pause_torrent', [id])
print('Response:', response)
def remove(self, name, destroyFiles=False):
matches = list(filter(lambda t: t.name == name, self.get_all()))
logger.info('Matches for {}: {}'.format(name, matches))
if (len(matches) > 1):
raise ValueError('Multiple files found matching key. Unable to remove.')
elif (len(matches) == 1):
torrent = matches[0]
response = self.client.call('core.remove_torrent', torrent.key, destroyFiles)
logger.info('Response: {}'.format(str(response)))
if (response == False):
raise AttributeError('Unable to remove torrent.')
return response
else:
logger.error('ERROR. No torrent found with that name.')
def filterOnValue(self, torrents, value):
filteredTorrents = []
for t in torrents:
value_template = {'key': None, 'name': None, value: None}
value_template['key'] = t.key
value_template['name'] = t.name
value_template[value] = getattr(t, value)
filteredTorrents.append(value_template)
return filteredTorrents
def progress(self):
attributes = ['progress', 'eta', 'state', 'finished']
all_torrents = self.get_all()
torrents = []
for i, attribute in enumerate(attributes):
if i < 1:
torrents = self.filterOnValue(all_torrents, attribute)
continue
torrents = [dict(e, **v) for e,v in zip(torrents, self.filterOnValue(all_torrents, attribute))]
return torrents
def __del__(self):
if hasattr(self, 'tunnel'):
logger.info('Closing ssh tunnel')
self.tunnel.stop()
class Torrent(object):
def __init__(self, key, name, progress, eta, save_path, state, paused, finished, files):
super(Torrent, self).__init__()
self.key = key
self.name = name
self.progress = "{0:.2f}".format(float(progress))
self.eta = eta
self.save_path = save_path
self.state = state
self.paused = paused
self.finished = finished
self.files = list(files)
def isFolder(self):
return len(self.files) > 1
def toBool(self, value):
return True if strtobool(value) else False
@classmethod
def fromDeluge(cls, d):
# Receive a dict with byte values, convert all elements to string values
d = convert(d)
toBool = lambda val: True if strtobool(val) else False
return cls(d['hash'], d['name'], d['progress'], d['eta'], d['save_path'], d['state'],
toBool(d['paused']), toBool(d['is_finished']), d['files'])
def toJSON(self):
return {'key': self.key, 'name': self.name, 'progress': self.progress, 'eta': self.eta,
'save_path': self.save_path, 'state': self.state, 'paused': self.paused,
'finished': self.finished, 'files': self.files, 'is_folder': self.isFolder()}
def __str__(self):
return "Name: {}, Progress: {}%, ETA: {}, State: {}, Paused: {}".format(
self.name, self.progress, self.eta, self.state, self.paused)
def signal_handler(signal, frame):
"""
Handle exit by Keyboardinterrupt
"""
logger.info('\nGood bye!')
sys.exit(0)
def main():
"""
Main function, parse the input
"""
signal.signal(signal.SIGINT, signal_handler)
arguments = docopt(__doc__, version='1')
# Set logging level for streamHandler
if arguments['--debug']:
ch.setLevel(logging.DEBUG)
elif arguments['--warning']:
ch.setLevel(logging.WARNING)
elif arguments['--error']:
ch.setLevel(logging.ERROR)
logger.info('Deluge client')
logger.debug(arguments)
# Get config settings
deluge = Deluge()
_id = arguments['TORRENT']
query = arguments['NAME']
magnet = arguments['MAGNET']
name = arguments['NAME']
_filter = [ a[2:] for a in ['--downloading', '--seeding', '--paused'] if arguments[a] ]
print(_id, query, _filter)
if arguments['add']:
logger.info('Add cmd selected with link {}'.format(magnet))
response = deluge.add(magnet)
print('Add response: ', response)
elif arguments['search']:
logger.info('Search cmd selected for query: {}'.format(query))
response = deluge.search(query)
[ pprint(t.toJSON()) for t in response ]
elif arguments['progress']:
logger.info('Progress cmd selected.')
pprint(deluge.progress())
exit(0)
[ pprint(t.toJSON()) for t in deluge.progress() ]
elif arguments['get']:
logger.info('Get cmd selected for id: {}'.format(_id))
response = deluge.get(_id)
pprint(response.toJSON())
elif arguments['ls']:
logger.info('List cmd selected')
[ pprint(t.toJSON()) for t in deluge.get_all(_filter=_filter) ]
elif arguments['toggle']:
logger.info('Toggling id: {}'.format(_id))
deluge.togglePaused(_id)
elif arguments['rm']:
destroy = arguments['--destroy']
logger.info('Remove by name: {}. Destroy files: {}'.format(name, destroy))
deluge.remove(name, destroy)
if __name__ == '__main__':
main()

View File

@@ -1,19 +0,0 @@
#!/usr/bin/bash
PYPI_VERSION=$(pip3 show delugeClient-kevin | awk '$1 ~ /Version:/ { print $2 }')
SOURCE_VERSION=$(python3 delugeClient/__version__.py)
printf "Source version:\t\t %s\n" $SOURCE_VERSION
printf "Remote PyPi version:\t %s\n" $PYPI_VERSION
function version {
echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
}
if [ $(version $SOURCE_VERSION) -gt $(version $PYPI_VERSION) ]; then
echo "Soure is newer than remote, publishing!"
exit 0
else
echo "Source is same or oldre than remote, nothing to do."
exit 1
fi

View File

@@ -1,7 +0,0 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"

View File

@@ -1,5 +1,14 @@
colored==1.4.4
deluge-client==1.9.0
requests==2.31.0
sshtunnel==0.4.0
typer==0.7.0
asn1crypto==0.24.0
bcrypt==3.1.4
cffi==1.11.5
colored==1.3.5
cryptography==2.3
deluge-client==1.6.0
docopt==0.6.2
idna==2.7
pyasn1==0.4.4
pycparser==2.18
PyNaCl==1.2.1
six==1.11.0
sshtunnel==0.1.4
websockets==6.0

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env python3.10
# -*- encoding: utf-8 -*-
from setuptools import setup, find_packages
from sys import path
from os.path import dirname
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
exec(open('delugeClient/__version__.py').read())
setup(
name="delugeClient-kevin",
version=__version__,
packages=find_packages(),
package_data={
'delugeClient': ['default_config.ini'],
},
python_requires=">=3.10",
author="KevinMidboe",
description="Deluge client with custom functions written in python",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/kevinmidboe/delugeClient",
install_requires=[
'colored>=1.4.4',
'deluge-client>=1.9.0',
'requests>=2.28.1',
'sshtunnel>=0.4.0',
'typer[all]>=0.7.0'
],
classifiers=[
'Programming Language :: Python',
'Operating System :: OS Independent',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3.10',
],
entry_points={
'console_scripts': [
'delugeclient = delugeClient.__main__:main',
],
}
)

42
utils.py Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @Author: kevinmidboe
# @Date: 2018-04-17 19:55:38
# @Last Modified by: KevinMidboe
# @Last Modified time: 2018-05-04 00:04:25
import logging
import colored
import json
from pprint import pprint
from colored import stylize
__all__ = ('ColorizeFilter', )
class ColorizeFilter(logging.Filter):
"""
Class for setting specific colors to levels of severity for log output
"""
color_by_level = {
10: 'chartreuse_3b',
20: 'white',
30: 'orange_1',
40: 'red'
}
logger = logging.getLogger('deluge_cli')
def filter(self, record):
record.raw_msg = record.msg
color = self.color_by_level.get(record.levelno)
if color:
record.msg = stylize(record.msg, colored.fg(color))
return True
def convert(data):
if isinstance(data, bytes): return data.decode('utf-8')
if isinstance(data, dict): return dict(map(convert, data.items()))
if isinstance(data, tuple): return map(convert, data)
json_data = json.dumps(data)
return json_data