diff --git a/couchpotato/core/downloaders/qbittorrent_.py b/couchpotato/core/downloaders/qbittorrent_.py index 9cfae4dde9..ebb575369e 100644 --- a/couchpotato/core/downloaders/qbittorrent_.py +++ b/couchpotato/core/downloaders/qbittorrent_.py @@ -30,11 +30,8 @@ def connect(self): url = cleanHost(self.conf('host'), protocol = True, ssl = False) if self.conf('username') and self.conf('password'): - self.qb = QBittorrentClient( - url, - username = self.conf('username'), - password = self.conf('password') - ) + self.qb = QBittorrentClient(url) + self.qb.login(username=self.conf('username'),password=self.conf('password')) else: self.qb = QBittorrentClient(url) @@ -45,10 +42,7 @@ def test(self): :return: bool """ - if self.connect(): - return True - - return False + return self.qb._is_authenticated def download(self, data = None, media = None, filedata = None): """ Send a torrent/nzb file to the downloader @@ -95,7 +89,7 @@ def download(self, data = None, media = None, filedata = None): # Send request to qBittorrent try: - self.qb.add_file(filedata) + self.qb.download_from_file(filedata, label=self.conf('label')) return self.downloadReturnId(torrent_hash) except Exception as e: @@ -127,14 +121,13 @@ def getAllDownloadStatus(self, ids): return [] try: - torrents = self.qb.get_torrents() + torrents = self.qb.torrents(label=self.conf('label')) release_downloads = ReleaseDownloadList(self) for torrent in torrents: if torrent.hash in ids: - torrent.update_general() # get extra info - torrent_filelist = torrent.get_files() + torrent_filelist = self.qb.get_torrent_files(torrent.hash) torrent_files = [] torrent_dir = os.path.join(torrent.save_path, torrent.name) @@ -179,8 +172,8 @@ def pause(self, release_download, pause = True): return False if pause: - return torrent.pause() - return torrent.resume() + return self.qb.pause(release_download['id']) + return self.qb.resume(release_download['id']) def removeFailed(self, release_download): log.info('%s failed downloading, deleting...', release_download['name']) @@ -193,15 +186,15 @@ def processComplete(self, release_download, delete_files): if not self.connect(): return False - torrent = self.qb.find_torrent(release_download['id']) + torrent = self.qb.get_torrent(release_download['id']) if torrent is None: return False if delete_files: - torrent.delete() # deletes torrent with data + self.qb.delete_permanently(release_download['id']) # deletes torrent with data else: - torrent.remove() # just removes the torrent, doesn't delete data + self.qb.delete(release_download['id']) # just removes the torrent, doesn't delete data return True @@ -235,6 +228,11 @@ def processComplete(self, release_download, delete_files): 'name': 'password', 'type': 'password', }, + { + 'name': 'label', + 'label': 'Torrent Label', + 'default': 'couchpotato', + }, { 'name': 'remove_complete', 'label': 'Remove torrent', diff --git a/libs/qbittorrent/__init__.py b/libs/qbittorrent/__init__.py index 5e3048b21b..b650ceb084 100644 --- a/libs/qbittorrent/__init__.py +++ b/libs/qbittorrent/__init__.py @@ -1 +1 @@ -__version__ = '0.1' \ No newline at end of file +__version__ = '0.2' diff --git a/libs/qbittorrent/base.py b/libs/qbittorrent/base.py deleted file mode 100644 index 328e008a17..0000000000 --- a/libs/qbittorrent/base.py +++ /dev/null @@ -1,62 +0,0 @@ -from urlparse import urljoin -import logging - -log = logging.getLogger(__name__) - - -class Base(object): - properties = {} - - def __init__(self, url, session, client=None): - self._client = client - self._url = url - self._session = session - - @staticmethod - def _convert(response, response_type): - if response_type == 'json': - try: - return response.json() - except ValueError: - pass - - return response - - def _get(self, path='', response_type='json', **kwargs): - r = self._session.get(urljoin(self._url, path), **kwargs) - return self._convert(r, response_type) - - def _post(self, path='', response_type='json', data=None, **kwargs): - r = self._session.post(urljoin(self._url, path), data, **kwargs) - return self._convert(r, response_type) - - def _fill(self, data): - for key, value in data.items(): - if self.set_property(self, key, value): - continue - - log.debug('%s is missing item with key "%s" and value %s', self.__class__, key, repr(value)) - - @classmethod - def parse(cls, client, data): - obj = cls(client._url, client._session, client) - obj._fill(data) - - return obj - - @classmethod - def set_property(cls, obj, key, value): - prop = cls.properties.get(key, {}) - - if prop.get('key'): - key = prop['key'] - - if not hasattr(obj, key): - return False - - - if prop.get('parse'): - value = prop['parse'](value) - - setattr(obj, key, value) - return True diff --git a/libs/qbittorrent/client.py b/libs/qbittorrent/client.py index bc59cd0ed9..a0e68783b1 100644 --- a/libs/qbittorrent/client.py +++ b/libs/qbittorrent/client.py @@ -1,72 +1,610 @@ -from qbittorrent.base import Base -from qbittorrent.torrent import Torrent -from requests import Session -from requests.auth import HTTPDigestAuth -import time +import requests +import json +class LoginRequired(Exception): + def __str__(self): + return 'Please login first.' -class QBittorrentClient(Base): - def __init__(self, url, username=None, password=None): - super(QBittorrentClient, self).__init__(url, Session()) - if username and password: - self._session.auth = HTTPDigestAuth(username, password) +class QBittorrentClient(object): + """class to interact with qBittorrent WEB API""" + def __init__(self, url): + if not url.endswith('/'): + url += '/' + self.url = url - def test_connection(self): - r = self._get(response_type='response') + session = requests.Session() + check_prefs = session.get(url+'query/preferences') - return r.status_code == 200 + if check_prefs.status_code == 200: + self._is_authenticated = True + self.session = session + else: + self._is_authenticated = False - def add_file(self, file): - self._post('command/upload', files={'torrent': file}) + def _get(self, endpoint, **kwargs): + """ + Method to perform GET request on the API. + + :param endpoint: Endpoint of the API. + :param kwargs: Other keyword arguments for requests. + + :return: Response of the GET request. + """ + return self._request(endpoint, 'get', **kwargs) + + def _post(self, endpoint, data, **kwargs): + """ + Method to perform POST request on the API. + + :param endpoint: Endpoint of the API. + :param data: POST DATA for the request. + :param kwargs: Other keyword arguments for requests. + + :return: Response of the POST request. + """ + return self._request(endpoint, 'post', data, **kwargs) + + def _request(self, endpoint, method, data=None, **kwargs): + """ + Method to hanle both GET and POST requests. + + :param endpoint: Endpoint of the API. + :param method: Method of HTTP request. + :param data: POST DATA for the request. + :param kwargs: Other keyword arguments. + + :return: Response for the request. + """ + final_url = self.url + endpoint + + if not self._is_authenticated: + raise LoginRequired + + rq = self.session + if method == 'get': + request = rq.get(final_url, **kwargs) + else: + request = rq.post(final_url, data, **kwargs) + + request.raise_for_status() + + if len(request.text) == 0: + data = json.loads('{}') + else: + try: + data = json.loads(request.text) + except ValueError: + data = request.text + + return data + + def login(self, username, password): + """ + Method to authenticate the qBittorrent Client. + + Declares a class attribute named ``session`` which + stores the authenticated session if the login is correct. + Else, shows the login error. + + :param username: Username. + :param password: Password. + + :return: Response to login request to the API. + """ + self.session = requests.Session() + login = self.session.post(self.url+'login', + data={'username': username, + 'password': password}) + if login.text == 'Ok.': + self._is_authenticated = True + else: + return login.text + + def logout(self): + """ + Logout the current session. + """ + response = self._get('logout') + self._is_authenticated = False + return response + + @property + def qbittorrent_version(self): + """ + Get qBittorrent version. + """ + return self._get('version/qbittorrent') + + @property + def api_version(self): + """ + Get WEB API version. + """ + return self._get('version/api') + + @property + def api_min_version(self): + """ + Get minimum WEB API version. + """ + return self._get('version/api_min') + + def shutdown(self): + """ + Shutdown qBittorrent. + """ + return self._get('command/shutdown') + + def torrents(self, status='active', label='', sort='priority', + reverse=False, limit=10, offset=0): + """ + Returns a list of torrents matching the supplied filters. + + :param status: Current status of the torrents. + :param label: Fetch all torrents with the supplied label. + :param sort: Sort torrents by. + :param reverse: Enable reverse sorting. + :param limit: Limit the number of torrents returned. + :param offset: Set offset (if less than 0, offset from end). + + :return: list() of torrent with matching filter. + """ + + STATUS_LIST = ['all', 'downloading', 'completed', + 'paused', 'active', 'inactive'] + if status not in STATUS_LIST: + raise ValueError("Invalid status.") + + params = { + 'filter': status, + 'label': label, + 'sort': sort, + 'reverse': reverse, + 'limit': limit, + 'offset': offset + } + + return self._get('query/torrents', params=params) + + def get_torrent(self, infohash): + """ + Get details of the torrent. + + :param infohash: INFO HASH of the torrent. + """ + return self._get('query/propertiesGeneral/' + infohash.lower()) + + def get_torrent_trackers(self, infohash): + """ + Get trackers for the torrent. + + :param infohash: INFO HASH of the torrent. + """ + return self._get('query/propertiesTrackers/' + infohash.lower()) + + def get_torrent_webseeds(self, infohash): + """ + Get webseeds for the torrent. + + :param infohash: INFO HASH of the torrent. + """ + return self._get('query/propertiesWebSeeds/' + infohash.lower()) + + def get_torrent_files(self, infohash): + """ + Get list of files for the torrent. + + :param infohash: INFO HASH of the torrent. + """ + return self._get('query/propertiesFiles/' + infohash.lower()) + + @property + def global_transfer_info(self): + """ + Get JSON data of the global transfer info of qBittorrent. + """ + return self._get('query/transferInfo') + + @property + def preferences(self): + """ + Get the current qBittorrent preferences. + Can also be used to assign individual preferences. + For setting multiple preferences at once, + see ``set_preferences`` method. + + Note: Even if this is a ``property``, + to fetch the current preferences dict, you are required + to call it like a bound method. + + Wrong:: + + qb.preferences + + Right:: + + qb.preferences() + + """ + prefs = self._get('query/preferences') + + class Proxy(Client): + """ + Proxy class to to allow assignment of individual preferences. + this class overrides some methods to ease things. + + Because of this, settings can be assigned like:: + + In [5]: prefs = qb.preferences() + + In [6]: prefs['autorun_enabled'] + Out[6]: True + + In [7]: prefs['autorun_enabled'] = False + + In [8]: prefs['autorun_enabled'] + Out[8]: False + + """ + + def __init__(self, url, prefs, auth, session): + super(Proxy, self).__init__(url) + self.prefs = prefs + self._is_authenticated = auth + self.session = session + + def __getitem__(self, key): + return self.prefs[key] + + def __setitem__(self, key, value): + kwargs = {key: value} + return self.set_preferences(**kwargs) + + def __call__(self): + return self.prefs + + return Proxy(self.url, prefs, self._is_authenticated, self.session) + + def sync(self, rid=0): + """ + Sync the torrents by supplied LAST RESPONSE ID. + Read more @ http://git.io/vEgXr + + :param rid: Response ID of last request. + """ + return self._get('sync/maindata', params={'rid': rid}) + + def download_from_link(self, link, + save_path=None, label=''): + """ + Download torrent using a link. + + :param link: URL Link or list of. + :param save_path: Path to download the torrent. + :param label: Label of the torrent(s). + + :return: Empty JSON data. + """ + if not isinstance(link, list): + link = [link] + data = {'urls': link} + + if save_path: + data.update({'savepath': save_path}) + if label: + data.update({'label': label}) + + return self._post('command/download', data=data) + + def download_from_file(self, file_buffer, + save_path=None, label=''): + """ + Download torrent using a file. + + :param file_buffer: Single file() buffer or list of. + :param save_path: Path to download the torrent. + :param label: Label of the torrent(s). + + :return: Empty JSON data. + """ + if isinstance(file_buffer, list): + torrent_files = {} + for i, f in enumerate(file_buffer): + torrent_files.update({'torrents%s' % i: f}) + print torrent_files + else: + torrent_files = {'torrents': file_buffer} - def add_url(self, urls): - if type(urls) is not list: - urls = [urls] + data = {} - urls = '%0A'.join(urls) + if save_path: + data.update({'savepath': save_path}) + if label: + data.update({'label': label}) + return self._post('command/upload', data=data, files=torrent_files) - self._post('command/download', data={'urls': urls}) + def add_trackers(self, infohash, trackers): + """ + Add trackers to a torrent. + + :param infohash: INFO HASH of torrent. + :param trackers: Trackers. + """ + data = {'hash': infohash.lower(), + 'urls': trackers} + return self._post('command/addTrackers', data=data) + + @staticmethod + def process_infohash_list(infohash_list): + """ + Method to convert the infohash_list to qBittorrent API friendly values. + + :param infohash_list: List of infohash. + """ + if isinstance(infohash_list, list): + data = {'hashes': '|'.join([h.lower() for h in infohash_list])} + else: + data = {'hashes': infohash_list.lower()} + return data + + def pause(self, infohash): + """ + Pause a torrent. + + :param infohash: INFO HASH of torrent. + """ + return self._post('command/pause', data={'hash': infohash.lower()}) + + def pause_all(self): + """ + Pause all torrents. + """ + return self._get('command/pauseAll') + + def pause_multiple(self, infohash_list): + """ + Pause multiple torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/pauseAll', data=data) + + def resume(self, infohash): + """ + Resume a paused torrent. + + :param infohash: INFO HASH of torrent. + """ + return self._post('command/resume', data={'hash': infohash.lower()}) + + def resume_all(self): + """ + Resume all torrents. + """ + return self._get('command/resumeAll') + + def resume_multiple(self, infohash_list): + """ + Resume multiple paused torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/resumeAll', data=data) + + def delete(self, infohash_list): + """ + Delete torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/delete', data=data) + + def delete_permanently(self, infohash_list): + """ + Permanently delete torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/deletePerm', data=data) + + def recheck(self, infohash_list): + """ + Recheck torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/recheck', data=data) + + def increase_priority(self, infohash_list): + """ + Increase priority of torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/increasePrio', data=data) + + def decrease_priority(self, infohash_list): + """ + Decrease priority of torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/decreasePrio', data=data) + + def set_max_priority(self, infohash_list): + """ + Set torrents to maximum priority level. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/topPrio', data=data) + + def set_min_priority(self, infohash_list): + """ + Set torrents to minimum priority level. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/bottomPrio', data=data) + + def set_file_priority(self, infohash, file_id, priority): + """ + Set file of a torrent to a supplied priority level. + + :param infohash: INFO HASH of torrent. + :param file_id: ID of the file to set priority. + :param priority: Priority level of the file. + """ + if priority not in [0, 1, 2, 7]: + raise ValueError("Invalid priority, refer WEB-UI docs for info.") + elif not isinstance(file_id, int): + raise TypeError("File ID must be an int") + + data = {'hash': infohash.lower(), + 'id': file_id, + 'priority': priority} + + return self._post('command/setFilePrio', data=data) + + # Get-set global download and upload speed limits. + + def get_global_download_limit(self): + """ + Get global download speed limit. + """ + return self._get('command/getGlobalDlLimit') + + def set_global_download_limit(self, limit): + """ + Set global download speed limit. + + :param limit: Speed limit in bytes. + """ + return self._post('command/setGlobalDlLimit', data={'limit': limit}) + + global_download_limit = property(get_global_download_limit, + set_global_download_limit) + + def get_global_upload_limit(self): + """ + Get global upload speed limit. + """ + return self._get('command/getGlobalUpLimit') + + def set_global_upload_limit(self, limit): + """ + Set global upload speed limit. + + :param limit: Speed limit in bytes. + """ + return self._post('command/setGlobalUpLimit', data={'limit': limit}) + + global_upload_limit = property(get_global_upload_limit, + set_global_upload_limit) + + # Get-set download and upload speed limits of the torrents. + def get_torrent_download_limit(self, infohash_list): + """ + Get download speed limit of the supplied torrents. - def get_torrents(self): - """Fetch all torrents + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/getTorrentsDlLimit', data=data) + + def set_torrent_download_limit(self, infohash_list, limit): + """ + Set download speed limit of the supplied torrents. + + :param infohash_list: Single or list() of infohashes. + :param limit: Speed limit in bytes. + """ + data = self.process_infohash_list(infohash_list) + data.update({'limit': limit}) + return self._post('command/setTorrentsDlLimit', data=data) + + def get_torrent_upload_limit(self, infohash_list): + """ + Get upoload speed limit of the supplied torrents. + + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/getTorrentsUpLimit', data=data) - :return: list of Torrent + def set_torrent_upload_limit(self, infohash_list, limit): """ - r = self._get('json/torrents') + Set upload speed limit of the supplied torrents. - return [Torrent.parse(self, x) for x in r] + :param infohash_list: Single or list() of infohashes. + :param limit: Speed limit in bytes. + """ + data = self.process_infohash_list(infohash_list) + data.update({'limit': limit}) + return self._post('command/setTorrentsUpLimit', data=data) - def get_torrent(self, hash, include_general=True, max_retries=5): - """Fetch details for torrent by info_hash. + # setting preferences + def set_preferences(self, **kwargs): + """ + Set preferences of qBittorrent. + Read all possible preferences @ http://git.io/vEgDQ - :param info_hash: Torrent info hash - :param include_general: Include general torrent properties - :param max_retries: Maximum number of retries to wait for torrent to appear in client + :param kwargs: set preferences in kwargs form. + """ + json_data = "json={}".format(json.dumps(kwargs)) + headers = {'content-type': 'application/x-www-form-urlencoded'} + return self._post('command/setPreferences', data=json_data, + headers=headers) - :rtype: Torrent or None + def get_alternative_speed_status(self): + """ + Get Alternative speed limits. (1/0) """ + return self._get('command/alternativeSpeedLimitsEnabled') - torrent = None - retries = 0 + alternative_speed_status = property(get_alternative_speed_status) - # Try find torrent in client - while retries < max_retries: - # TODO this wouldn't be very efficient with large numbers of torrents on the client - torrents = dict([(t.hash, t) for t in self.get_torrents()]) + def toggle_alternative_speed(self): + """ + Toggle alternative speed limits. + """ + return self._get('command/toggleAlternativeSpeedLimits') - if hash in torrents: - torrent = torrents[hash] - break + def toggle_sequential_download(self, infohash_list): + """ + Toggle sequential download in supplied torrents. - retries += 1 - time.sleep(1) + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/toggleSequentialDownload', data=data) + + def toggle_first_last_piece_priority(self, infohash_list): + """ + Toggle first/last piece priority of supplied torrents. - if torrent is None: - return None + :param infohash_list: Single or list() of infohashes. + """ + data = self.process_infohash_list(infohash_list) + return self._post('command/toggleFirstLastPiecePrio', data=data) - # Fetch general properties for torrent - if include_general: - torrent.update_general() + def force_start(self, infohash_list, value=True): + """ + Force start selected torrents. - return torrent + :param infohash_list: Single or list() of infohashes. + :param value: Force start value (bool) + """ + data = self.process_infohash_list(infohash_list) + data.update({'value': json.dumps(value)}) + return self._post('command/setForceStart', data=data) diff --git a/libs/qbittorrent/file.py b/libs/qbittorrent/file.py deleted file mode 100644 index 29ba04e56c..0000000000 --- a/libs/qbittorrent/file.py +++ /dev/null @@ -1,15 +0,0 @@ -from qbittorrent.base import Base - - -class File(Base): - def __init__(self, url, session, client=None): - super(File, self).__init__(url, session, client) - - self.name = None - - self.progress = None - self.priority = None - - self.is_seed = None - - self.size = None diff --git a/libs/qbittorrent/helpers.py b/libs/qbittorrent/helpers.py deleted file mode 100644 index 253f03e84e..0000000000 --- a/libs/qbittorrent/helpers.py +++ /dev/null @@ -1,7 +0,0 @@ -def try_convert(value, to_type, default=None): - try: - return to_type(value) - except ValueError: - return default - except TypeError: - return default diff --git a/libs/qbittorrent/torrent.py b/libs/qbittorrent/torrent.py deleted file mode 100644 index 68ec2ce01d..0000000000 --- a/libs/qbittorrent/torrent.py +++ /dev/null @@ -1,96 +0,0 @@ -from qbittorrent.base import Base -from qbittorrent.file import File -from qbittorrent.helpers import try_convert - - -class Torrent(Base): - properties = { - 'num_seeds': { - 'key': 'seeds', - 'parse': lambda value: try_convert(value, int) - }, - 'num_leechs': { - 'key': 'leechs', - 'parse': lambda value: try_convert(value, int) - }, - 'ratio': { - 'parse': lambda value: try_convert(value, float) - } - } - - def __init__(self, url, session, client=None): - super(Torrent, self).__init__(url, session, client) - - self.hash = None - self.name = None - - self.state = None - self.ratio = None - self.progress = None - self.priority = None - - self.seeds = None - self.leechs = None - - # General properties - self.comment = None - self.save_path = None - - self.eta = None - self.size = None - self.dlspeed = None - self.upspeed = None - self.nb_connections = None - self.share_ratio = None - self.piece_size = None - self.total_wasted = None - self.total_downloaded = None - self.total_uploaded = None - self.creation_date = None - self.time_elapsed = None - self.up_limit = None - self.dl_limit = None - - # - # Commands - # - - def pause(self): - self._post('command/pause', data={'hash': self.hash}) - - def resume(self): - self._post('command/resume', data={'hash': self.hash}) - - def remove(self): - self._post('command/delete', data={'hashes': self.hash}) - - def delete(self): - self._post('command/deletePerm', data={'hashes': self.hash}) - - def recheck(self): - self._post('command/recheck', data={'hash': self.hash}) - - # - # Fetch details - # - - def get_files(self): - r = self._get('json/propertiesFiles/%s' % self.hash) - - return [File.parse(self._client, x) for x in r] - - def get_trackers(self): - pass - - # - # Update torrent details - # - - def update_general(self): - r = self._get('json/propertiesGeneral/%s' % self.hash) - - if r: - self._fill(r) - return True - - return False