# # 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()