From 4bd86a3ec62c8ae6a7ddd2969d34dec215e14ade Mon Sep 17 00:00:00 2001 From: JackDandy Date: Mon, 23 Dec 2019 22:40:05 +0000 Subject: [PATCH] Change overhaul qBittorrent 4.2.1 client to add compatibility for breaking API 2.4. Add search setting for qBittorrent client "Start torrent paused". Add search setting for qBittorrent client "Add release at top priority". Add option choose custom variable to use for label in rTorrent Torrent Results. Add warning to rTorrent users not to use space in label. Change overhaul DiskStation client to add compatibility for latest API. Change improve Synology DownloadStation functions. Add search setting for DiskStation client "Start torrent paused". Fix the priority set for snatched items is now also set for episodes without air date. Change NZBGet client to use property .priority of SearchResult. --- CHANGES.md | 16 +- gui/slick/interfaces/default/config_search.tmpl | 22 +- gui/slick/js/configSearch.js | 12 +- lib/rtorrent/torrent.py | 2 +- sickbeard/__init__.py | 9 +- sickbeard/clients/__init__.py | 1 + sickbeard/clients/download_station.py | 379 ++++++++++++++--- sickbeard/clients/generic.py | 128 ++++-- sickbeard/clients/qbittorrent.py | 531 ++++++++++++++++++++---- sickbeard/clients/rtorrent.py | 59 ++- sickbeard/nzbget.py | 7 +- sickbeard/search.py | 6 +- sickbeard/webserve.py | 6 +- 13 files changed, 955 insertions(+), 223 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index eeb152a..2d44134 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,18 @@ -### 0.20.14 (2019-12-20 00:15:00 UTC) +### 0.20.15 (2019-12-23 22:40:00 UTC) + +* Change overhaul qBittorrent 4.2.1 client to add compatibility for breaking API 2.4 +* Add search setting for qBittorrent client "Start torrent paused" +* Add search setting for qBittorrent client "Add release at top priority" +* Add option choose custom variable to use for label in rTorrent Torrent Results +* Add warning to rTorrent users not to use space in label +* Change overhaul DiskStation client to add compatibility for latest API +* Change improve Synology DownloadStation functions +* Add search setting for DiskStation client "Start torrent paused" +* Fix the priority set for snatched items is now also set for episodes without air date +* Change NZBGet client to use property .priority of SearchResult + + +### 0.20.14 (2019-12-20 00:15:00 UTC) * Fix fetching static files for Kodi repo diff --git a/gui/slick/interfaces/default/config_search.tmpl b/gui/slick/interfaces/default/config_search.tmpl index 58403ad..0a667b3 100755 --- a/gui/slick/interfaces/default/config_search.tmpl +++ b/gui/slick/interfaces/default/config_search.tmpl @@ -1,6 +1,8 @@ #import sickbeard #from sickbeard import clients #from sickbeard.helpers import starify +<% def sg_var(varname, default=False): return getattr(sickbeard, varname, default) %>#slurp# +<% def sg_str(varname, default=''): return getattr(sickbeard, varname, default) %>#slurp# ## #set global $title = 'Config - Media Search' #set global $header = 'Search Settings' @@ -400,7 +402,7 @@ - priority for daily snatches (no backlog) + applies to releases from last 7 days @@ -531,9 +533,16 @@ Set torrent label/category + + @@ -568,19 +577,26 @@ Start torrent paused > -

add .torrent to client but do not start downloading

+

pause item in client as soon as it allows (note: a small transfer can occur)

