From bf29c4078229d5424ba7c16f16eeec6a45283fa1 Mon Sep 17 00:00:00 2001 From: BenjV Date: Tue, 18 May 2021 15:24:36 +0200 Subject: [PATCH 1/5] add support for new DownloadStation API --- medusa/clients/torrent/downloadstation.py | 307 +++++++++------------- medusa/clients/torrent/generic.py | 185 +++++-------- 2 files changed, 185 insertions(+), 307 deletions(-) diff --git a/medusa/clients/torrent/downloadstation.py b/medusa/clients/torrent/downloadstation.py index e834cf1a8d..362b20cbe1 100644 --- a/medusa/clients/torrent/downloadstation.py +++ b/medusa/clients/torrent/downloadstation.py @@ -9,18 +9,15 @@ from __future__ import unicode_literals -import json import logging -import os -import re - +import importlib from medusa import app from medusa.clients.torrent.generic import GenericClient -from medusa.helpers import handle_requests_exception from medusa.logger.adapters.style import BraceAdapter - from requests.compat import urljoin -from requests.exceptions import RequestException +from requests import session +import os +import re log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) @@ -29,7 +26,7 @@ class DownloadStationAPI(GenericClient): """Synology Download Station API class.""" - def __init__(self, host=None, username=None, password=None): + def __init__(self, host=None, username=None, password=None, torrent_path=None): """Constructor. :param host: @@ -39,195 +36,135 @@ def __init__(self, host=None, username=None, password=None): :param password: :type password: string """ - super(DownloadStationAPI, self).__init__('DownloadStation', host, username, password) - - self.urls = { - 'login': urljoin(self.host, 'webapi/auth.cgi'), - 'task': urljoin(self.host, 'webapi/DownloadStation/task.cgi'), - 'info': urljoin(self.host, '/webapi/DownloadStation/info.cgi'), + super(DownloadStationAPI, self).__init__('DownloadStation', host, username, password, torrent_path) + self.error_map = { 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', + 119: 'SID not found', + 120: 'Wrong parameter', + 400: 'File upload failed', + 401: 'Max number of tasks reached', + 402: 'Destination denied', + 403: 'Destination does not exist', + 404: 'Invalid task id', + 405: 'Invalid task action', + 406: 'No default destination', + 407: 'Set destination failed', + 408: 'File does not exist'} + self.url = self.host + app.TORRENT_PATH = re.sub(r'^/volume\d*/', '', app.TORRENT_PATH) + + def _check_path(self): + """Validate the destination.""" + if not self.torrent_path: + return True + log.info(f'{self.name} checking if "{self.torrent_path}" is a valid share') + self.url = urljoin(self.host, 'webapi/entry.cgi') + params = { + 'api': 'SYNO.Core.Share', + 'version': 1, + 'method' : 'list' } + # Here we get all available shares from DSM) + if not self._request(method='get', params=params): + return False + jdata = self.response.json() + if not jdata['success']: + err_code = jdata.get('error',{}).get('code', 100) + self.message = f"Could not get the list of shares from {self.name}: {self.error_map[err_code]}" + log.warning(self.message) + return False - self.url = self.urls['task'] - - self.error_map = { - 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', - } - self.checked_destination = False - self.destination = app.TORRENT_PATH - - def _check_response(self): - """Check if session is still valid.""" - try: - jdata = self.response.json() - except ValueError: - self.session.cookies.clear() - self.auth = False - return self.auth + # Walk through the available shares and check if the path is a valid share (if present we remove the volume name). + for share in jdata.get('data', {}).get('shares', ''): + if self.torrent_path.startswith(f"{share['vol_path']}/{share['name']}"): + fullpath = self.torrent_path + self.torrent_path = fullpath.replace(f"{share['vol_path']}/",'') + break + elif self.torrent_path.lstrip('/').startswith(f"{share['name']}"): + self.torrent_path = self.torrent_path.lstrip('/') + fullpath = f"{share['vol_path']}/{self.torrent_path}" + break + else: + # No break occurred, so the destination is not a valid share + self.message = f'"{self.torrent_path}" is not a valid location' + return False + if os.access(fullpath, os.W_OK | os.X_OK): + return True else: - self.auth = jdata.get('success') - if not self.auth: - error_code = jdata.get('error', {}).get('code') - log.warning('Error: {error!r}', {'error': self.error_map.get(error_code, jdata)}) - self.session.cookies.clear() + self.message = f'This user does not have the correct permissions to use "{fullpath}"' + log.warning(self.message) + return False - return self.auth def _get_auth(self): - if self.session.cookies and self.auth: - return self.auth - - params = { - 'api': 'SYNO.API.Auth', - 'version': 2, - 'method': 'login', - 'account': self.username, - 'passwd': self.password, - 'session': 'DownloadStation', - 'format': 'cookie', - } - - try: - self.response = self.session.get(self.urls['login'], params=params, verify=False) - self.response.raise_for_status() - except RequestException as error: - handle_requests_exception(error) - self.session.cookies.clear() + """Login to DownloadStation""" + errmap = { 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'} + self.url = urljoin(self.host, 'webapi/auth.cgi') + params = { 'method': 'login', + 'api': 'SYNO.API.Auth', + 'version': 3, + 'session': 'DownloadStation', + 'account': self.username, + 'passwd': self.password} + self.auth = True + if not self._request(method='get', params=params): self.auth = False - return self.auth + return False + jdata = self.response.json() + if jdata.get('success'): + self.auth = True else: - return self._check_response() + err_code = jdata.get('error', {}).get('code', 100) + self.message = f"{self.name} login error: {errmap[err_code]}" + log.warning(self.message) + self.auth = False + return self.auth def _add_torrent_uri(self, result): - - torrent_path = app.TORRENT_PATH - - data = { - 'api': 'SYNO.DownloadStation.Task', - 'version': '1', - 'method': 'create', - 'session': 'DownloadStation', - 'uri': result.url, - } - - if not self._check_destination(): - return False - - if torrent_path: - data['destination'] = torrent_path - log.debug('Add torrent URI with data: {0}', json.dumps(data)) - self._request(method='post', data=data) - return self._check_response() + # parameters "type' and "destination" must be coded as a json string so we add double quotes to those parameters + params = { 'api' : 'SYNO.DownloadStation2.Task', + 'version' : 2, + 'method' : 'create', + 'create_list': 'false', + 'destination': f'"{app.TORRENT_PATH}"', + 'type' : '"url"', + 'url' : result.url} + return self._add_torrent(params) def _add_torrent_file(self, result): - # The API in the latest version of Download Station (3.8.16.-3566) - # is broken for downloading via a file, only uri's are working correct. - if result.url[:4].lower() in ['http', 'magn']: - return self._add_torrent_uri(result) - - torrent_path = app.TORRENT_PATH - - data = { - 'api': 'SYNO.DownloadStation.Task', - 'version': '1', - 'method': 'create', - 'session': 'DownloadStation', - } - - if not self._check_destination(): - return False - - if torrent_path: - data['destination'] = torrent_path - - files = {'file': ('{name}.torrent'.format(name=result.name), result.content)} - - log.debug('Add torrent files with data: {0}', json.dumps(data)) - self._request(method='post', data=data, files=files) - return self._check_response() - - def _check_destination(self): - """Validate and set torrent destination.""" - torrent_path = app.TORRENT_PATH - - if not (self.auth or self._get_auth()): + # parameters "type" and "file" must be individually coded as a json string so we add double quotes to those parameters + # the "file" paramater (torrent in this case) must corrospondent with the key in the files parameter + params = {'api' : 'SYNO.DownloadStation2.Task', + 'version' : 2, + 'method' : 'create', + 'create_list': 'false', + 'destination': f'"{app.TORRENT_PATH}"', + 'type' : '"file"', + 'file' : '["torrent"]'} + torrent_file = {'torrent': (f'{result.name}.torrent', result.content)} + return self._add_torrent(params, torrent_file) + + + def _add_torrent(self, params, torrent_file=None): + """Add a torrent to DownloadStation""" + self.url = urljoin(app.TORRENT_HOST, 'webapi/entry.cgi') + if not self._request(method='post', data=params, files=torrent_file): return False - - if self.checked_destination and self.destination == torrent_path: + jdata = self.response.json() + if jdata['success']: + log.info(f"Torrent added as task {jdata['data']['task_id']} to {self.name}") return True + log.warning(f"Add torrent error: {self.error_map[jdata.get('error',{}).get('code', 100)]}") + return False - params = { - 'api': 'SYNO.DownloadStation.Info', - 'version': 2, - 'method': 'getinfo', - 'session': 'DownloadStation', - } - - try: - self.response = self.session.get(self.urls['info'], params=params, verify=False, timeout=120) - self.response.raise_for_status() - except RequestException as error: - handle_requests_exception(error) - self.session.cookies.clear() - self.auth = False - return False - - destination = '' - if self._check_response(): - jdata = self.response.json() - version_string = jdata.get('data', {}).get('version_string') - if not version_string: - log.warning('Could not get the version string from DSM:' - ' {response}', {'response': jdata}) - return False - - if version_string.startswith('DSM 6'): - # This is DSM6, lets make sure the location is relative - if torrent_path and os.path.isabs(torrent_path): - torrent_path = re.sub(r'^/volume\d/', '', torrent_path).lstrip('/') - else: - # Since they didn't specify the location in the settings, - # lets make sure the default is relative, - # or forcefully set the location setting - params.update({ - 'method': 'getconfig', - 'version': 2, - }) - - try: - self.response = self.session.get(self.urls['info'], params=params, verify=False, timeout=120) - self.response.raise_for_status() - except RequestException as error: - handle_requests_exception(error) - self.session.cookies.clear() - self.auth = False - return False - - if self._check_response(): - jdata = self.response.json() - destination = jdata.get('data', {}).get('default_destination') - if destination and os.path.isabs(destination): - torrent_path = re.sub(r'^/volume\d/', '', destination).lstrip('/') - else: - log.info('Default destination could not be' - ' determined for DSM6: {response}', - {'response': jdata}) - - return False - - if destination or torrent_path: - log.info('Destination is now {path}', - {'path': torrent_path or destination}) - - self.checked_destination = True - self.destination = torrent_path - return True - - -api = DownloadStationAPI +api = DownloadStationAPI \ No newline at end of file diff --git a/medusa/clients/torrent/generic.py b/medusa/clients/torrent/generic.py index b2ce78cfdb..dd402ad82f 100644 --- a/medusa/clients/torrent/generic.py +++ b/medusa/clients/torrent/generic.py @@ -19,7 +19,7 @@ from medusa.logger.adapters.style import BraceAdapter from medusa.session.core import ClientSession -import requests +import certifi log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) @@ -28,7 +28,7 @@ class GenericClient(object): """Base class for all torrent clients.""" - def __init__(self, name, host=None, username=None, password=None): + def __init__(self, name, host=None, username=None, password=None, torrent_path=None): """Constructor. :param name: @@ -44,67 +44,30 @@ def __init__(self, name, host=None, username=None, password=None): self.username = app.TORRENT_USERNAME if username is None else username self.password = app.TORRENT_PASSWORD if password is None else password self.host = app.TORRENT_HOST if host is None else host + self.torrent_path = app.TORRENT_PATH if torrent_path is None else torrent_path self.rpcurl = app.TORRENT_RPCURL self.url = None self.response = None self.auth = None - self.last_time = time.time() + self.message = None self.session = ClientSession() self.session.auth = (self.username, self.password) - - def _request(self, method='get', params=None, data=None, files=None, cookies=None): - - if time.time() > self.last_time + 1800 or not self.auth: - self.last_time = time.time() - self._get_auth() - - text_params = str(params) - text_data = str(data) - text_files = str(files) - log.debug( - '{name}: Requested a {method} connection to {url} with' - ' params: {params} Data: {data} Files: {files}', { - 'name': self.name, - 'method': method.upper(), - 'url': self.url, - 'params': text_params[0:99] + '...' if len(text_params) > 102 else text_params, - 'data': text_data[0:99] + '...' if len(text_data) > 102 else text_data, - 'files': text_files[0:99] + '...' if len(text_files) > 102 else text_files, - } - ) - - if not self.auth: - log.warning('{name}: Authentication Failed', {'name': self.name}) + self.verify = certifi.where() + + if self._get_auth(): return False - try: - self.response = self.session.request(method, self.url, params=params, data=data, files=files, - cookies=cookies, timeout=120, verify=False) - except (requests.exceptions.MissingSchema, requests.exceptions.InvalidURL) as error: - log.warning('{name}: Invalid Host: {error}', {'name': self.name, 'error': error}) - return False - except requests.exceptions.RequestException as error: - log.warning('{name}: Error occurred during request: {error}', - {'name': self.name, 'error': error}) - return False - except Exception as error: - log.error('{name}: Unknown exception raised when sending torrent to' - ' {name}: {error}', {'name': self.name, 'error': error}) - return False + def _request(self, method='get', params=None, data=None, files=None, cookies=None): + self.response = self.session.request(method, self.url, params=params, data=data, files=files, timeout=60, verify=self.verify) if not self.response: - log.warning('{name}: Unable to reach torrent client', {'name': self.name}) - return False - - if self.response.status_code == 401: - log.error('{name}: Invalid Username or Password, check your config', {'name': self.name}) + log.warning(f"{self.name} {method.upper()} call to {self.url} failed!") + self.message = f'Connect to {self.name} on "{self.host}" failed!' return False - - code_description = http_code_description(self.response.status_code) - if code_description is not None: - log.info('{name}: {code}', {'name': self.name, 'code': code_description}) + if self.response.status_code >= 400: + log.warning(f'{self.name}: Unable to reach torrent client. Reason: {self.response.reason}') + self.message = f"Failed to connect to {self.name} reason: {self.response.reason}" return False - log.debug('{name}: Response to {method} request is {response}', { 'name': self.name, 'method': method.upper(), @@ -117,6 +80,7 @@ def _get_auth(self): """Return the auth_id needed for the client.""" raise NotImplementedError + def _add_torrent_uri(self, result): """Return the True/False from the client when a torrent is added via url (magnet or .torrent link). @@ -133,6 +97,12 @@ def _add_torrent_file(self, result): """ raise NotImplementedError + def _check_path(self): + """Check if the destination path is correct.""" + log.debug('dummy check path function') + return True + + def _set_torrent_label(self, result): """Return the True/False from the client when a torrent is set with label. @@ -195,14 +165,13 @@ def _set_torrent_pause(self, result): @staticmethod def _get_info_hash(result): - + result.hash = None if result.url.startswith('magnet:'): result.hash = re.findall(r'urn:btih:([\w]{32,40})', result.url)[0] if len(result.hash) == 32: hash_b16 = b16encode(b32decode(result.hash)).lower() result.hash = hash_b16.decode('utf-8') else: - try: # `bencodepy` is monkeypatched in `medusa.init` torrent_bdecode = BENCODE.decode(result.content, allow_extra_data=True) @@ -219,10 +188,11 @@ def _get_info_hash(result): 'WHERE name = ? '.format(provider=result.provider.get_id()), [result.name] ) + return False except Exception: log.error(traceback.format_exc()) - - return result + return False + return True def send_torrent(self, result): """Add torrent to the client. @@ -232,66 +202,47 @@ def send_torrent(self, result): :return: :rtype: str or bool """ - r_code = False - log.debug('Calling {name} Client', {'name': self.name}) - if not self.auth: - if not self._get_auth(): - log.warning('{name}: Authentication Failed', {'name': self.name}) - return r_code + # Sets per provider seed ratio + result.ratio = result.provider.seed_ratio() + # check for the hash and add it if not there try: - # Sets per provider seed ratio - result.ratio = result.provider.seed_ratio() - - # lazy fix for now, I'm sure we already do this somewhere else too - result = self._get_info_hash(result) - if not result.hash: + raise Exception() + except: + if not self._get_info_hash(result): return False - if result.url.startswith('magnet:'): - r_code = self._add_torrent_uri(result) - else: - r_code = self._add_torrent_file(result) - - if not r_code: - log.warning('{name}: Unable to send Torrent', - {'name': self.name}) - return False + if result.url.startswith('magnet:'): + log.info(f'Adding "{result.url}" to {self.name}') + r_code = self._add_torrent_uri(result) + else: + log.info(f'Adding "{result.name}" torrent to {self.name}') + r_code = self._add_torrent_file(result) - if not self._set_torrent_pause(result): - log.error('{name}: Unable to set the pause for Torrent', - {'name': self.name}) + if not r_code: + log.warning(f'{self.name}: Unable to send Torrent') + return False - if not self._set_torrent_label(result): - log.error('{name}: Unable to set the label for Torrent', - {'name': self.name}) + if not self._set_torrent_pause(result): + log.error(f'{self.name}: Unable to set the pause for Torrent') - if not self._set_torrent_ratio(result): - log.error('{name}: Unable to set the ratio for Torrent', - {'name': self.name}) + if not self._set_torrent_label(result): + log.error(f'{self.name}: Unable to set the label for Torrent') - if not self._set_torrent_seed_time(result): - log.error('{name}: Unable to set the seed time for Torrent', - {'name': self.name}) + if not self._set_torrent_ratio(result): + log.error(f'{self.name}: Unable to set the ratio for Torrent') - if not self._set_torrent_path(result): - log.error('{name}: Unable to set the path for Torrent', - {'name': self.name}) + if not self._set_torrent_seed_time(result): + log.error(f'{self.name}: Unable to set the seed time for Torrent') - if result.priority != 0 and not self._set_torrent_priority(result): - log.error('{name}: Unable to set priority for Torrent', - {'name': self.name}) + if not self._set_torrent_path(result): + log.error(f'{self.name}: Unable to set the path for Torrent') - except Exception as msg: - log.error('{name}: Failed Sending Torrent', - {'name': self.name}) - log.debug('{name}: Exception raised when sending torrent {result}.' - ' Error: {error}', - {'name': self.name, 'result': result, 'error': msg}) - return r_code + if result.priority != 0 and not self._set_torrent_priority(result): + log.error(f'{self.name}: Unable to set priority for Torrent') return r_code @@ -301,27 +252,16 @@ def test_authentication(self): :return: :rtype: tuple(bool, str) """ - try: - self.response = self.session.get(self.url, timeout=120, verify=False) - except requests.exceptions.ConnectionError: - return False, 'Error: {name} Connection Error'.format(name=self.name) - except (requests.exceptions.MissingSchema, requests.exceptions.InvalidURL): - return False, 'Error: Invalid {name} host'.format(name=self.name) - - if not self.response: - return False, 'Unable to connect to {name}'.format(name=self.name) - - if self.response.status_code == 401: - return False, 'Error: Invalid {name} Username or Password, check your config!'.format(name=self.name) + r_code = self._get_auth() + if self.message: + return r_code, self.message + elif not self._check_path(): + return False, self.message + if r_code: + return True, 'Success: Connected and Authenticated' + else: + return False, f'Error: Unable to get {self.name} authentication, check your input!' - try: - self._get_auth() - if self.response.status_code == 200 and self.auth: - return True, 'Success: Connected and Authenticated' - else: - return False, 'Error: Unable to get {name} Authentication, check your config!'.format(name=self.name) - except Exception as error: - return False, 'Unable to connect to {name}. Error: {msg}'.format(name=self.name, msg=error) def remove_torrent(self, info_hash): """Remove torrent from client using given info_hash. @@ -358,7 +298,8 @@ def remove_ratio_reached(self): It loops in all hashes returned from client and check if it is in the snatch history if its then it checks if we already processed media from the torrent (episode status `Downloaded`) - If is a RARed torrent then we don't have a media file so we check if that hash is from an + If is a RARed + torrent then we don't have a media file so we check if that hash is from an episode that has a `Downloaded` status """ raise NotImplementedError From 3804d3939d05c41dcac43568c08414b40581bbf9 Mon Sep 17 00:00:00 2001 From: p0psicles Date: Sun, 30 May 2021 10:59:42 +0200 Subject: [PATCH 2/5] Fixed lint warnings * Use BraceAdapter formatting * Use single quotes for f-strings * Fixed docstrings formatting * Bad newlines * Unused imports / import ordering --- medusa/clients/torrent/downloadstation.py | 150 ++++++++++++---------- medusa/clients/torrent/generic.py | 54 ++++---- 2 files changed, 109 insertions(+), 95 deletions(-) diff --git a/medusa/clients/torrent/downloadstation.py b/medusa/clients/torrent/downloadstation.py index 362b20cbe1..162120ac6b 100644 --- a/medusa/clients/torrent/downloadstation.py +++ b/medusa/clients/torrent/downloadstation.py @@ -10,14 +10,14 @@ from __future__ import unicode_literals import logging -import importlib +import os +import re + from medusa import app from medusa.clients.torrent.generic import GenericClient from medusa.logger.adapters.style import BraceAdapter + from requests.compat import urljoin -from requests import session -import os -import re log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) @@ -27,7 +27,7 @@ class DownloadStationAPI(GenericClient): """Synology Download Station API class.""" def __init__(self, host=None, username=None, password=None, torrent_path=None): - """Constructor. + """Downloadstationapi constructor. :param host: :type host: string @@ -37,25 +37,27 @@ def __init__(self, host=None, username=None, password=None, torrent_path=None): :type password: string """ super(DownloadStationAPI, self).__init__('DownloadStation', host, username, password, torrent_path) - self.error_map = { 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', - 119: 'SID not found', - 120: 'Wrong parameter', - 400: 'File upload failed', - 401: 'Max number of tasks reached', - 402: 'Destination denied', - 403: 'Destination does not exist', - 404: 'Invalid task id', - 405: 'Invalid task action', - 406: 'No default destination', - 407: 'Set destination failed', - 408: 'File does not exist'} + self.error_map = { + 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', + 119: 'SID not found', + 120: 'Wrong parameter', + 400: 'File upload failed', + 401: 'Max number of tasks reached', + 402: 'Destination denied', + 403: 'Destination does not exist', + 404: 'Invalid task id', + 405: 'Invalid task action', + 406: 'No default destination', + 407: 'Set destination failed', + 408: 'File does not exist' + } self.url = self.host app.TORRENT_PATH = re.sub(r'^/volume\d*/', '', app.TORRENT_PATH) @@ -63,20 +65,23 @@ def _check_path(self): """Validate the destination.""" if not self.torrent_path: return True - log.info(f'{self.name} checking if "{self.torrent_path}" is a valid share') + log.info('{name} checking if "{torrent_path}" is a valid share', { + 'name': self.name, 'torrent_path': self.torrent_path + }) self.url = urljoin(self.host, 'webapi/entry.cgi') params = { 'api': 'SYNO.Core.Share', 'version': 1, - 'method' : 'list' + 'method': 'list' } - # Here we get all available shares from DSM) + + # Get all available shares from DSM) if not self._request(method='get', params=params): return False jdata = self.response.json() if not jdata['success']: - err_code = jdata.get('error',{}).get('code', 100) - self.message = f"Could not get the list of shares from {self.name}: {self.error_map[err_code]}" + err_code = jdata.get('error', {}).get('code', 100) + self.message = f'Could not get the list of shares from {self.name}: {self.error_map[err_code]}' log.warning(self.message) return False @@ -84,14 +89,14 @@ def _check_path(self): for share in jdata.get('data', {}).get('shares', ''): if self.torrent_path.startswith(f"{share['vol_path']}/{share['name']}"): fullpath = self.torrent_path - self.torrent_path = fullpath.replace(f"{share['vol_path']}/",'') + self.torrent_path = fullpath.replace(f"{share['vol_path']}/", '') break elif self.torrent_path.lstrip('/').startswith(f"{share['name']}"): self.torrent_path = self.torrent_path.lstrip('/') fullpath = f"{share['vol_path']}/{self.torrent_path}" break else: - # No break occurred, so the destination is not a valid share + # No break occurred, so the destination is not a valid share self.message = f'"{self.torrent_path}" is not a valid location' return False if os.access(fullpath, os.W_OK | os.X_OK): @@ -101,21 +106,24 @@ def _check_path(self): log.warning(self.message) return False - def _get_auth(self): - """Login to DownloadStation""" - errmap = { 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'} + """Downloadstation login.""" + errmap = { + 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' + } self.url = urljoin(self.host, 'webapi/auth.cgi') - params = { 'method': 'login', - 'api': 'SYNO.API.Auth', - 'version': 3, - 'session': 'DownloadStation', - 'account': self.username, - 'passwd': self.password} + params = { + 'method': 'login', + 'api': 'SYNO.API.Auth', + 'version': 3, + 'session': 'DownloadStation', + 'account': self.username, + 'passwd': self.password + } self.auth = True if not self._request(method='get', params=params): self.auth = False @@ -125,46 +133,54 @@ def _get_auth(self): self.auth = True else: err_code = jdata.get('error', {}).get('code', 100) - self.message = f"{self.name} login error: {errmap[err_code]}" + self.message = f'{self.name} login error: {errmap[err_code]}' log.warning(self.message) self.auth = False return self.auth def _add_torrent_uri(self, result): - # parameters "type' and "destination" must be coded as a json string so we add double quotes to those parameters - params = { 'api' : 'SYNO.DownloadStation2.Task', - 'version' : 2, - 'method' : 'create', - 'create_list': 'false', - 'destination': f'"{app.TORRENT_PATH}"', - 'type' : '"url"', - 'url' : result.url} + # parameters "type' and "destination" must be coded as a json string so we add double quotes to those parameters + params = { + 'api': 'SYNO.DownloadStation2.Task', + 'version': 2, + 'method': 'create', + 'create_list': 'false', + 'destination': f'"{app.TORRENT_PATH}"', + 'type': '"url"', + 'url': result.url + } return self._add_torrent(params) def _add_torrent_file(self, result): - # parameters "type" and "file" must be individually coded as a json string so we add double quotes to those parameters - # the "file" paramater (torrent in this case) must corrospondent with the key in the files parameter - params = {'api' : 'SYNO.DownloadStation2.Task', - 'version' : 2, - 'method' : 'create', - 'create_list': 'false', - 'destination': f'"{app.TORRENT_PATH}"', - 'type' : '"file"', - 'file' : '["torrent"]'} + # Parameters "type" and "file" must be individually coded as a json string so we add double quotes to those parameters + # The "file" paramater (torrent in this case) must corrospondent with the key in the files parameter + params = { + 'api': 'SYNO.DownloadStation2.Task', + 'version': 2, + 'method': 'create', + 'create_list': 'false', + 'destination': f'"{app.TORRENT_PATH}"', + 'type': '"file"', + 'file': '["torrent"]' + } torrent_file = {'torrent': (f'{result.name}.torrent', result.content)} return self._add_torrent(params, torrent_file) - def _add_torrent(self, params, torrent_file=None): - """Add a torrent to DownloadStation""" + """Add a torrent to DownloadStation.""" self.url = urljoin(app.TORRENT_HOST, 'webapi/entry.cgi') if not self._request(method='post', data=params, files=torrent_file): return False jdata = self.response.json() if jdata['success']: - log.info(f"Torrent added as task {jdata['data']['task_id']} to {self.name}") + log.info('Torrent added as task {task_id} to {self_name}', { + 'task_id': jdata['data']['task_id'], 'self_name': self.name + }) return True - log.warning(f"Add torrent error: {self.error_map[jdata.get('error',{}).get('code', 100)]}") + log.warning('Add torrent error: {error}', { + 'error': self.error_map[jdata.get('error', {}).get('code', 100)] + }) return False -api = DownloadStationAPI \ No newline at end of file + +api = DownloadStationAPI diff --git a/medusa/clients/torrent/generic.py b/medusa/clients/torrent/generic.py index dd402ad82f..9251521174 100644 --- a/medusa/clients/torrent/generic.py +++ b/medusa/clients/torrent/generic.py @@ -5,21 +5,19 @@ import logging import re -import time import traceback from base64 import b16encode, b32decode from builtins import object -from builtins import str from hashlib import sha1 from bencodepy import BencodeDecodeError, DEFAULT as BENCODE +import certifi + from medusa import app, db -from medusa.helper.common import http_code_description from medusa.logger.adapters.style import BraceAdapter from medusa.session.core import ClientSession -import certifi log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) @@ -29,7 +27,7 @@ class GenericClient(object): """Base class for all torrent clients.""" def __init__(self, name, host=None, username=None, password=None, torrent_path=None): - """Constructor. + """Genericclient Constructor. :param name: :type name: string @@ -53,7 +51,7 @@ def __init__(self, name, host=None, username=None, password=None, torrent_path=N self.session = ClientSession() self.session.auth = (self.username, self.password) self.verify = certifi.where() - + if self._get_auth(): return False @@ -61,26 +59,28 @@ def _request(self, method='get', params=None, data=None, files=None, cookies=Non self.response = self.session.request(method, self.url, params=params, data=data, files=files, timeout=60, verify=self.verify) if not self.response: - log.warning(f"{self.name} {method.upper()} call to {self.url} failed!") + log.warning('{name} {method} call to {url} failed!', { + 'name': self.name, 'method': method.upper(), 'url': self.url + }) self.message = f'Connect to {self.name} on "{self.host}" failed!' return False - if self.response.status_code >= 400: - log.warning(f'{self.name}: Unable to reach torrent client. Reason: {self.response.reason}') - self.message = f"Failed to connect to {self.name} reason: {self.response.reason}" + if self.response.status_code >= 400: + log.warning('{name}: Unable to reach torrent client. Reason: {reason}', { + 'name': self.name, 'reason': self.response.reason + }) + self.message = f'Failed to connect to {self.name} reason: {self.response.reason}' return False log.debug('{name}: Response to {method} request is {response}', { 'name': self.name, 'method': method.upper(), 'response': self.response.text[0:1024] + '...' if len(self.response.text) > 1027 else self.response.text }) - return True def _get_auth(self): """Return the auth_id needed for the client.""" raise NotImplementedError - def _add_torrent_uri(self, result): """Return the True/False from the client when a torrent is added via url (magnet or .torrent link). @@ -99,10 +99,8 @@ def _add_torrent_file(self, result): def _check_path(self): """Check if the destination path is correct.""" - log.debug('dummy check path function') return True - def _set_torrent_label(self, result): """Return the True/False from the client when a torrent is set with label. @@ -202,8 +200,6 @@ def send_torrent(self, result): :return: :rtype: str or bool """ - - # Sets per provider seed ratio result.ratio = result.provider.seed_ratio() @@ -211,38 +207,41 @@ def send_torrent(self, result): try: if not result.hash: raise Exception() - except: + # TODO: refactor this later. + except Exception: if not self._get_info_hash(result): return False if result.url.startswith('magnet:'): - log.info(f'Adding "{result.url}" to {self.name}') + log.info('Adding "{url}" to {name}', {'url': result.url, 'name': self.name}) r_code = self._add_torrent_uri(result) else: - log.info(f'Adding "{result.name}" torrent to {self.name}') + log.info('Adding "{result_name}" torrent to {name}', { + 'result_name': result.name, 'name': self.name + }) r_code = self._add_torrent_file(result) if not r_code: - log.warning(f'{self.name}: Unable to send Torrent') + log.warning('{name}: Unable to send Torrent', {'name': self.name}) return False if not self._set_torrent_pause(result): - log.error(f'{self.name}: Unable to set the pause for Torrent') + log.error('{name}: Unable to set the pause for Torrent', {'name': self.name}) if not self._set_torrent_label(result): - log.error(f'{self.name}: Unable to set the label for Torrent') + log.error('{name}: Unable to set the label for Torrent', {'name': self.name}) if not self._set_torrent_ratio(result): - log.error(f'{self.name}: Unable to set the ratio for Torrent') + log.error('{name}: Unable to set the ratio for Torrent', {'name': self.name}) if not self._set_torrent_seed_time(result): - log.error(f'{self.name}: Unable to set the seed time for Torrent') + log.error('{name}: Unable to set the seed time for Torrent', {'name': self.name}) if not self._set_torrent_path(result): - log.error(f'{self.name}: Unable to set the path for Torrent') + log.error('{name}: Unable to set the path for Torrent', {'name': self.name}) if result.priority != 0 and not self._set_torrent_priority(result): - log.error(f'{self.name}: Unable to set priority for Torrent') + log.error('{name}: Unable to set priority for Torrent', {'name': self.name}) return r_code @@ -262,7 +261,6 @@ def test_authentication(self): else: return False, f'Error: Unable to get {self.name} authentication, check your input!' - def remove_torrent(self, info_hash): """Remove torrent from client using given info_hash. @@ -299,7 +297,7 @@ def remove_ratio_reached(self): It loops in all hashes returned from client and check if it is in the snatch history if its then it checks if we already processed media from the torrent (episode status `Downloaded`) If is a RARed - torrent then we don't have a media file so we check if that hash is from an + torrent then we don't have a media file so we check if that hash is from an episode that has a `Downloaded` status """ raise NotImplementedError From f2e77250d9a10a27bb54d3a7bbe8fdc8e12d0d16 Mon Sep 17 00:00:00 2001 From: p0psicles Date: Mon, 31 May 2021 08:41:34 +0200 Subject: [PATCH 3/5] Replace raise exception with evaluation --- medusa/clients/torrent/generic.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/medusa/clients/torrent/generic.py b/medusa/clients/torrent/generic.py index 9251521174..46eeec4b84 100644 --- a/medusa/clients/torrent/generic.py +++ b/medusa/clients/torrent/generic.py @@ -204,13 +204,8 @@ def send_torrent(self, result): result.ratio = result.provider.seed_ratio() # check for the hash and add it if not there - try: - if not result.hash: - raise Exception() - # TODO: refactor this later. - except Exception: - if not self._get_info_hash(result): - return False + if not result.hash and not self._get_info_hash(result): + return False if result.url.startswith('magnet:'): log.info('Adding "{url}" to {name}', {'url': result.url, 'name': self.name}) From e43ac7e446d80172b69c3eeceb0f0bc5e5ed259c Mon Sep 17 00:00:00 2001 From: p0psicles Date: Mon, 31 May 2021 08:52:57 +0200 Subject: [PATCH 4/5] Use cert verification if enabled through app.TORRENT_VERIFY_CERT * No need to initialize result.hash. As this attribute is always available. --- medusa/clients/torrent/generic.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/medusa/clients/torrent/generic.py b/medusa/clients/torrent/generic.py index 46eeec4b84..813030f996 100644 --- a/medusa/clients/torrent/generic.py +++ b/medusa/clients/torrent/generic.py @@ -57,7 +57,10 @@ def __init__(self, name, host=None, username=None, password=None, torrent_path=N def _request(self, method='get', params=None, data=None, files=None, cookies=None): - self.response = self.session.request(method, self.url, params=params, data=data, files=files, timeout=60, verify=self.verify) + self.response = self.session.request( + method, self.url, params=params, data=data, files=files, timeout=60, cookies=cookies, + verify=self.verify if app.TORRENT_VERIFY_CERT else False + ) if not self.response: log.warning('{name} {method} call to {url} failed!', { 'name': self.name, 'method': method.upper(), 'url': self.url @@ -163,7 +166,6 @@ def _set_torrent_pause(self, result): @staticmethod def _get_info_hash(result): - result.hash = None if result.url.startswith('magnet:'): result.hash = re.findall(r'urn:btih:([\w]{32,40})', result.url)[0] if len(result.hash) == 32: From 1c5112e006a4413e893415624e303f18f9a11e16 Mon Sep 17 00:00:00 2001 From: p0psicles Date: Mon, 31 May 2021 12:21:30 +0200 Subject: [PATCH 5/5] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5016967e4..717b77fb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ #### Improvements #### Fixes +- Add support for new synology download station api. Credits to Benjv. ([9555](https://github.com/pymedusa/Medusa/pull/9555)) -----