Browse Source

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.
tags/release_0.20.15^2
JackDandy 5 years ago
parent
commit
4bd86a3ec6
  1. 16
      CHANGES.md
  2. 22
      gui/slick/interfaces/default/config_search.tmpl
  3. 12
      gui/slick/js/configSearch.js
  4. 2
      lib/rtorrent/torrent.py
  5. 9
      sickbeard/__init__.py
  6. 1
      sickbeard/clients/__init__.py
  7. 379
      sickbeard/clients/download_station.py
  8. 126
      sickbeard/clients/generic.py
  9. 441
      sickbeard/clients/qbittorrent.py
  10. 57
      sickbeard/clients/rtorrent.py
  11. 3
      sickbeard/nzbget.py
  12. 6
      sickbeard/search.py
  13. 6
      sickbeard/webserve.py

16
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

22
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 @@
<option value="100"${prio_veryhigh}>Very high</option>
<option value="900"${prio_force}>Force</option>
</select>
<span>priority for daily snatches (no backlog)</span>
<span>applies to releases from last 7 days</span>
</span>
</label>
</div>
@ -531,9 +533,16 @@
<span class="component-title">Set torrent label<span class="qbittorrent">/category</span></span>
<span class="component-desc">
<input type="text" name="torrent_label" id="torrent_label" value="$sickbeard.TORRENT_LABEL" class="form-control input-sm input200">
<span class="rtorrent" style="display:none">
<p style="float:left;margin-right:6px">as custom#</p>
<input class="rtorrent form-control input-sm" style="width:40px" type="text" name="torrent_label_var" id="torrent_label_var" value="$sg_str('TORRENT_LABEL_VAR', '1')">
</span>
<span id="label-warning-deluge" style="display:none"><p>(blank spaces are not allowed)</p>
<p class="clear-left note">note: label plugin must be enabled in Deluge clients</p>
</span>
<span class="rtorrent" style="display:none">
<p class="clear-left note">note: do not use space char in label</p>
</span>
<span class="qbittorrent" style="display:none"><p>(qB 3.3.1 and newer clients)</p></span>
</span>
</label>
@ -568,19 +577,26 @@
<span class="component-title">Start torrent paused</span>
<span class="component-desc">
<input type="checkbox" name="torrent_paused" class="enabler" id="torrent_paused"<%= html_checked if sickbeard.TORRENT_PAUSED == True else '' %>>
<p>add .torrent to client but do <b style="font-weight:900">not</b> start downloading</p>
<p>pause item in client as soon as it allows (note: a small transfer can occur)</p>
</span>
</label>
</div>
<div class="field-pair" id="torrent-high-bandwidth-option">
<label>
<label class="transmission">
<span class="component-title">Allow high bandwidth</span>
<span class="component-desc">
<input type="checkbox" name="torrent_high_bandwidth" class="enabler" id="torrent_high_bandwidth"<%= html_checked if sickbeard.TORRENT_HIGH_BANDWIDTH == True else '' %>>
<p>use high bandwidth allocation if priority is high</p>
</span>
</label>
<label class="qbittorrent">
<span class="component-title">Add release at top priority</span>
<span class="component-desc">
<input type="checkbox" name="torrent_high_bandwidth" class="enabler" id="torrent_high_bandwidth"<%= html_checked if sickbeard.TORRENT_HIGH_BANDWIDTH == True else '' %>>
<p>applies to releases from last 7 days</p>
</span>
</label>
</div>
<div class="test-notification" id="test-torrent-result">Click below to test</div>

12
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;
}

2
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',

9
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_')

1
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',

379
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 = '<br>%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 = '<br>%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()

126
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)
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 should return the auth_id needed for the client
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 None
return []
def _active_state(self, ids=None):
"""
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 []
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

441
sickbeard/clients/qbittorrent.py

@ -14,63 +14,442 @@
# You should have received a copy of the GNU General Public License
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
import re
import time
import urllib
from .generic import GenericClient
from .. import helpers, logger
import sickbeard
from sickbeard import helpers
from sickbeard.clients.generic import GenericClient
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 _get_auth(self):
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)))
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
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
def api_version(self):
return result or True
return helpers.tryInt(helpers.getURL('%sversion/api' % self.host, session=self.session))
def _action(self, act, ids, filter_func):
def _post_api(self, cmd='', **kwargs):
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')]
return helpers.getURL('%scommand/%s' % (self.host, cmd), session=self.session, **kwargs) in ('', 'Ok.')
# 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')]
def _add_torrent(self, cmd, **kwargs):
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(' ', '_')
label_dict = {'label': label, 'category': label, 'savepath': sickbeard.TORRENT_PATH}
if 'post_data' in kwargs:
kwargs['post_data'].update(label_dict)
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.update({'post_data': label_dict})
return self._post_api(cmd, **kwargs)
kwargs = dict(post_data=params, files={'torrents': ('%s.torrent' % data.name, data.content)})
def _add_torrent_uri(self, result):
task_stamp = int(sickbeard.sbdatetime.sbdatetime.now().totimestamp(default=0))
response = self._client_request(('torrents/add', 'command/%s' % cmd)[not self.api_ns], **kwargs)
return self._add_torrent('download', post_data={'urls': result.url})
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 _add_torrent_file(self, result):
def api_found(self):
return self._add_torrent('upload', files={'torrents': ('%s.torrent' % result.name, result.content)})
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'))
###
# 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 _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
# def _set_torrent_pause(self, result):
#
# return self._post_api(('resume', 'pause')[sickbeard.TORRENT_PAUSED], post_data={'hash': result.hash})
# 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()

57
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):
def _get_auth(self):
self.auth = None
if self.host:
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'
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
except (StandardError, Exception):
return False, 'Error: Unable to connect to %s' % self.name
return self.auth
api = RtorrentAPI()

3
sickbeard/nzbget.py

@ -83,8 +83,7 @@ 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:
if 1 == nzb.priority:
add_to_top = True
nzbget_prio = sickbeard.NZBGET_PRIORITY

6
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)

6
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)

Loading…
Cancel
Save