From 2a60c5248321c7b6f5d7fb47371ca67ffcc1679e Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 11 Mar 2014 22:28:56 +0100 Subject: [PATCH] Move downloaders to single file --- couchpotato/core/downloaders/blackhole.py | 155 ++++++++ couchpotato/core/downloaders/blackhole/__init__.py | 56 --- couchpotato/core/downloaders/blackhole/main.py | 102 ----- couchpotato/core/downloaders/deluge.py | 382 +++++++++++++++++++ couchpotato/core/downloaders/deluge/__init__.py | 91 ----- couchpotato/core/downloaders/deluge/main.py | 293 -------------- couchpotato/core/downloaders/nzbget.py | 291 ++++++++++++++ couchpotato/core/downloaders/nzbget/__init__.py | 77 ---- couchpotato/core/downloaders/nzbget/main.py | 216 ----------- couchpotato/core/downloaders/nzbvortex.py | 243 ++++++++++++ couchpotato/core/downloaders/nzbvortex/__init__.py | 57 --- couchpotato/core/downloaders/nzbvortex/main.py | 188 --------- couchpotato/core/downloaders/pneumatic.py | 109 ++++++ couchpotato/core/downloaders/pneumatic/__init__.py | 38 -- couchpotato/core/downloaders/pneumatic/main.py | 73 ---- couchpotato/core/downloaders/rtorrent.py | 384 +++++++++++++++++++ couchpotato/core/downloaders/rtorrent/__init__.py | 100 ----- couchpotato/core/downloaders/rtorrent/main.py | 293 -------------- couchpotato/core/downloaders/sabnzbd.py | 279 ++++++++++++++ couchpotato/core/downloaders/sabnzbd/__init__.py | 79 ---- couchpotato/core/downloaders/sabnzbd/main.py | 203 ---------- couchpotato/core/downloaders/synology.py | 213 +++++++++++ couchpotato/core/downloaders/synology/__init__.py | 53 --- couchpotato/core/downloaders/synology/main.py | 162 -------- couchpotato/core/downloaders/transmission.py | 345 +++++++++++++++++ .../core/downloaders/transmission/__init__.py | 95 ----- couchpotato/core/downloaders/transmission/main.py | 253 ------------- couchpotato/core/downloaders/utorrent.py | 420 +++++++++++++++++++++ couchpotato/core/downloaders/utorrent/__init__.py | 80 ---- couchpotato/core/downloaders/utorrent/main.py | 342 ----------------- 30 files changed, 2821 insertions(+), 2851 deletions(-) create mode 100644 couchpotato/core/downloaders/blackhole.py delete mode 100644 couchpotato/core/downloaders/blackhole/__init__.py delete mode 100644 couchpotato/core/downloaders/blackhole/main.py create mode 100644 couchpotato/core/downloaders/deluge.py delete mode 100644 couchpotato/core/downloaders/deluge/__init__.py delete mode 100644 couchpotato/core/downloaders/deluge/main.py create mode 100644 couchpotato/core/downloaders/nzbget.py delete mode 100644 couchpotato/core/downloaders/nzbget/__init__.py delete mode 100644 couchpotato/core/downloaders/nzbget/main.py create mode 100644 couchpotato/core/downloaders/nzbvortex.py delete mode 100644 couchpotato/core/downloaders/nzbvortex/__init__.py delete mode 100644 couchpotato/core/downloaders/nzbvortex/main.py create mode 100644 couchpotato/core/downloaders/pneumatic.py delete mode 100644 couchpotato/core/downloaders/pneumatic/__init__.py delete mode 100644 couchpotato/core/downloaders/pneumatic/main.py create mode 100644 couchpotato/core/downloaders/rtorrent.py delete mode 100755 couchpotato/core/downloaders/rtorrent/__init__.py delete mode 100755 couchpotato/core/downloaders/rtorrent/main.py create mode 100644 couchpotato/core/downloaders/sabnzbd.py delete mode 100644 couchpotato/core/downloaders/sabnzbd/__init__.py delete mode 100644 couchpotato/core/downloaders/sabnzbd/main.py create mode 100644 couchpotato/core/downloaders/synology.py delete mode 100644 couchpotato/core/downloaders/synology/__init__.py delete mode 100644 couchpotato/core/downloaders/synology/main.py create mode 100644 couchpotato/core/downloaders/transmission.py delete mode 100644 couchpotato/core/downloaders/transmission/__init__.py delete mode 100644 couchpotato/core/downloaders/transmission/main.py create mode 100644 couchpotato/core/downloaders/utorrent.py delete mode 100644 couchpotato/core/downloaders/utorrent/__init__.py delete mode 100644 couchpotato/core/downloaders/utorrent/main.py diff --git a/couchpotato/core/downloaders/blackhole.py b/couchpotato/core/downloaders/blackhole.py new file mode 100644 index 0000000..e58db5a --- /dev/null +++ b/couchpotato/core/downloaders/blackhole.py @@ -0,0 +1,155 @@ +from __future__ import with_statement +from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.encoding import sp +from couchpotato.core.logger import CPLog +from couchpotato.environment import Env +import os +import traceback + +log = CPLog(__name__) + +autoload = 'Blackhole' + + +class Blackhole(Downloader): + + protocol = ['nzb', 'torrent', 'torrent_magnet'] + status_support = False + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + directory = self.conf('directory') + if not directory or not os.path.isdir(directory): + log.error('No directory set for blackhole %s download.', data.get('protocol')) + else: + try: + if not filedata or len(filedata) < 50: + try: + if data.get('protocol') == 'torrent_magnet': + filedata = self.magnetToTorrent(data.get('url')) + data['protocol'] = 'torrent' + except: + log.error('Failed download torrent via magnet url: %s', traceback.format_exc()) + + if not filedata or len(filedata) < 50: + log.error('No nzb/torrent available: %s', data.get('url')) + return False + + file_name = self.createFileName(data, filedata, media) + full_path = os.path.join(directory, file_name) + + if self.conf('create_subdir'): + try: + new_path = os.path.splitext(full_path)[0] + if not os.path.exists(new_path): + os.makedirs(new_path) + full_path = os.path.join(new_path, file_name) + except: + log.error('Couldnt create sub dir, reverting to old one: %s', full_path) + + try: + if not os.path.isfile(full_path): + log.info('Downloading %s to %s.', (data.get('protocol'), full_path)) + with open(full_path, 'wb') as f: + f.write(filedata) + os.chmod(full_path, Env.getPermission('file')) + return self.downloadReturnId('') + else: + log.info('File %s already exists.', full_path) + return self.downloadReturnId('') + + except: + log.error('Failed to download to blackhole %s', traceback.format_exc()) + pass + + except: + log.info('Failed to download file %s: %s', (data.get('name'), traceback.format_exc())) + return False + + return False + + def test(self): + directory = self.conf('directory') + if directory and os.path.isdir(directory): + + test_file = sp(os.path.join(directory, 'couchpotato_test.txt')) + + # Check if folder is writable + self.createFile(test_file, 'This is a test file') + if os.path.isfile(test_file): + os.remove(test_file) + return True + + return False + + def getEnabledProtocol(self): + if self.conf('use_for') == 'both': + return super(Blackhole, self).getEnabledProtocol() + elif self.conf('use_for') == 'torrent': + return ['torrent', 'torrent_magnet'] + else: + return ['nzb'] + + def isEnabled(self, manual = False, data = None): + if not data: data = {} + for_protocol = ['both'] + if data and 'torrent' in data.get('protocol'): + for_protocol.append('torrent') + elif data: + for_protocol.append(data.get('protocol')) + + return super(Blackhole, self).isEnabled(manual, data) and \ + ((self.conf('use_for') in for_protocol)) + + +config = [{ + 'name': 'blackhole', + 'order': 30, + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'blackhole', + 'label': 'Black hole', + 'description': 'Download the NZB/Torrent to a specific folder. Note: Seeding and copying/linking features do not work with Black hole.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': True, + 'type': 'enabler', + 'radio_group': 'nzb,torrent', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Directory where the .nzb (or .torrent) file is saved to.', + 'default': getDownloadDir() + }, + { + 'name': 'use_for', + 'label': 'Use for', + 'default': 'both', + 'type': 'dropdown', + 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrent', 'torrent')], + }, + { + 'name': 'create_subdir', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Create a sub directory when saving the .nzb (or .torrent).', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/blackhole/__init__.py b/couchpotato/core/downloaders/blackhole/__init__.py deleted file mode 100644 index 92d18e7..0000000 --- a/couchpotato/core/downloaders/blackhole/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -from .main import Blackhole -from couchpotato.core.helpers.variable import getDownloadDir - - -def start(): - return Blackhole() - -config = [{ - 'name': 'blackhole', - 'order': 30, - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'blackhole', - 'label': 'Black hole', - 'description': 'Download the NZB/Torrent to a specific folder. Note: Seeding and copying/linking features do not work with Black hole.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': True, - 'type': 'enabler', - 'radio_group': 'nzb,torrent', - }, - { - 'name': 'directory', - 'type': 'directory', - 'description': 'Directory where the .nzb (or .torrent) file is saved to.', - 'default': getDownloadDir() - }, - { - 'name': 'use_for', - 'label': 'Use for', - 'default': 'both', - 'type': 'dropdown', - 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrent', 'torrent')], - }, - { - 'name': 'create_subdir', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Create a sub directory when saving the .nzb (or .torrent).', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py deleted file mode 100644 index 9a01835..0000000 --- a/couchpotato/core/downloaders/blackhole/main.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import with_statement -from couchpotato.core.downloaders.base import Downloader -from couchpotato.core.helpers.encoding import sp -from couchpotato.core.logger import CPLog -from couchpotato.environment import Env -import os -import traceback - -log = CPLog(__name__) - - -class Blackhole(Downloader): - - protocol = ['nzb', 'torrent', 'torrent_magnet'] - status_support = False - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - directory = self.conf('directory') - if not directory or not os.path.isdir(directory): - log.error('No directory set for blackhole %s download.', data.get('protocol')) - else: - try: - if not filedata or len(filedata) < 50: - try: - if data.get('protocol') == 'torrent_magnet': - filedata = self.magnetToTorrent(data.get('url')) - data['protocol'] = 'torrent' - except: - log.error('Failed download torrent via magnet url: %s', traceback.format_exc()) - - if not filedata or len(filedata) < 50: - log.error('No nzb/torrent available: %s', data.get('url')) - return False - - file_name = self.createFileName(data, filedata, media) - full_path = os.path.join(directory, file_name) - - if self.conf('create_subdir'): - try: - new_path = os.path.splitext(full_path)[0] - if not os.path.exists(new_path): - os.makedirs(new_path) - full_path = os.path.join(new_path, file_name) - except: - log.error('Couldnt create sub dir, reverting to old one: %s', full_path) - - try: - if not os.path.isfile(full_path): - log.info('Downloading %s to %s.', (data.get('protocol'), full_path)) - with open(full_path, 'wb') as f: - f.write(filedata) - os.chmod(full_path, Env.getPermission('file')) - return self.downloadReturnId('') - else: - log.info('File %s already exists.', full_path) - return self.downloadReturnId('') - - except: - log.error('Failed to download to blackhole %s', traceback.format_exc()) - pass - - except: - log.info('Failed to download file %s: %s', (data.get('name'), traceback.format_exc())) - return False - - return False - - def test(self): - directory = self.conf('directory') - if directory and os.path.isdir(directory): - - test_file = sp(os.path.join(directory, 'couchpotato_test.txt')) - - # Check if folder is writable - self.createFile(test_file, 'This is a test file') - if os.path.isfile(test_file): - os.remove(test_file) - return True - - return False - - def getEnabledProtocol(self): - if self.conf('use_for') == 'both': - return super(Blackhole, self).getEnabledProtocol() - elif self.conf('use_for') == 'torrent': - return ['torrent', 'torrent_magnet'] - else: - return ['nzb'] - - def isEnabled(self, manual = False, data = None): - if not data: data = {} - for_protocol = ['both'] - if data and 'torrent' in data.get('protocol'): - for_protocol.append('torrent') - elif data: - for_protocol.append(data.get('protocol')) - - return super(Blackhole, self).isEnabled(manual, data) and \ - ((self.conf('use_for') in for_protocol)) diff --git a/couchpotato/core/downloaders/deluge.py b/couchpotato/core/downloaders/deluge.py new file mode 100644 index 0000000..65e4aa9 --- /dev/null +++ b/couchpotato/core/downloaders/deluge.py @@ -0,0 +1,382 @@ +from base64 import b64encode, b16encode, b32decode +from bencode import bencode as benc, bdecode +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.helpers.encoding import isInt, sp +from couchpotato.core.helpers.variable import tryFloat, cleanHost +from couchpotato.core.logger import CPLog +from datetime import timedelta +from hashlib import sha1 +from synchronousdeluge import DelugeClient +import os.path +import re +import traceback + +log = CPLog(__name__) + +autoload = 'Deluge' + + +class Deluge(Downloader): + + protocol = ['torrent', 'torrent_magnet'] + log = CPLog(__name__) + drpc = None + + def connect(self, reconnect = False): + # Load host from config and split out port. + host = cleanHost(self.conf('host'), protocol = False).split(':') + if not isInt(host[1]): + log.error('Config properties are not filled in correctly, port is missing.') + return False + + if not self.drpc or reconnect: + self.drpc = DelugeRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + + return self.drpc + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + log.info('Sending "%s" (%s) to Deluge.', (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + # Set parameters for Deluge + options = { + 'add_paused': self.conf('paused', default = 0), + 'label': self.conf('label') + } + + if self.conf('directory'): + if os.path.isdir(self.conf('directory')): + options['download_location'] = self.conf('directory') + else: + log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) + + if self.conf('completed_directory'): + if os.path.isdir(self.conf('completed_directory')): + options['move_completed'] = 1 + options['move_completed_path'] = self.conf('completed_directory') + else: + log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) + + if data.get('seed_ratio'): + options['stop_at_ratio'] = 1 + options['stop_ratio'] = tryFloat(data.get('seed_ratio')) + +# Deluge only has seed time as a global option. Might be added in +# in a future API release. +# if data.get('seed_time'): + + # Send request to Deluge + if data.get('protocol') == 'torrent_magnet': + remote_torrent = self.drpc.add_torrent_magnet(data.get('url'), options) + else: + filename = self.createFileName(data, filedata, media) + remote_torrent = self.drpc.add_torrent_file(filename, filedata, options) + + if not remote_torrent: + log.error('Failed sending torrent to Deluge') + return False + + log.info('Torrent sent to Deluge successfully.') + return self.downloadReturnId(remote_torrent) + + def test(self): + if self.connect(True) and self.drpc.test(): + return True + return False + + def getAllDownloadStatus(self, ids): + + log.debug('Checking Deluge download status.') + + if not self.connect(): + return [] + + release_downloads = ReleaseDownloadList(self) + + queue = self.drpc.get_alltorrents(ids) + + if not queue: + log.debug('Nothing in queue or error') + return [] + + for torrent_id in queue: + torrent = queue[torrent_id] + + if not 'hash' in torrent: + # When given a list of ids, deluge will return an empty item for a non-existant torrent. + continue + + log.debug('name=%s / id=%s / save_path=%s / move_on_completed=%s / move_completed_path=%s / hash=%s / progress=%s / state=%s / eta=%s / ratio=%s / stop_ratio=%s / is_seed=%s / is_finished=%s / paused=%s', (torrent['name'], torrent['hash'], torrent['save_path'], torrent['move_on_completed'], torrent['move_completed_path'], torrent['hash'], torrent['progress'], torrent['state'], torrent['eta'], torrent['ratio'], torrent['stop_ratio'], torrent['is_seed'], torrent['is_finished'], torrent['paused'])) + + # Deluge has no easy way to work out if a torrent is stalled or failing. + #status = 'failed' + status = 'busy' + if torrent['is_seed'] and tryFloat(torrent['ratio']) < tryFloat(torrent['stop_ratio']): + # We have torrent['seeding_time'] to work out what the seeding time is, but we do not + # have access to the downloader seed_time, as with deluge we have no way to pass it + # when the torrent is added. So Deluge will only look at the ratio. + # See above comment in download(). + status = 'seeding' + elif torrent['is_seed'] and torrent['is_finished'] and torrent['paused'] and torrent['state'] == 'Paused': + status = 'completed' + + download_dir = sp(torrent['save_path']) + if torrent['move_on_completed']: + download_dir = torrent['move_completed_path'] + + torrent_files = [] + for file_item in torrent['files']: + torrent_files.append(sp(os.path.join(download_dir, file_item['path']))) + + release_downloads.append({ + 'id': torrent['hash'], + 'name': torrent['name'], + 'status': status, + 'original_status': torrent['state'], + 'seed_ratio': torrent['ratio'], + 'timeleft': str(timedelta(seconds = torrent['eta'])), + 'folder': sp(download_dir if len(torrent_files) == 1 else os.path.join(download_dir, torrent['name'])), + 'files': '|'.join(torrent_files), + }) + + return release_downloads + + def pause(self, release_download, pause = True): + if pause: + return self.drpc.pause_torrent([release_download['id']]) + else: + return self.drpc.resume_torrent([release_download['id']]) + + def removeFailed(self, release_download): + log.info('%s failed downloading, deleting...', release_download['name']) + return self.drpc.remove_torrent(release_download['id'], True) + + def processComplete(self, release_download, delete_files = False): + log.debug('Requesting Deluge to remove the torrent %s%s.', (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) + return self.drpc.remove_torrent(release_download['id'], remove_local_data = delete_files) + + +class DelugeRPC(object): + + host = 'localhost' + port = 58846 + username = None + password = None + client = None + + def __init__(self, host = 'localhost', port = 58846, username = None, password = None): + super(DelugeRPC, self).__init__() + + self.host = host + self.port = port + self.username = username + self.password = password + + def connect(self): + self.client = DelugeClient() + self.client.connect(self.host, int(self.port), self.username, self.password) + + def test(self): + try: + self.connect() + except: + return False + return True + + def add_torrent_magnet(self, torrent, options): + torrent_id = False + try: + self.connect() + torrent_id = self.client.core.add_torrent_magnet(torrent, options).get() + if not torrent_id: + torrent_id = self._check_torrent(True, torrent) + + if torrent_id and options['label']: + self.client.label.set_torrent(torrent_id, options['label']).get() + except Exception as err: + log.error('Failed to add torrent magnet %s: %s %s', (torrent, err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + return torrent_id + + def add_torrent_file(self, filename, torrent, options): + torrent_id = False + try: + self.connect() + torrent_id = self.client.core.add_torrent_file(filename, b64encode(torrent), options).get() + if not torrent_id: + torrent_id = self._check_torrent(False, torrent) + + if torrent_id and options['label']: + self.client.label.set_torrent(torrent_id, options['label']).get() + except Exception as err: + log.error('Failed to add torrent file %s: %s %s', (filename, err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + return torrent_id + + def get_alltorrents(self, ids): + ret = False + try: + self.connect() + ret = self.client.core.get_torrents_status({'id': ids}, ('name', 'hash', 'save_path', 'move_completed_path', 'progress', 'state', 'eta', 'ratio', 'stop_ratio', 'is_seed', 'is_finished', 'paused', 'move_on_completed', 'files')).get() + except Exception as err: + log.error('Failed to get all torrents: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + return ret + + def pause_torrent(self, torrent_ids): + try: + self.connect() + self.client.core.pause_torrent(torrent_ids).get() + except Exception as err: + log.error('Failed to pause torrent: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + def resume_torrent(self, torrent_ids): + try: + self.connect() + self.client.core.resume_torrent(torrent_ids).get() + except Exception as err: + log.error('Failed to resume torrent: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + def remove_torrent(self, torrent_id, remove_local_data): + ret = False + try: + self.connect() + ret = self.client.core.remove_torrent(torrent_id, remove_local_data).get() + except Exception as err: + log.error('Failed to remove torrent: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + return ret + + def disconnect(self): + self.client.disconnect() + + def _check_torrent(self, magnet, torrent): + # Torrent not added, check if it already existed. + if magnet: + torrent_hash = re.findall('urn:btih:([\w]{32,40})', torrent)[0] + else: + info = bdecode(torrent)["info"] + torrent_hash = sha1(benc(info)).hexdigest() + + # Convert base 32 to hex + if len(torrent_hash) == 32: + torrent_hash = b16encode(b32decode(torrent_hash)) + + torrent_hash = torrent_hash.lower() + torrent_check = self.client.core.get_torrent_status(torrent_hash, {}).get() + if torrent_check['hash']: + return torrent_hash + + return False + + +config = [{ + 'name': 'deluge', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'deluge', + 'label': 'Deluge', + 'description': 'Use Deluge to download torrents.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'host', + 'default': 'localhost:58846', + 'description': 'Hostname with port. Usually localhost:58846', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Download to this directory. Keep empty for default Deluge download directory.', + }, + { + 'name': 'completed_directory', + 'type': 'directory', + 'description': 'Move completed torrent to this directory. Keep empty for default Deluge options.', + 'advanced': True, + }, + { + 'name': 'label', + 'description': 'Label to add to torrents in the Deluge UI.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'type': 'bool', + 'default': True, + 'advanced': True, + 'description': 'Remove the torrent from Deluge after it has finished seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/deluge/__init__.py b/couchpotato/core/downloaders/deluge/__init__.py deleted file mode 100644 index 09fae75..0000000 --- a/couchpotato/core/downloaders/deluge/__init__.py +++ /dev/null @@ -1,91 +0,0 @@ -from .main import Deluge - - -def start(): - return Deluge() - -config = [{ - 'name': 'deluge', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'deluge', - 'label': 'Deluge', - 'description': 'Use Deluge to download torrents.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'torrent', - }, - { - 'name': 'host', - 'default': 'localhost:58846', - 'description': 'Hostname with port. Usually localhost:58846', - }, - { - 'name': 'username', - }, - { - 'name': 'password', - 'type': 'password', - }, - { - 'name': 'directory', - 'type': 'directory', - 'description': 'Download to this directory. Keep empty for default Deluge download directory.', - }, - { - 'name': 'completed_directory', - 'type': 'directory', - 'description': 'Move completed torrent to this directory. Keep empty for default Deluge options.', - 'advanced': True, - }, - { - 'name': 'label', - 'description': 'Label to add to torrents in the Deluge UI.', - }, - { - 'name': 'remove_complete', - 'label': 'Remove torrent', - 'type': 'bool', - 'default': True, - 'advanced': True, - 'description': 'Remove the torrent from Deluge after it has finished seeding.', - }, - { - 'name': 'delete_files', - 'label': 'Remove files', - 'default': True, - 'type': 'bool', - 'advanced': True, - 'description': 'Also remove the leftover files.', - }, - { - 'name': 'paused', - 'type': 'bool', - 'advanced': True, - 'default': False, - 'description': 'Add the torrent paused.', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - { - 'name': 'delete_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Delete a release after the download has failed.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/deluge/main.py b/couchpotato/core/downloaders/deluge/main.py deleted file mode 100644 index 5930095..0000000 --- a/couchpotato/core/downloaders/deluge/main.py +++ /dev/null @@ -1,293 +0,0 @@ -from base64 import b64encode, b16encode, b32decode -from bencode import bencode as benc, bdecode -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.helpers.encoding import isInt, sp -from couchpotato.core.helpers.variable import tryFloat, cleanHost -from couchpotato.core.logger import CPLog -from datetime import timedelta -from hashlib import sha1 -from synchronousdeluge import DelugeClient -import os.path -import re -import traceback - -log = CPLog(__name__) - - -class Deluge(Downloader): - - protocol = ['torrent', 'torrent_magnet'] - log = CPLog(__name__) - drpc = None - - def connect(self, reconnect = False): - # Load host from config and split out port. - host = cleanHost(self.conf('host'), protocol = False).split(':') - if not isInt(host[1]): - log.error('Config properties are not filled in correctly, port is missing.') - return False - - if not self.drpc or reconnect: - self.drpc = DelugeRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) - - return self.drpc - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - log.info('Sending "%s" (%s) to Deluge.', (data.get('name'), data.get('protocol'))) - - if not self.connect(): - return False - - if not filedata and data.get('protocol') == 'torrent': - log.error('Failed sending torrent, no data') - return False - - # Set parameters for Deluge - options = { - 'add_paused': self.conf('paused', default = 0), - 'label': self.conf('label') - } - - if self.conf('directory'): - if os.path.isdir(self.conf('directory')): - options['download_location'] = self.conf('directory') - else: - log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) - - if self.conf('completed_directory'): - if os.path.isdir(self.conf('completed_directory')): - options['move_completed'] = 1 - options['move_completed_path'] = self.conf('completed_directory') - else: - log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) - - if data.get('seed_ratio'): - options['stop_at_ratio'] = 1 - options['stop_ratio'] = tryFloat(data.get('seed_ratio')) - -# Deluge only has seed time as a global option. Might be added in -# in a future API release. -# if data.get('seed_time'): - - # Send request to Deluge - if data.get('protocol') == 'torrent_magnet': - remote_torrent = self.drpc.add_torrent_magnet(data.get('url'), options) - else: - filename = self.createFileName(data, filedata, media) - remote_torrent = self.drpc.add_torrent_file(filename, filedata, options) - - if not remote_torrent: - log.error('Failed sending torrent to Deluge') - return False - - log.info('Torrent sent to Deluge successfully.') - return self.downloadReturnId(remote_torrent) - - def test(self): - if self.connect(True) and self.drpc.test(): - return True - return False - - def getAllDownloadStatus(self, ids): - - log.debug('Checking Deluge download status.') - - if not self.connect(): - return [] - - release_downloads = ReleaseDownloadList(self) - - queue = self.drpc.get_alltorrents(ids) - - if not queue: - log.debug('Nothing in queue or error') - return [] - - for torrent_id in queue: - torrent = queue[torrent_id] - - if not 'hash' in torrent: - # When given a list of ids, deluge will return an empty item for a non-existant torrent. - continue - - log.debug('name=%s / id=%s / save_path=%s / move_on_completed=%s / move_completed_path=%s / hash=%s / progress=%s / state=%s / eta=%s / ratio=%s / stop_ratio=%s / is_seed=%s / is_finished=%s / paused=%s', (torrent['name'], torrent['hash'], torrent['save_path'], torrent['move_on_completed'], torrent['move_completed_path'], torrent['hash'], torrent['progress'], torrent['state'], torrent['eta'], torrent['ratio'], torrent['stop_ratio'], torrent['is_seed'], torrent['is_finished'], torrent['paused'])) - - # Deluge has no easy way to work out if a torrent is stalled or failing. - #status = 'failed' - status = 'busy' - if torrent['is_seed'] and tryFloat(torrent['ratio']) < tryFloat(torrent['stop_ratio']): - # We have torrent['seeding_time'] to work out what the seeding time is, but we do not - # have access to the downloader seed_time, as with deluge we have no way to pass it - # when the torrent is added. So Deluge will only look at the ratio. - # See above comment in download(). - status = 'seeding' - elif torrent['is_seed'] and torrent['is_finished'] and torrent['paused'] and torrent['state'] == 'Paused': - status = 'completed' - - download_dir = sp(torrent['save_path']) - if torrent['move_on_completed']: - download_dir = torrent['move_completed_path'] - - torrent_files = [] - for file_item in torrent['files']: - torrent_files.append(sp(os.path.join(download_dir, file_item['path']))) - - release_downloads.append({ - 'id': torrent['hash'], - 'name': torrent['name'], - 'status': status, - 'original_status': torrent['state'], - 'seed_ratio': torrent['ratio'], - 'timeleft': str(timedelta(seconds = torrent['eta'])), - 'folder': sp(download_dir if len(torrent_files) == 1 else os.path.join(download_dir, torrent['name'])), - 'files': '|'.join(torrent_files), - }) - - return release_downloads - - def pause(self, release_download, pause = True): - if pause: - return self.drpc.pause_torrent([release_download['id']]) - else: - return self.drpc.resume_torrent([release_download['id']]) - - def removeFailed(self, release_download): - log.info('%s failed downloading, deleting...', release_download['name']) - return self.drpc.remove_torrent(release_download['id'], True) - - def processComplete(self, release_download, delete_files = False): - log.debug('Requesting Deluge to remove the torrent %s%s.', (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) - return self.drpc.remove_torrent(release_download['id'], remove_local_data = delete_files) - - -class DelugeRPC(object): - - host = 'localhost' - port = 58846 - username = None - password = None - client = None - - def __init__(self, host = 'localhost', port = 58846, username = None, password = None): - super(DelugeRPC, self).__init__() - - self.host = host - self.port = port - self.username = username - self.password = password - - def connect(self): - self.client = DelugeClient() - self.client.connect(self.host, int(self.port), self.username, self.password) - - def test(self): - try: - self.connect() - except: - return False - return True - - def add_torrent_magnet(self, torrent, options): - torrent_id = False - try: - self.connect() - torrent_id = self.client.core.add_torrent_magnet(torrent, options).get() - if not torrent_id: - torrent_id = self._check_torrent(True, torrent) - - if torrent_id and options['label']: - self.client.label.set_torrent(torrent_id, options['label']).get() - except Exception as err: - log.error('Failed to add torrent magnet %s: %s %s', (torrent, err, traceback.format_exc())) - finally: - if self.client: - self.disconnect() - - return torrent_id - - def add_torrent_file(self, filename, torrent, options): - torrent_id = False - try: - self.connect() - torrent_id = self.client.core.add_torrent_file(filename, b64encode(torrent), options).get() - if not torrent_id: - torrent_id = self._check_torrent(False, torrent) - - if torrent_id and options['label']: - self.client.label.set_torrent(torrent_id, options['label']).get() - except Exception as err: - log.error('Failed to add torrent file %s: %s %s', (filename, err, traceback.format_exc())) - finally: - if self.client: - self.disconnect() - - return torrent_id - - def get_alltorrents(self, ids): - ret = False - try: - self.connect() - ret = self.client.core.get_torrents_status({'id': ids}, ('name', 'hash', 'save_path', 'move_completed_path', 'progress', 'state', 'eta', 'ratio', 'stop_ratio', 'is_seed', 'is_finished', 'paused', 'move_on_completed', 'files')).get() - except Exception as err: - log.error('Failed to get all torrents: %s %s', (err, traceback.format_exc())) - finally: - if self.client: - self.disconnect() - return ret - - def pause_torrent(self, torrent_ids): - try: - self.connect() - self.client.core.pause_torrent(torrent_ids).get() - except Exception as err: - log.error('Failed to pause torrent: %s %s', (err, traceback.format_exc())) - finally: - if self.client: - self.disconnect() - - def resume_torrent(self, torrent_ids): - try: - self.connect() - self.client.core.resume_torrent(torrent_ids).get() - except Exception as err: - log.error('Failed to resume torrent: %s %s', (err, traceback.format_exc())) - finally: - if self.client: - self.disconnect() - - def remove_torrent(self, torrent_id, remove_local_data): - ret = False - try: - self.connect() - ret = self.client.core.remove_torrent(torrent_id, remove_local_data).get() - except Exception as err: - log.error('Failed to remove torrent: %s %s', (err, traceback.format_exc())) - finally: - if self.client: - self.disconnect() - return ret - - def disconnect(self): - self.client.disconnect() - - def _check_torrent(self, magnet, torrent): - # Torrent not added, check if it already existed. - if magnet: - torrent_hash = re.findall('urn:btih:([\w]{32,40})', torrent)[0] - else: - info = bdecode(torrent)["info"] - torrent_hash = sha1(benc(info)).hexdigest() - - # Convert base 32 to hex - if len(torrent_hash) == 32: - torrent_hash = b16encode(b32decode(torrent_hash)) - - torrent_hash = torrent_hash.lower() - torrent_check = self.client.core.get_torrent_status(torrent_hash, {}).get() - if torrent_check['hash']: - return torrent_hash - - return False diff --git a/couchpotato/core/downloaders/nzbget.py b/couchpotato/core/downloaders/nzbget.py new file mode 100644 index 0000000..743e276 --- /dev/null +++ b/couchpotato/core/downloaders/nzbget.py @@ -0,0 +1,291 @@ +from base64 import standard_b64encode +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.helpers.encoding import ss, sp +from couchpotato.core.helpers.variable import tryInt, md5, cleanHost +from couchpotato.core.logger import CPLog +from datetime import timedelta +import re +import shutil +import socket +import traceback +import xmlrpclib + +log = CPLog(__name__) + +autoload = 'NZBGet' + + +class NZBGet(Downloader): + + protocol = ['nzb'] + rpc = 'xmlrpc' + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + if not filedata: + log.error('Unable to get NZB file: %s', traceback.format_exc()) + return False + + log.info('Sending "%s" to NZBGet.', data.get('name')) + + nzb_name = ss('%s.nzb' % self.createNzbName(data, media)) + + rpc = self.getRPC() + + try: + if rpc.writelog('INFO', 'CouchPotato connected to drop off %s.' % nzb_name): + log.debug('Successfully connected to NZBGet') + else: + log.info('Successfully connected to NZBGet, but unable to send a message') + except socket.error: + log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') + return False + except xmlrpclib.ProtocolError as e: + if e.errcode == 401: + log.error('Password is incorrect.') + else: + log.error('Protocol Error: %s', e) + return False + + if re.search(r"^0", rpc.version()): + xml_response = rpc.append(nzb_name, self.conf('category'), False, standard_b64encode(filedata.strip())) + else: + xml_response = rpc.append(nzb_name, self.conf('category'), tryInt(self.conf('priority')), False, standard_b64encode(filedata.strip())) + + if xml_response: + log.info('NZB sent successfully to NZBGet') + nzb_id = md5(data['url']) # about as unique as they come ;) + couchpotato_id = "couchpotato=" + nzb_id + groups = rpc.listgroups() + file_id = [item['LastID'] for item in groups if item['NZBFilename'] == nzb_name] + confirmed = rpc.editqueue("GroupSetParameter", 0, couchpotato_id, file_id) + if confirmed: + log.debug('couchpotato parameter set in nzbget download') + return self.downloadReturnId(nzb_id) + else: + log.error('NZBGet could not add %s to the queue.', nzb_name) + return False + + def test(self): + rpc = self.getRPC() + + try: + if rpc.writelog('INFO', 'CouchPotato connected to test connection'): + log.debug('Successfully connected to NZBGet') + else: + log.info('Successfully connected to NZBGet, but unable to send a message') + except socket.error: + log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') + return False + except xmlrpclib.ProtocolError as e: + if e.errcode == 401: + log.error('Password is incorrect.') + else: + log.error('Protocol Error: %s', e) + return False + + return True + + def getAllDownloadStatus(self, ids): + + log.debug('Checking NZBGet download status.') + + rpc = self.getRPC() + + try: + if rpc.writelog('INFO', 'CouchPotato connected to check status'): + log.debug('Successfully connected to NZBGet') + else: + log.info('Successfully connected to NZBGet, but unable to send a message') + except socket.error: + log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') + return [] + except xmlrpclib.ProtocolError as e: + if e.errcode == 401: + log.error('Password is incorrect.') + else: + log.error('Protocol Error: %s', e) + return [] + + # Get NZBGet data + try: + status = rpc.status() + groups = rpc.listgroups() + queue = rpc.postqueue(0) + history = rpc.history() + except: + log.error('Failed getting data: %s', traceback.format_exc(1)) + return [] + + release_downloads = ReleaseDownloadList(self) + + for nzb in groups: + try: + nzb_id = [param['Value'] for param in nzb['Parameters'] if param['Name'] == 'couchpotato'][0] + except: + nzb_id = nzb['NZBID'] + + if nzb_id in ids: + log.debug('Found %s in NZBGet download queue', nzb['NZBFilename']) + timeleft = -1 + try: + if nzb['ActiveDownloads'] > 0 and nzb['DownloadRate'] > 0 and not (status['DownloadPaused'] or status['Download2Paused']): + timeleft = str(timedelta(seconds = nzb['RemainingSizeMB'] / status['DownloadRate'] * 2 ^ 20)) + except: + pass + + release_downloads.append({ + 'id': nzb_id, + 'name': nzb['NZBFilename'], + 'original_status': 'DOWNLOADING' if nzb['ActiveDownloads'] > 0 else 'QUEUED', + # Seems to have no native API function for time left. This will return the time left after NZBGet started downloading this item + 'timeleft': timeleft, + }) + + for nzb in queue: # 'Parameters' is not passed in rpc.postqueue + if nzb['NZBID'] in ids: + log.debug('Found %s in NZBGet postprocessing queue', nzb['NZBFilename']) + release_downloads.append({ + 'id': nzb['NZBID'], + 'name': nzb['NZBFilename'], + 'original_status': nzb['Stage'], + 'timeleft': str(timedelta(seconds = 0)) if not status['PostPaused'] else -1, + }) + + for nzb in history: + try: + nzb_id = [param['Value'] for param in nzb['Parameters'] if param['Name'] == 'couchpotato'][0] + except: + nzb_id = nzb['NZBID'] + + if nzb_id in ids: + log.debug('Found %s in NZBGet history. ParStatus: %s, ScriptStatus: %s, Log: %s', (nzb['NZBFilename'] , nzb['ParStatus'], nzb['ScriptStatus'] , nzb['Log'])) + release_downloads.append({ + 'id': nzb_id, + 'name': nzb['NZBFilename'], + 'status': 'completed' if nzb['ParStatus'] in ['SUCCESS', 'NONE'] and nzb['ScriptStatus'] in ['SUCCESS', 'NONE'] else 'failed', + 'original_status': nzb['ParStatus'] + ', ' + nzb['ScriptStatus'], + 'timeleft': str(timedelta(seconds = 0)), + 'folder': sp(nzb['DestDir']) + }) + + return release_downloads + + def removeFailed(self, release_download): + + log.info('%s failed downloading, deleting...', release_download['name']) + + rpc = self.getRPC() + + try: + if rpc.writelog('INFO', 'CouchPotato connected to delete some history'): + log.debug('Successfully connected to NZBGet') + else: + log.info('Successfully connected to NZBGet, but unable to send a message') + except socket.error: + log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') + return False + except xmlrpclib.ProtocolError as e: + if e.errcode == 401: + log.error('Password is incorrect.') + else: + log.error('Protocol Error: %s', e) + return False + + try: + history = rpc.history() + nzb_id = None + path = None + + for hist in history: + for param in hist['Parameters']: + if param['Name'] == 'couchpotato' and param['Value'] == release_download['id']: + nzb_id = hist['ID'] + path = hist['DestDir'] + + if nzb_id and path and rpc.editqueue('HistoryDelete', 0, "", [tryInt(nzb_id)]): + shutil.rmtree(path, True) + except: + log.error('Failed deleting: %s', traceback.format_exc(0)) + return False + + return True + + def getRPC(self): + url = cleanHost(host = self.conf('host'), ssl = self.conf('ssl'), username = self.conf('username'), password = self.conf('password')) + self.rpc + return xmlrpclib.ServerProxy(url) + + +config = [{ + 'name': 'nzbget', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'nzbget', + 'label': 'NZBGet', + 'description': 'Use NZBGet to download NZBs.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'nzb', + }, + { + 'name': 'host', + 'default': 'localhost:6789', + 'description': 'Hostname with port. Usually localhost:6789', + }, + { + 'name': 'ssl', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Use HyperText Transfer Protocol Secure, or https', + }, + { + 'name': 'username', + 'default': 'nzbget', + 'advanced': True, + 'description': 'Set a different username to connect. Default: nzbget', + }, + { + 'name': 'password', + 'type': 'password', + 'description': 'Default NZBGet password is tegbzn6789', + }, + { + 'name': 'category', + 'default': 'Movies', + 'description': 'The category CP places the nzb in. Like movies or couchpotato', + }, + { + 'name': 'priority', + 'advanced': True, + 'default': '0', + 'type': 'dropdown', + 'values': [('Very Low', -100), ('Low', -50), ('Normal', 0), ('High', 50), ('Very High', 100)], + 'description': 'Only change this if you are using NZBget 9.0 or higher', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/nzbget/__init__.py b/couchpotato/core/downloaders/nzbget/__init__.py deleted file mode 100644 index 551eb42..0000000 --- a/couchpotato/core/downloaders/nzbget/__init__.py +++ /dev/null @@ -1,77 +0,0 @@ -from .main import NZBGet - - -def start(): - return NZBGet() - -config = [{ - 'name': 'nzbget', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'nzbget', - 'label': 'NZBGet', - 'description': 'Use NZBGet to download NZBs.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'nzb', - }, - { - 'name': 'host', - 'default': 'localhost:6789', - 'description': 'Hostname with port. Usually localhost:6789', - }, - { - 'name': 'ssl', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Use HyperText Transfer Protocol Secure, or https', - }, - { - 'name': 'username', - 'default': 'nzbget', - 'advanced': True, - 'description': 'Set a different username to connect. Default: nzbget', - }, - { - 'name': 'password', - 'type': 'password', - 'description': 'Default NZBGet password is tegbzn6789', - }, - { - 'name': 'category', - 'default': 'Movies', - 'description': 'The category CP places the nzb in. Like movies or couchpotato', - }, - { - 'name': 'priority', - 'advanced': True, - 'default': '0', - 'type': 'dropdown', - 'values': [('Very Low', -100), ('Low', -50), ('Normal', 0), ('High', 50), ('Very High', 100)], - 'description': 'Only change this if you are using NZBget 9.0 or higher', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - { - 'name': 'delete_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Delete a release after the download has failed.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py deleted file mode 100644 index 3dad867..0000000 --- a/couchpotato/core/downloaders/nzbget/main.py +++ /dev/null @@ -1,216 +0,0 @@ -from base64 import standard_b64encode -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.helpers.encoding import ss, sp -from couchpotato.core.helpers.variable import tryInt, md5, cleanHost -from couchpotato.core.logger import CPLog -from datetime import timedelta -import re -import shutil -import socket -import traceback -import xmlrpclib - -log = CPLog(__name__) - - -class NZBGet(Downloader): - - protocol = ['nzb'] - rpc = 'xmlrpc' - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - if not filedata: - log.error('Unable to get NZB file: %s', traceback.format_exc()) - return False - - log.info('Sending "%s" to NZBGet.', data.get('name')) - - nzb_name = ss('%s.nzb' % self.createNzbName(data, media)) - - rpc = self.getRPC() - - try: - if rpc.writelog('INFO', 'CouchPotato connected to drop off %s.' % nzb_name): - log.debug('Successfully connected to NZBGet') - else: - log.info('Successfully connected to NZBGet, but unable to send a message') - except socket.error: - log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') - return False - except xmlrpclib.ProtocolError as e: - if e.errcode == 401: - log.error('Password is incorrect.') - else: - log.error('Protocol Error: %s', e) - return False - - if re.search(r"^0", rpc.version()): - xml_response = rpc.append(nzb_name, self.conf('category'), False, standard_b64encode(filedata.strip())) - else: - xml_response = rpc.append(nzb_name, self.conf('category'), tryInt(self.conf('priority')), False, standard_b64encode(filedata.strip())) - - if xml_response: - log.info('NZB sent successfully to NZBGet') - nzb_id = md5(data['url']) # about as unique as they come ;) - couchpotato_id = "couchpotato=" + nzb_id - groups = rpc.listgroups() - file_id = [item['LastID'] for item in groups if item['NZBFilename'] == nzb_name] - confirmed = rpc.editqueue("GroupSetParameter", 0, couchpotato_id, file_id) - if confirmed: - log.debug('couchpotato parameter set in nzbget download') - return self.downloadReturnId(nzb_id) - else: - log.error('NZBGet could not add %s to the queue.', nzb_name) - return False - - def test(self): - rpc = self.getRPC() - - try: - if rpc.writelog('INFO', 'CouchPotato connected to test connection'): - log.debug('Successfully connected to NZBGet') - else: - log.info('Successfully connected to NZBGet, but unable to send a message') - except socket.error: - log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') - return False - except xmlrpclib.ProtocolError as e: - if e.errcode == 401: - log.error('Password is incorrect.') - else: - log.error('Protocol Error: %s', e) - return False - - return True - - def getAllDownloadStatus(self, ids): - - log.debug('Checking NZBGet download status.') - - rpc = self.getRPC() - - try: - if rpc.writelog('INFO', 'CouchPotato connected to check status'): - log.debug('Successfully connected to NZBGet') - else: - log.info('Successfully connected to NZBGet, but unable to send a message') - except socket.error: - log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') - return [] - except xmlrpclib.ProtocolError as e: - if e.errcode == 401: - log.error('Password is incorrect.') - else: - log.error('Protocol Error: %s', e) - return [] - - # Get NZBGet data - try: - status = rpc.status() - groups = rpc.listgroups() - queue = rpc.postqueue(0) - history = rpc.history() - except: - log.error('Failed getting data: %s', traceback.format_exc(1)) - return [] - - release_downloads = ReleaseDownloadList(self) - - for nzb in groups: - try: - nzb_id = [param['Value'] for param in nzb['Parameters'] if param['Name'] == 'couchpotato'][0] - except: - nzb_id = nzb['NZBID'] - - if nzb_id in ids: - log.debug('Found %s in NZBGet download queue', nzb['NZBFilename']) - timeleft = -1 - try: - if nzb['ActiveDownloads'] > 0 and nzb['DownloadRate'] > 0 and not (status['DownloadPaused'] or status['Download2Paused']): - timeleft = str(timedelta(seconds = nzb['RemainingSizeMB'] / status['DownloadRate'] * 2 ^ 20)) - except: - pass - - release_downloads.append({ - 'id': nzb_id, - 'name': nzb['NZBFilename'], - 'original_status': 'DOWNLOADING' if nzb['ActiveDownloads'] > 0 else 'QUEUED', - # Seems to have no native API function for time left. This will return the time left after NZBGet started downloading this item - 'timeleft': timeleft, - }) - - for nzb in queue: # 'Parameters' is not passed in rpc.postqueue - if nzb['NZBID'] in ids: - log.debug('Found %s in NZBGet postprocessing queue', nzb['NZBFilename']) - release_downloads.append({ - 'id': nzb['NZBID'], - 'name': nzb['NZBFilename'], - 'original_status': nzb['Stage'], - 'timeleft': str(timedelta(seconds = 0)) if not status['PostPaused'] else -1, - }) - - for nzb in history: - try: - nzb_id = [param['Value'] for param in nzb['Parameters'] if param['Name'] == 'couchpotato'][0] - except: - nzb_id = nzb['NZBID'] - - if nzb_id in ids: - log.debug('Found %s in NZBGet history. ParStatus: %s, ScriptStatus: %s, Log: %s', (nzb['NZBFilename'] , nzb['ParStatus'], nzb['ScriptStatus'] , nzb['Log'])) - release_downloads.append({ - 'id': nzb_id, - 'name': nzb['NZBFilename'], - 'status': 'completed' if nzb['ParStatus'] in ['SUCCESS', 'NONE'] and nzb['ScriptStatus'] in ['SUCCESS', 'NONE'] else 'failed', - 'original_status': nzb['ParStatus'] + ', ' + nzb['ScriptStatus'], - 'timeleft': str(timedelta(seconds = 0)), - 'folder': sp(nzb['DestDir']) - }) - - return release_downloads - - def removeFailed(self, release_download): - - log.info('%s failed downloading, deleting...', release_download['name']) - - rpc = self.getRPC() - - try: - if rpc.writelog('INFO', 'CouchPotato connected to delete some history'): - log.debug('Successfully connected to NZBGet') - else: - log.info('Successfully connected to NZBGet, but unable to send a message') - except socket.error: - log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') - return False - except xmlrpclib.ProtocolError as e: - if e.errcode == 401: - log.error('Password is incorrect.') - else: - log.error('Protocol Error: %s', e) - return False - - try: - history = rpc.history() - nzb_id = None - path = None - - for hist in history: - for param in hist['Parameters']: - if param['Name'] == 'couchpotato' and param['Value'] == release_download['id']: - nzb_id = hist['ID'] - path = hist['DestDir'] - - if nzb_id and path and rpc.editqueue('HistoryDelete', 0, "", [tryInt(nzb_id)]): - shutil.rmtree(path, True) - except: - log.error('Failed deleting: %s', traceback.format_exc(0)) - return False - - return True - - def getRPC(self): - url = cleanHost(host = self.conf('host'), ssl = self.conf('ssl'), username = self.conf('username'), password = self.conf('password')) + self.rpc - return xmlrpclib.ServerProxy(url) diff --git a/couchpotato/core/downloaders/nzbvortex.py b/couchpotato/core/downloaders/nzbvortex.py new file mode 100644 index 0000000..1084e66 --- /dev/null +++ b/couchpotato/core/downloaders/nzbvortex.py @@ -0,0 +1,243 @@ +from base64 import b64encode +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.helpers.encoding import tryUrlencode, sp +from couchpotato.core.helpers.variable import cleanHost +from couchpotato.core.logger import CPLog +from urllib2 import URLError +from uuid import uuid4 +import hashlib +import httplib +import json +import os +import socket +import ssl +import sys +import time +import traceback +import urllib2 + +log = CPLog(__name__) + +autoload = 'NZBVortex' + + +class NZBVortex(Downloader): + + protocol = ['nzb'] + api_level = None + session_id = None + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + # Send the nzb + try: + nzb_filename = self.createFileName(data, filedata, media) + self.call('nzb/add', files = {'file': (nzb_filename, filedata)}) + + time.sleep(10) + raw_statuses = self.call('nzb') + nzb_id = [nzb['id'] for nzb in raw_statuses.get('nzbs', []) if os.path.basename(nzb['nzbFileName']) == nzb_filename][0] + return self.downloadReturnId(nzb_id) + except: + log.error('Something went wrong sending the NZB file: %s', traceback.format_exc()) + return False + + def test(self): + try: + login_result = self.login() + except: + return False + + return login_result + + def getAllDownloadStatus(self, ids): + + raw_statuses = self.call('nzb') + + release_downloads = ReleaseDownloadList(self) + for nzb in raw_statuses.get('nzbs', []): + if nzb['id'] in ids: + + # Check status + status = 'busy' + if nzb['state'] == 20: + status = 'completed' + elif nzb['state'] in [21, 22, 24]: + status = 'failed' + + release_downloads.append({ + 'id': nzb['id'], + 'name': nzb['uiTitle'], + 'status': status, + 'original_status': nzb['state'], + 'timeleft': -1, + 'folder': sp(nzb['destinationPath']), + }) + + return release_downloads + + def removeFailed(self, release_download): + + log.info('%s failed downloading, deleting...', release_download['name']) + + try: + self.call('nzb/%s/cancel' % release_download['id']) + except: + log.error('Failed deleting: %s', traceback.format_exc(0)) + return False + + return True + + def login(self): + + nonce = self.call('auth/nonce', auth = False).get('authNonce') + cnonce = uuid4().hex + hashed = b64encode(hashlib.sha256('%s:%s:%s' % (nonce, cnonce, self.conf('api_key'))).digest()) + + params = { + 'nonce': nonce, + 'cnonce': cnonce, + 'hash': hashed + } + + login_data = self.call('auth/login', parameters = params, auth = False) + + # Save for later + if login_data.get('loginResult') == 'successful': + self.session_id = login_data.get('sessionID') + return True + + log.error('Login failed, please check you api-key') + return False + + def call(self, call, parameters = None, repeat = False, auth = True, *args, **kwargs): + + # Login first + if not parameters: parameters = {} + if not self.session_id and auth: + self.login() + + # Always add session id to request + if self.session_id: + parameters['sessionid'] = self.session_id + + params = tryUrlencode(parameters) + + url = cleanHost(self.conf('host'), ssl = self.conf('ssl')) + 'api/' + call + + try: + data = self.urlopen('%s?%s' % (url, params), *args, **kwargs) + + if data: + return json.loads(data) + except URLError as e: + if hasattr(e, 'code') and e.code == 403: + # Try login and do again + if not repeat: + self.login() + return self.call(call, parameters = parameters, repeat = True, **kwargs) + + log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc())) + except: + log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc())) + + return {} + + def getApiLevel(self): + + if not self.api_level: + + url = cleanHost(self.conf('host')) + 'api/app/apilevel' + + try: + data = self.urlopen(url, show_error = False) + self.api_level = float(json.loads(data).get('apilevel')) + except URLError as e: + if hasattr(e, 'code') and e.code == 403: + log.error('This version of NZBVortex isn\'t supported. Please update to 2.8.6 or higher') + else: + log.error('NZBVortex doesn\'t seem to be running or maybe the remote option isn\'t enabled yet: %s', traceback.format_exc(1)) + + return self.api_level + + def isEnabled(self, manual = False, data = None): + if not data: data = {} + return super(NZBVortex, self).isEnabled(manual, data) and self.getApiLevel() + + +class HTTPSConnection(httplib.HTTPSConnection): + def __init__(self, *args, **kwargs): + httplib.HTTPSConnection.__init__(self, *args, **kwargs) + + def connect(self): + sock = socket.create_connection((self.host, self.port), self.timeout) + if sys.version_info < (2, 6, 7): + if hasattr(self, '_tunnel_host'): + self.sock = sock + self._tunnel() + else: + if self._tunnel_host: + self.sock = sock + self._tunnel() + + self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, ssl_version = ssl.PROTOCOL_TLSv1) + + +class HTTPSHandler(urllib2.HTTPSHandler): + def https_open(self, req): + return self.do_open(HTTPSConnection, req) + + +config = [{ + 'name': 'nzbvortex', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'nzbvortex', + 'label': 'NZBVortex', + 'description': 'Use NZBVortex to download NZBs.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'nzb', + }, + { + 'name': 'host', + 'default': 'localhost:4321', + 'description': 'Hostname with port. Usually localhost:4321', + }, + { + 'name': 'ssl', + 'default': 1, + 'type': 'bool', + 'advanced': True, + 'description': 'Use HyperText Transfer Protocol Secure, or https', + }, + { + 'name': 'api_key', + 'label': 'Api Key', + }, + { + 'name': 'manual', + 'default': False, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/nzbvortex/__init__.py b/couchpotato/core/downloaders/nzbvortex/__init__.py deleted file mode 100644 index 1c2d699..0000000 --- a/couchpotato/core/downloaders/nzbvortex/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -from .main import NZBVortex - - -def start(): - return NZBVortex() - -config = [{ - 'name': 'nzbvortex', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'nzbvortex', - 'label': 'NZBVortex', - 'description': 'Use NZBVortex to download NZBs.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'nzb', - }, - { - 'name': 'host', - 'default': 'localhost:4321', - 'description': 'Hostname with port. Usually localhost:4321', - }, - { - 'name': 'ssl', - 'default': 1, - 'type': 'bool', - 'advanced': True, - 'description': 'Use HyperText Transfer Protocol Secure, or https', - }, - { - 'name': 'api_key', - 'label': 'Api Key', - }, - { - 'name': 'manual', - 'default': False, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - { - 'name': 'delete_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Delete a release after the download has failed.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/nzbvortex/main.py b/couchpotato/core/downloaders/nzbvortex/main.py deleted file mode 100644 index d1525c8..0000000 --- a/couchpotato/core/downloaders/nzbvortex/main.py +++ /dev/null @@ -1,188 +0,0 @@ -from base64 import b64encode -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.helpers.encoding import tryUrlencode, sp -from couchpotato.core.helpers.variable import cleanHost -from couchpotato.core.logger import CPLog -from urllib2 import URLError -from uuid import uuid4 -import hashlib -import httplib -import json -import os -import socket -import ssl -import sys -import time -import traceback -import urllib2 - -log = CPLog(__name__) - - -class NZBVortex(Downloader): - - protocol = ['nzb'] - api_level = None - session_id = None - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - # Send the nzb - try: - nzb_filename = self.createFileName(data, filedata, media) - self.call('nzb/add', files = {'file': (nzb_filename, filedata)}) - - time.sleep(10) - raw_statuses = self.call('nzb') - nzb_id = [nzb['id'] for nzb in raw_statuses.get('nzbs', []) if os.path.basename(nzb['nzbFileName']) == nzb_filename][0] - return self.downloadReturnId(nzb_id) - except: - log.error('Something went wrong sending the NZB file: %s', traceback.format_exc()) - return False - - def test(self): - try: - login_result = self.login() - except: - return False - - return login_result - - def getAllDownloadStatus(self, ids): - - raw_statuses = self.call('nzb') - - release_downloads = ReleaseDownloadList(self) - for nzb in raw_statuses.get('nzbs', []): - if nzb['id'] in ids: - - # Check status - status = 'busy' - if nzb['state'] == 20: - status = 'completed' - elif nzb['state'] in [21, 22, 24]: - status = 'failed' - - release_downloads.append({ - 'id': nzb['id'], - 'name': nzb['uiTitle'], - 'status': status, - 'original_status': nzb['state'], - 'timeleft': -1, - 'folder': sp(nzb['destinationPath']), - }) - - return release_downloads - - def removeFailed(self, release_download): - - log.info('%s failed downloading, deleting...', release_download['name']) - - try: - self.call('nzb/%s/cancel' % release_download['id']) - except: - log.error('Failed deleting: %s', traceback.format_exc(0)) - return False - - return True - - def login(self): - - nonce = self.call('auth/nonce', auth = False).get('authNonce') - cnonce = uuid4().hex - hashed = b64encode(hashlib.sha256('%s:%s:%s' % (nonce, cnonce, self.conf('api_key'))).digest()) - - params = { - 'nonce': nonce, - 'cnonce': cnonce, - 'hash': hashed - } - - login_data = self.call('auth/login', parameters = params, auth = False) - - # Save for later - if login_data.get('loginResult') == 'successful': - self.session_id = login_data.get('sessionID') - return True - - log.error('Login failed, please check you api-key') - return False - - def call(self, call, parameters = None, repeat = False, auth = True, *args, **kwargs): - - # Login first - if not parameters: parameters = {} - if not self.session_id and auth: - self.login() - - # Always add session id to request - if self.session_id: - parameters['sessionid'] = self.session_id - - params = tryUrlencode(parameters) - - url = cleanHost(self.conf('host'), ssl = self.conf('ssl')) + 'api/' + call - - try: - data = self.urlopen('%s?%s' % (url, params), *args, **kwargs) - - if data: - return json.loads(data) - except URLError as e: - if hasattr(e, 'code') and e.code == 403: - # Try login and do again - if not repeat: - self.login() - return self.call(call, parameters = parameters, repeat = True, **kwargs) - - log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc())) - except: - log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc())) - - return {} - - def getApiLevel(self): - - if not self.api_level: - - url = cleanHost(self.conf('host')) + 'api/app/apilevel' - - try: - data = self.urlopen(url, show_error = False) - self.api_level = float(json.loads(data).get('apilevel')) - except URLError as e: - if hasattr(e, 'code') and e.code == 403: - log.error('This version of NZBVortex isn\'t supported. Please update to 2.8.6 or higher') - else: - log.error('NZBVortex doesn\'t seem to be running or maybe the remote option isn\'t enabled yet: %s', traceback.format_exc(1)) - - return self.api_level - - def isEnabled(self, manual = False, data = None): - if not data: data = {} - return super(NZBVortex, self).isEnabled(manual, data) and self.getApiLevel() - - -class HTTPSConnection(httplib.HTTPSConnection): - def __init__(self, *args, **kwargs): - httplib.HTTPSConnection.__init__(self, *args, **kwargs) - - def connect(self): - sock = socket.create_connection((self.host, self.port), self.timeout) - if sys.version_info < (2, 6, 7): - if hasattr(self, '_tunnel_host'): - self.sock = sock - self._tunnel() - else: - if self._tunnel_host: - self.sock = sock - self._tunnel() - - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, ssl_version = ssl.PROTOCOL_TLSv1) - - -class HTTPSHandler(urllib2.HTTPSHandler): - def https_open(self, req): - return self.do_open(HTTPSConnection, req) diff --git a/couchpotato/core/downloaders/pneumatic.py b/couchpotato/core/downloaders/pneumatic.py new file mode 100644 index 0000000..823cd38 --- /dev/null +++ b/couchpotato/core/downloaders/pneumatic.py @@ -0,0 +1,109 @@ +from __future__ import with_statement +from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.encoding import sp +from couchpotato.core.logger import CPLog +import os +import traceback + +log = CPLog(__name__) + +autoload = 'Pneumatic' + + +class Pneumatic(Downloader): + + protocol = ['nzb'] + strm_syntax = 'plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb=%s&nzbname=%s' + status_support = False + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + directory = self.conf('directory') + if not directory or not os.path.isdir(directory): + log.error('No directory set for .strm downloads.') + else: + try: + if not filedata or len(filedata) < 50: + log.error('No nzb available!') + return False + + full_path = os.path.join(directory, self.createFileName(data, filedata, media)) + + try: + if not os.path.isfile(full_path): + log.info('Downloading %s to %s.', (data.get('protocol'), full_path)) + with open(full_path, 'wb') as f: + f.write(filedata) + + nzb_name = self.createNzbName(data, media) + strm_path = os.path.join(directory, nzb_name) + + strm_file = open(strm_path + '.strm', 'wb') + strmContent = self.strm_syntax % (full_path, nzb_name) + strm_file.write(strmContent) + strm_file.close() + + return self.downloadReturnId('') + + else: + log.info('File %s already exists.', full_path) + return self.downloadReturnId('') + + except: + log.error('Failed to download .strm: %s', traceback.format_exc()) + pass + + except: + log.info('Failed to download file %s: %s', (data.get('name'), traceback.format_exc())) + return False + return False + + def test(self): + directory = self.conf('directory') + if directory and os.path.isdir(directory): + + test_file = sp(os.path.join(directory, 'couchpotato_test.txt')) + + # Check if folder is writable + self.createFile(test_file, 'This is a test file') + if os.path.isfile(test_file): + os.remove(test_file) + return True + + return False + + +config = [{ + 'name': 'pneumatic', + 'order': 30, + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'pneumatic', + 'label': 'Pneumatic', + 'description': 'Use Pneumatic to download .strm files.', + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Directory where the .strm file is saved to.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/pneumatic/__init__.py b/couchpotato/core/downloaders/pneumatic/__init__.py deleted file mode 100644 index 698643f..0000000 --- a/couchpotato/core/downloaders/pneumatic/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -from .main import Pneumatic - - -def start(): - return Pneumatic() - -config = [{ - 'name': 'pneumatic', - 'order': 30, - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'pneumatic', - 'label': 'Pneumatic', - 'description': 'Use Pneumatic to download .strm files.', - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - }, - { - 'name': 'directory', - 'type': 'directory', - 'description': 'Directory where the .strm file is saved to.', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/pneumatic/main.py b/couchpotato/core/downloaders/pneumatic/main.py deleted file mode 100644 index bc1f6d0..0000000 --- a/couchpotato/core/downloaders/pneumatic/main.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import with_statement -from couchpotato.core.downloaders.base import Downloader -from couchpotato.core.helpers.encoding import sp -from couchpotato.core.logger import CPLog -import os -import traceback - -log = CPLog(__name__) - - -class Pneumatic(Downloader): - - protocol = ['nzb'] - strm_syntax = 'plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb=%s&nzbname=%s' - status_support = False - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - directory = self.conf('directory') - if not directory or not os.path.isdir(directory): - log.error('No directory set for .strm downloads.') - else: - try: - if not filedata or len(filedata) < 50: - log.error('No nzb available!') - return False - - full_path = os.path.join(directory, self.createFileName(data, filedata, media)) - - try: - if not os.path.isfile(full_path): - log.info('Downloading %s to %s.', (data.get('protocol'), full_path)) - with open(full_path, 'wb') as f: - f.write(filedata) - - nzb_name = self.createNzbName(data, media) - strm_path = os.path.join(directory, nzb_name) - - strm_file = open(strm_path + '.strm', 'wb') - strmContent = self.strm_syntax % (full_path, nzb_name) - strm_file.write(strmContent) - strm_file.close() - - return self.downloadReturnId('') - - else: - log.info('File %s already exists.', full_path) - return self.downloadReturnId('') - - except: - log.error('Failed to download .strm: %s', traceback.format_exc()) - pass - - except: - log.info('Failed to download file %s: %s', (data.get('name'), traceback.format_exc())) - return False - return False - - def test(self): - directory = self.conf('directory') - if directory and os.path.isdir(directory): - - test_file = sp(os.path.join(directory, 'couchpotato_test.txt')) - - # Check if folder is writable - self.createFile(test_file, 'This is a test file') - if os.path.isfile(test_file): - os.remove(test_file) - return True - - return False diff --git a/couchpotato/core/downloaders/rtorrent.py b/couchpotato/core/downloaders/rtorrent.py new file mode 100644 index 0000000..e2936ca --- /dev/null +++ b/couchpotato/core/downloaders/rtorrent.py @@ -0,0 +1,384 @@ +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.event import fireEvent, addEvent +from couchpotato.core.helpers.encoding import sp +from couchpotato.core.helpers.variable import cleanHost, splitString +from couchpotato.core.logger import CPLog +from base64 import b16encode, b32decode +from bencode import bencode, bdecode +from datetime import timedelta +from hashlib import sha1 +from rtorrent import RTorrent +from rtorrent.err import MethodError +from urlparse import urlparse +import os +from scandir import scandir + +log = CPLog(__name__) + +autoload = 'rTorrent' + + +class rTorrent(Downloader): + + protocol = ['torrent', 'torrent_magnet'] + rt = None + error_msg = '' + + # Migration url to host options + def __init__(self): + super(rTorrent, self).__init__() + + addEvent('app.load', self.migrate) + addEvent('setting.save.rtorrent.*.after', self.settingsChanged) + + def migrate(self): + + url = self.conf('url') + if url: + host_split = splitString(url.split('://')[-1], split_on = '/') + + self.conf('ssl', value = url.startswith('https')) + self.conf('host', value = host_split[0].strip()) + self.conf('rpc_url', value = '/'.join(host_split[1:])) + + self.deleteConf('url') + + def settingsChanged(self): + # Reset active connection if settings have changed + if self.rt: + log.debug('Settings have changed, closing active connection') + + self.rt = None + return True + + def connect(self, reconnect = False): + # Already connected? + if not reconnect and self.rt is not None: + return self.rt + + url = cleanHost(self.conf('host'), protocol = True, ssl = self.conf('ssl')) + parsed = urlparse(url) + + # rpc_url is only used on http/https scgi pass-through + if parsed.scheme in ['http', 'https']: + url += self.conf('rpc_url') + + if self.conf('username') and self.conf('password'): + self.rt = RTorrent( + url, + self.conf('username'), + self.conf('password') + ) + else: + self.rt = RTorrent(url) + + self.error_msg = '' + try: + self.rt._verify_conn() + except AssertionError as e: + self.error_msg = e.message + self.rt = None + + return self.rt + + def test(self): + if self.connect(True): + return True + + if self.error_msg: + return False, 'Connection failed: ' + self.error_msg + + return False + + def updateProviderGroup(self, name, data): + if data.get('seed_time'): + log.info('seeding time ignored, not supported') + + if not name: + return False + + if not self.connect(): + return False + + views = self.rt.get_views() + + if name not in views: + self.rt.create_group(name) + + group = self.rt.get_group(name) + + try: + if data.get('seed_ratio'): + ratio = int(float(data.get('seed_ratio')) * 100) + log.debug('Updating provider ratio to %s, group name: %s', (ratio, name)) + + # Explicitly set all group options to ensure it is setup correctly + group.set_upload('1M') + group.set_min(ratio) + group.set_max(ratio) + group.set_command('d.stop') + group.enable() + else: + # Reset group action and disable it + group.set_command() + group.disable() + except MethodError as err: + log.error('Unable to set group options: %s', err.msg) + return False + + return True + + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + log.debug('Sending "%s" to rTorrent.', (data.get('name'))) + + if not self.connect(): + return False + + group_name = 'cp_' + data.get('provider').lower() + if not self.updateProviderGroup(group_name, data): + return False + + torrent_params = {} + if self.conf('label'): + torrent_params['label'] = self.conf('label') + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + # Try download magnet torrents + if data.get('protocol') == 'torrent_magnet': + filedata = self.magnetToTorrent(data.get('url')) + + if filedata is False: + return False + + data['protocol'] = 'torrent' + + info = bdecode(filedata)["info"] + torrent_hash = sha1(bencode(info)).hexdigest().upper() + + # Convert base 32 to hex + if len(torrent_hash) == 32: + torrent_hash = b16encode(b32decode(torrent_hash)) + + # Send request to rTorrent + try: + # Send torrent to rTorrent + torrent = self.rt.load_torrent(filedata, verify_retries=10) + + if not torrent: + log.error('Unable to find the torrent, did it fail to load?') + return False + + # Set label + if self.conf('label'): + torrent.set_custom(1, self.conf('label')) + + if self.conf('directory'): + torrent.set_directory(self.conf('directory')) + + # Set Ratio Group + torrent.set_visible(group_name) + + # Start torrent + if not self.conf('paused', default = 0): + torrent.start() + + return self.downloadReturnId(torrent_hash) + except Exception as err: + log.error('Failed to send torrent to rTorrent: %s', err) + return False + + def getTorrentStatus(self, torrent): + if torrent.hashing or torrent.hash_checking or torrent.message: + return 'busy' + + if not torrent.complete: + return 'busy' + + if not torrent.open: + return 'completed' + + if torrent.state and torrent.active: + return 'seeding' + + return 'busy' + + def getAllDownloadStatus(self, ids): + log.debug('Checking rTorrent download status.') + + if not self.connect(): + return [] + + try: + torrents = self.rt.get_torrents() + + release_downloads = ReleaseDownloadList(self) + + for torrent in torrents: + if torrent.info_hash in ids: + torrent_directory = os.path.normpath(torrent.directory) + torrent_files = [] + + for file in torrent.get_files(): + if not os.path.normpath(file.path).startswith(torrent_directory): + file_path = os.path.join(torrent_directory, file.path.lstrip('/')) + else: + file_path = file.path + + torrent_files.append(sp(file_path)) + + release_downloads.append({ + 'id': torrent.info_hash, + 'name': torrent.name, + 'status': self.getTorrentStatus(torrent), + 'seed_ratio': torrent.ratio, + 'original_status': torrent.state, + 'timeleft': str(timedelta(seconds = float(torrent.left_bytes) / torrent.down_rate)) if torrent.down_rate > 0 else -1, + 'folder': sp(torrent.directory), + 'files': '|'.join(torrent_files) + }) + + return release_downloads + + except Exception as err: + log.error('Failed to get status from rTorrent: %s', err) + return [] + + def pause(self, release_download, pause = True): + if not self.connect(): + return False + + torrent = self.rt.find_torrent(release_download['id']) + if torrent is None: + return False + + if pause: + return torrent.pause() + return torrent.resume() + + def removeFailed(self, release_download): + log.info('%s failed downloading, deleting...', release_download['name']) + return self.processComplete(release_download, delete_files = True) + + def processComplete(self, release_download, delete_files): + log.debug('Requesting rTorrent to remove the torrent %s%s.', + (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) + + if not self.connect(): + return False + + torrent = self.rt.find_torrent(release_download['id']) + + if torrent is None: + return False + + if delete_files: + for file_item in torrent.get_files(): # will only delete files, not dir/sub-dir + os.unlink(os.path.join(torrent.directory, file_item.path)) + + if torrent.is_multi_file() and torrent.directory.endswith(torrent.name): + # Remove empty directories bottom up + try: + for path, _, _ in scandir.walk(torrent.directory, topdown = False): + os.rmdir(path) + except OSError: + log.info('Directory "%s" contains extra files, unable to remove', torrent.directory) + + torrent.erase() # just removes the torrent, doesn't delete data + + return True + + +config = [{ + 'name': 'rtorrent', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'rtorrent', + 'label': 'rTorrent', + 'description': '', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'host', + 'default': 'localhost:80', + 'description': 'RPC Communication URI. Usually scgi://localhost:5000, ' + 'httprpc://localhost/rutorrent or localhost:80' + }, + { + 'name': 'ssl', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Use HyperText Transfer Protocol Secure, or https', + }, + { + 'name': 'rpc_url', + 'type': 'string', + 'default': 'RPC2', + 'advanced': True, + 'description': 'Change if your RPC mount is at a different path.', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'label', + 'description': 'Label to apply on added torrents.', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Download to this directory. Keep empty for default rTorrent download directory.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': False, + 'advanced': True, + 'type': 'bool', + 'description': 'Remove the torrent after it finishes seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py deleted file mode 100755 index f793cad..0000000 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ /dev/null @@ -1,100 +0,0 @@ -from .main import rTorrent - - -def start(): - return rTorrent() - -config = [{ - 'name': 'rtorrent', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'rtorrent', - 'label': 'rTorrent', - 'description': '', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'torrent', - }, -# @RuudBurger: How do I migrate this? -# { -# 'name': 'url', -# 'default': 'http://localhost:80/RPC2', -# 'description': 'XML-RPC Endpoint URI. Usually scgi://localhost:5000 ' -# 'or http://localhost:80/RPC2' -# }, - { - 'name': 'host', - 'default': 'localhost:80', - 'description': 'RPC Communication URI. Usually scgi://localhost:5000, ' - 'httprpc://localhost/rutorrent or localhost:80' - }, - { - 'name': 'ssl', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Use HyperText Transfer Protocol Secure, or https', - }, - { - 'name': 'rpc_url', - 'type': 'string', - 'default': 'RPC2', - 'advanced': True, - 'description': 'Change if your RPC mount is at a different path.', - }, - { - 'name': 'username', - }, - { - 'name': 'password', - 'type': 'password', - }, - { - 'name': 'label', - 'description': 'Label to apply on added torrents.', - }, - { - 'name': 'directory', - 'type': 'directory', - 'description': 'Download to this directory. Keep empty for default rTorrent download directory.', - }, - { - 'name': 'remove_complete', - 'label': 'Remove torrent', - 'default': False, - 'advanced': True, - 'type': 'bool', - 'description': 'Remove the torrent after it finishes seeding.', - }, - { - 'name': 'delete_files', - 'label': 'Remove files', - 'default': True, - 'type': 'bool', - 'advanced': True, - 'description': 'Also remove the leftover files.', - }, - { - 'name': 'paused', - 'type': 'bool', - 'advanced': True, - 'default': False, - 'description': 'Add the torrent paused.', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py deleted file mode 100755 index 79ab948..0000000 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ /dev/null @@ -1,293 +0,0 @@ -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.event import fireEvent, addEvent -from couchpotato.core.helpers.encoding import sp -from couchpotato.core.helpers.variable import cleanHost, splitString -from couchpotato.core.logger import CPLog -from base64 import b16encode, b32decode -from bencode import bencode, bdecode -from datetime import timedelta -from hashlib import sha1 -from rtorrent import RTorrent -from rtorrent.err import MethodError -from urlparse import urlparse -import os -from scandir import scandir - -log = CPLog(__name__) - - -class rTorrent(Downloader): - - protocol = ['torrent', 'torrent_magnet'] - rt = None - error_msg = '' - - # Migration url to host options - def __init__(self): - super(rTorrent, self).__init__() - - addEvent('app.load', self.migrate) - addEvent('setting.save.rtorrent.*.after', self.settingsChanged) - - def migrate(self): - - url = self.conf('url') - if url: - host_split = splitString(url.split('://')[-1], split_on = '/') - - self.conf('ssl', value = url.startswith('https')) - self.conf('host', value = host_split[0].strip()) - self.conf('rpc_url', value = '/'.join(host_split[1:])) - - self.deleteConf('url') - - def settingsChanged(self): - # Reset active connection if settings have changed - if self.rt: - log.debug('Settings have changed, closing active connection') - - self.rt = None - return True - - def connect(self, reconnect = False): - # Already connected? - if not reconnect and self.rt is not None: - return self.rt - - url = cleanHost(self.conf('host'), protocol = True, ssl = self.conf('ssl')) - parsed = urlparse(url) - - # rpc_url is only used on http/https scgi pass-through - if parsed.scheme in ['http', 'https']: - url += self.conf('rpc_url') - - if self.conf('username') and self.conf('password'): - self.rt = RTorrent( - url, - self.conf('username'), - self.conf('password') - ) - else: - self.rt = RTorrent(url) - - self.error_msg = '' - try: - self.rt._verify_conn() - except AssertionError as e: - self.error_msg = e.message - self.rt = None - - return self.rt - - def test(self): - if self.connect(True): - return True - - if self.error_msg: - return False, 'Connection failed: ' + self.error_msg - - return False - - def updateProviderGroup(self, name, data): - if data.get('seed_time'): - log.info('seeding time ignored, not supported') - - if not name: - return False - - if not self.connect(): - return False - - views = self.rt.get_views() - - if name not in views: - self.rt.create_group(name) - - group = self.rt.get_group(name) - - try: - if data.get('seed_ratio'): - ratio = int(float(data.get('seed_ratio')) * 100) - log.debug('Updating provider ratio to %s, group name: %s', (ratio, name)) - - # Explicitly set all group options to ensure it is setup correctly - group.set_upload('1M') - group.set_min(ratio) - group.set_max(ratio) - group.set_command('d.stop') - group.enable() - else: - # Reset group action and disable it - group.set_command() - group.disable() - except MethodError as err: - log.error('Unable to set group options: %s', err.msg) - return False - - return True - - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - log.debug('Sending "%s" to rTorrent.', (data.get('name'))) - - if not self.connect(): - return False - - group_name = 'cp_' + data.get('provider').lower() - if not self.updateProviderGroup(group_name, data): - return False - - torrent_params = {} - if self.conf('label'): - torrent_params['label'] = self.conf('label') - - if not filedata and data.get('protocol') == 'torrent': - log.error('Failed sending torrent, no data') - return False - - # Try download magnet torrents - if data.get('protocol') == 'torrent_magnet': - filedata = self.magnetToTorrent(data.get('url')) - - if filedata is False: - return False - - data['protocol'] = 'torrent' - - info = bdecode(filedata)["info"] - torrent_hash = sha1(bencode(info)).hexdigest().upper() - - # Convert base 32 to hex - if len(torrent_hash) == 32: - torrent_hash = b16encode(b32decode(torrent_hash)) - - # Send request to rTorrent - try: - # Send torrent to rTorrent - torrent = self.rt.load_torrent(filedata, verify_retries=10) - - if not torrent: - log.error('Unable to find the torrent, did it fail to load?') - return False - - # Set label - if self.conf('label'): - torrent.set_custom(1, self.conf('label')) - - if self.conf('directory'): - torrent.set_directory(self.conf('directory')) - - # Set Ratio Group - torrent.set_visible(group_name) - - # Start torrent - if not self.conf('paused', default = 0): - torrent.start() - - return self.downloadReturnId(torrent_hash) - except Exception as err: - log.error('Failed to send torrent to rTorrent: %s', err) - return False - - def getTorrentStatus(self, torrent): - if torrent.hashing or torrent.hash_checking or torrent.message: - return 'busy' - - if not torrent.complete: - return 'busy' - - if not torrent.open: - return 'completed' - - if torrent.state and torrent.active: - return 'seeding' - - return 'busy' - - def getAllDownloadStatus(self, ids): - log.debug('Checking rTorrent download status.') - - if not self.connect(): - return [] - - try: - torrents = self.rt.get_torrents() - - release_downloads = ReleaseDownloadList(self) - - for torrent in torrents: - if torrent.info_hash in ids: - torrent_directory = os.path.normpath(torrent.directory) - torrent_files = [] - - for file in torrent.get_files(): - if not os.path.normpath(file.path).startswith(torrent_directory): - file_path = os.path.join(torrent_directory, file.path.lstrip('/')) - else: - file_path = file.path - - torrent_files.append(sp(file_path)) - - release_downloads.append({ - 'id': torrent.info_hash, - 'name': torrent.name, - 'status': self.getTorrentStatus(torrent), - 'seed_ratio': torrent.ratio, - 'original_status': torrent.state, - 'timeleft': str(timedelta(seconds = float(torrent.left_bytes) / torrent.down_rate)) if torrent.down_rate > 0 else -1, - 'folder': sp(torrent.directory), - 'files': '|'.join(torrent_files) - }) - - return release_downloads - - except Exception as err: - log.error('Failed to get status from rTorrent: %s', err) - return [] - - def pause(self, release_download, pause = True): - if not self.connect(): - return False - - torrent = self.rt.find_torrent(release_download['id']) - if torrent is None: - return False - - if pause: - return torrent.pause() - return torrent.resume() - - def removeFailed(self, release_download): - log.info('%s failed downloading, deleting...', release_download['name']) - return self.processComplete(release_download, delete_files = True) - - def processComplete(self, release_download, delete_files): - log.debug('Requesting rTorrent to remove the torrent %s%s.', - (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) - - if not self.connect(): - return False - - torrent = self.rt.find_torrent(release_download['id']) - - if torrent is None: - return False - - if delete_files: - for file_item in torrent.get_files(): # will only delete files, not dir/sub-dir - os.unlink(os.path.join(torrent.directory, file_item.path)) - - if torrent.is_multi_file() and torrent.directory.endswith(torrent.name): - # Remove empty directories bottom up - try: - for path, _, _ in scandir.walk(torrent.directory, topdown = False): - os.rmdir(path) - except OSError: - log.info('Directory "%s" contains extra files, unable to remove', torrent.directory) - - torrent.erase() # just removes the torrent, doesn't delete data - - return True diff --git a/couchpotato/core/downloaders/sabnzbd.py b/couchpotato/core/downloaders/sabnzbd.py new file mode 100644 index 0000000..33e51fa --- /dev/null +++ b/couchpotato/core/downloaders/sabnzbd.py @@ -0,0 +1,279 @@ +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.helpers.encoding import tryUrlencode, ss, sp +from couchpotato.core.helpers.variable import cleanHost, mergeDicts +from couchpotato.core.logger import CPLog +from couchpotato.environment import Env +from datetime import timedelta +from urllib2 import URLError +import json +import os +import traceback + +log = CPLog(__name__) + +autoload = 'Sabnzbd' + + +class Sabnzbd(Downloader): + + protocol = ['nzb'] + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + log.info('Sending "%s" to SABnzbd.', data.get('name')) + + req_params = { + 'cat': self.conf('category'), + 'mode': 'addurl', + 'nzbname': self.createNzbName(data, media), + 'priority': self.conf('priority'), + } + + nzb_filename = None + if filedata: + if len(filedata) < 50: + log.error('No proper nzb available: %s', filedata) + return False + + # If it's a .rar, it adds the .rar extension, otherwise it stays .nzb + nzb_filename = self.createFileName(data, filedata, media) + req_params['mode'] = 'addfile' + else: + req_params['name'] = data.get('url') + + try: + if nzb_filename and req_params.get('mode') is 'addfile': + sab_data = self.call(req_params, files = {'nzbfile': (ss(nzb_filename), filedata)}) + else: + sab_data = self.call(req_params) + except URLError: + log.error('Failed sending release, probably wrong HOST: %s', traceback.format_exc(0)) + return False + except: + log.error('Failed sending release, use API key, NOT the NZB key: %s', traceback.format_exc(0)) + return False + + log.debug('Result from SAB: %s', sab_data) + if sab_data.get('status') and not sab_data.get('error'): + log.info('NZB sent to SAB successfully.') + if filedata: + return self.downloadReturnId(sab_data.get('nzo_ids')[0]) + else: + return True + else: + log.error('Error getting data from SABNZBd: %s', sab_data) + return False + + def test(self): + try: + sab_data = self.call({ + 'mode': 'version', + }) + v = sab_data.split('.') + if int(v[0]) == 0 and int(v[1]) < 7: + return False, 'Your Sabnzbd client is too old, please update to newest version.' + + # the version check will work even with wrong api key, so we need the next check as well + sab_data = self.call({ + 'mode': 'qstatus', + }) + if not sab_data: + return False + except: + return False + + return True + + def getAllDownloadStatus(self, ids): + + log.debug('Checking SABnzbd download status.') + + # Go through Queue + try: + queue = self.call({ + 'mode': 'queue', + }) + except: + log.error('Failed getting queue: %s', traceback.format_exc(1)) + return [] + + # Go through history items + try: + history = self.call({ + 'mode': 'history', + 'limit': 15, + }) + except: + log.error('Failed getting history json: %s', traceback.format_exc(1)) + return [] + + release_downloads = ReleaseDownloadList(self) + + # Get busy releases + for nzb in queue.get('slots', []): + if nzb['nzo_id'] in ids: + status = 'busy' + if 'ENCRYPTED / ' in nzb['filename']: + status = 'failed' + + release_downloads.append({ + 'id': nzb['nzo_id'], + 'name': nzb['filename'], + 'status': status, + 'original_status': nzb['status'], + 'timeleft': nzb['timeleft'] if not queue['paused'] else -1, + }) + + # Get old releases + for nzb in history.get('slots', []): + if nzb['nzo_id'] in ids: + status = 'busy' + if nzb['status'] == 'Failed' or (nzb['status'] == 'Completed' and nzb['fail_message'].strip()): + status = 'failed' + elif nzb['status'] == 'Completed': + status = 'completed' + + release_downloads.append({ + 'id': nzb['nzo_id'], + 'name': nzb['name'], + 'status': status, + 'original_status': nzb['status'], + 'timeleft': str(timedelta(seconds = 0)), + 'folder': sp(os.path.dirname(nzb['storage']) if os.path.isfile(nzb['storage']) else nzb['storage']), + }) + + return release_downloads + + def removeFailed(self, release_download): + + log.info('%s failed downloading, deleting...', release_download['name']) + + try: + self.call({ + 'mode': 'queue', + 'name': 'delete', + 'del_files': '1', + 'value': release_download['id'] + }, use_json = False) + self.call({ + 'mode': 'history', + 'name': 'delete', + 'del_files': '1', + 'value': release_download['id'] + }, use_json = False) + except: + log.error('Failed deleting: %s', traceback.format_exc(0)) + return False + + return True + + def processComplete(self, release_download, delete_files = False): + log.debug('Requesting SabNZBd to remove the NZB %s.', release_download['name']) + + try: + self.call({ + 'mode': 'history', + 'name': 'delete', + 'del_files': '0', + 'value': release_download['id'] + }, use_json = False) + except: + log.error('Failed removing: %s', traceback.format_exc(0)) + return False + + return True + + def call(self, request_params, use_json = True, **kwargs): + + url = cleanHost(self.conf('host'), ssl = self.conf('ssl')) + 'api?' + tryUrlencode(mergeDicts(request_params, { + 'apikey': self.conf('api_key'), + 'output': 'json' + })) + + data = self.urlopen(url, timeout = 60, show_error = False, headers = {'User-Agent': Env.getIdentifier()}, **kwargs) + if use_json: + d = json.loads(data) + if d.get('error'): + log.error('Error getting data from SABNZBd: %s', d.get('error')) + return {} + + return d.get(request_params['mode']) or d + else: + return data + + +config = [{ + 'name': 'sabnzbd', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'sabnzbd', + 'label': 'Sabnzbd', + 'description': 'Use SABnzbd (0.7+) to download NZBs.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'nzb', + }, + { + 'name': 'host', + 'default': 'localhost:8080', + }, + { + 'name': 'ssl', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Use HyperText Transfer Protocol Secure, or https', + }, + { + 'name': 'api_key', + 'label': 'Api Key', + 'description': 'Used for all calls to Sabnzbd.', + }, + { + 'name': 'category', + 'label': 'Category', + 'description': 'The category CP places the nzb in. Like movies or couchpotato', + }, + { + 'name': 'priority', + 'label': 'Priority', + 'type': 'dropdown', + 'default': '0', + 'advanced': True, + 'values': [('Paused', -2), ('Low', -1), ('Normal', 0), ('High', 1), ('Forced', 2)], + 'description': 'Add to the queue with this priority.', + }, + { + 'name': 'manual', + 'default': False, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'remove_complete', + 'advanced': True, + 'label': 'Remove NZB', + 'default': False, + 'type': 'bool', + 'description': 'Remove the NZB from history after it completed.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/sabnzbd/__init__.py b/couchpotato/core/downloaders/sabnzbd/__init__.py deleted file mode 100644 index 2990078..0000000 --- a/couchpotato/core/downloaders/sabnzbd/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -from .main import Sabnzbd - - -def start(): - return Sabnzbd() - -config = [{ - 'name': 'sabnzbd', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'sabnzbd', - 'label': 'Sabnzbd', - 'description': 'Use SABnzbd (0.7+) to download NZBs.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'nzb', - }, - { - 'name': 'host', - 'default': 'localhost:8080', - }, - { - 'name': 'ssl', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Use HyperText Transfer Protocol Secure, or https', - }, - { - 'name': 'api_key', - 'label': 'Api Key', - 'description': 'Used for all calls to Sabnzbd.', - }, - { - 'name': 'category', - 'label': 'Category', - 'description': 'The category CP places the nzb in. Like movies or couchpotato', - }, - { - 'name': 'priority', - 'label': 'Priority', - 'type': 'dropdown', - 'default': '0', - 'advanced': True, - 'values': [('Paused', -2), ('Low', -1), ('Normal', 0), ('High', 1), ('Forced', 2)], - 'description': 'Add to the queue with this priority.', - }, - { - 'name': 'manual', - 'default': False, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - { - 'name': 'remove_complete', - 'advanced': True, - 'label': 'Remove NZB', - 'default': False, - 'type': 'bool', - 'description': 'Remove the NZB from history after it completed.', - }, - { - 'name': 'delete_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Delete a release after the download has failed.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py deleted file mode 100644 index ba58c09..0000000 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ /dev/null @@ -1,203 +0,0 @@ -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.helpers.encoding import tryUrlencode, ss, sp -from couchpotato.core.helpers.variable import cleanHost, mergeDicts -from couchpotato.core.logger import CPLog -from couchpotato.environment import Env -from datetime import timedelta -from urllib2 import URLError -import json -import os -import traceback - -log = CPLog(__name__) - - -class Sabnzbd(Downloader): - - protocol = ['nzb'] - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - log.info('Sending "%s" to SABnzbd.', data.get('name')) - - req_params = { - 'cat': self.conf('category'), - 'mode': 'addurl', - 'nzbname': self.createNzbName(data, media), - 'priority': self.conf('priority'), - } - - nzb_filename = None - if filedata: - if len(filedata) < 50: - log.error('No proper nzb available: %s', filedata) - return False - - # If it's a .rar, it adds the .rar extension, otherwise it stays .nzb - nzb_filename = self.createFileName(data, filedata, media) - req_params['mode'] = 'addfile' - else: - req_params['name'] = data.get('url') - - try: - if nzb_filename and req_params.get('mode') is 'addfile': - sab_data = self.call(req_params, files = {'nzbfile': (ss(nzb_filename), filedata)}) - else: - sab_data = self.call(req_params) - except URLError: - log.error('Failed sending release, probably wrong HOST: %s', traceback.format_exc(0)) - return False - except: - log.error('Failed sending release, use API key, NOT the NZB key: %s', traceback.format_exc(0)) - return False - - log.debug('Result from SAB: %s', sab_data) - if sab_data.get('status') and not sab_data.get('error'): - log.info('NZB sent to SAB successfully.') - if filedata: - return self.downloadReturnId(sab_data.get('nzo_ids')[0]) - else: - return True - else: - log.error('Error getting data from SABNZBd: %s', sab_data) - return False - - def test(self): - try: - sab_data = self.call({ - 'mode': 'version', - }) - v = sab_data.split('.') - if int(v[0]) == 0 and int(v[1]) < 7: - return False, 'Your Sabnzbd client is too old, please update to newest version.' - - # the version check will work even with wrong api key, so we need the next check as well - sab_data = self.call({ - 'mode': 'qstatus', - }) - if not sab_data: - return False - except: - return False - - return True - - def getAllDownloadStatus(self, ids): - - log.debug('Checking SABnzbd download status.') - - # Go through Queue - try: - queue = self.call({ - 'mode': 'queue', - }) - except: - log.error('Failed getting queue: %s', traceback.format_exc(1)) - return [] - - # Go through history items - try: - history = self.call({ - 'mode': 'history', - 'limit': 15, - }) - except: - log.error('Failed getting history json: %s', traceback.format_exc(1)) - return [] - - release_downloads = ReleaseDownloadList(self) - - # Get busy releases - for nzb in queue.get('slots', []): - if nzb['nzo_id'] in ids: - status = 'busy' - if 'ENCRYPTED / ' in nzb['filename']: - status = 'failed' - - release_downloads.append({ - 'id': nzb['nzo_id'], - 'name': nzb['filename'], - 'status': status, - 'original_status': nzb['status'], - 'timeleft': nzb['timeleft'] if not queue['paused'] else -1, - }) - - # Get old releases - for nzb in history.get('slots', []): - if nzb['nzo_id'] in ids: - status = 'busy' - if nzb['status'] == 'Failed' or (nzb['status'] == 'Completed' and nzb['fail_message'].strip()): - status = 'failed' - elif nzb['status'] == 'Completed': - status = 'completed' - - release_downloads.append({ - 'id': nzb['nzo_id'], - 'name': nzb['name'], - 'status': status, - 'original_status': nzb['status'], - 'timeleft': str(timedelta(seconds = 0)), - 'folder': sp(os.path.dirname(nzb['storage']) if os.path.isfile(nzb['storage']) else nzb['storage']), - }) - - return release_downloads - - def removeFailed(self, release_download): - - log.info('%s failed downloading, deleting...', release_download['name']) - - try: - self.call({ - 'mode': 'queue', - 'name': 'delete', - 'del_files': '1', - 'value': release_download['id'] - }, use_json = False) - self.call({ - 'mode': 'history', - 'name': 'delete', - 'del_files': '1', - 'value': release_download['id'] - }, use_json = False) - except: - log.error('Failed deleting: %s', traceback.format_exc(0)) - return False - - return True - - def processComplete(self, release_download, delete_files = False): - log.debug('Requesting SabNZBd to remove the NZB %s.', release_download['name']) - - try: - self.call({ - 'mode': 'history', - 'name': 'delete', - 'del_files': '0', - 'value': release_download['id'] - }, use_json = False) - except: - log.error('Failed removing: %s', traceback.format_exc(0)) - return False - - return True - - def call(self, request_params, use_json = True, **kwargs): - - url = cleanHost(self.conf('host'), ssl = self.conf('ssl')) + 'api?' + tryUrlencode(mergeDicts(request_params, { - 'apikey': self.conf('api_key'), - 'output': 'json' - })) - - data = self.urlopen(url, timeout = 60, show_error = False, headers = {'User-Agent': Env.getIdentifier()}, **kwargs) - if use_json: - d = json.loads(data) - if d.get('error'): - log.error('Error getting data from SABNZBd: %s', d.get('error')) - return {} - - return d.get(request_params['mode']) or d - else: - return data - diff --git a/couchpotato/core/downloaders/synology.py b/couchpotato/core/downloaders/synology.py new file mode 100644 index 0000000..edc732a --- /dev/null +++ b/couchpotato/core/downloaders/synology.py @@ -0,0 +1,213 @@ +from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.encoding import isInt +from couchpotato.core.helpers.variable import cleanHost +from couchpotato.core.logger import CPLog +import json +import requests +import traceback + +log = CPLog(__name__) + +autoload = 'Synology' + + +class Synology(Downloader): + + protocol = ['nzb', 'torrent', 'torrent_magnet'] + status_support = False + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + response = False + log.error('Sending "%s" (%s) to Synology.', (data['name'], data['protocol'])) + + # Load host from config and split out port. + host = cleanHost(self.conf('host'), protocol = False).split(':') + if not isInt(host[1]): + log.error('Config properties are not filled in correctly, port is missing.') + return False + + try: + # Send request to Synology + srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) + if data['protocol'] == 'torrent_magnet': + log.info('Adding torrent URL %s', data['url']) + response = srpc.create_task(url = data['url']) + elif data['protocol'] in ['nzb', 'torrent']: + log.info('Adding %s' % data['protocol']) + if not filedata: + log.error('No %s data found', data['protocol']) + else: + filename = data['name'] + '.' + data['protocol'] + response = srpc.create_task(filename = filename, filedata = filedata) + except: + log.error('Exception while adding torrent: %s', traceback.format_exc()) + finally: + return self.downloadReturnId('') if response else False + + def test(self): + host = cleanHost(self.conf('host'), protocol = False).split(':') + try: + srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) + test_result = srpc.test() + except: + return False + + return test_result + + def getEnabledProtocol(self): + if self.conf('use_for') == 'both': + return super(Synology, self).getEnabledProtocol() + elif self.conf('use_for') == 'torrent': + return ['torrent', 'torrent_magnet'] + else: + return ['nzb'] + + def isEnabled(self, manual = False, data = None): + if not data: data = {} + + for_protocol = ['both'] + if data and 'torrent' in data.get('protocol'): + for_protocol.append('torrent') + elif data: + for_protocol.append(data.get('protocol')) + + return super(Synology, self).isEnabled(manual, data) and\ + ((self.conf('use_for') in for_protocol)) + + +class SynologyRPC(object): + + """SynologyRPC lite library""" + + def __init__(self, host = 'localhost', port = 5000, username = None, password = None): + + super(SynologyRPC, self).__init__() + + self.download_url = 'http://%s:%s/webapi/DownloadStation/task.cgi' % (host, port) + self.auth_url = 'http://%s:%s/webapi/auth.cgi' % (host, port) + self.username = username + self.password = password + self.session_name = 'DownloadStation' + + def _login(self): + if self.username and self.password: + args = {'api': 'SYNO.API.Auth', 'account': self.username, 'passwd': self.password, 'version': 2, + 'method': 'login', 'session': self.session_name, 'format': 'sid'} + response = self._req(self.auth_url, args) + if response['success']: + self.sid = response['data']['sid'] + log.debug('sid=%s', self.sid) + else: + log.error('Couldn\'t login to Synology, %s', response) + return response['success'] + else: + log.error('User or password missing, not using authentication.') + return False + + def _logout(self): + args = {'api':'SYNO.API.Auth', 'version':1, 'method':'logout', 'session':self.session_name, '_sid':self.sid} + return self._req(self.auth_url, args) + + def _req(self, url, args, files = None): + response = {'success': False} + try: + req = requests.post(url, data = args, files = files) + req.raise_for_status() + response = json.loads(req.text) + if response['success']: + log.info('Synology action successfull') + return response + except requests.ConnectionError as err: + log.error('Synology connection error, check your config %s', err) + except requests.HTTPError as err: + log.error('SynologyRPC HTTPError: %s', err) + except Exception as err: + log.error('Exception: %s', err) + finally: + return response + + def create_task(self, url = None, filename = None, filedata = None): + """ Creates new download task in Synology DownloadStation. Either specify + url or pair (filename, filedata). + + Returns True if task was created, False otherwise + """ + result = False + # login + if self._login(): + args = {'api': 'SYNO.DownloadStation.Task', + 'version': '1', + 'method': 'create', + '_sid': self.sid} + if url: + log.info('Login success, adding torrent URI') + args['uri'] = url + response = self._req(self.download_url, args = args) + log.info('Response: %s', response) + result = response['success'] + elif filename and filedata: + log.info('Login success, adding torrent') + files = {'file': (filename, filedata)} + response = self._req(self.download_url, args = args, files = files) + log.info('Response: %s', response) + result = response['success'] + else: + log.error('Invalid use of SynologyRPC.create_task: either url or filename+filedata must be specified') + self._logout() + + return result + + def test(self): + return bool(self._login()) + + +config = [{ + 'name': 'synology', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'synology', + 'label': 'Synology', + 'description': 'Use Synology Download Station to download.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'nzb,torrent', + }, + { + 'name': 'host', + 'default': 'localhost:5000', + 'description': 'Hostname with port. Usually localhost:5000', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'use_for', + 'label': 'Use for', + 'default': 'both', + 'type': 'dropdown', + 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrent', 'torrent')], + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/synology/__init__.py b/couchpotato/core/downloaders/synology/__init__.py deleted file mode 100644 index d0c57c2..0000000 --- a/couchpotato/core/downloaders/synology/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -from .main import Synology - - -def start(): - return Synology() - -config = [{ - 'name': 'synology', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'synology', - 'label': 'Synology', - 'description': 'Use Synology Download Station to download.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'nzb,torrent', - }, - { - 'name': 'host', - 'default': 'localhost:5000', - 'description': 'Hostname with port. Usually localhost:5000', - }, - { - 'name': 'username', - }, - { - 'name': 'password', - 'type': 'password', - }, - { - 'name': 'use_for', - 'label': 'Use for', - 'default': 'both', - 'type': 'dropdown', - 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrent', 'torrent')], - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py deleted file mode 100644 index 7e5b609..0000000 --- a/couchpotato/core/downloaders/synology/main.py +++ /dev/null @@ -1,162 +0,0 @@ -from couchpotato.core.downloaders.base import Downloader -from couchpotato.core.helpers.encoding import isInt -from couchpotato.core.helpers.variable import cleanHost -from couchpotato.core.logger import CPLog -import json -import requests -import traceback - -log = CPLog(__name__) - - -class Synology(Downloader): - - protocol = ['nzb', 'torrent', 'torrent_magnet'] - status_support = False - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - response = False - log.error('Sending "%s" (%s) to Synology.', (data['name'], data['protocol'])) - - # Load host from config and split out port. - host = cleanHost(self.conf('host'), protocol = False).split(':') - if not isInt(host[1]): - log.error('Config properties are not filled in correctly, port is missing.') - return False - - try: - # Send request to Synology - srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) - if data['protocol'] == 'torrent_magnet': - log.info('Adding torrent URL %s', data['url']) - response = srpc.create_task(url = data['url']) - elif data['protocol'] in ['nzb', 'torrent']: - log.info('Adding %s' % data['protocol']) - if not filedata: - log.error('No %s data found', data['protocol']) - else: - filename = data['name'] + '.' + data['protocol'] - response = srpc.create_task(filename = filename, filedata = filedata) - except: - log.error('Exception while adding torrent: %s', traceback.format_exc()) - finally: - return self.downloadReturnId('') if response else False - - def test(self): - host = cleanHost(self.conf('host'), protocol = False).split(':') - try: - srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) - test_result = srpc.test() - except: - return False - - return test_result - - def getEnabledProtocol(self): - if self.conf('use_for') == 'both': - return super(Synology, self).getEnabledProtocol() - elif self.conf('use_for') == 'torrent': - return ['torrent', 'torrent_magnet'] - else: - return ['nzb'] - - def isEnabled(self, manual = False, data = None): - if not data: data = {} - - for_protocol = ['both'] - if data and 'torrent' in data.get('protocol'): - for_protocol.append('torrent') - elif data: - for_protocol.append(data.get('protocol')) - - return super(Synology, self).isEnabled(manual, data) and\ - ((self.conf('use_for') in for_protocol)) - - -class SynologyRPC(object): - - """SynologyRPC lite library""" - - def __init__(self, host = 'localhost', port = 5000, username = None, password = None): - - super(SynologyRPC, self).__init__() - - self.download_url = 'http://%s:%s/webapi/DownloadStation/task.cgi' % (host, port) - self.auth_url = 'http://%s:%s/webapi/auth.cgi' % (host, port) - self.username = username - self.password = password - self.session_name = 'DownloadStation' - - def _login(self): - if self.username and self.password: - args = {'api': 'SYNO.API.Auth', 'account': self.username, 'passwd': self.password, 'version': 2, - 'method': 'login', 'session': self.session_name, 'format': 'sid'} - response = self._req(self.auth_url, args) - if response['success']: - self.sid = response['data']['sid'] - log.debug('sid=%s', self.sid) - else: - log.error('Couldn\'t login to Synology, %s', response) - return response['success'] - else: - log.error('User or password missing, not using authentication.') - return False - - def _logout(self): - args = {'api':'SYNO.API.Auth', 'version':1, 'method':'logout', 'session':self.session_name, '_sid':self.sid} - return self._req(self.auth_url, args) - - def _req(self, url, args, files = None): - response = {'success': False} - try: - req = requests.post(url, data = args, files = files) - req.raise_for_status() - response = json.loads(req.text) - if response['success']: - log.info('Synology action successfull') - return response - except requests.ConnectionError as err: - log.error('Synology connection error, check your config %s', err) - except requests.HTTPError as err: - log.error('SynologyRPC HTTPError: %s', err) - except Exception as err: - log.error('Exception: %s', err) - finally: - return response - - def create_task(self, url = None, filename = None, filedata = None): - """ Creates new download task in Synology DownloadStation. Either specify - url or pair (filename, filedata). - - Returns True if task was created, False otherwise - """ - result = False - # login - if self._login(): - args = {'api': 'SYNO.DownloadStation.Task', - 'version': '1', - 'method': 'create', - '_sid': self.sid} - if url: - log.info('Login success, adding torrent URI') - args['uri'] = url - response = self._req(self.download_url, args = args) - log.info('Response: %s', response) - result = response['success'] - elif filename and filedata: - log.info('Login success, adding torrent') - files = {'file': (filename, filedata)} - response = self._req(self.download_url, args = args, files = files) - log.info('Response: %s', response) - result = response['success'] - else: - log.error('Invalid use of SynologyRPC.create_task: either url or filename+filedata must be specified') - self._logout() - - return result - - def test(self): - return bool(self._login()) diff --git a/couchpotato/core/downloaders/transmission.py b/couchpotato/core/downloaders/transmission.py new file mode 100644 index 0000000..a956780 --- /dev/null +++ b/couchpotato/core/downloaders/transmission.py @@ -0,0 +1,345 @@ +from base64 import b64encode +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.helpers.encoding import isInt, sp +from couchpotato.core.helpers.variable import tryInt, tryFloat, cleanHost +from couchpotato.core.logger import CPLog +from datetime import timedelta +import httplib +import json +import os.path +import re +import urllib2 + +log = CPLog(__name__) + +autoload = 'Transmission' + + +class Transmission(Downloader): + + protocol = ['torrent', 'torrent_magnet'] + log = CPLog(__name__) + trpc = None + + def connect(self, reconnect = False): + # Load host from config and split out port. + host = cleanHost(self.conf('host'), protocol = False).split(':') + if not isInt(host[1]): + log.error('Config properties are not filled in correctly, port is missing.') + return False + + if not self.trpc or reconnect: + self.trpc = TransmissionRPC(host[0], port = host[1], rpc_url = self.conf('rpc_url').strip('/ '), username = self.conf('username'), password = self.conf('password')) + + return self.trpc + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + log.info('Sending "%s" (%s) to Transmission.', (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + # Set parameters for adding torrent + params = { + 'paused': self.conf('paused', default = False) + } + + if self.conf('directory'): + if os.path.isdir(self.conf('directory')): + params['download-dir'] = self.conf('directory') + else: + log.error('Download directory from Transmission settings: %s doesn\'t exist', self.conf('directory')) + + # Change parameters of torrent + torrent_params = {} + if data.get('seed_ratio'): + torrent_params['seedRatioLimit'] = tryFloat(data.get('seed_ratio')) + torrent_params['seedRatioMode'] = 1 + + if data.get('seed_time'): + torrent_params['seedIdleLimit'] = tryInt(data.get('seed_time')) * 60 + torrent_params['seedIdleMode'] = 1 + + # Send request to Transmission + if data.get('protocol') == 'torrent_magnet': + remote_torrent = self.trpc.add_torrent_uri(data.get('url'), arguments = params) + torrent_params['trackerAdd'] = self.torrent_trackers + else: + remote_torrent = self.trpc.add_torrent_file(b64encode(filedata), arguments = params) + + if not remote_torrent: + log.error('Failed sending torrent to Transmission') + return False + + # Change settings of added torrents + if torrent_params: + self.trpc.set_torrent(remote_torrent['torrent-added']['hashString'], torrent_params) + + log.info('Torrent sent to Transmission successfully.') + return self.downloadReturnId(remote_torrent['torrent-added']['hashString']) + + def test(self): + if self.connect(True) and self.trpc.get_session(): + return True + return False + + def getAllDownloadStatus(self, ids): + + log.debug('Checking Transmission download status.') + + if not self.connect(): + return [] + + release_downloads = ReleaseDownloadList(self) + + return_params = { + 'fields': ['id', 'name', 'hashString', 'percentDone', 'status', 'eta', 'isStalled', 'isFinished', 'downloadDir', 'uploadRatio', 'secondsSeeding', 'seedIdleLimit', 'files'] + } + + session = self.trpc.get_session() + queue = self.trpc.get_alltorrents(return_params) + if not (queue and queue.get('torrents')): + log.debug('Nothing in queue or error') + return [] + + for torrent in queue['torrents']: + if torrent['hashString'] in ids: + log.debug('name=%s / id=%s / downloadDir=%s / hashString=%s / percentDone=%s / status=%s / isStalled=%s / eta=%s / uploadRatio=%s / isFinished=%s / incomplete-dir-enabled=%s / incomplete-dir=%s', + (torrent['name'], torrent['id'], torrent['downloadDir'], torrent['hashString'], torrent['percentDone'], torrent['status'], torrent.get('isStalled', 'N/A'), torrent['eta'], torrent['uploadRatio'], torrent['isFinished'], session['incomplete-dir-enabled'], session['incomplete-dir'])) + + status = 'busy' + if torrent.get('isStalled') and not torrent['percentDone'] == 1 and self.conf('stalled_as_failed'): + status = 'failed' + elif torrent['status'] == 0 and torrent['percentDone'] == 1: + status = 'completed' + elif torrent['status'] in [5, 6]: + status = 'seeding' + + if session['incomplete-dir-enabled'] and status == 'busy': + torrent_folder = session['incomplete-dir'] + else: + torrent_folder = torrent['downloadDir'] + + torrent_files = [] + for file_item in torrent['files']: + torrent_files.append(sp(os.path.join(torrent_folder, file_item['name']))) + + release_downloads.append({ + 'id': torrent['hashString'], + 'name': torrent['name'], + 'status': status, + 'original_status': torrent['status'], + 'seed_ratio': torrent['uploadRatio'], + 'timeleft': str(timedelta(seconds = torrent['eta'])), + 'folder': sp(torrent_folder if len(torrent_files) == 1 else os.path.join(torrent_folder, torrent['name'])), + 'files': '|'.join(torrent_files) + }) + + return release_downloads + + def pause(self, release_download, pause = True): + if pause: + return self.trpc.stop_torrent(release_download['id']) + else: + return self.trpc.start_torrent(release_download['id']) + + def removeFailed(self, release_download): + log.info('%s failed downloading, deleting...', release_download['name']) + return self.trpc.remove_torrent(release_download['id'], True) + + def processComplete(self, release_download, delete_files = False): + log.debug('Requesting Transmission to remove the torrent %s%s.', (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) + return self.trpc.remove_torrent(release_download['id'], delete_files) + +class TransmissionRPC(object): + + """TransmissionRPC lite library""" + def __init__(self, host = 'localhost', port = 9091, rpc_url = 'transmission', username = None, password = None): + + super(TransmissionRPC, self).__init__() + + self.url = 'http://' + host + ':' + str(port) + '/' + rpc_url + '/rpc' + self.tag = 0 + self.session_id = 0 + self.session = {} + if username and password: + password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password(realm = None, uri = self.url, user = username, passwd = password) + opener = urllib2.build_opener(urllib2.HTTPBasicAuthHandler(password_manager), urllib2.HTTPDigestAuthHandler(password_manager)) + opener.addheaders = [('User-agent', 'couchpotato-transmission-client/1.0')] + urllib2.install_opener(opener) + elif username or password: + log.debug('User or password missing, not using authentication.') + self.session = self.get_session() + + def _request(self, ojson): + self.tag += 1 + headers = {'x-transmission-session-id': str(self.session_id)} + request = urllib2.Request(self.url, json.dumps(ojson).encode('utf-8'), headers) + try: + open_request = urllib2.urlopen(request) + response = json.loads(open_request.read()) + log.debug('request: %s', json.dumps(ojson)) + log.debug('response: %s', json.dumps(response)) + if response['result'] == 'success': + log.debug('Transmission action successful') + return response['arguments'] + else: + log.debug('Unknown failure sending command to Transmission. Return text is: %s', response['result']) + return False + except httplib.InvalidURL as err: + log.error('Invalid Transmission host, check your config %s', err) + return False + except urllib2.HTTPError as err: + if err.code == 401: + log.error('Invalid Transmission Username or Password, check your config') + return False + elif err.code == 409: + msg = str(err.read()) + try: + self.session_id = \ + re.search('X-Transmission-Session-Id:\s*(\w+)', msg).group(1) + log.debug('X-Transmission-Session-Id: %s', self.session_id) + + # #resend request with the updated header + + return self._request(ojson) + except: + log.error('Unable to get Transmission Session-Id %s', err) + else: + log.error('TransmissionRPC HTTPError: %s', err) + except urllib2.URLError as err: + log.error('Unable to connect to Transmission %s', err) + + def get_session(self): + post_data = {'method': 'session-get', 'tag': self.tag} + return self._request(post_data) + + def add_torrent_uri(self, torrent, arguments): + arguments['filename'] = torrent + post_data = {'arguments': arguments, 'method': 'torrent-add', 'tag': self.tag} + return self._request(post_data) + + def add_torrent_file(self, torrent, arguments): + arguments['metainfo'] = torrent + post_data = {'arguments': arguments, 'method': 'torrent-add', 'tag': self.tag} + return self._request(post_data) + + def set_torrent(self, torrent_id, arguments): + arguments['ids'] = torrent_id + post_data = {'arguments': arguments, 'method': 'torrent-set', 'tag': self.tag} + return self._request(post_data) + + def get_alltorrents(self, arguments): + post_data = {'arguments': arguments, 'method': 'torrent-get', 'tag': self.tag} + return self._request(post_data) + + def stop_torrent(self, torrent_id): + post_data = {'arguments': {'ids': torrent_id}, 'method': 'torrent-stop', 'tag': self.tag} + return self._request(post_data) + + def start_torrent(self, torrent_id): + post_data = {'arguments': {'ids': torrent_id}, 'method': 'torrent-start', 'tag': self.tag} + return self._request(post_data) + + def remove_torrent(self, torrent_id, delete_local_data): + post_data = {'arguments': {'ids': torrent_id, 'delete-local-data': delete_local_data}, 'method': 'torrent-remove', 'tag': self.tag} + return self._request(post_data) + + +config = [{ + 'name': 'transmission', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'transmission', + 'label': 'Transmission', + 'description': 'Use Transmission to download torrents.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'host', + 'default': 'localhost:9091', + 'description': 'Hostname with port. Usually localhost:9091', + }, + { + 'name': 'rpc_url', + 'type': 'string', + 'default': 'transmission', + 'advanced': True, + 'description': 'Change if you don\'t run Transmission RPC at the default url.', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Download to this directory. Keep empty for default Transmission download directory.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Remove the torrent from Transmission after it finished seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'stalled_as_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Consider a stalled torrent as failed', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/transmission/__init__.py b/couchpotato/core/downloaders/transmission/__init__.py deleted file mode 100644 index 4c9b4aa..0000000 --- a/couchpotato/core/downloaders/transmission/__init__.py +++ /dev/null @@ -1,95 +0,0 @@ -from .main import Transmission - - -def start(): - return Transmission() - -config = [{ - 'name': 'transmission', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'transmission', - 'label': 'Transmission', - 'description': 'Use Transmission to download torrents.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'torrent', - }, - { - 'name': 'host', - 'default': 'localhost:9091', - 'description': 'Hostname with port. Usually localhost:9091', - }, - { - 'name': 'rpc_url', - 'type': 'string', - 'default': 'transmission', - 'advanced': True, - 'description': 'Change if you don\'t run Transmission RPC at the default url.', - }, - { - 'name': 'username', - }, - { - 'name': 'password', - 'type': 'password', - }, - { - 'name': 'directory', - 'type': 'directory', - 'description': 'Download to this directory. Keep empty for default Transmission download directory.', - }, - { - 'name': 'remove_complete', - 'label': 'Remove torrent', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Remove the torrent from Transmission after it finished seeding.', - }, - { - 'name': 'delete_files', - 'label': 'Remove files', - 'default': True, - 'type': 'bool', - 'advanced': True, - 'description': 'Also remove the leftover files.', - }, - { - 'name': 'paused', - 'type': 'bool', - 'advanced': True, - 'default': False, - 'description': 'Add the torrent paused.', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - { - 'name': 'stalled_as_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Consider a stalled torrent as failed', - }, - { - 'name': 'delete_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Delete a release after the download has failed.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py deleted file mode 100644 index 4c42bf0..0000000 --- a/couchpotato/core/downloaders/transmission/main.py +++ /dev/null @@ -1,253 +0,0 @@ -from base64 import b64encode -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.helpers.encoding import isInt, sp -from couchpotato.core.helpers.variable import tryInt, tryFloat, cleanHost -from couchpotato.core.logger import CPLog -from datetime import timedelta -import httplib -import json -import os.path -import re -import urllib2 - -log = CPLog(__name__) - - -class Transmission(Downloader): - - protocol = ['torrent', 'torrent_magnet'] - log = CPLog(__name__) - trpc = None - - def connect(self, reconnect = False): - # Load host from config and split out port. - host = cleanHost(self.conf('host'), protocol = False).split(':') - if not isInt(host[1]): - log.error('Config properties are not filled in correctly, port is missing.') - return False - - if not self.trpc or reconnect: - self.trpc = TransmissionRPC(host[0], port = host[1], rpc_url = self.conf('rpc_url').strip('/ '), username = self.conf('username'), password = self.conf('password')) - - return self.trpc - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - log.info('Sending "%s" (%s) to Transmission.', (data.get('name'), data.get('protocol'))) - - if not self.connect(): - return False - - if not filedata and data.get('protocol') == 'torrent': - log.error('Failed sending torrent, no data') - return False - - # Set parameters for adding torrent - params = { - 'paused': self.conf('paused', default = False) - } - - if self.conf('directory'): - if os.path.isdir(self.conf('directory')): - params['download-dir'] = self.conf('directory') - else: - log.error('Download directory from Transmission settings: %s doesn\'t exist', self.conf('directory')) - - # Change parameters of torrent - torrent_params = {} - if data.get('seed_ratio'): - torrent_params['seedRatioLimit'] = tryFloat(data.get('seed_ratio')) - torrent_params['seedRatioMode'] = 1 - - if data.get('seed_time'): - torrent_params['seedIdleLimit'] = tryInt(data.get('seed_time')) * 60 - torrent_params['seedIdleMode'] = 1 - - # Send request to Transmission - if data.get('protocol') == 'torrent_magnet': - remote_torrent = self.trpc.add_torrent_uri(data.get('url'), arguments = params) - torrent_params['trackerAdd'] = self.torrent_trackers - else: - remote_torrent = self.trpc.add_torrent_file(b64encode(filedata), arguments = params) - - if not remote_torrent: - log.error('Failed sending torrent to Transmission') - return False - - # Change settings of added torrents - if torrent_params: - self.trpc.set_torrent(remote_torrent['torrent-added']['hashString'], torrent_params) - - log.info('Torrent sent to Transmission successfully.') - return self.downloadReturnId(remote_torrent['torrent-added']['hashString']) - - def test(self): - if self.connect(True) and self.trpc.get_session(): - return True - return False - - def getAllDownloadStatus(self, ids): - - log.debug('Checking Transmission download status.') - - if not self.connect(): - return [] - - release_downloads = ReleaseDownloadList(self) - - return_params = { - 'fields': ['id', 'name', 'hashString', 'percentDone', 'status', 'eta', 'isStalled', 'isFinished', 'downloadDir', 'uploadRatio', 'secondsSeeding', 'seedIdleLimit', 'files'] - } - - session = self.trpc.get_session() - queue = self.trpc.get_alltorrents(return_params) - if not (queue and queue.get('torrents')): - log.debug('Nothing in queue or error') - return [] - - for torrent in queue['torrents']: - if torrent['hashString'] in ids: - log.debug('name=%s / id=%s / downloadDir=%s / hashString=%s / percentDone=%s / status=%s / isStalled=%s / eta=%s / uploadRatio=%s / isFinished=%s / incomplete-dir-enabled=%s / incomplete-dir=%s', - (torrent['name'], torrent['id'], torrent['downloadDir'], torrent['hashString'], torrent['percentDone'], torrent['status'], torrent.get('isStalled', 'N/A'), torrent['eta'], torrent['uploadRatio'], torrent['isFinished'], session['incomplete-dir-enabled'], session['incomplete-dir'])) - - status = 'busy' - if torrent.get('isStalled') and not torrent['percentDone'] == 1 and self.conf('stalled_as_failed'): - status = 'failed' - elif torrent['status'] == 0 and torrent['percentDone'] == 1: - status = 'completed' - elif torrent['status'] in [5, 6]: - status = 'seeding' - - if session['incomplete-dir-enabled'] and status == 'busy': - torrent_folder = session['incomplete-dir'] - else: - torrent_folder = torrent['downloadDir'] - - torrent_files = [] - for file_item in torrent['files']: - torrent_files.append(sp(os.path.join(torrent_folder, file_item['name']))) - - release_downloads.append({ - 'id': torrent['hashString'], - 'name': torrent['name'], - 'status': status, - 'original_status': torrent['status'], - 'seed_ratio': torrent['uploadRatio'], - 'timeleft': str(timedelta(seconds = torrent['eta'])), - 'folder': sp(torrent_folder if len(torrent_files) == 1 else os.path.join(torrent_folder, torrent['name'])), - 'files': '|'.join(torrent_files) - }) - - return release_downloads - - def pause(self, release_download, pause = True): - if pause: - return self.trpc.stop_torrent(release_download['id']) - else: - return self.trpc.start_torrent(release_download['id']) - - def removeFailed(self, release_download): - log.info('%s failed downloading, deleting...', release_download['name']) - return self.trpc.remove_torrent(release_download['id'], True) - - def processComplete(self, release_download, delete_files = False): - log.debug('Requesting Transmission to remove the torrent %s%s.', (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) - return self.trpc.remove_torrent(release_download['id'], delete_files) - -class TransmissionRPC(object): - - """TransmissionRPC lite library""" - def __init__(self, host = 'localhost', port = 9091, rpc_url = 'transmission', username = None, password = None): - - super(TransmissionRPC, self).__init__() - - self.url = 'http://' + host + ':' + str(port) + '/' + rpc_url + '/rpc' - self.tag = 0 - self.session_id = 0 - self.session = {} - if username and password: - password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() - password_manager.add_password(realm = None, uri = self.url, user = username, passwd = password) - opener = urllib2.build_opener(urllib2.HTTPBasicAuthHandler(password_manager), urllib2.HTTPDigestAuthHandler(password_manager)) - opener.addheaders = [('User-agent', 'couchpotato-transmission-client/1.0')] - urllib2.install_opener(opener) - elif username or password: - log.debug('User or password missing, not using authentication.') - self.session = self.get_session() - - def _request(self, ojson): - self.tag += 1 - headers = {'x-transmission-session-id': str(self.session_id)} - request = urllib2.Request(self.url, json.dumps(ojson).encode('utf-8'), headers) - try: - open_request = urllib2.urlopen(request) - response = json.loads(open_request.read()) - log.debug('request: %s', json.dumps(ojson)) - log.debug('response: %s', json.dumps(response)) - if response['result'] == 'success': - log.debug('Transmission action successful') - return response['arguments'] - else: - log.debug('Unknown failure sending command to Transmission. Return text is: %s', response['result']) - return False - except httplib.InvalidURL as err: - log.error('Invalid Transmission host, check your config %s', err) - return False - except urllib2.HTTPError as err: - if err.code == 401: - log.error('Invalid Transmission Username or Password, check your config') - return False - elif err.code == 409: - msg = str(err.read()) - try: - self.session_id = \ - re.search('X-Transmission-Session-Id:\s*(\w+)', msg).group(1) - log.debug('X-Transmission-Session-Id: %s', self.session_id) - - # #resend request with the updated header - - return self._request(ojson) - except: - log.error('Unable to get Transmission Session-Id %s', err) - else: - log.error('TransmissionRPC HTTPError: %s', err) - except urllib2.URLError as err: - log.error('Unable to connect to Transmission %s', err) - - def get_session(self): - post_data = {'method': 'session-get', 'tag': self.tag} - return self._request(post_data) - - def add_torrent_uri(self, torrent, arguments): - arguments['filename'] = torrent - post_data = {'arguments': arguments, 'method': 'torrent-add', 'tag': self.tag} - return self._request(post_data) - - def add_torrent_file(self, torrent, arguments): - arguments['metainfo'] = torrent - post_data = {'arguments': arguments, 'method': 'torrent-add', 'tag': self.tag} - return self._request(post_data) - - def set_torrent(self, torrent_id, arguments): - arguments['ids'] = torrent_id - post_data = {'arguments': arguments, 'method': 'torrent-set', 'tag': self.tag} - return self._request(post_data) - - def get_alltorrents(self, arguments): - post_data = {'arguments': arguments, 'method': 'torrent-get', 'tag': self.tag} - return self._request(post_data) - - def stop_torrent(self, torrent_id): - post_data = {'arguments': {'ids': torrent_id}, 'method': 'torrent-stop', 'tag': self.tag} - return self._request(post_data) - - def start_torrent(self, torrent_id): - post_data = {'arguments': {'ids': torrent_id}, 'method': 'torrent-start', 'tag': self.tag} - return self._request(post_data) - - def remove_torrent(self, torrent_id, delete_local_data): - post_data = {'arguments': {'ids': torrent_id, 'delete-local-data': delete_local_data}, 'method': 'torrent-remove', 'tag': self.tag} - return self._request(post_data) - diff --git a/couchpotato/core/downloaders/utorrent.py b/couchpotato/core/downloaders/utorrent.py new file mode 100644 index 0000000..345b17e --- /dev/null +++ b/couchpotato/core/downloaders/utorrent.py @@ -0,0 +1,420 @@ +from base64 import b16encode, b32decode +from bencode import bencode as benc, bdecode +from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList +from couchpotato.core.helpers.encoding import isInt, ss, sp +from couchpotato.core.helpers.variable import tryInt, tryFloat, cleanHost +from couchpotato.core.logger import CPLog +from datetime import timedelta +from hashlib import sha1 +from multipartpost import MultipartPostHandler +import cookielib +import httplib +import json +import os +import re +import stat +import time +import urllib +import urllib2 + +log = CPLog(__name__) + +autoload = 'uTorrent' + + +class uTorrent(Downloader): + + protocol = ['torrent', 'torrent_magnet'] + utorrent_api = None + status_flags = { + 'STARTED' : 1, + 'CHECKING' : 2, + 'CHECK-START' : 4, + 'CHECKED' : 8, + 'ERROR' : 16, + 'PAUSED' : 32, + 'QUEUED' : 64, + 'LOADED' : 128 + } + + def connect(self): + # Load host from config and split out port. + host = cleanHost(self.conf('host'), protocol = False).split(':') + if not isInt(host[1]): + log.error('Config properties are not filled in correctly, port is missing.') + return False + + self.utorrent_api = uTorrentAPI(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + + return self.utorrent_api + + def download(self, data = None, media = None, filedata = None): + if not media: media = {} + if not data: data = {} + + log.debug("Sending '%s' (%s) to uTorrent.", (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + settings = self.utorrent_api.get_settings() + if not settings: + return False + + #Fix settings in case they are not set for CPS compatibility + new_settings = {} + if not (settings.get('seed_prio_limitul') == 0 and settings['seed_prio_limitul_flag']): + new_settings['seed_prio_limitul'] = 0 + new_settings['seed_prio_limitul_flag'] = True + log.info('Updated uTorrent settings to set a torrent to complete after it the seeding requirements are met.') + + if settings.get('bt.read_only_on_complete'): #This doesn't work as this option seems to be not available through the api. Mitigated with removeReadOnly function + new_settings['bt.read_only_on_complete'] = False + log.info('Updated uTorrent settings to not set the files to read only after completing.') + + if new_settings: + self.utorrent_api.set_settings(new_settings) + + torrent_params = {} + if self.conf('label'): + torrent_params['label'] = self.conf('label') + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + if data.get('protocol') == 'torrent_magnet': + torrent_hash = re.findall('urn:btih:([\w]{32,40})', data.get('url'))[0].upper() + torrent_params['trackers'] = '%0D%0A%0D%0A'.join(self.torrent_trackers) + else: + info = bdecode(filedata)['info'] + torrent_hash = sha1(benc(info)).hexdigest().upper() + + torrent_filename = self.createFileName(data, filedata, media) + + if data.get('seed_ratio'): + torrent_params['seed_override'] = 1 + torrent_params['seed_ratio'] = tryInt(tryFloat(data['seed_ratio']) * 1000) + + if data.get('seed_time'): + torrent_params['seed_override'] = 1 + torrent_params['seed_time'] = tryInt(data['seed_time']) * 3600 + + # Convert base 32 to hex + if len(torrent_hash) == 32: + torrent_hash = b16encode(b32decode(torrent_hash)) + + # Send request to uTorrent + if data.get('protocol') == 'torrent_magnet': + self.utorrent_api.add_torrent_uri(torrent_filename, data.get('url')) + else: + self.utorrent_api.add_torrent_file(torrent_filename, filedata) + + # Change settings of added torrent + self.utorrent_api.set_torrent(torrent_hash, torrent_params) + if self.conf('paused', default = 0): + self.utorrent_api.pause_torrent(torrent_hash) + + return self.downloadReturnId(torrent_hash) + + def test(self): + if self.connect(): + build_version = self.utorrent_api.get_build() + if not build_version: + return False + if build_version < 25406: # This build corresponds to version 3.0.0 stable + return False, 'Your uTorrent client is too old, please update to newest version.' + return True + + return False + + def getAllDownloadStatus(self, ids): + + log.debug('Checking uTorrent download status.') + + if not self.connect(): + return [] + + release_downloads = ReleaseDownloadList(self) + + data = self.utorrent_api.get_status() + if not data: + log.error('Error getting data from uTorrent') + return [] + + queue = json.loads(data) + if queue.get('error'): + log.error('Error getting data from uTorrent: %s', queue.get('error')) + return [] + + if not queue.get('torrents'): + log.debug('Nothing in queue') + return [] + + # Get torrents + for torrent in queue['torrents']: + if torrent[0] in ids: + + #Get files of the torrent + torrent_files = [] + try: + torrent_files = json.loads(self.utorrent_api.get_files(torrent[0])) + torrent_files = [sp(os.path.join(torrent[26], torrent_file[0])) for torrent_file in torrent_files['files'][1]] + except: + log.debug('Failed getting files from torrent: %s', torrent[2]) + + status = 'busy' + if (torrent[1] & self.status_flags['STARTED'] or torrent[1] & self.status_flags['QUEUED']) and torrent[4] == 1000: + status = 'seeding' + elif (torrent[1] & self.status_flags['ERROR']): + status = 'failed' + elif torrent[4] == 1000: + status = 'completed' + + if not status == 'busy': + self.removeReadOnly(torrent_files) + + release_downloads.append({ + 'id': torrent[0], + 'name': torrent[2], + 'status': status, + 'seed_ratio': float(torrent[7]) / 1000, + 'original_status': torrent[1], + 'timeleft': str(timedelta(seconds = torrent[10])), + 'folder': sp(torrent[26]), + 'files': '|'.join(torrent_files) + }) + + return release_downloads + + def pause(self, release_download, pause = True): + if not self.connect(): + return False + return self.utorrent_api.pause_torrent(release_download['id'], pause) + + def removeFailed(self, release_download): + log.info('%s failed downloading, deleting...', release_download['name']) + if not self.connect(): + return False + return self.utorrent_api.remove_torrent(release_download['id'], remove_data = True) + + def processComplete(self, release_download, delete_files = False): + log.debug('Requesting uTorrent to remove the torrent %s%s.', (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) + if not self.connect(): + return False + return self.utorrent_api.remove_torrent(release_download['id'], remove_data = delete_files) + + def removeReadOnly(self, files): + #Removes all read-on ly flags in a for all files + for filepath in files: + if os.path.isfile(filepath): + #Windows only needs S_IWRITE, but we bitwise-or with current perms to preserve other permission bits on Linux + os.chmod(filepath, stat.S_IWRITE | os.stat(filepath).st_mode) + +class uTorrentAPI(object): + + def __init__(self, host = 'localhost', port = 8000, username = None, password = None): + + super(uTorrentAPI, self).__init__() + + self.url = 'http://' + str(host) + ':' + str(port) + '/gui/' + self.token = '' + self.last_time = time.time() + cookies = cookielib.CookieJar() + self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies), MultipartPostHandler) + self.opener.addheaders = [('User-agent', 'couchpotato-utorrent-client/1.0')] + if username and password: + password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password(realm = None, uri = self.url, user = username, passwd = password) + self.opener.add_handler(urllib2.HTTPBasicAuthHandler(password_manager)) + self.opener.add_handler(urllib2.HTTPDigestAuthHandler(password_manager)) + elif username or password: + log.debug('User or password missing, not using authentication.') + self.token = self.get_token() + + def _request(self, action, data = None): + if time.time() > self.last_time + 1800: + self.last_time = time.time() + self.token = self.get_token() + request = urllib2.Request(self.url + '?token=' + self.token + '&' + action, data) + try: + open_request = self.opener.open(request) + response = open_request.read() + if response: + return response + else: + log.debug('Unknown failure sending command to uTorrent. Return text is: %s', response) + except httplib.InvalidURL as err: + log.error('Invalid uTorrent host, check your config %s', err) + except urllib2.HTTPError as err: + if err.code == 401: + log.error('Invalid uTorrent Username or Password, check your config') + else: + log.error('uTorrent HTTPError: %s', err) + except urllib2.URLError as err: + log.error('Unable to connect to uTorrent %s', err) + return False + + def get_token(self): + request = self.opener.open(self.url + 'token.html') + token = re.findall('(.*?)uTorrent (3.0+) to download torrents.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'host', + 'default': 'localhost:8000', + 'description': 'Port can be found in settings when enabling WebUI.', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'label', + 'description': 'Label to add torrent as.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Remove the torrent from uTorrent after it finished seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/utorrent/__init__.py b/couchpotato/core/downloaders/utorrent/__init__.py deleted file mode 100644 index da16095..0000000 --- a/couchpotato/core/downloaders/utorrent/__init__.py +++ /dev/null @@ -1,80 +0,0 @@ -from .main import uTorrent - - -def start(): - return uTorrent() - -config = [{ - 'name': 'utorrent', - 'groups': [ - { - 'tab': 'downloaders', - 'list': 'download_providers', - 'name': 'utorrent', - 'label': 'uTorrent', - 'description': 'Use uTorrent (3.0+) to download torrents.', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'default': 0, - 'type': 'enabler', - 'radio_group': 'torrent', - }, - { - 'name': 'host', - 'default': 'localhost:8000', - 'description': 'Port can be found in settings when enabling WebUI.', - }, - { - 'name': 'username', - }, - { - 'name': 'password', - 'type': 'password', - }, - { - 'name': 'label', - 'description': 'Label to add torrent as.', - }, - { - 'name': 'remove_complete', - 'label': 'Remove torrent', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Remove the torrent from uTorrent after it finished seeding.', - }, - { - 'name': 'delete_files', - 'label': 'Remove files', - 'default': True, - 'type': 'bool', - 'advanced': True, - 'description': 'Also remove the leftover files.', - }, - { - 'name': 'paused', - 'type': 'bool', - 'advanced': True, - 'default': False, - 'description': 'Add the torrent paused.', - }, - { - 'name': 'manual', - 'default': 0, - 'type': 'bool', - 'advanced': True, - 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', - }, - { - 'name': 'delete_failed', - 'default': True, - 'advanced': True, - 'type': 'bool', - 'description': 'Delete a release after the download has failed.', - }, - ], - } - ], -}] diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py deleted file mode 100644 index 6a5e425..0000000 --- a/couchpotato/core/downloaders/utorrent/main.py +++ /dev/null @@ -1,342 +0,0 @@ -from base64 import b16encode, b32decode -from bencode import bencode as benc, bdecode -from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList -from couchpotato.core.helpers.encoding import isInt, ss, sp -from couchpotato.core.helpers.variable import tryInt, tryFloat, cleanHost -from couchpotato.core.logger import CPLog -from datetime import timedelta -from hashlib import sha1 -from multipartpost import MultipartPostHandler -import cookielib -import httplib -import json -import os -import re -import stat -import time -import urllib -import urllib2 - -log = CPLog(__name__) - - -class uTorrent(Downloader): - - protocol = ['torrent', 'torrent_magnet'] - utorrent_api = None - status_flags = { - 'STARTED' : 1, - 'CHECKING' : 2, - 'CHECK-START' : 4, - 'CHECKED' : 8, - 'ERROR' : 16, - 'PAUSED' : 32, - 'QUEUED' : 64, - 'LOADED' : 128 - } - - def connect(self): - # Load host from config and split out port. - host = cleanHost(self.conf('host'), protocol = False).split(':') - if not isInt(host[1]): - log.error('Config properties are not filled in correctly, port is missing.') - return False - - self.utorrent_api = uTorrentAPI(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) - - return self.utorrent_api - - def download(self, data = None, media = None, filedata = None): - if not media: media = {} - if not data: data = {} - - log.debug("Sending '%s' (%s) to uTorrent.", (data.get('name'), data.get('protocol'))) - - if not self.connect(): - return False - - settings = self.utorrent_api.get_settings() - if not settings: - return False - - #Fix settings in case they are not set for CPS compatibility - new_settings = {} - if not (settings.get('seed_prio_limitul') == 0 and settings['seed_prio_limitul_flag']): - new_settings['seed_prio_limitul'] = 0 - new_settings['seed_prio_limitul_flag'] = True - log.info('Updated uTorrent settings to set a torrent to complete after it the seeding requirements are met.') - - if settings.get('bt.read_only_on_complete'): #This doesn't work as this option seems to be not available through the api. Mitigated with removeReadOnly function - new_settings['bt.read_only_on_complete'] = False - log.info('Updated uTorrent settings to not set the files to read only after completing.') - - if new_settings: - self.utorrent_api.set_settings(new_settings) - - torrent_params = {} - if self.conf('label'): - torrent_params['label'] = self.conf('label') - - if not filedata and data.get('protocol') == 'torrent': - log.error('Failed sending torrent, no data') - return False - - if data.get('protocol') == 'torrent_magnet': - torrent_hash = re.findall('urn:btih:([\w]{32,40})', data.get('url'))[0].upper() - torrent_params['trackers'] = '%0D%0A%0D%0A'.join(self.torrent_trackers) - else: - info = bdecode(filedata)['info'] - torrent_hash = sha1(benc(info)).hexdigest().upper() - - torrent_filename = self.createFileName(data, filedata, media) - - if data.get('seed_ratio'): - torrent_params['seed_override'] = 1 - torrent_params['seed_ratio'] = tryInt(tryFloat(data['seed_ratio']) * 1000) - - if data.get('seed_time'): - torrent_params['seed_override'] = 1 - torrent_params['seed_time'] = tryInt(data['seed_time']) * 3600 - - # Convert base 32 to hex - if len(torrent_hash) == 32: - torrent_hash = b16encode(b32decode(torrent_hash)) - - # Send request to uTorrent - if data.get('protocol') == 'torrent_magnet': - self.utorrent_api.add_torrent_uri(torrent_filename, data.get('url')) - else: - self.utorrent_api.add_torrent_file(torrent_filename, filedata) - - # Change settings of added torrent - self.utorrent_api.set_torrent(torrent_hash, torrent_params) - if self.conf('paused', default = 0): - self.utorrent_api.pause_torrent(torrent_hash) - - return self.downloadReturnId(torrent_hash) - - def test(self): - if self.connect(): - build_version = self.utorrent_api.get_build() - if not build_version: - return False - if build_version < 25406: # This build corresponds to version 3.0.0 stable - return False, 'Your uTorrent client is too old, please update to newest version.' - return True - - return False - - def getAllDownloadStatus(self, ids): - - log.debug('Checking uTorrent download status.') - - if not self.connect(): - return [] - - release_downloads = ReleaseDownloadList(self) - - data = self.utorrent_api.get_status() - if not data: - log.error('Error getting data from uTorrent') - return [] - - queue = json.loads(data) - if queue.get('error'): - log.error('Error getting data from uTorrent: %s', queue.get('error')) - return [] - - if not queue.get('torrents'): - log.debug('Nothing in queue') - return [] - - # Get torrents - for torrent in queue['torrents']: - if torrent[0] in ids: - - #Get files of the torrent - torrent_files = [] - try: - torrent_files = json.loads(self.utorrent_api.get_files(torrent[0])) - torrent_files = [sp(os.path.join(torrent[26], torrent_file[0])) for torrent_file in torrent_files['files'][1]] - except: - log.debug('Failed getting files from torrent: %s', torrent[2]) - - status = 'busy' - if (torrent[1] & self.status_flags['STARTED'] or torrent[1] & self.status_flags['QUEUED']) and torrent[4] == 1000: - status = 'seeding' - elif (torrent[1] & self.status_flags['ERROR']): - status = 'failed' - elif torrent[4] == 1000: - status = 'completed' - - if not status == 'busy': - self.removeReadOnly(torrent_files) - - release_downloads.append({ - 'id': torrent[0], - 'name': torrent[2], - 'status': status, - 'seed_ratio': float(torrent[7]) / 1000, - 'original_status': torrent[1], - 'timeleft': str(timedelta(seconds = torrent[10])), - 'folder': sp(torrent[26]), - 'files': '|'.join(torrent_files) - }) - - return release_downloads - - def pause(self, release_download, pause = True): - if not self.connect(): - return False - return self.utorrent_api.pause_torrent(release_download['id'], pause) - - def removeFailed(self, release_download): - log.info('%s failed downloading, deleting...', release_download['name']) - if not self.connect(): - return False - return self.utorrent_api.remove_torrent(release_download['id'], remove_data = True) - - def processComplete(self, release_download, delete_files = False): - log.debug('Requesting uTorrent to remove the torrent %s%s.', (release_download['name'], ' and cleanup the downloaded files' if delete_files else '')) - if not self.connect(): - return False - return self.utorrent_api.remove_torrent(release_download['id'], remove_data = delete_files) - - def removeReadOnly(self, files): - #Removes all read-on ly flags in a for all files - for filepath in files: - if os.path.isfile(filepath): - #Windows only needs S_IWRITE, but we bitwise-or with current perms to preserve other permission bits on Linux - os.chmod(filepath, stat.S_IWRITE | os.stat(filepath).st_mode) - -class uTorrentAPI(object): - - def __init__(self, host = 'localhost', port = 8000, username = None, password = None): - - super(uTorrentAPI, self).__init__() - - self.url = 'http://' + str(host) + ':' + str(port) + '/gui/' - self.token = '' - self.last_time = time.time() - cookies = cookielib.CookieJar() - self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies), MultipartPostHandler) - self.opener.addheaders = [('User-agent', 'couchpotato-utorrent-client/1.0')] - if username and password: - password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() - password_manager.add_password(realm = None, uri = self.url, user = username, passwd = password) - self.opener.add_handler(urllib2.HTTPBasicAuthHandler(password_manager)) - self.opener.add_handler(urllib2.HTTPDigestAuthHandler(password_manager)) - elif username or password: - log.debug('User or password missing, not using authentication.') - self.token = self.get_token() - - def _request(self, action, data = None): - if time.time() > self.last_time + 1800: - self.last_time = time.time() - self.token = self.get_token() - request = urllib2.Request(self.url + '?token=' + self.token + '&' + action, data) - try: - open_request = self.opener.open(request) - response = open_request.read() - if response: - return response - else: - log.debug('Unknown failure sending command to uTorrent. Return text is: %s', response) - except httplib.InvalidURL as err: - log.error('Invalid uTorrent host, check your config %s', err) - except urllib2.HTTPError as err: - if err.code == 401: - log.error('Invalid uTorrent Username or Password, check your config') - else: - log.error('uTorrent HTTPError: %s', err) - except urllib2.URLError as err: - log.error('Unable to connect to uTorrent %s', err) - return False - - def get_token(self): - request = self.opener.open(self.url + 'token.html') - token = re.findall('(.*?)