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)