mirror of
https://github.com/SickGear/SickGear.git
synced 2024-12-27 13:13:37 +00:00
419 lines
19 KiB
Python
419 lines
19 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/>.
|
||
|
#
|
||
|
# Uses the Synology Download Station API:
|
||
|
# http://download.synology.com/download/Document/DeveloperGuide/Synology_Download_Station_Web_API.pdf
|
||
|
|
||
|
from datetime import datetime
|
||
|
import re
|
||
|
import time
|
||
|
|
||
|
from .generic import GenericClient
|
||
|
from .. import logger
|
||
|
from ..sgdatetime import timestamp_near
|
||
|
import sickgear
|
||
|
|
||
|
from _23 import filter_iter, filter_list, map_list, unquote_plus
|
||
|
from six import string_types
|
||
|
|
||
|
# noinspection PyUnreachableCode
|
||
|
if False:
|
||
|
from typing import AnyStr, Callable, Optional, Union
|
||
|
from ..classes import TorrentSearchResult
|
||
|
|
||
|
|
||
|
class DownloadStationAPI(GenericClient):
|
||
|
|
||
|
def __init__(self, host=None, username=None, password=None):
|
||
|
|
||
|
super(DownloadStationAPI, self).__init__('DownloadStation', host, username, password)
|
||
|
|
||
|
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 = False
|
||
|
self._auth_version = None
|
||
|
self._auth_path = None
|
||
|
self._task_version = None
|
||
|
self._task_path = None
|
||
|
|
||
|
common_errors = {
|
||
|
-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',
|
||
|
107: 'Session interrupted by duplicate login', 108: 'Failed to upload the file',
|
||
|
109: 'The network connection is unstable or the system is busy',
|
||
|
110: 'The network connection is unstable or the system is busy',
|
||
|
111: 'The network connection is unstable or the system is busy',
|
||
|
114: 'Lost parameters for this API', 115: 'Not allowed to upload a file',
|
||
|
116: 'Not allowed to perform for a demo site',
|
||
|
117: 'The network connection is unstable or the system is busy',
|
||
|
118: 'The network connection is unstable or the system is busy',
|
||
|
119: 'Invalid session', 150: 'Request source IP does not match the login IP'
|
||
|
}
|
||
|
|
||
|
def _error(self, msg):
|
||
|
# type: (AnyStr) -> None
|
||
|
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',
|
||
|
403: 'Destination path does not exist', 404: 'Invalid task id', 405: 'Invalid task action',
|
||
|
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):
|
||
|
# type: (Optional[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
|
||
|
"""
|
||
|
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_list(lambda tf: wanted(tf) and tf.get('size') or 0, f)) or None,
|
||
|
wanted_down=sum(map_list(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_list(
|
||
|
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'), string_types)
|
||
|
and sum(map_list(lambda tf: wanted(tf) and downloaded(tf) or 0, file_list(ti))))
|
||
|
result = map_list(lambda t: base_state(
|
||
|
t, t.get('additional', {}).get('detail', {}), t.get('additional', {}).get('transfer', {}), file_list(t)),
|
||
|
filter_list(lambda t: t['status'] in ('downloading', 'seeding', 'finished') and valid_stat(t),
|
||
|
tasks))
|
||
|
|
||
|
return result
|
||
|
|
||
|
def _tinf(self, ids=None, err=False):
|
||
|
# type: (Optional[AnyStr, list], bool) -> list
|
||
|
"""
|
||
|
Fetch client task information
|
||
|
:param ids: Optional id(s) to get task info for. None to get all task info
|
||
|
: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 = None is not ids
|
||
|
for rid in rids:
|
||
|
try:
|
||
|
if not self._testmode:
|
||
|
# noinspection PyTypeChecker
|
||
|
tasks = self._client_request(('list', 'getinfo')[getinfo], t_id=rid,
|
||
|
t_params=dict(additional='detail,file,transfer'))['data']['tasks']
|
||
|
else:
|
||
|
# noinspection PyUnresolvedReferences
|
||
|
tasks = (filter_list(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_iter(lambda d: isinstance(d.get('title'), string_types) and d.get('title'), result):
|
||
|
t['title'] = unquote_plus(t.get('title'))
|
||
|
|
||
|
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 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):
|
||
|
# 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
|
||
|
"""
|
||
|
return self._action(
|
||
|
'pause', ids,
|
||
|
lambda t: self._ignore_state(t) or
|
||
|
(not isinstance(t.get('status'), string_types) or 'paused' not in t.get('status')) and
|
||
|
True is not self._client_request('pause', t.get('id')))
|
||
|
|
||
|
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
|
||
|
(not isinstance(t.get('status'), string_types) or 'paused' in t.get('status')) and
|
||
|
True is not self._client_request('resume', t.get('id')))
|
||
|
|
||
|
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
|
||
|
isinstance(t.get('status'), string_types) 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):
|
||
|
# 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 to 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 map_list(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_iter(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_iter(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):
|
||
|
# type: (TorrentSearchResult) -> Union[AnyStr, bool]
|
||
|
"""
|
||
|
Add magnet to client (overridden class function)
|
||
|
:param search_result: A populated search result object
|
||
|
:return: Id of task in client, True if added but no ID, else Falsy if nothing added
|
||
|
"""
|
||
|
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):
|
||
|
# type: (TorrentSearchResult) -> Union[AnyStr, bool]
|
||
|
"""
|
||
|
Add file to client (overridden class function)
|
||
|
:param search_result: A populated search result object
|
||
|
:return: Id of task in client, True if added but no ID, else Falsy if nothing added
|
||
|
"""
|
||
|
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):
|
||
|
# type: (Optional[dict], Optional[dict]) -> Optional[AnyStr, bool]
|
||
|
"""
|
||
|
Create client task
|
||
|
:param uri: URI param for client API
|
||
|
:param files: file param for client API
|
||
|
:return: Id of task in client, True if created but no id found, else Falsy if nothing created
|
||
|
"""
|
||
|
if self._testmode:
|
||
|
# noinspection PyUnresolvedReferences
|
||
|
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 sickgear.TORRENT_PATH:
|
||
|
params['destination'] = re.sub(r'^/(volume\d*/)?', '', sickgear.TORRENT_PATH)
|
||
|
|
||
|
task_stamp = int(timestamp_near(datetime.now()))
|
||
|
response = self._client_request('create', t_params=params, files=files)
|
||
|
# noinspection PyUnresolvedReferences
|
||
|
if response and response.get('success'):
|
||
|
for s in (1, 3, 5, 10, 15, 30, 60):
|
||
|
tasks = filter_list(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):
|
||
|
# type: (list, Optional[dict], Optional[dict]) -> list
|
||
|
"""
|
||
|
Check if uri or file exists in task list
|
||
|
:param tasks: Tasks list
|
||
|
:param uri: URI to check against
|
||
|
:param files: File to check against
|
||
|
:return: Zero or more found record(s).
|
||
|
"""
|
||
|
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_list(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):
|
||
|
# type: (AnyStr, Optional[AnyStr], Optional[dict], Optional[dict]) -> Union[bool, list, object]
|
||
|
"""
|
||
|
Send a request to client
|
||
|
:param method: Api task to invoke
|
||
|
:param t_id: Optional id to perform task on
|
||
|
:param t_params: Optional additional task request parameters
|
||
|
:param files: Optional file to send
|
||
|
:return: True if t_id success, DS API response object if t_params success, list of error items,
|
||
|
else Falsy if failure
|
||
|
"""
|
||
|
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_list(lambda r: r.get('error'), response.get('data', {})) or True
|
||
|
|
||
|
return response
|
||
|
|
||
|
def _get_auth(self):
|
||
|
# type: (...) -> Union[AnyStr, bool]
|
||
|
"""
|
||
|
Authenticate with client (overridden class function)
|
||
|
:return: client auth_id or False on failure
|
||
|
"""
|
||
|
if self._testmode:
|
||
|
return True
|
||
|
|
||
|
self.auth = None
|
||
|
self._errmsg = None
|
||
|
response = {}
|
||
|
try:
|
||
|
response = self.session.get(self.url_info, verify=False,
|
||
|
params=dict(method='query', api='SYNO.API.Info', version=1,
|
||
|
query='SYNO.API.Auth,SYNO.DownloadStation.Task')).json()
|
||
|
if response.get('success') and response.get('data'):
|
||
|
data = response.get('data')
|
||
|
for key, member in (('SYNO.API.Auth', 'auth'), ('SYNO.DownloadStation.Task', 'task')):
|
||
|
self.__setattr__('_%s_version' % member, data[key]['maxVersion'])
|
||
|
self.__setattr__('_%s_path' % member, data[key]['path'])
|
||
|
self.url = self.url_base + self._task_path
|
||
|
else:
|
||
|
raise ValueError
|
||
|
except (BaseException, Exception):
|
||
|
return self._error(self.common_errors.get(response.get('error', {}).get('code', -1)))
|
||
|
|
||
|
response = {}
|
||
|
try:
|
||
|
params = dict(method='login', api='SYNO.API.Auth',
|
||
|
version=(1, (2, self._auth_version)[7 <= self._auth_version])[1 < self._auth_version],
|
||
|
account=self.username, passwd=self.password, session='DownloadStation')
|
||
|
params.update(({}, dict(format='sid'))[1 < self._auth_version])
|
||
|
|
||
|
response = self.session.get(self.url_base + self._auth_path, params=params, verify=False).json()
|
||
|
if response.get('success') and response.get('data'):
|
||
|
self.auth = response['data']['sid']
|
||
|
else:
|
||
|
raise ValueError
|
||
|
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',
|
||
|
406: 'Enforce to authenticate with 2-factor authentication code', 407: 'Blocked IP source',
|
||
|
408: 'Expired password cannot change', 409: 'Expired password', 410: 'Password must be changed'
|
||
|
}.get(err_code, 'No known API.Auth response'))
|
||
|
|
||
|
return self.auth
|
||
|
|
||
|
|
||
|
api = DownloadStationAPI()
|