-
Click below to test
diff --git a/gui/slick/js/configSearch.js b/gui/slick/js/configSearch.js index ba43f16..0a4f409 100644 --- a/gui/slick/js/configSearch.js +++ b/gui/slick/js/configSearch.js @@ -65,6 +65,7 @@ $(document).ready(function(){ verifyCertOption = '#torrent-verify-cert-option', labelOption = '#torrent-label-option', qBitTorrent = '.qbittorrent', + rTorrent = '.rtorrent', synology = '.synology', transmission = '.transmission', pathOption = '#torrent-path-option', @@ -75,7 +76,7 @@ $(document).ready(function(){ torrentHost$ = $('#torrent_host'); $([labelWarningDeluge, hostDescDeluge, hostDescRtorrent, verifyCertOption, seedTimeOption, - highBandwidthOption, qBitTorrent, synology, transmission].join(',')).hide(); + highBandwidthOption, qBitTorrent, rTorrent, synology, transmission].join(',')).hide(); $([hostDesc, usernameOption, pathOption, labelOption, pathBlank, pausedOption].join(',')).show(); $(pathOption).find('.fileBrowser').show(); @@ -98,18 +99,17 @@ $(document).ready(function(){ $([transmission, highBandwidthOption].join(',')).show(); break; case 'qbittorrent': - // Setting Paused is buggy on qB, remove from use - client = 'qBittorrent'; hidePausedOption = !0; hidePathBlank = !0; - $(qBitTorrent).show(); + client = 'qBittorrent'; hidePathBlank = !0; + $([qBitTorrent, highBandwidthOption].join(',')).show(); break; case 'download_station': - client = 'Synology DS'; hideLabelOption = !0; hidePausedOption = !0; + client = 'Synology DS'; hideLabelOption = !0; $(pathOption).find('.fileBrowser').hide(); $(synology).show(); break; case 'rtorrent': client = 'rTorrent'; hideHostDesc = !0; - $(hostDescRtorrent).show(); + $([rTorrent, hostDescRtorrent].join(',')).show(); torrentHost$.on('blur', handleSCGI); break; } diff --git a/lib/rtorrent/torrent.py b/lib/rtorrent/torrent.py index 05734f4..edb13d0 100644 --- a/lib/rtorrent/torrent.py +++ b/lib/rtorrent/torrent.py @@ -515,7 +515,7 @@ methods = [ Method(Torrent, 'get_bitfield', 'd.get_bitfield', aliases=('d.bitfield',)), Method(Torrent, 'get_local_id_html', 'd.get_local_id_html', - aliases=('d.bitfield',)), + aliases=('d.local_id_html',)), Method(Torrent, 'get_connection_leech', 'd.get_connection_leech', aliases=('d.connection_leech',)), Method(Torrent, 'get_peers_accounted', 'd.get_peers_accounted', diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index bc5b4d0..9c26fae 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -287,6 +287,7 @@ TORRENT_SEED_TIME = 0 TORRENT_PAUSED = False TORRENT_HIGH_BANDWIDTH = False TORRENT_LABEL = '' +TORRENT_LABEL_VAR = 1 TORRENT_VERIFY_CERT = False USE_EMBY = False @@ -636,7 +637,8 @@ def init_stage_1(console_logging): NZBGET_SCRIPT_VERSION, NZBGET_MAP # Search Settings/Torrent search global USE_TORRENTS, TORRENT_METHOD, TORRENT_DIR, TORRENT_HOST, TORRENT_USERNAME, TORRENT_PASSWORD, \ - TORRENT_LABEL, TORRENT_PATH, TORRENT_SEED_TIME, TORRENT_PAUSED, TORRENT_HIGH_BANDWIDTH, TORRENT_VERIFY_CERT + TORRENT_LABEL, TORRENT_LABEL_VAR, TORRENT_PATH, TORRENT_SEED_TIME, TORRENT_PAUSED, \ + TORRENT_HIGH_BANDWIDTH, TORRENT_VERIFY_CERT # Media Providers global PROVIDER_ORDER, NEWZNAB_DATA, PROVIDER_HOMES # Subtitles @@ -971,6 +973,7 @@ def init_stage_1(console_logging): TORRENT_PAUSED = bool(check_setting_int(CFG, 'TORRENT', 'torrent_paused', 0)) TORRENT_HIGH_BANDWIDTH = bool(check_setting_int(CFG, 'TORRENT', 'torrent_high_bandwidth', 0)) TORRENT_LABEL = check_setting_str(CFG, 'TORRENT', 'torrent_label', '') + TORRENT_LABEL_VAR = check_setting_int(CFG, 'TORRENT', 'torrent_label_var', 1) TORRENT_VERIFY_CERT = bool(check_setting_int(CFG, 'TORRENT', 'torrent_verify_cert', 0)) USE_EMBY = bool(check_setting_int(CFG, 'Emby', 'use_emby', 0)) @@ -1866,6 +1869,7 @@ def save_config(): ('paused', int(TORRENT_PAUSED)), ('high_bandwidth', int(TORRENT_HIGH_BANDWIDTH)), ('label', TORRENT_LABEL), + ('label_var', int(TORRENT_LABEL_VAR)), ('verify_cert', int(TORRENT_VERIFY_CERT)), ]), # ----------------------------------- @@ -2022,7 +2026,8 @@ def save_config(): new_config[cfg] = {} for (k, v) in filter(lambda (_, y): any([y]) or ( # allow saving where item value default is non-zero but 0 is a required setting value - cfg_lc in ('kodi', 'xbmc', 'synoindex', 'nzbget') and _ in ('always_on', 'priority')), items): + cfg_lc in ('kodi', 'xbmc', 'synoindex', 'nzbget', 'torrent') and _ in ('always_on', 'priority') + or (_ == 'label_var' and 'rtorrent' == new_config['General']['torrent_method'])), items): k = '%s' in k and (k % cfg_lc) or (cfg_lc + '_' + k) # correct for cases where keys are named in an inconsistent manner to parent stanza k = k.replace('blackhole_', '').replace('sabnzbd_', 'sab_') diff --git a/sickbeard/clients/__init__.py b/sickbeard/clients/__init__.py index 5e6b113..69844f1 100644 --- a/sickbeard/clients/__init__.py +++ b/sickbeard/clients/__init__.py @@ -35,6 +35,7 @@ def get_client_instance(name): module = __import__('sickbeard.clients.%s' % name.lower(), fromlist=__all__) return getattr(module, module.api.__class__.__name__) + # Mapping error status codes to official W3C names http_error_code = { 300: 'Multiple Choices', diff --git a/sickbeard/clients/download_station.py b/sickbeard/clients/download_station.py index e8bdc2b..e654a68 100644 --- a/sickbeard/clients/download_station.py +++ b/sickbeard/clients/download_station.py @@ -18,8 +18,11 @@ # http://download.synology.com/download/Document/DeveloperGuide/Synology_Download_Station_Web_API.pdf import re +import time +import urllib + import sickbeard -from sickbeard import logger +from sickbeard import logger, sbdatetime from sickbeard.clients.generic import GenericClient @@ -29,14 +32,19 @@ class DownloadStationAPI(GenericClient): super(DownloadStationAPI, self).__init__('DownloadStation', host, username, password) - self.host = self.host.rstrip('/') + '/' self.url_base = self.host + 'webapi/' self.url_info = self.url_base + 'query.cgi' self.url = self.url_base + 'DownloadStation/task.cgi' self._errmsg = None + self._testmode = int(sickbeard.ENV.get('DEVENV')) + if self._testmode: + from download_station_data import client_data + self._testdata, self.auth, self._task_version = client_data, '1234', 3 + self._testid = 'dbid_3185' + common_errors = { - -1: 'Unknown response error', 100: 'Unknown error', 101: 'Invalid parameter', + -1: 'Could not get a response', 100: 'Unknown error', 101: 'Invalid parameter', 102: 'The requested API does not exist', 103: 'The requested method does not exist', 104: 'The requested version does not support the functionality', 105: 'The logged in session does not have permission', 106: 'Session timeout', @@ -44,10 +52,13 @@ class DownloadStationAPI(GenericClient): } def _error(self, msg): - self._errmsg = '
%s replied with: %s.' % (self.name, msg) - logger.log('%s replied with: %s' % (self.name, msg), logger.ERROR) + + out = '%s%s: %s' % (self.name, (' replied with', '')['Could not' in msg], msg) + self._errmsg = '
%s.' % out + logger.log(out, logger.ERROR) def _error_task(self, response): + err_code = response.get('error', {}).get('code', -1) return self._error(self.common_errors.get(err_code) or { 400: 'File upload failed', 401: 'Max number of tasks reached', 402: 'Destination denied', @@ -55,8 +66,314 @@ class DownloadStationAPI(GenericClient): 406: 'No default destination', 407: 'Set destination failed', 408: 'File does not exist' }.get(err_code, 'Unknown error code')) + def _active_state(self, ids=None): + """ + Fetch state of items, return items that are actually downloading or seeding + :param ids: Optional id(s) to get state info for. None to get all + :type ids: list or None + :return: Zero or more object(s) assigned with state `down`loading or `seed`ing + :rtype: list + """ + tasks = self._tinf(ids) + downloaded = (lambda item, d=0: item.get('size_downloaded') or d) # bytes + wanted = (lambda item: item.get('wanted')) # wanted will == tally/downloaded if all files are selected + base_state = (lambda t, d, tx, f: dict( + id=t['id'], title=t['title'], total_size=t.get('size') or 0, + added_ts=d.get('create_time'), last_completed_ts=d.get('completed_time'), + last_started_ts=d.get('started_time'), seed_elapsed_secs=d.get('seedelapsed'), + wanted_size=sum(map(lambda tf: wanted(tf) and tf.get('size') or 0, f)) or None, + wanted_down=sum(map(lambda tf: wanted(tf) and downloaded(tf) or 0, f)) or None, + tally_down=downloaded(tx), + tally_up=tx.get('size_uploaded'), + state='done' if re.search('finish', t['status']) else ('seed', 'down')[any(filter( + lambda tf: wanted(tf) and (downloaded(tf, -1) < tf.get('size', 0)), f))] + )) + # only available during "download" and "seeding" + file_list = (lambda t: t.get('additional', {}).get('file', {})) + valid_stat = (lambda ti: not ti.get('error') and isinstance(ti.get('status'), basestring) + and sum(map(lambda tf: wanted(tf) and downloaded(tf) or 0, file_list(ti)))) + result = map(lambda t: base_state( + t, t.get('additional', {}).get('detail', {}), t.get('additional', {}).get('transfer', {}), file_list(t)), + filter(lambda t: t['status'] in ('downloading', 'seeding', 'finished') and valid_stat(t), tasks)) + + return result + + def _tinf(self, ids=None, err=False): + """ + Fetch client task information + :param ids: Optional id(s) to get task info for. None to get all task info + :type ids: list or None + :param err: Optional return error dict instead of empty array + :type err: Boolean + :return: Zero or more task object(s) from response + :rtype: list + """ + result = [] + rids = (ids if isinstance(ids, (list, type(None))) else [x.strip() for x in ids.split(',')]) or [None] + getinfo = None is not ids + for rid in rids: + try: + if not self._testmode: + tasks = self._client_request(('list', 'getinfo')[getinfo], t_id=rid, + t_params=dict(additional='detail,file,transfer'))['data']['tasks'] + else: + tasks = (filter(lambda d: d.get('id') == rid, self._testdata), self._testdata)[not rid] + result += tasks and (isinstance(tasks, list) and tasks or (isinstance(tasks, dict) and [tasks])) \ + or ([], [{'error': True, 'id': rid}])[err] + except (BaseException, Exception): + if getinfo: + result += [dict(error=True, id=rid)] + for t in filter(lambda d: isinstance(d.get('title'), basestring) and d.get('title'), result): + t['title'] = urllib.unquote_plus(t.get('title')) + + return result + + def _set_torrent_pause(self, search_result): + """ + Set torrent as paused used for the "add as paused" feature (overridden class function) + :param search_result: A populated search result object + :type search_result: TorrentSearchResult + :return: Success or Falsy if fail + :rtype: bool + """ + if not sickbeard.TORRENT_PAUSED or not self.created_id: + return super(DownloadStationAPI, self)._set_torrent_pause(search_result) + + return True is self._pause_torrent(self.created_id) + + @staticmethod + def _ignore_state(task): + return bool(task.get('error')) + + def _pause_torrent(self, ids): + """ + Pause item(s) + :param ids: Id(s) to pause + :type ids: list or string + :return: True/Falsy if success/failure else Id(s) that failed to be paused + :rtype: bool or list + """ + return self._action( + 'pause', ids, + lambda t: self._ignore_state(t) or + (not isinstance(t.get('status'), basestring) or 'paused' not in t.get('status')) and + True is not self._client_request('pause', t.get('id'))) + + def _resume_torrent(self, ids): + """ + Resume task(s) in client + :param ids: Id(s) to act on + :type ids: list or string + :return: True if success, Id(s) that could not be resumed, else Falsy if failure + :rtype: bool or list + """ + return self._perform_task( + 'resume', ids, + lambda t: self._ignore_state(t) or + (not isinstance(t.get('status'), basestring) or 'paused' in t.get('status')) and + True is not self._client_request('resume', t.get('id'))) + + def _delete_torrent(self, ids): + """ + Delete task(s) from client + :param ids: Id(s) to act on + :type ids: list or string + :return: True if success, Id(s) that could not be deleted, else Falsy if failure + :rtype: bool or list + """ + return self._perform_task( + 'delete', ids, + lambda t: self._ignore_state(t) or + isinstance(t.get('status'), basestring) and + True is not self._client_request('delete', t.get('id')), + pause_first=True) + + def _perform_task(self, method, ids, filter_func, pause_first=False): + """ + Set up and send a method to client + :param method: Either `resume` or `delete` + :type method: string + :param ids: Id(s) to perform method on + :type ids: list or string + :param filter_func: Call back function to filter tasks as failed or erroneous + :type Function + :param pause_first: True if task should be paused prior to invoking method + :type Boolean + :return: True if success, Id(s) that could not be acted upon, else Falsy if failure + :rtype: bool or list + """ + if isinstance(ids, (basestring, list)): + rids = ids if isinstance(ids, list) else map(lambda x: x.strip(), ids.split(',')) + + result = pause_first and self._pause_torrent(rids) # get items not paused + result = (isinstance(result, list) and result or []) + for t_id in list(set(rids) - (isinstance(result, list) and set(result) or set())): # perform on paused ids + if True is not self._action(method, t_id, filter_func): + result += [t_id] # failed item + + return result or True + + def _action(self, act, ids, filter_func): + + if isinstance(ids, (basestring, list)): + item = dict(fail=[], ignore=[]) + for task in filter(filter_func, self._tinf(ids, err=True)): + item[('fail', 'ignore')[self._ignore_state(task)]] += [task.get('id')] + + # retry items not acted on + retry_ids = item['fail'] + tries = (1, 3, 5, 10, 15, 15, 30, 60) + i = 0 + while retry_ids: + for i in tries: + logger.log('%s: retry %s %s item(s) in %ss' % (self.name, act, len(item['fail']), i), logger.DEBUG) + time.sleep(i) + item['fail'] = [] + for task in filter(filter_func, self._tinf(retry_ids, err=True)): + item[('fail', 'ignore')[self._ignore_state(task)]] += [task.get('id')] + + if not item['fail']: + retry_ids = None + break + retry_ids = item['fail'] + else: + if max(tries) == i: + logger.log('%s: failed to %s %s item(s) after %s tries over %s mins, aborted' % + (self.name, act, len(item['fail']), len(tries), sum(tries) / 60), logger.DEBUG) + + return (item['fail'] + item['ignore']) or True + + def _add_torrent_uri(self, search_result): + """ + Add magnet to client (overridden class function) + :param search_result: A populated search result object + :type search_result: TorrentSearchResult + :return: Id of task in client, True if added but no ID, else Falsy if nothing added + :rtype: string or bool + """ + if 3 <= self._task_version: + return self._add_torrent(uri={'uri': search_result.url}) + + logger.log('%s: the API at %s doesn\'t support torrent magnet, download skipped' % + (self.name, self.host), logger.WARNING) + + def _add_torrent_file(self, search_result): + """ + Add file to client (overridden class function) + :param search_result: A populated search result object + :type search_result: TorrentSearchResult + :return: Id of task in client, True if added but no ID, else Falsy if nothing added + :rtype: string or bool + """ + return self._add_torrent( + files={'file': ('%s.torrent' % re.sub(r'(\.torrent)+$', '', search_result.name), search_result.content)}) + + def _add_torrent(self, uri=None, files=None): + """ + Create client task + :param uri: URI param for client API + :type uri: dict or None + :param files: file param for client API + :type files: dict or None + :return: Id of task in client, True if created but no id found, else Falsy if nothing created + :rtype: string or bool + """ + if self._testmode: + return self._testid + + tasks = self._tinf() + if self._client_has(tasks, uri=uri): + return self._error('Could not create task, the magnet URI is in use') + if self._client_has(tasks, files=files): + return self._error('Could not create task, torrent file already added') + + params = dict() + if uri: + params.update(uri) + if 1 < self._task_version and sickbeard.TORRENT_PATH: + params['destination'] = re.sub(r'^/(volume\d*/)?', '', sickbeard.TORRENT_PATH) + + task_stamp = int(sickbeard.sbdatetime.sbdatetime.now().totimestamp(default=0)) + response = self._client_request('create', t_params=params, files=files) + if response and response.get('success'): + for s in (1, 3, 5, 10, 15, 30, 60): + tasks = filter(lambda t: task_stamp <= t['additional']['detail']['create_time'], self._tinf()) + try: + return str(self._client_has(tasks, uri, files)[0].get('id')) + except IndexError: + time.sleep(s) + return True + + @staticmethod + def _client_has(tasks, uri=None, files=None): + """ + Check if uri or file exists in task list + :param tasks: Tasks list + :type tasks: list + :param uri: URI to check against + :type uri: dict or None + :param files: File to check against + :type files: dict or None + :return: Zero or more found record(s). + :rtype: list + """ + result = [] + if uri or files: + u = isinstance(uri, dict) and (uri.get('uri', '') or '').lower() or None + f = isinstance(files, dict) and (files.get('file', [''])[0]).lower() or None + result = filter(lambda t: u and t['additional']['detail']['uri'].lower() == u + or f and t['additional']['detail']['uri'].lower() in f, tasks) + return result + + def _client_request(self, method, t_id=None, t_params=None, files=None): + """ + Send a request to client + :param method: Api task to invoke + :type method: basestring + :param t_id: Optional id to perform task on + :type t_id: string or None + :param t_params: Optional additional task request parameters + :type t_params: dict or None + :param files: Optional file to send + :type files: dict or None + :return: True if t_id success, response if t_params success, list of error items, else Falsy if failure + :rtype: bool, DS API response object, or list + """ + if self._testmode: + return True + + params = dict(method=method, api='SYNO.DownloadStation.Task', version='1', _sid=self.auth) + if t_id: + params['id'] = t_id + if t_params: + params.update(t_params) + + self._errmsg = None + response = {} + kw_args = (dict(method='get', params=params), dict(method='post', data=params))[method in ('create',)] + kw_args.update(dict(files=files)) + try: + response = self._request(**kw_args).json() + if not response.get('success'): + raise ValueError + except (BaseException, Exception): + return self._error_task(response) + + if None is not t_id and None is t_params and 'create' != method: + return filter(lambda r: r.get('error'), response.get('data', {})) or True + + return response + def _get_auth(self): + """ + Authenticate with client (overridden class function) + :return: client auth_id or False on failure + :rtype: string or bool + """ + if self._testmode: + return True + self.auth = None self._errmsg = None response = {} try: @@ -71,7 +388,7 @@ class DownloadStationAPI(GenericClient): self.url = self.url_base + self._task_path else: raise ValueError - except (StandardError, BaseException): + except (BaseException, Exception): return self._error(self.common_errors.get(response.get('error', {}).get('code', -1))) response = {} @@ -85,60 +402,14 @@ class DownloadStationAPI(GenericClient): self.auth = response['data']['sid'] else: raise ValueError - except (StandardError, BaseException): + except (BaseException, Exception): err_code = response.get('error', {}).get('code', -1) return self._error(self.common_errors.get(err_code) or { 400: 'No such account or incorrect password', 401: 'Account disabled', 402: 'Permission denied', 403: '2-step verification code required', 404: 'Failed to authenticate 2-step verification code' - }.get(err_code, 'Unknown error code')) + }.get(err_code, 'No known API.Auth response')) return self.auth - def _add_torrent_uri(self, result): - - return self._create(uri={'uri': result.url}) - - def _add_torrent_file(self, result): - - return self._create(files={'file': ('%s.torrent' % result.name, result.content)}) - - def _create(self, uri=None, files=None): - - params = dict(method='create', api='SYNO.DownloadStation.Task', version='1', _sid=self.auth) - if 1 < self._task_version and sickbeard.TORRENT_PATH: - params['destination'] = re.sub('^/(volume\d*/)?', '', sickbeard.TORRENT_PATH) - if uri: - params.update(uri) - - self._errmsg = None - response = {} - try: - response = self._request(method='post', data=params, files=files).json() - if not response.get('success'): - raise ValueError - except (StandardError, BaseException): - return self._error_task(response) - - return True - - def _list(self): - - params = dict(method='list', api='SYNO.DownloadStation.Task', version='1', _sid=self.auth, - additional='detail,file') - - self._errmsg = None - response = {} - try: - response = self._request(method='get', params=params).json() - if not response.get('success'): - raise ValueError - except (StandardError, BaseException): - return self._error_task(response) - - # downloading = [x for x in response['data']['tasks'] if x['status'] in ('downloading',)] - # finished = [x for x in response['data']['tasks'] if x['status'] in ('finished', 'seeding')] - - return True - api = DownloadStationAPI() diff --git a/sickbeard/clients/generic.py b/sickbeard/clients/generic.py index 48af932..9eca348 100644 --- a/sickbeard/clients/generic.py +++ b/sickbeard/clients/generic.py @@ -1,14 +1,13 @@ +from base64 import b16encode, b32decode +from hashlib import sha1 import re import time -from hashlib import sha1 -from base64 import b16encode, b32decode +import requests import sickbeard from sickbeard import logger from sickbeard.exceptions import ex -from sickbeard.clients import http_error_code from lib.bencode import bencode, bdecode -from lib import requests class GenericClient(object): @@ -17,26 +16,24 @@ class GenericClient(object): self.name = name self.username = sickbeard.TORRENT_USERNAME if username is None else username self.password = sickbeard.TORRENT_PASSWORD if password is None else password - self.host = sickbeard.TORRENT_HOST if host is None else host + self.host = sickbeard.TORRENT_HOST if host is None else host.rstrip('/') + '/' self.url = None self.auth = None self.last_time = time.time() self.session = requests.session() self.session.auth = (self.username, self.password) + self.created_id = None - def _request(self, method='get', params=None, data=None, files=None, **kwargs): + def _log_request_details(self, method, params=None, data=None, files=None, **kwargs): - params = params or {} + logger.log('%s: sending %s request to %s with ...' % (self.name, method, self.url), logger.DEBUG) - if time.time() > self.last_time + 1800 or not self.auth: - self.last_time = time.time() - self._get_auth() - - logger.log('%s: sending %s request to %s with ...' % (self.name, method.upper(), self.url), logger.DEBUG) lines = [('params', (str(params), '')[not params]), ('data', (str(data), '')[not data]), ('files', (str(files), '')[not files]), + ('post_data', (str(kwargs.get('post_data')), '')[not kwargs.get('post_data')]), + ('post_json', (str(kwargs.get('post_json')), '')[not kwargs.get('post_json')]), ('json', (str(kwargs.get('json')), '')[not kwargs.get('json')])] m, c = 300, 100 type_chunks = [(linetype, [ln[i:i + c] for i in range(0, min(len(ln), m), c)]) for linetype, ln in lines if ln] @@ -51,9 +48,19 @@ class GenericClient(object): for out in output: logger.log(out, logger.DEBUG) - if not self.auth: - logger.log('%s: Authentication Failed' % self.name, logger.ERROR) - return False + def _request(self, method='get', params=None, data=None, files=None, **kwargs): + + params = params or {} + + if time.time() > self.last_time + 1800 or not self.auth: + self.last_time = time.time() + + if not self._get_auth(): + logger.log('%s: Authentication failed' % self.name, logger.ERROR) + return False + + # self._log_request_details(method, params, data, files, **kwargs) + try: response = self.session.__getattribute__(method)(self.url, params=params, data=data, files=files, timeout=kwargs.pop('timeout', 120), verify=False, **kwargs) @@ -61,13 +68,13 @@ class GenericClient(object): logger.log('%s: Unable to connect %s' % (self.name, ex(e)), logger.ERROR) return False except (requests.exceptions.MissingSchema, requests.exceptions.InvalidURL): - logger.log('%s: Invalid Host' % self.name, logger.ERROR) + logger.log('%s: Invalid host' % self.name, logger.ERROR) return False except requests.exceptions.HTTPError as e: - logger.log('%s: Invalid HTTP Request %s' % (self.name, ex(e)), logger.ERROR) + logger.log('%s: Invalid HTTP request %s' % (self.name, ex(e)), logger.ERROR) return False except requests.exceptions.Timeout as e: - logger.log('%s: Connection Timeout %s' % (self.name, ex(e)), logger.ERROR) + logger.log('%s: Connection timeout %s' % (self.name, ex(e)), logger.ERROR) return False except Exception as e: logger.log('%s: Unknown exception raised when sending torrent to %s: %s' % (self.name, self.name, ex(e)), @@ -75,22 +82,37 @@ class GenericClient(object): return False if 401 == response.status_code: - logger.log('%s: Invalid Username or Password, check your config' % self.name, logger.ERROR) + logger.log('%s: Invalid username or password, check your config' % self.name, logger.ERROR) return False - if response.status_code in http_error_code.keys(): - logger.log('%s: %s' % (self.name, http_error_code[response.status_code]), logger.DEBUG) + if response.status_code in sickbeard.clients.http_error_code: + logger.log('%s: %s' % (self.name, sickbeard.clients.http_error_code[response.status_code]), logger.DEBUG) return False logger.log('%s: Response to %s request is %s' % (self.name, method.upper(), response.text), logger.DEBUG) return response - def _get_auth(self): + def _tinf(self, ids=None): + """ + This should be overridden and return client fetched task information + + :param ids: Optional id(s) to get task info for. None to get all task info + :type ids: list or None + :return: Zero or more task object(s) from response + :rtype: list + """ + return [] + + def _active_state(self, ids=None): """ - This should be overridden and should return the auth_id needed for the client + This should be overridden to fetch state of items, return items that are actually downloading or seeding + :param ids: Optional id(s) to get state info for. None to get all + :type ids: list or None + :return: Zero or more object(s) assigned with state `down`loading or `seed`ing + :rtype: list """ - return None + return [] def _add_torrent_uri(self, result): """ @@ -148,6 +170,27 @@ class GenericClient(object): """ return True + def _resume_torrent(self, ids): + """ + This should be overridden to resume task(s) in client + + :param ids: Id(s) to act on + :type ids: list or string + :return: True if success, Id(s) that could not be resumed, else Falsy if failure + :rtype: bool or list + """ + return False + + def _delete_torrent(self, ids): + """ + This should be overridden to delete task(s) from client + :param ids: Id(s) to act on + :type ids: list or string + :return: True if success, Id(s) that could not be deleted, else Falsy if failure + :rtype: bool or list + """ + return False + @staticmethod def _get_torrent_hash(result): @@ -165,10 +208,10 @@ class GenericClient(object): r_code = False - logger.log('Calling %s Client' % self.name, logger.DEBUG) + logger.log('Calling %s client' % self.name, logger.DEBUG) if not self._get_auth(): - logger.log('%s: Authentication Failed' % self.name, logger.ERROR) + logger.log('%s: Authentication failed' % self.name, logger.ERROR) return r_code try: @@ -187,6 +230,7 @@ class GenericClient(object): else: r_code = self._add_torrent_file(result) + self.created_id = isinstance(r_code, basestring) and r_code or None if not r_code: logger.log('%s: Unable to send torrent to client' % self.name, logger.ERROR) return False @@ -212,29 +256,33 @@ class GenericClient(object): except Exception as e: logger.log('%s: Failed sending torrent: %s - %s' % (self.name, result.name, result.hash), logger.ERROR) logger.log('%s: Exception raised when sending torrent: %s' % (self.name, ex(e)), logger.DEBUG) - return r_code return r_code - def test_authentication(self): - + def _get_auth(self): + """ + This may be overridden and should return the auth_id needed for the client + """ try: response = self.session.get(self.url, timeout=120, verify=False) if 401 == response.status_code: - return False, 'Error: Invalid %s Username or Password, check your config!' % self.name + return False, 'Error: Invalid %s username or password, check your config!' % self.name except requests.exceptions.ConnectionError: - return False, 'Error: %s Connection Error' % self.name + return False, 'Error: Connecting to %s' % self.name except (requests.exceptions.MissingSchema, requests.exceptions.InvalidURL): return False, 'Error: Invalid %s host' % self.name + def test_authentication(self): + try: - authenticated = self._get_auth() - # FIXME: This test is redundant - if authenticated and self.auth: - return True, 'Success: Connected and Authenticated' - if getattr(self, '_errmsg', None): - return False, 'Error: Failed to get %s authentication.%s' % (self.name, self._errmsg) - return False, 'Error: Unable to get %s authentication, check your config!' % self.name - except (StandardError, Exception): - return False, 'Error: Unable to connect to %s' % self.name + result = self._get_auth() + if result: + return ((True, 'Success: Connected and authenticated to %s' % self.name), + result)[isinstance(result, tuple)] + + failed_msg = 'Error: Failed %s authentication.%s' % (self.name, getattr(self, '_errmsg', None) or '') + except (BaseException, Exception): + failed_msg = 'Error: Unable to connect to %s' % self.name + + return False, failed_msg diff --git a/sickbeard/clients/qbittorrent.py b/sickbeard/clients/qbittorrent.py index ce4b1f8..17c6cd9 100644 --- a/sickbeard/clients/qbittorrent.py +++ b/sickbeard/clients/qbittorrent.py @@ -1,76 +1,455 @@ -# -# This file is part of SickGear. -# -# SickGear is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# SickGear is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with SickGear. If not, see . - -import sickbeard -from sickbeard import helpers -from sickbeard.clients.generic import GenericClient - - -class QbittorrentAPI(GenericClient): - def __init__(self, host=None, username=None, password=None): - - super(QbittorrentAPI, self).__init__('qBittorrent', host, username, password) - - self.url = self.host - self.session.headers.update({'Origin': self.host}) - - def _get_auth(self): - - self.auth = (6 < self.api_version() and - 'Ok' in helpers.getURL('%slogin' % self.host, session=self.session, - post_data={'username': self.username, 'password': self.password})) - return self.auth - - def api_version(self): - - return helpers.tryInt(helpers.getURL('%sversion/api' % self.host, session=self.session)) - - def _post_api(self, cmd='', **kwargs): - - return helpers.getURL('%scommand/%s' % (self.host, cmd), session=self.session, **kwargs) in ('', 'Ok.') - - def _add_torrent(self, cmd, **kwargs): - - label = sickbeard.TORRENT_LABEL.replace(' ', '_') - label_dict = {'label': label, 'category': label, 'savepath': sickbeard.TORRENT_PATH} - if 'post_data' in kwargs: - kwargs['post_data'].update(label_dict) - else: - kwargs.update({'post_data': label_dict}) - return self._post_api(cmd, **kwargs) - - def _add_torrent_uri(self, result): - - return self._add_torrent('download', post_data={'urls': result.url}) - - def _add_torrent_file(self, result): - - return self._add_torrent('upload', files={'torrents': ('%s.torrent' % result.name, result.content)}) - - ### - # An issue in qB can lead to actions being ignored during the initial period after a file is added. - # Therefore, actions that need to be applied to existing items will be disabled unless fixed. - ### - # def _set_torrent_priority(self, result): - # - # return self._post_api('%screasePrio' % ('de', 'in')[1 == result.priority], post_data={'hashes': result.hash}) - - # def _set_torrent_pause(self, result): - # - # return self._post_api(('resume', 'pause')[sickbeard.TORRENT_PAUSED], post_data={'hash': result.hash}) - - -api = QbittorrentAPI() +# +# This file is part of SickGear. +# +# SickGear is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SickGear is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SickGear. If not, see . + +import re +import time +import urllib + +from .generic import GenericClient +from .. import helpers, logger +import sickbeard + +from requests.exceptions import HTTPError + +from six import string_types + + +class QbittorrentAPI(GenericClient): + + def __init__(self, host=None, username=None, password=None): + + super(QbittorrentAPI, self).__init__('qBittorrent', host, username, password) + + self.url = self.host + self.session.headers.update({'Origin': self.host}) + self.api_ns = None + + def _active_state(self, ids=None): + """ + Fetch state of items, return items that are actually downloading or seeding + :param ids: Optional id(s) to get state info for. None to get all + :type ids: list, string or None + :return: Zero or more object(s) assigned with state `down`loading or `seed`ing + :rtype: list + """ + downloaded = (lambda item: float(item.get('progress') or 0) * (item.get('size') or 0)) # bytes + wanted = (lambda item: item.get('priority')) # wanted will == tally/downloaded if all files are selected + base_state = (lambda t, gp, f: dict( + id=t['hash'], title=t['name'], total_size=gp.get('total_size') or 0, + added_ts=gp.get('addition_date'), last_completed_ts=gp.get('completion_date'), + last_started_ts=None, seed_elapsed_secs=gp.get('seeding_time'), + wanted_size=sum(map(lambda tf: wanted(tf) and tf.get('size') or 0, f)) or None, + wanted_down=sum(map(lambda tf: wanted(tf) and downloaded(tf) or 0, f)) or None, + tally_down=sum(map(lambda tf: downloaded(tf) or 0, f)) or None, + tally_up=gp.get('total_uploaded'), + state='done' if 'pausedUP' == t.get('state') else ('down', 'seed')['up' in t.get('state').lower()] + )) + file_list = (lambda ti: self._client_request( + ('torrents/files', 'query/propertiesFiles/%s' % ti['hash'])[not self.api_ns], + params=({'hash': ti['hash']}, {})[not self.api_ns], json=True) or {}) + valid_stat = (lambda ti: not self._ignore_state(ti) + and sum(map(lambda tf: wanted(tf) and downloaded(tf) or 0, file_list(ti)))) + result = map(lambda t: base_state(t, self._tinf(t['hash'])[0], file_list(t)), + filter(lambda t: re.search('(?i)queue|stall|(up|down)load|pausedUP', t['state']) and valid_stat(t), + self._tinf(ids, False))) + + return result + + def _tinf(self, ids=None, use_props=True, err=False): + """ + Fetch client task information + :param ids: Optional id(s) to get task info for. None to get all task info + :type ids: list or None + :param use_props: Optional override forces retrieval of torrents info instead of torrent generic properties + :type ids: Boolean + :param err: Optional return error dict instead of empty array + :type err: Boolean + :return: Zero or more task object(s) from response + :rtype: list + """ + result = [] + rids = (ids if isinstance(ids, (list, type(None))) else [x.strip() for x in ids.split(',')]) or [None] + getinfo = use_props and None is not ids + params = {} + cmd = ('torrents/info', 'query/torrents')[not self.api_ns] + if not getinfo: + label = sickbeard.TORRENT_LABEL.replace(' ', '_') + if label and not ids: + params['category'] = label + for rid in rids: + if getinfo: + if self.api_ns: + cmd = 'torrents/properties' + params['hash'] = rid + else: + cmd = 'query/propertiesGeneral/%s' % rid + elif rid: + params['hashes'] = rid + try: + tasks = self._client_request(cmd, params=params, timeout=60, json=True) + result += tasks and (isinstance(tasks, list) and tasks or (isinstance(tasks, dict) and [tasks])) \ + or ([], [{'state': 'error', 'hash': rid}])[err] + except (BaseException, Exception): + if getinfo: + result += [dict(error=True, id=rid)] + for t in filter(lambda d: isinstance(d.get('name'), string_types) and d.get('name'), (result, [])[getinfo]): + t['name'] = urllib.unquote_plus(t.get('name')) + + return result + + def _set_torrent_pause(self, search_result): + """ + Set torrent as paused used for the "add as paused" feature (overridden class function) + :param search_result: A populated search result object + :type search_result: TorrentSearchResult + :return: Success or Falsy if fail + :rtype: bool + """ + if not sickbeard.TORRENT_PAUSED: + return super(QbittorrentAPI, self)._set_torrent_pause(search_result) + + return True is self._pause_torrent(search_result.hash) + + def _set_torrent_label(self, search_result): + if not sickbeard.TORRENT_LABEL.replace(' ', '_'): + return super(QbittorrentAPI, self)._set_torrent_label(search_result) + + return True is self._label_torrent(search_result.hash) + + def _set_torrent_priority(self, search_result): + if 1 != search_result.priority: + return super(QbittorrentAPI, self)._set_torrent_priority(search_result) + + return True is self._maxpri_torrent(search_result.hash) + + @staticmethod + def _ignore_state(task): + return bool(re.search(r'(?i)error', task.get('state') or '')) + + def _maxpri_torrent(self, ids): + """ + Set maximal priority in queue to torrent task + :param ids: ID(s) to promote + :type ids: list or string + :return: True/Falsy if success/failure else Id(s) that failed to be changed + :rtype: bool or list + """ + def _maxpri_filter(t): + mark_fail = True + if not self._ignore_state(t): + if 1 >= t.get('priority'): + return not mark_fail + + params = {'hashes': t.get('hash')} + post_data = None + if not self.api_ns: + post_data = params + params = None + + response = self._client_request( + '%s/topPrio' % ('torrents', 'command')[not self.api_ns], + params=params, post_data=post_data, raise_status_code=True) + if True is response: + task = self._tinf(t.get('hash'), use_props=False, err=True)[0] + return 1 < task.get('priority') or self._ignore_state(task) # then mark fail + elif isinstance(response, string_types) and 'queueing' in response.lower(): + logger.log('%s: %s' % (self.name, response), logger.ERROR) + return not mark_fail + return mark_fail + + return self._action('topPrio', ids, lambda t: _maxpri_filter(t)) + + def _label_torrent(self, ids): + """ + Set label/category to torrent task + :param ids: ID(s) to change + :type ids: list or string + :return: True/Falsy if success/failure else Id(s) that failed to be changed + :rtype: bool or list + """ + def _label_filter(t): + mark_fail = True + if not self._ignore_state(t): + label = sickbeard.TORRENT_LABEL.replace(' ', '_') + if label in t.get('category'): + return not mark_fail + + response = self._client_request( + '%s/setCategory' % ('torrents', 'command')[not self.api_ns], + post_data={'hashes': t.get('hash'), 'category': label, 'label': label}, raise_status_code=True) + if True is response: + task = self._tinf(t.get('hash'), use_props=False, err=True)[0] + return label not in task.get('category') or self._ignore_state(task) # then mark fail + elif isinstance(response, string_types) and 'incorrect' in response.lower(): + logger.log('%s: %s. "%s" isn\'t known to qB' % (self.name, response, label), logger.ERROR) + return not mark_fail + return mark_fail + + return self._action('label', ids, lambda t: _label_filter(t)) + + def _pause_torrent(self, ids): + """ + Pause item(s) + :param ids: Id(s) to pause + :type ids: list or string + :return: True/Falsy if success/failure else Id(s) that failed to be paused + :rtype: bool or list + """ + def _pause_filter(t): + mark_fail = True + if not self._ignore_state(t): + if 'paused' in t.get('state'): + return not mark_fail + if True is self._client_request( + '%s/pause' % ('torrents', 'command')[not self.api_ns], + post_data={'hash' + ('es', '')[not self.api_ns]: t.get('hash')}): + task = self._tinf(t.get('hash'), use_props=False, err=True)[0] + return 'paused' not in task.get('state') or self._ignore_state(task) # then mark fail + return mark_fail + + # check task state stability, and call pause where not paused + sample_size = 10 + iv = 0.5 + states = [] + for i in range(0, sample_size): + states += [self._tinf(ids, False)[0]['state']] + if 'paused' not in states[-1]: + self._action('pause', ids, lambda t: _pause_filter(t)) + break + time.sleep(iv) + + # as precaution, if was unstable, do another pass + sample_size = 10 + iterations = int((5 + sample_size) * iv * (1 / iv)) # timeout, ought never happen + while 1 != len(set(states)) and iterations: + for i in range(0, sample_size): + states += [self._tinf(ids, False)[0]['state']] + if 'paused' not in states[-1] and True is not self._action('pause', ids, lambda t: _pause_filter(t)): + time.sleep(iv) + iterations -= 1 + if iterations: + continue + iterations = None + break + states = states[-sample_size:] + + return 'paused' in states[-1] + + def _resume_torrent(self, ids): + """ + Resume task(s) in client + :param ids: Id(s) to act on + :type ids: list or string + :return: True if success, Id(s) that could not be resumed, else Falsy if failure + :rtype: bool or list + """ + return self._perform_task( + 'resume', ids, + lambda t: self._ignore_state(t) or + ('paused' in t.get('state')) and + True is not self._client_request( + '%s/resume' % ('torrents', 'command')[not self.api_ns], + post_data={'hash' + ('es', '')[not self.api_ns]: t.get('hash')})) + + def _delete_torrent(self, ids): + """ + Delete task(s) from client + :param ids: Id(s) to act on + :type ids: list or string + :return: True if success, Id(s) that could not be deleted, else Falsy if failure + :rtype: bool or list + """ + return self._perform_task( + 'delete', ids, + lambda t: self._ignore_state(t) or + True is not self._client_request( + ('torrents/delete', 'command/deletePerm')[not self.api_ns], + post_data=dict([('hashes', t.get('hash'))] + ([('deleteFiles', True)], [])[not self.api_ns])), + pause_first=True) + + def _perform_task(self, method, ids, filter_func, pause_first=False): + """ + Set up and send a method to client + :param method: Either `resume` or `delete` + :type method: string + :param ids: Id(s) to perform method on + :type ids: list or string + :param filter_func: Call back function passed to _action that will filter tasks as failed or erroneous + :type Function + :param pause_first: True if task should be paused prior to invoking method + :type Boolean + :return: True if success, Id(s) that could not be acted upon, else Falsy if failure + :rtype: Boolean or list + """ + if isinstance(ids, (string_types, list)): + rids = ids if isinstance(ids, list) else map(lambda x: x.strip(), ids.split(',')) + + result = pause_first and self._pause_torrent(rids) # get items not paused + result = (isinstance(result, list) and result or []) + for t_id in list(set(rids) - (isinstance(result, list) and set(result) or set())): # perform on paused ids + if True is not self._action(method, t_id, filter_func): + result += [t_id] # failed item + + return result or True + + def _action(self, act, ids, filter_func): + + if isinstance(ids, (string_types, list)): + item = dict(fail=[], ignore=[]) + for task in filter(filter_func, self._tinf(ids, use_props=False, err=True)): + item[('fail', 'ignore')[self._ignore_state(task)]] += [task.get('hash')] + + # retry items that are not acted on + retry_ids = item['fail'] + tries = (1, 3, 5, 10, 15, 15, 30, 60) + i = 0 + while retry_ids: + for i in tries: + logger.log('%s: retry %s %s item(s) in %ss' % (self.name, act, len(item['fail']), i), logger.DEBUG) + time.sleep(i) + item['fail'] = [] + for task in filter(filter_func, self._tinf(retry_ids, use_props=False, err=True)): + item[('fail', 'ignore')[self._ignore_state(task)]] += [task.get('hash')] + + if not item['fail']: + retry_ids = None + break + retry_ids = item['fail'] + else: + if max(tries) == i: + logger.log('%s: failed to %s %s item(s) after %s tries over %s mins, aborted' % + (self.name, act, len(item['fail']), len(tries), sum(tries) / 60), logger.DEBUG) + + return (item['fail'] + item['ignore']) or True + + def _add_torrent_uri(self, search_result): + """ + Add magnet to client (overridden class function) + :param search_result: A populated search result object + :type search_result: TorrentSearchResult + :return: True if created, else Falsy if nothing created + :rtype: Boolean or None + """ + return search_result and self._add_torrent('download', search_result) or False + + def _add_torrent_file(self, search_result): + """ + Add file to client (overridden class function) + :param search_result: A populated search result object + :type search_result: TorrentSearchResult + :return: True if created, else Falsy if nothing created + :rtype: Boolean or None + """ + return search_result and self._add_torrent('upload', search_result) or False + + def _add_torrent(self, cmd, data): + """ + Create client task + :param cmd: Command for client API v6, converted up for newer API + :type cmd: String + :param data: A populated search result object + :type data: TorrentSearchResult + :return: True if created, else Falsy if nothing created + :rtype: Boolean or None + """ + if self._tinf(data.hash): + logger.log('Could not create task, the hash is already in use', logger.ERROR) + return + + label = sickbeard.TORRENT_LABEL.replace(' ', '_') + params = dict( + ([('category', label), ('label', label)], [])[not label] + + ([('paused', ('false', 'true')[bool(sickbeard.TORRENT_PAUSED)])], [])[not sickbeard.TORRENT_PAUSED] + + ([('savepath', sickbeard.TORRENT_PATH)], [])[not sickbeard.TORRENT_PATH] + ) + + if 'download' == cmd: + params.update(dict(urls=data.url)) + kwargs = dict(post_data=params) + else: + kwargs = dict(post_data=params, files={'torrents': ('%s.torrent' % data.name, data.content)}) + + task_stamp = int(sickbeard.sbdatetime.sbdatetime.now().totimestamp(default=0)) + response = self._client_request(('torrents/add', 'command/%s' % cmd)[not self.api_ns], **kwargs) + + if True is response: + for s in (1, 3, 5, 10, 15, 30, 60): + if filter(lambda t: task_stamp <= t['addition_date'], self._tinf(data.hash)): + return data.hash + time.sleep(s) + return True + + def api_found(self): + + try: + v = self._client_request('app/webapiVersion').split('.') + return (2, 0) < tuple([helpers.tryInt(x) for x in '.'.join(v + ['0'] * (4 - len(v))).split('.')]) + except AttributeError: + return 6 < helpers.tryInt(self._client_request('version/api')) + + def _client_request(self, cmd='', **kwargs): + """ + Send a request to client + :param cmd: Api task to invoke + :type cmd: string_types + :param kwargs: keyword arguments to pass thru to helpers getURL function + :type kwargs: mixed + :return: JSON decoded response dict, True if success and no response body, Text error or None if failure, + :rtype: Dict, Boolean, String or None + """ + authless = bool(re.search('(?i)login|version', cmd)) + if authless or self.auth: + if not authless and not self._get_auth(): + logger.log('%s: Authentication failed' % self.name, logger.ERROR) + return + + # self._log_request_details('%s%s' % (self.api_ns, cmd.strip('/')), **kwargs) + response = None + try: + response = helpers.getURL('%s%s%s' % (self.host, self.api_ns, cmd.strip('/')), + session=self.session, **kwargs) + except HTTPError as e: + if e.response.status_code in (409, 403): + response = e.response.text + except (StandardError, Exception): + pass + if isinstance(response, string_types): + if response[0:3].lower() in ('', 'ok.'): + return True + elif response[0:4].lower() == 'fail': + return False + return response + + def _get_auth(self): + """ + Authenticate with client (overridden class function) + :return: True on success, or False on failure + :rtype: Boolean + """ + post_data = dict(username=self.username, password=self.password) + self.api_ns = 'api/v2/' + response = self._client_request('auth/login', post_data=post_data, raise_status_code=True) + if isinstance(response, string_types) and 'banned' in response.lower(): + logger.log('%s: %s' % (self.name, response), logger.ERROR) + response = False + elif not response: + self.api_ns = '' + response = self._client_request('login', post_data=post_data) + self.auth = response and self.api_found() + return self.auth + + +api = QbittorrentAPI() diff --git a/sickbeard/clients/rtorrent.py b/sickbeard/clients/rtorrent.py index ac372fc..e28f1d3 100644 --- a/sickbeard/clients/rtorrent.py +++ b/sickbeard/clients/rtorrent.py @@ -16,7 +16,7 @@ from lib.rtorrent.compat import xmlrpclib from lib.rtorrent import RTorrent -from sickbeard import helpers, logger +from sickbeard import logger from sickbeard.clients.generic import GenericClient import sickbeard @@ -29,18 +29,13 @@ class RtorrentAPI(GenericClient): super(RtorrentAPI, self).__init__('rTorrent', host, username, password) - def _get_auth(self): + def _add_torrent_uri(self, search_result): - self.auth = None - if self.host: - try: - if self.host.startswith('scgi:'): - self.username = self.password = None - self.auth = RTorrent(self.host, self.username, self.password) - except (AssertionError, xmlrpclib.ProtocolError): - pass + return search_result and self._add_torrent('magnet', search_result) or False - return self.auth + def _add_torrent_file(self, search_result): + + return search_result and self._add_torrent('file', search_result) or False def _add_torrent(self, cmd, data): torrent = None @@ -51,11 +46,13 @@ class RtorrentAPI(GenericClient): logger.log('%s: Item already exists %s' % (self.name, data.name), logger.WARNING) raise + custom_var = (1, sickbeard.TORRENT_LABEL_VAR or '')[0 <= sickbeard.TORRENT_LABEL_VAR <= 5] params = { 'start': not sickbeard.TORRENT_PAUSED, - 'extra': ([], ['d.set_custom1=%s' % sickbeard.TORRENT_LABEL])[any([sickbeard.TORRENT_LABEL])] + - ([], ['d.set_directory=%s' % sickbeard.TORRENT_PATH])[any([sickbeard.TORRENT_PATH])] - or None} + 'extra': ([], ['d.set_custom%s=%s' % (custom_var, sickbeard.TORRENT_LABEL)])[ + any([sickbeard.TORRENT_LABEL])] + + ([], ['d.set_directory=%s' % sickbeard.TORRENT_PATH])[ + any([sickbeard.TORRENT_PATH])] or None} # Send magnet to rTorrent if 'file' == cmd: torrent = self.auth.load_torrent(data.content, **params) @@ -63,24 +60,16 @@ class RtorrentAPI(GenericClient): torrent = self.auth.load_magnet(data.url, data.hash, **params) if torrent and sickbeard.TORRENT_LABEL: - label = torrent.get_custom(1) + label = torrent.get_custom(custom_var) if sickbeard.TORRENT_LABEL != label: - logger.log('%s: could not change custom1 category \'%s\' to \'%s\' for %s' % ( - self.name, label, sickbeard.TORRENT_LABEL, torrent.name), logger.WARNING) + logger.log('%s: could not change custom%s label value \'%s\' to \'%s\' for %s' % ( + self.name, custom_var, label, sickbeard.TORRENT_LABEL, torrent.name), logger.WARNING) except(Exception, BaseException): pass return any([torrent]) - def _add_torrent_file(self, result): - - return result and self._add_torrent('file', result) or False - - def _add_torrent_uri(self, result): - - return result and self._add_torrent('magnet', result) or False - # def _set_torrent_ratio(self, name): # if not name: @@ -117,14 +106,20 @@ class RtorrentAPI(GenericClient): # return True - def test_authentication(self): - try: - if not self._get_auth(): - return False, 'Error: Unable to get %s authentication, check your config!' % self.name - return True, 'Success: Connected and Authenticated' + def _get_auth(self): - except (StandardError, Exception): - return False, 'Error: Unable to connect to %s' % self.name + self.auth = None + if self.host: + try: + if self.host.startswith('scgi:'): + self.username = self.password = None + self.auth = RTorrent(self.host, self.username, self.password) + except (AssertionError, xmlrpclib.ProtocolError): + pass + + # do tests here + + return self.auth api = RtorrentAPI() diff --git a/sickbeard/nzbget.py b/sickbeard/nzbget.py index 6766d84..06f732d 100644 --- a/sickbeard/nzbget.py +++ b/sickbeard/nzbget.py @@ -83,10 +83,9 @@ def send_nzb(nzb): sickbeard.indexerApi(curEp.show.indexer).config.get('dupekey', ''), curEp.show.indexerid) dupekey += '-%s.%s' % (curEp.season, curEp.episode) - if datetime.date.today() - curEp.airdate <= datetime.timedelta(days=7) or \ - datetime.date.fromordinal(1) >= curEp.airdate: - add_to_top = True - nzbget_prio = sickbeard.NZBGET_PRIORITY + if 1 == nzb.priority: + add_to_top = True + nzbget_prio = sickbeard.NZBGET_PRIORITY if Quality.UNKNOWN != nzb.quality: dupescore = nzb.quality * 100 diff --git a/sickbeard/search.py b/sickbeard/search.py index 472d8f0..673e897 100644 --- a/sickbeard/search.py +++ b/sickbeard/search.py @@ -112,7 +112,8 @@ def snatch_episode(result, end_status=SNATCHED): if sickbeard.ALLOW_HIGH_PRIORITY: # if it aired recently make it high priority for cur_ep in result.episodes: - if datetime.date.today() - cur_ep.airdate <= datetime.timedelta(days=7): + if datetime.date.today() - cur_ep.airdate <= datetime.timedelta(days=7) or \ + datetime.date.fromordinal(1) >= cur_ep.airdate: result.priority = 1 if 0 < result.properlevel: end_status = SNATCHED_PROPER @@ -161,8 +162,7 @@ def snatch_episode(result, end_status=SNATCHED): logger.log(u'Torrent content failed to download from %s' % result.url, logger.ERROR) return False # Snatches torrent with client - client = clients.get_client_instance(sickbeard.TORRENT_METHOD)() - dl_result = client.send_torrent(result) + dl_result = clients.get_client_instance(sickbeard.TORRENT_METHOD)().send_torrent(result) if getattr(result, 'cache_file', None): helpers.remove_file_failed(result.cache_file) diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 3a9ca2f..a73de1d 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -6119,7 +6119,7 @@ class ConfigSearch(Config): download_propers=None, propers_webdl_onegrp=None, allow_high_priority=None, torrent_dir=None, torrent_username=None, torrent_password=None, torrent_host=None, - torrent_label=None, torrent_path=None, torrent_verify_cert=None, + torrent_label=None, torrent_label_var=None, torrent_path=None, torrent_verify_cert=None, torrent_seed_time=None, torrent_paused=None, torrent_high_bandwidth=None, ignore_words=None, require_words=None, backlog_nofull=None): @@ -6183,6 +6183,10 @@ class ConfigSearch(Config): if set('*') != set(torrent_password): sickbeard.TORRENT_PASSWORD = torrent_password sickbeard.TORRENT_LABEL = torrent_label + sickbeard.TORRENT_LABEL_VAR = config.to_int((0, torrent_label_var)['rtorrent' == torrent_method], 1) + if not (0 <= sickbeard.TORRENT_LABEL_VAR <= 5): + logger.log('Setting rTorrent custom%s is not 0-5, defaulting to custom1' % torrent_label_var, logger.DEBUG) + sickbeard.TORRENT_LABEL_VAR = 1 sickbeard.TORRENT_VERIFY_CERT = config.checkbox_to_value(torrent_verify_cert) sickbeard.TORRENT_PATH = torrent_path sickbeard.TORRENT_SEED_TIME = config.to_int(torrent_seed_time, 0)