mirror of
https://github.com/SickGear/SickGear.git
synced 2024-12-23 19:23:37 +00:00
32987134ba
Cleanup most init warnings. Cleanup some vars, pythonic instead of js. Some typos and python var/func names for Scheduler. Remove legacy handlers deprecated in 2020. Remove some legacy tagged stuff. Cleanup ConfigParser and 23.py Change cleanup vendored scandir. Remove redundant pkg_resources.py in favour of the vendor folder. Remove backports. Remove trakt checker. Change remove redundant WindowsSelectorEventLoopPolicy from webserveInit. Cleanup varnames and providers Various minor tidy ups to remove ide warnings.
443 lines
20 KiB
Python
443 lines
20 KiB
Python
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
from datetime import datetime
|
|
import re
|
|
import time
|
|
|
|
from .generic import GenericClient
|
|
from .. import logger
|
|
from ..helpers import get_url, try_int
|
|
from ..sgdatetime import timestamp_near
|
|
import sickgear
|
|
|
|
from requests.exceptions import HTTPError
|
|
|
|
from _23 import unquote_plus
|
|
from six import string_types
|
|
|
|
# noinspection PyUnreachableCode
|
|
if False:
|
|
from typing import Any, AnyStr, Callable, Optional, Union
|
|
from ..classes import TorrentSearchResult
|
|
|
|
|
|
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):
|
|
# type: (Optional[AnyStr, list]) -> list
|
|
"""
|
|
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
|
|
:return: Zero or more object(s) assigned with state `down`loading or `seed`ing
|
|
"""
|
|
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(list(map(lambda tf: wanted(tf) and tf.get('size') or 0, f))) or None,
|
|
wanted_down=sum(list(map(lambda tf: wanted(tf) and downloaded(tf) or 0, f))) or None,
|
|
tally_down=sum(list(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(list(map(lambda tf: wanted(tf) and downloaded(tf) or 0, file_list(ti)))))
|
|
result = list(map(lambda t: base_state(t, self._tinf(t['hash'])[0], file_list(t)),
|
|
list(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):
|
|
# type: (Optional[list], bool, bool) -> list
|
|
"""
|
|
Fetch client task information
|
|
:param ids: Optional id(s) to get task info for. None to get all task info
|
|
:param use_props: Optional override forces retrieval of torrents info instead of torrent generic properties
|
|
:param err: Optional return error dict instead of empty array
|
|
:return: Zero or more task object(s) from response
|
|
"""
|
|
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 = sickgear.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'] = unquote_plus(t.get('name'))
|
|
|
|
return result
|
|
|
|
def _set_torrent_pause(self, search_result):
|
|
# type: (TorrentSearchResult) -> bool
|
|
"""
|
|
Set torrent as paused used for the "add as paused" feature (overridden class function)
|
|
:param search_result: A populated search result object
|
|
:return: Success or Falsy if fail
|
|
"""
|
|
if not sickgear.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 sickgear.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):
|
|
# type: (Union[AnyStr, list]) -> Union[bool, list]
|
|
"""
|
|
Set maximal priority in queue to torrent task
|
|
:param ids: ID(s) to promote
|
|
:return: True/Falsy if success/failure else ID(s) that failed to be changed
|
|
"""
|
|
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):
|
|
# type: (Union[AnyStr, list]) -> Union[bool, list]
|
|
"""
|
|
Set label/category to torrent task
|
|
:param ids: ID(s) to change
|
|
:return: True/Falsy if success/failure else ID(s) that failed to be changed
|
|
"""
|
|
def _label_filter(t):
|
|
mark_fail = True
|
|
if not self._ignore_state(t):
|
|
label = sickgear.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):
|
|
# type: (Union[AnyStr, list]) -> Union[bool, list]
|
|
"""
|
|
Pause item(s)
|
|
:param ids: ID(s) to pause
|
|
:return: True/Falsy if success/failure else ID(s) that failed to be paused
|
|
"""
|
|
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):
|
|
# type: (Union[AnyStr, list]) -> Union[bool, list]
|
|
"""
|
|
Resume task(s) in client
|
|
:param ids: ID(s) to act on
|
|
:return: True if success, ID(s) that could not be resumed, else Falsy if failure
|
|
"""
|
|
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):
|
|
# type: (Union[AnyStr, list]) -> Union[bool, list]
|
|
"""
|
|
Delete task(s) from client
|
|
:param ids: ID(s) to act on
|
|
:return: True if success, ID(s) that could not be deleted, else Falsy if failure
|
|
"""
|
|
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):
|
|
# type: (AnyStr, Union[AnyStr, list], Callable, bool) -> Union[bool, list]
|
|
"""
|
|
Set up and send a method to client
|
|
:param method: Either `resume` or `delete`
|
|
:param ids: ID(s) to perform method on
|
|
:param filter_func: Call back function passed to _action that will filter tasks as failed or erroneous
|
|
:param pause_first: True if task should be paused prior to invoking method
|
|
:return: True if success, ID(s) that could not be acted upon, else Falsy if failure
|
|
"""
|
|
if isinstance(ids, (string_types, list)):
|
|
rids = ids if isinstance(ids, list) else list(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):
|
|
# type: (TorrentSearchResult) -> Optional[bool]
|
|
"""
|
|
Add magnet to client (overridden class function)
|
|
:param search_result: A populated search result object
|
|
:return: True if created, else Falsy if nothing created
|
|
"""
|
|
return search_result and self._add_torrent('download', search_result) or False
|
|
|
|
def _add_torrent_file(self, search_result):
|
|
# type: (TorrentSearchResult) -> Optional[bool]
|
|
"""
|
|
Add file to client (overridden class function)
|
|
:param search_result: A populated search result object
|
|
:return: True if created, else Falsy if nothing created
|
|
"""
|
|
return search_result and self._add_torrent('upload', search_result) or False
|
|
|
|
def _add_torrent(self, cmd, data):
|
|
# type: (AnyStr, TorrentSearchResult) -> Optional[bool]
|
|
"""
|
|
Create client task
|
|
:param cmd: Command for client API v6, converted up for newer API
|
|
:param data: A populated search result object
|
|
:return: True if created, else Falsy if nothing created
|
|
"""
|
|
if self._tinf(data.hash):
|
|
logger.log('Could not create task, the hash is already in use', logger.ERROR)
|
|
return
|
|
|
|
label = sickgear.TORRENT_LABEL.replace(' ', '_')
|
|
params = dict(
|
|
([('category', label), ('label', label)], [])[not label]
|
|
+ ([('paused', ('false', 'true')[bool(sickgear.TORRENT_PAUSED)])], [])[not sickgear.TORRENT_PAUSED]
|
|
+ ([('savepath', sickgear.TORRENT_PATH)], [])[not sickgear.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(timestamp_near(datetime.now()))
|
|
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 list(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([try_int(x) for x in '.'.join(v + ['0'] * (4 - len(v))).split('.')])
|
|
except AttributeError:
|
|
return 6 < try_int(self._client_request('version/api'))
|
|
|
|
def _client_request(self, cmd='', **kwargs):
|
|
# type: (AnyStr, Any) -> Optional[AnyStr, bool, dict, list]
|
|
"""
|
|
Send a request to client
|
|
:param cmd: Api task to invoke
|
|
:param kwargs: keyword arguments to pass through to helpers getURL function
|
|
:return: JSON decoded response dict, True if success and no response body, Text error or None if failure,
|
|
"""
|
|
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 = get_url('%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 (BaseException, 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()
|