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
+
+ as custom#
+
+
(blank spaces are not allowed)
note: label plugin must be enabled in Deluge clients
+
+ note: do not use space char in label
+
(qB 3.3.1 and newer clients)
@@ -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)