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)) ----- diff --git a/medusa/clients/torrent/downloadstation.py b/medusa/clients/torrent/downloadstation.py index e834cf1a8d..162120ac6b 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 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 log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) @@ -29,8 +26,8 @@ class DownloadStationAPI(GenericClient): """Synology Download Station API class.""" - def __init__(self, host=None, username=None, password=None): - """Constructor. + def __init__(self, host=None, username=None, password=None, torrent_path=None): + """Downloadstationapi constructor. :param host: :type host: string @@ -39,16 +36,7 @@ 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'), - } - - self.url = self.urls['task'] - + super(DownloadStationAPI, self).__init__('DownloadStation', host, username, password, torrent_path) self.error_map = { 100: 'Unknown error', 101: 'Invalid parameter', @@ -58,176 +46,141 @@ def __init__(self, host=None, username=None, password=None): 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.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 - 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.url = self.host + app.TORRENT_PATH = re.sub(r'^/volume\d*/', '', app.TORRENT_PATH) - return self.auth + def _check_path(self): + """Validate the destination.""" + if not self.torrent_path: + return True + 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' + } - def _get_auth(self): - if self.session.cookies and self.auth: - return self.auth + # 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 + # 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.message = f'This user does not have the correct permissions to use "{fullpath}"' + log.warning(self.message) + return False + + def _get_auth(self): + """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 = { - 'api': 'SYNO.API.Auth', - 'version': 2, 'method': 'login', - 'account': self.username, - 'passwd': self.password, + 'api': 'SYNO.API.Auth', + 'version': 3, 'session': 'DownloadStation', - 'format': 'cookie', + 'account': self.username, + 'passwd': self.password } - - 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() + 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', + # 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', - 'session': 'DownloadStation', - 'uri': result.url, + 'create_list': 'false', + 'destination': f'"{app.TORRENT_PATH}"', + 'type': '"url"', + 'url': 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() + 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()): - return False - - if self.checked_destination and self.destination == torrent_path: - return True - + # 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.DownloadStation.Info', + 'api': 'SYNO.DownloadStation2.Task', 'version': 2, - 'method': 'getinfo', - 'session': 'DownloadStation', + '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) - 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 + 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 - - 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 + jdata = self.response.json() + if jdata['success']: + 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('Add torrent error: {error}', { + 'error': self.error_map[jdata.get('error', {}).get('code', 100)] + }) + return False api = DownloadStationAPI diff --git a/medusa/clients/torrent/generic.py b/medusa/clients/torrent/generic.py index b2ce78cfdb..813030f996 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 requests log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) @@ -28,8 +26,8 @@ class GenericClient(object): """Base class for all torrent clients.""" - def __init__(self, name, host=None, username=None, password=None): - """Constructor. + def __init__(self, name, host=None, username=None, password=None, torrent_path=None): + """Genericclient Constructor. :param name: :type name: string @@ -44,73 +42,42 @@ 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) + self.verify = certifi.where() - 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}) + 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, cookies=cookies, + verify=self.verify if app.TORRENT_VERIFY_CERT else False + ) if not self.response: - log.warning('{name}: Unable to reach torrent client', {'name': self.name}) + 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 == 401: - log.error('{name}: Invalid Username or Password, check your config', {'name': self.name}) - 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('{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): @@ -133,6 +100,10 @@ def _add_torrent_file(self, result): """ raise NotImplementedError + def _check_path(self): + """Check if the destination path is correct.""" + return True + def _set_torrent_label(self, result): """Return the True/False from the client when a torrent is set with label. @@ -195,14 +166,12 @@ def _set_torrent_pause(self, result): @staticmethod def _get_info_hash(result): - 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,43 @@ 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() - 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: - return False - - if result.url.startswith('magnet:'): - r_code = self._add_torrent_uri(result) - else: - r_code = self._add_torrent_file(result) + # check for the hash and add it if not there + if not result.hash and not self._get_info_hash(result): + return False - if not r_code: - log.warning('{name}: Unable to send Torrent', - {'name': self.name}) - return False + if result.url.startswith('magnet:'): + log.info('Adding "{url}" to {name}', {'url': result.url, 'name': self.name}) + r_code = self._add_torrent_uri(result) + else: + log.info('Adding "{result_name}" torrent to {name}', { + 'result_name': result.name, 'name': 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('{name}: Unable to send Torrent', {'name': self.name}) + 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('{name}: Unable to set the pause for Torrent', {'name': self.name}) - 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('{name}: Unable to set the label for Torrent', {'name': self.name}) - 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('{name}: Unable to set the ratio for Torrent', {'name': self.name}) - 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('{name}: Unable to set the seed time for Torrent', {'name': self.name}) - 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('{name}: Unable to set the path for Torrent', {'name': self.name}) - 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('{name}: Unable to set priority for Torrent', {'name': self.name}) return r_code @@ -301,27 +248,15 @@ 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) - - 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) + 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!' def remove_torrent(self, info_hash): """Remove torrent from client using given info_hash. @@ -358,7 +293,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