diff --git a/couchpotato/core/downloaders/transmission/__init__.py b/couchpotato/core/downloaders/transmission/__init__.py new file mode 100644 index 00000000..bc695a3e --- /dev/null +++ b/couchpotato/core/downloaders/transmission/__init__.py @@ -0,0 +1,50 @@ +from .main import Transmission + +def start(): + return Transmission() + +config = [{ + 'name': 'transmission', + 'groups': [ + { + 'tab': 'downloaders', + 'name': 'transmission', + 'label': 'Transmission', + 'description': 'Send NZBs to your NZBGet installation.', + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + }, + { + 'name': 'host', + 'default': 'localhost:9091', + 'description': 'Hostname with port. Usually localhost:9091', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + }, + { + 'name': 'paused', + 'type': 'bool', + 'description': 'Add the torrent paused.', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Where should Transmission saved the downloaded files?', + }, + { + 'name': 'ratio', + 'default': 10, + 'advanced': True, + 'description': 'Stop transfer when reaching ratio', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py new file mode 100644 index 00000000..42276ddf --- /dev/null +++ b/couchpotato/core/downloaders/transmission/main.py @@ -0,0 +1,52 @@ +from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.encoding import isInt +from couchpotato.core.logger import CPLog +from libs import transmissionrpc + +log = CPLog(__name__) + + +class Transmission(Downloader): + + type = ['torrent'] + + def download(self, data = {}, movie = {}): + + if self.isDisabled() or not self.isCorrectType(data.get('type')): + return + + log.info('Sending "%s" to Transmission.' % data.get('name')) + + + # Load host from config and split out port. + host = self.conf('host').split(':') + if not isInt(host[1]): + log.error("Config properties are not filled in correctly, port is missing.") + return False + + # Set parameters for Transmission + params = { + 'paused': self.conf('paused', 0), + 'download_dir': self.conf('directory', None) + } + change_params = { + 'seedRatioLimit': self.conf('ratio'), + 'seedRatioMode': 1 if self.conf('ratio') else 0 + } + + try: + tc = transmissionrpc.Client(host[0], port = host[1], user = self.conf('username'), password = self.conf('password')) + tr_id = tc.add_uri(data.get('url'), **params) + + # Change settings of added torrents + for item in tr_id: + try: + tc.change(item, timeout = None, **change_params) + except transmissionrpc.TransmissionError, e: + log.error('Failed to change settings for transfer in transmission: %s' % e) + + return True + + except transmissionrpc.TransmissionError, e: + log.error('Failed to send link to transmission: %s' % e) + return False diff --git a/libs/transmissionrpc/LICENSE b/libs/transmissionrpc/LICENSE new file mode 100755 index 00000000..a910542a --- /dev/null +++ b/libs/transmissionrpc/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2008-2010 Erik Svensson + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/libs/transmissionrpc/__init__.py b/libs/transmissionrpc/__init__.py new file mode 100755 index 00000000..82f7e215 --- /dev/null +++ b/libs/transmissionrpc/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +from transmissionrpc.constants import DEFAULT_PORT, DEFAULT_TIMEOUT, STATUS, PRIORITY, RATIO_LIMIT, LOGGER +from transmissionrpc.error import TransmissionError, HTTPHandlerError +from transmissionrpc.httphandler import HTTPHandler, DefaultHTTPHandler +from transmissionrpc.torrent import Torrent +from transmissionrpc.session import Session +from transmissionrpc.client import Client +from transmissionrpc.utils import add_stdout_logger + +__author__ = u'Erik Svensson ' +__version__ = u'0.7' +__copyright__ = u'Copyright (c) 2008-2010 Erik Svensson' +__license__ = u'MIT' diff --git a/libs/transmissionrpc/client.py b/libs/transmissionrpc/client.py new file mode 100755 index 00000000..c68a3399 --- /dev/null +++ b/libs/transmissionrpc/client.py @@ -0,0 +1,643 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +import os, re, time +import warnings +import httplib, urllib2, urlparse, base64 + +try: + import json +except ImportError: + import simplejson as json + +from transmissionrpc.constants import DEFAULT_PORT, DEFAULT_TIMEOUT +from transmissionrpc.error import TransmissionError, HTTPHandlerError +from transmissionrpc.utils import LOGGER, get_arguments, make_rpc_name, argument_value_convert, rpc_bool +from transmissionrpc.httphandler import DefaultHTTPHandler +from transmissionrpc.torrent import Torrent +from transmissionrpc.session import Session + +def debug_httperror(error): + """ + Log the Transmission RPC HTTP error. + """ + try: + data = json.loads(error.data) + except ValueError: + data = error.data + LOGGER.debug( + json.dumps( + { + 'response': { + 'url': error.url, + 'code': error.code, + 'msg': error.message, + 'headers': error.headers, + 'data': data, + } + }, + indent=2 + ) + ) + +""" +Torrent ids + +Many functions in Client takes torrent id. A torrent id can either be id or +hashString. When suppling multiple id's it is possible to use a list mixed +with both id and hashString. + +Timeouts + +Since most methods results in HTTP requests against Transmission, it is +possible to provide a argument called ``timeout``. Timeout is only effective +when using Python 2.6 or later and the default timeout is 30 seconds. +""" + +class Client(object): + """ + Client is the class handling the Transmission JSON-RPC client protocol. + """ + + def __init__(self, address='localhost', port=DEFAULT_PORT, user=None, password=None, http_handler=None, timeout=None): + if isinstance(timeout, (int, long, float)): + self._query_timeout = float(timeout) + else: + self._query_timeout = DEFAULT_TIMEOUT + urlo = urlparse.urlparse(address) + if urlo.scheme == '': + base_url = 'http://' + address + ':' + str(port) + self.url = base_url + '/transmission/rpc' + else: + if urlo.port: + self.url = urlo.scheme + '://' + urlo.hostname + ':' + str(urlo.port) + urlo.path + else: + self.url = urlo.scheme + '://' + urlo.hostname + urlo.path + LOGGER.info('Using custom URL "' + self.url + '".') + if urlo.username and urlo.password: + user = urlo.username + password = urlo.password + elif urlo.username or urlo.password: + LOGGER.warning('Either user or password missing, not using authentication.') + if http_handler == None: + self.http_handler = DefaultHTTPHandler() + else: + if hasattr(http_handler, 'set_authentication') and hasattr(http_handler, 'request'): + self.http_handler = http_handler + else: + raise ValueError('Invalid HTTP handler.') + if user and password: + self.http_handler.set_authentication(self.url, user, password) + elif user or password: + LOGGER.warning('Either user or password missing, not using authentication.') + self._sequence = 0 + self.session = Session() + self.session_id = 0 + self.server_version = None + self.protocol_version = None + self.get_session() + self.torrent_get_arguments = get_arguments('torrent-get' + , self.rpc_version) + + def _get_timeout(self): + """ + Get current timeout for HTTP queries. + """ + return self._query_timeout + + def _set_timeout(self, value): + """ + Set timeout for HTTP queries. + """ + self._query_timeout = float(value) + + def _del_timeout(self): + """ + Reset the HTTP query timeout to the default. + """ + self._query_timeout = DEFAULT_TIMEOUT + + timeout = property(_get_timeout, _set_timeout, _del_timeout, doc="HTTP query timeout.") + + def _http_query(self, query, timeout=None): + """ + Query Transmission through HTTP. + """ + headers = {'x-transmission-session-id': str(self.session_id)} + request_count = 0 + if timeout == None: + timeout = self._query_timeout + while True: + LOGGER.debug(json.dumps({'url': self.url, 'headers': headers, 'query': query, 'timeout': timeout}, indent=2)) + try: + result = self.http_handler.request(self.url, query, headers, timeout) + break + except HTTPHandlerError, error: + if error.code == 409: + LOGGER.info('Server responded with 409, trying to set session-id.') + if request_count > 1: + raise TransmissionError('Session ID negotiation failed.', error) + if 'x-transmission-session-id' in error.headers: + self.session_id = error.headers['x-transmission-session-id'] + headers = {'x-transmission-session-id': str(self.session_id)} + else: + debug_httperror(error) + raise TransmissionError('Unknown conflict.', error) + else: + debug_httperror(error) + raise TransmissionError('Request failed.', error) + request_count = request_count + 1 + return result + + def _request(self, method, arguments=None, ids=None, require_ids=False, timeout=None): + """ + Send json-rpc request to Transmission using http POST + """ + if not isinstance(method, (str, unicode)): + raise ValueError('request takes method as string') + if arguments == None: + arguments = {} + if not isinstance(arguments, dict): + raise ValueError('request takes arguments as dict') + ids = self._format_ids(ids) + if len(ids) > 0: + arguments['ids'] = ids + elif require_ids: + raise ValueError('request require ids') + + query = json.dumps({'tag': self._sequence, 'method': method + , 'arguments': arguments}) + self._sequence += 1 + start = time.time() + http_data = self._http_query(query, timeout) + elapsed = time.time() - start + LOGGER.info('http request took %.3f s' % (elapsed)) + + try: + data = json.loads(http_data) + except ValueError, error: + LOGGER.error('Error: ' + str(error)) + LOGGER.error('Request: \"%s\"' % (query)) + LOGGER.error('HTTP data: \"%s\"' % (http_data)) + raise + + LOGGER.debug(json.dumps(data, indent=2)) + if 'result' in data: + if data['result'] != 'success': + raise TransmissionError('Query failed with result \"%s\".' % (data['result'])) + else: + raise TransmissionError('Query failed without result.') + + results = {} + if method == 'torrent-get': + for item in data['arguments']['torrents']: + results[item['id']] = Torrent(self, item) + if self.protocol_version == 2 and 'peers' not in item: + self.protocol_version = 1 + elif method == 'torrent-add': + item = data['arguments']['torrent-added'] + results[item['id']] = Torrent(self, item) + elif method == 'session-get': + self._update_session(data['arguments']) + elif method == 'session-stats': + # older versions of T has the return data in "session-stats" + if 'session-stats' in data['arguments']: + self._update_session(data['arguments']['session-stats']) + else: + self._update_session(data['arguments']) + elif method in ('port-test', 'blocklist-update'): + results = data['arguments'] + else: + return None + + return results + + def _format_ids(self, args): + """ + Take things and make them valid torrent identifiers + """ + ids = [] + + if args == None: + pass + elif isinstance(args, (int, long)): + ids.append(args) + elif isinstance(args, (str, unicode)): + for item in re.split(u'[ ,]+', args): + if len(item) == 0: + continue + addition = None + try: + # handle index + addition = [int(item)] + except ValueError: + pass + if not addition: + # handle hashes + try: + int(item, 16) + addition = [item] + except ValueError: + pass + if not addition: + # handle index ranges i.e. 5:10 + match = re.match(u'^(\d+):(\d+)$', item) + if match: + try: + idx_from = int(match.group(1)) + idx_to = int(match.group(2)) + addition = range(idx_from, idx_to + 1) + except ValueError: + pass + if not addition: + raise ValueError(u'Invalid torrent id, \"%s\"' % item) + ids.extend(addition) + elif isinstance(args, (list)): + for item in args: + ids.extend(self._format_ids(item)) + else: + raise ValueError(u'Invalid torrent id') + return ids + + def _update_session(self, data): + """ + Update session data. + """ + self.session.update(data) + + def _update_server_version(self): + if self.server_version == None: + version_major = 1 + version_minor = 30 + version_changeset = 0 + version_parser = re.compile('(\d).(\d+) \((\d+)\)') + if hasattr(self.session, 'version'): + match = version_parser.match(self.session.version) + if (match): + version_major = int(match.group(1)) + version_minor = int(match.group(2)) + version_changeset = match.group(3) + self.server_version = (version_major, version_minor, version_changeset) + + @property + def rpc_version(self): + """ + Get the Transmission RPC version. Trying to deduct if the server don't have a version value. + """ + if self.protocol_version == None: + # Ugly fix for 2.12 reporting rpc-version 10, but having new arguments + if (self.server_version and (self.server_version[0] == 2 and self.server_version[1] == 12)): + self.protocol_version = 11 + elif hasattr(self.session, 'rpc_version'): + self.protocol_version = self.session.rpc_version + elif hasattr(self.session, 'version'): + self.protocol_version = 3 + else: + self.protocol_version = 2 + return self.protocol_version + + def _rpc_version_warning(self, version): + """ + Add a warning to the log if the Transmission RPC version is lower then the provided version. + """ + if self.rpc_version < version: + LOGGER.warning('Using feature not supported by server. RPC version for server %d, feature introduced in %d.' % (self.rpc_version, version)) + + def add(self, data, timeout=None, **kwargs): + """ + Add torrent to transfers list. Takes a base64 encoded .torrent file in data. + Additional arguments are: + + ===================== ==== ============================================================= + Argument RPC Description + ===================== ==== ============================================================= + ``bandwidthPriority`` 8 - Priority for this transfer. + ``download_dir`` 1 - The directory where the downloaded contents will be saved in. + ``filename`` 1 - A filepath or URL to a torrent file or a magnet link. + ``files_unwanted`` 1 - A list of file id's that shouldn't be downloaded. + ``files_wanted`` 1 - A list of file id's that should be downloaded. + ``metainfo`` 1 - The content of a torrent file, base64 encoded. + ``paused`` 1 - If True, does not start the transfer when added. + ``peer_limit`` 1 - Maximum number of peers allowed. + ``priority_high`` 1 - A list of file id's that should have high priority. + ``priority_low`` 1 - A list of file id's that should have low priority. + ``priority_normal`` 1 - A list of file id's that should have normal priority. + ===================== ==== ============================================================= + """ + args = {} + if data: + args = {'metainfo': data} + elif 'metainfo' not in kwargs and 'filename' not in kwargs: + raise ValueError('No torrent data or torrent uri.') + for key, value in kwargs.iteritems(): + argument = make_rpc_name(key) + (arg, val) = argument_value_convert('torrent-add', + argument, value, self.rpc_version) + args[arg] = val + return self._request('torrent-add', args, timeout=timeout) + + def add_uri(self, uri, **kwargs): + """ + Add torrent to transfers list. Takes a uri to a torrent, supporting + all uri's supported by Transmissions torrent-add 'filename' + argument. Additional arguments are: + + ===================== ==== ============================================================= + Argument RPC Description + ===================== ==== ============================================================= + ``bandwidthPriority`` 8 - Priority for this transfer. + ``download_dir`` 1 - The directory where the downloaded contents will be saved in. + ``files_unwanted`` 1 - A list of file id's that shouldn't be downloaded. + ``files_wanted`` 1 - A list of file id's that should be downloaded. + ``paused`` 1 - If True, does not start the transfer when added. + ``peer_limit`` 1 - Maximum number of peers allowed. + ``priority_high`` 1 - A list of file id's that should have high priority. + ``priority_low`` 1 - A list of file id's that should have low priority. + ``priority_normal`` 1 - A list of file id's that should have normal priority. + ===================== ==== ============================================================= + """ + if uri == None: + raise ValueError('add_uri requires a URI.') + # there has been some problem with T's built in torrent fetcher, + # use a python one instead + parseduri = urlparse.urlparse(uri) + torrent_data = None + if parseduri.scheme in ['file', 'ftp', 'ftps', 'http', 'https']: + torrent_file = urllib2.urlopen(uri) + torrent_data = base64.b64encode(torrent_file.read()) + if torrent_data: + return self.add(torrent_data, **kwargs) + else: + return self.add(None, filename=uri, **kwargs) + + def remove(self, ids, delete_data=False, timeout=None): + """ + remove torrent(s) with provided id(s). Local data is removed if + delete_data is True, otherwise not. + """ + self._rpc_version_warning(3) + self._request('torrent-remove', + {'delete-local-data':rpc_bool(delete_data)}, ids, True, timeout=timeout) + + def start(self, ids, timeout=None): + """start torrent(s) with provided id(s)""" + self._request('torrent-start', {}, ids, True, timeout=timeout) + + def stop(self, ids, timeout=None): + """stop torrent(s) with provided id(s)""" + self._request('torrent-stop', {}, ids, True, timeout=timeout) + + def verify(self, ids, timeout=None): + """verify torrent(s) with provided id(s)""" + self._request('torrent-verify', {}, ids, True, timeout=timeout) + + def reannounce(self, ids, timeout=None): + """Reannounce torrent(s) with provided id(s)""" + self._rpc_version_warning(5) + self._request('torrent-reannounce', {}, ids, True, timeout=timeout) + + def info(self, ids=None, arguments=None, timeout=None): + """Get detailed information for torrent(s) with provided id(s).""" + if not arguments: + arguments = self.torrent_get_arguments + return self._request('torrent-get', {'fields': arguments}, ids, timeout=timeout) + + def get_files(self, ids=None, timeout=None): + """ + Get list of files for provided torrent id(s). If ids is empty, + information for all torrents are fetched. This function returns a dictonary + for each requested torrent id holding the information about the files. + + :: + + { + : { + : { + 'name': , + 'size': , + 'completed': , + 'priority': , + 'selected': + } + + ... + } + + ... + } + """ + fields = ['id', 'name', 'hashString', 'files', 'priorities', 'wanted'] + request_result = self._request('torrent-get', {'fields': fields}, ids, timeout=timeout) + result = {} + for tid, torrent in request_result.iteritems(): + result[tid] = torrent.files() + return result + + def set_files(self, items, timeout=None): + """ + Set file properties. Takes a dictonary with similar contents as the result + of `get_files`. + + :: + + { + : { + : { + 'priority': , + 'selected': + } + + ... + } + + ... + } + """ + if not isinstance(items, dict): + raise ValueError('Invalid file description') + for tid, files in items.iteritems(): + if not isinstance(files, dict): + continue + wanted = [] + unwanted = [] + priority_high = [] + priority_normal = [] + priority_low = [] + for fid, file_desc in files.iteritems(): + if not isinstance(file_desc, dict): + continue + if 'selected' in file_desc and file_desc['selected']: + wanted.append(fid) + else: + unwanted.append(fid) + if 'priority' in file_desc: + if file_desc['priority'] == 'high': + priority_high.append(fid) + elif file_desc['priority'] == 'normal': + priority_normal.append(fid) + elif file_desc['priority'] == 'low': + priority_low.append(fid) + self.change([tid], files_wanted = wanted + , files_unwanted = unwanted + , priority_high = priority_high + , priority_normal = priority_normal + , priority_low = priority_low, timeout=timeout) + + def list(self, timeout=None): + """list all torrents""" + fields = ['id', 'hashString', 'name', 'sizeWhenDone', 'leftUntilDone' + , 'eta', 'status', 'rateUpload', 'rateDownload', 'uploadedEver' + , 'downloadedEver'] + return self._request('torrent-get', {'fields': fields}, timeout=timeout) + + def change(self, ids, timeout=None, **kwargs): + """ + Change torrent parameters for the torrent(s) with the supplied id's. The + parameters are: + + ============================ ===== =============== ======================================================================================= + Argument RPC Replaced by Description + ============================ ===== =============== ======================================================================================= + ``bandwidthPriority`` 5 - Priority for this transfer. + ``downloadLimit`` 5 - Set the speed limit for download in Kib/s. + ``downloadLimited`` 5 - Enable download speed limiter. + ``files_unwanted`` 1 - A list of file id's that shouldn't be downloaded. + ``files_wanted`` 1 - A list of file id's that should be downloaded. + ``honorsSessionLimits`` 5 - Enables or disables the transfer to honour the upload limit set in the session. + ``ids`` 1 - Local download location. + ``peer_limit`` 1 - The peer limit for the torrents. + ``priority_high`` 1 - A list of file id's that should have high priority. + ``priority_low`` 1 - A list of file id's that should have normal priority. + ``priority_normal`` 1 - A list of file id's that should have low priority. + ``seedIdleLimit`` 10 - Seed inactivity limit in minutes. + ``seedIdleMode`` 10 - Seed inactivity mode. 0 = Use session limit, 1 = Use transfer limit, 2 = Disable limit. + ``seedRatioLimit`` 5 - Seeding ratio. + ``seedRatioMode`` 5 - Which ratio to use. 0 = Use session limit, 1 = Use transfer limit, 2 = Disable limit. + ``speed_limit_down`` 1 - 5 downloadLimit Set the speed limit for download in Kib/s. + ``speed_limit_down_enabled`` 1 - 5 downloadLimited Enable download speed limiter. + ``speed_limit_up`` 1 - 5 uploadLimit Set the speed limit for upload in Kib/s. + ``speed_limit_up_enabled`` 1 - 5 uploadLimited Enable upload speed limiter. + ``trackerAdd`` 10 - Array of string with announce URLs to add. + ``trackerRemove`` 10 - Array of ids of trackers to remove. + ``trackerReplace`` 10 - Array of (id, url) tuples where the announce URL should be replaced. + ``uploadLimit`` 5 - Set the speed limit for upload in Kib/s. + ``uploadLimited`` 5 - Enable upload speed limiter. + ============================ ===== =============== ======================================================================================= + + .. NOTE:: + transmissionrpc will try to automatically fix argument errors. + """ + args = {} + for key, value in kwargs.iteritems(): + argument = make_rpc_name(key) + (arg, val) = argument_value_convert('torrent-set' + , argument, value, self.rpc_version) + args[arg] = val + + if len(args) > 0: + self._request('torrent-set', args, ids, True, timeout=timeout) + else: + ValueError("No arguments to set") + + def move(self, ids, location, timeout=None): + """Move torrent data to the new location.""" + self._rpc_version_warning(6) + args = {'location': location, 'move': True} + self._request('torrent-set-location', args, ids, True, timeout=timeout) + + def locate(self, ids, location, timeout=None): + """Locate torrent data at the location.""" + self._rpc_version_warning(6) + args = {'location': location, 'move': False} + self._request('torrent-set-location', args, ids, True, timeout=timeout) + + def get_session(self, timeout=None): + """Get session parameters""" + self._request('session-get', timeout=timeout) + self._update_server_version() + return self.session + + def set_session(self, timeout=None, **kwargs): + """ + Set session parameters. The parameters are: + + ================================ ===== ================= ========================================================================================================================== + Argument RPC Replaced by Description + ================================ ===== ================= ========================================================================================================================== + ``alt_speed_down`` 5 - Alternate session download speed limit (in Kib/s). + ``alt_speed_enabled`` 5 - Enables alternate global download speed limiter. + ``alt_speed_time_begin`` 5 - Time when alternate speeds should be enabled. Minutes after midnight. + ``alt_speed_time_day`` 5 - Enables alternate speeds scheduling these days. + ``alt_speed_time_enabled`` 5 - Enables alternate speeds scheduling. + ``alt_speed_time_end`` 5 - Time when alternate speeds should be disabled. Minutes after midnight. + ``alt_speed_up`` 5 - Alternate session upload speed limit (in Kib/s). + ``blocklist_enabled`` 5 - Enables the block list + ``cache_size_mb`` 10 - The maximum size of the disk cache in MB + ``dht_enabled`` 6 - Enables DHT. + ``download_dir`` 1 - Set the session download directory. + ``encryption`` 1 - Set the session encryption mode, one of ``required``, ``preferred`` or ``tolerated``. + ``idle_seeding_limit`` 10 - The default seed inactivity limit in minutes. + ``idle_seeding_limit_enabled`` 10 - Enables the default seed inactivity limit + ``incomplete_dir`` 7 - The path to the directory of incomplete transfer data. + ``incomplete_dir_enabled`` 7 - Enables the incomplete transfer data directory. Otherwise data for incomplete transfers are stored in the download target. + ``lpd_enabled`` 9 - Enables local peer discovery for public torrents. + ``peer_limit`` 1 - 5 peer-limit-global Maximum number of peers + ``peer_limit_global`` 5 - Maximum number of peers + ``peer_limit_per_torrent`` 5 - Maximum number of peers per transfer + ``peer_port`` 5 - Peer port. + ``peer_port_random_on_start`` 5 - Enables randomized peer port on start of Transmission. + ``pex_allowed`` 1 - 5 pex-enabled Allowing PEX in public torrents. + ``pex_enabled`` 5 - Allowing PEX in public torrents. + ``port`` 1 - 5 peer-port Peer port. + ``port_forwarding_enabled`` 1 - Enables port forwarding. + ``rename_partial_files`` 8 - Appends ".part" to incomplete files + ``script_torrent_done_enabled`` 9 - Whether or not to call the "done" script. + ``script_torrent_done_filename`` 9 - Filename of the script to run when the transfer is done. + ``seedRatioLimit`` 5 - Seed ratio limit. 1.0 means 1:1 download and upload ratio. + ``seedRatioLimited`` 5 - Enables seed ration limit. + ``speed_limit_down`` 1 - Download speed limit (in Kib/s). + ``speed_limit_down_enabled`` 1 - Enables download speed limiting. + ``speed_limit_up`` 1 - Upload speed limit (in Kib/s). + ``speed_limit_up_enabled`` 1 - Enables upload speed limiting. + ``start_added_torrents`` 9 - Added torrents will be started right away. + ``trash_original_torrent_files`` 9 - The .torrent file of added torrents will be deleted. + ================================ ===== ================= ========================================================================================================================== + + .. NOTE:: + transmissionrpc will try to automatically fix argument errors. + """ + args = {} + for key, value in kwargs.iteritems(): + if key == 'encryption' and value not in ['required', 'preferred', 'tolerated']: + raise ValueError('Invalid encryption value') + argument = make_rpc_name(key) + (arg, val) = argument_value_convert('session-set' + , argument, value, self.rpc_version) + args[arg] = val + if len(args) > 0: + self._request('session-set', args, timeout=timeout) + + def blocklist_update(self, timeout=None): + """Update block list. Returns the size of the block list.""" + self._rpc_version_warning(5) + result = self._request('blocklist-update', timeout=timeout) + if 'blocklist-size' in result: + return result['blocklist-size'] + return None + + def port_test(self, timeout=None): + """ + Tests to see if your incoming peer port is accessible from the + outside world. + """ + self._rpc_version_warning(5) + result = self._request('port-test', timeout=timeout) + if 'port-is-open' in result: + return result['port-is-open'] + return None + + def session_stats(self, timeout=None): + """Get session statistics""" + self._request('session-stats', timeout=timeout) + return self.session diff --git a/libs/transmissionrpc/constants.py b/libs/transmissionrpc/constants.py new file mode 100755 index 00000000..56fae51e --- /dev/null +++ b/libs/transmissionrpc/constants.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +import logging + +LOGGER = logging.getLogger('transmissionrpc') +LOGGER.setLevel(logging.ERROR) + +def mirror_dict(source): + """ + Creates a dictionary with all values as keys and all keys as values. + """ + source.update(dict((value, key) for key, value in source.iteritems())) + return source + +DEFAULT_PORT = 9091 + +DEFAULT_TIMEOUT = 30.0 + +TR_STATUS_CHECK_WAIT = (1<<0) +TR_STATUS_CHECK = (1<<1) +TR_STATUS_DOWNLOAD = (1<<2) +TR_STATUS_SEED = (1<<3) +TR_STATUS_STOPPED = (1<<4) + +STATUS = mirror_dict({ + 'check pending' : TR_STATUS_CHECK_WAIT, + 'checking' : TR_STATUS_CHECK, + 'downloading' : TR_STATUS_DOWNLOAD, + 'seeding' : TR_STATUS_SEED, + 'stopped' : TR_STATUS_STOPPED, +}) + +TR_PRI_LOW = -1 +TR_PRI_NORMAL = 0 +TR_PRI_HIGH = 1 + +PRIORITY = mirror_dict({ + 'low' : TR_PRI_LOW, + 'normal' : TR_PRI_NORMAL, + 'high' : TR_PRI_HIGH +}) + +TR_RATIOLIMIT_GLOBAL = 0 # follow the global settings +TR_RATIOLIMIT_SINGLE = 1 # override the global settings, seeding until a certain ratio +TR_RATIOLIMIT_UNLIMITED = 2 # override the global settings, seeding regardless of ratio + +RATIO_LIMIT = mirror_dict({ + 'global' : TR_RATIOLIMIT_GLOBAL, + 'single' : TR_RATIOLIMIT_SINGLE, + 'unlimited' : TR_RATIOLIMIT_UNLIMITED +}) + +TR_IDLELIMIT_GLOBAL = 0 # follow the global settings +TR_IDLELIMIT_SINGLE = 1 # override the global settings, seeding until a certain idle time +TR_IDLELIMIT_UNLIMITED = 2 # override the global settings, seeding regardless of activity + +IDLE_LIMIT = mirror_dict({ + 'global' : TR_RATIOLIMIT_GLOBAL, + 'single' : TR_RATIOLIMIT_SINGLE, + 'unlimited' : TR_RATIOLIMIT_UNLIMITED +}) + +# A note on argument maps +# These maps are used to verify *-set methods. The information is structured in +# a tree. +# set +- - [, , , , , ] +# | +- - [, , , , , ] +# | +# get +- - [, , , , , ] +# +- - [, , , , , ] + +# Arguments for torrent methods +TORRENT_ARGS = { + 'get' : { + 'activityDate': ('number', 1, None, None, None, ''), + 'addedDate': ('number', 1, None, None, None, ''), + 'announceResponse': ('string', 1, 7, None, None, ''), + 'announceURL': ('string', 1, 7, None, None, ''), + 'bandwidthPriority': ('number', 5, None, None, None, ''), + 'comment': ('string', 1, None, None, None, ''), + 'corruptEver': ('number', 1, None, None, None, ''), + 'creator': ('string', 1, None, None, None, ''), + 'dateCreated': ('number', 1, None, None, None, ''), + 'desiredAvailable': ('number', 1, None, None, None, ''), + 'doneDate': ('number', 1, None, None, None, ''), + 'downloadDir': ('string', 4, None, None, None, ''), + 'downloadedEver': ('number', 1, None, None, None, ''), + 'downloaders': ('number', 4, 7, None, None, ''), + 'downloadLimit': ('number', 1, None, None, None, ''), + 'downloadLimited': ('boolean', 5, None, None, None, ''), + 'downloadLimitMode': ('number', 1, 5, None, None, ''), + 'error': ('number', 1, None, None, None, ''), + 'errorString': ('number', 1, None, None, None, ''), + 'eta': ('number', 1, None, None, None, ''), + 'files': ('array', 1, None, None, None, ''), + 'fileStats': ('array', 5, None, None, None, ''), + 'hashString': ('string', 1, None, None, None, ''), + 'haveUnchecked': ('number', 1, None, None, None, ''), + 'haveValid': ('number', 1, None, None, None, ''), + 'honorsSessionLimits': ('boolean', 5, None, None, None, ''), + 'id': ('number', 1, None, None, None, ''), + 'isFinished': ('boolean', 9, None, None, None, ''), + 'isPrivate': ('boolean', 1, None, None, None, ''), + 'lastAnnounceTime': ('number', 1, 7, None, None, ''), + 'lastScrapeTime': ('number', 1, 7, None, None, ''), + 'leechers': ('number', 1, 7, None, None, ''), + 'leftUntilDone': ('number', 1, None, None, None, ''), + 'magnetLink': ('string', 7, None, None, None, ''), + 'manualAnnounceTime': ('number', 1, None, None, None, ''), + 'maxConnectedPeers': ('number', 1, None, None, None, ''), + 'metadataPercentComplete': ('number', 7, None, None, None, ''), + 'name': ('string', 1, None, None, None, ''), + 'nextAnnounceTime': ('number', 1, 7, None, None, ''), + 'nextScrapeTime': ('number', 1, 7, None, None, ''), + 'peer-limit': ('number', 5, None, None, None, ''), + 'peers': ('array', 2, None, None, None, ''), + 'peersConnected': ('number', 1, None, None, None, ''), + 'peersFrom': ('object', 1, None, None, None, ''), + 'peersGettingFromUs': ('number', 1, None, None, None, ''), + 'peersKnown': ('number', 1, None, None, None, ''), + 'peersSendingToUs': ('number', 1, None, None, None, ''), + 'percentDone': ('double', 5, None, None, None, ''), + 'pieces': ('string', 5, None, None, None, ''), + 'pieceCount': ('number', 1, None, None, None, ''), + 'pieceSize': ('number', 1, None, None, None, ''), + 'priorities': ('array', 1, None, None, None, ''), + 'rateDownload': ('number', 1, None, None, None, ''), + 'rateUpload': ('number', 1, None, None, None, ''), + 'recheckProgress': ('double', 1, None, None, None, ''), + 'scrapeResponse': ('string', 1, 7, None, None, ''), + 'scrapeURL': ('string', 1, 7, None, None, ''), + 'seeders': ('number', 1, 7, None, None, ''), + 'seedIdleLimit': ('number', 10, None, None, None, ''), + 'seedIdleMode': ('number', 10, None, None, None, ''), + 'seedRatioLimit': ('double', 5, None, None, None, ''), + 'seedRatioMode': ('number', 5, None, None, None, ''), + 'sizeWhenDone': ('number', 1, None, None, None, ''), + 'startDate': ('number', 1, None, None, None, ''), + 'status': ('number', 1, None, None, None, ''), + 'swarmSpeed': ('number', 1, 7, None, None, ''), + 'timesCompleted': ('number', 1, 7, None, None, ''), + 'trackers': ('array', 1, None, None, None, ''), + 'trackerStats': ('object', 7, None, None, None, ''), + 'totalSize': ('number', 1, None, None, None, ''), + 'torrentFile': ('string', 5, None, None, None, ''), + 'uploadedEver': ('number', 1, None, None, None, ''), + 'uploadLimit': ('number', 1, None, None, None, ''), + 'uploadLimitMode': ('number', 1, 5, None, None, ''), + 'uploadLimited': ('boolean', 5, None, None, None, ''), + 'uploadRatio': ('double', 1, None, None, None, ''), + 'wanted': ('array', 1, None, None, None, ''), + 'webseeds': ('array', 1, None, None, None, ''), + 'webseedsSendingToUs': ('number', 1, None, None, None, ''), + }, + 'set': { + 'bandwidthPriority': ('number', 5, None, None, None, 'Priority for this transfer.'), + 'downloadLimit': ('number', 5, None, 'speed-limit-down', None, 'Set the speed limit for download in Kib/s.'), + 'downloadLimited': ('boolean', 5, None, 'speed-limit-down-enabled', None, 'Enable download speed limiter.'), + 'files-wanted': ('array', 1, None, None, None, "A list of file id's that should be downloaded."), + 'files-unwanted': ('array', 1, None, None, None, "A list of file id's that shouldn't be downloaded."), + 'honorsSessionLimits': ('boolean', 5, None, None, None, "Enables or disables the transfer to honour the upload limit set in the session."), + 'ids': ('array', 1, None, None, None, 'Local download location.'), + 'peer-limit': ('number', 1, None, None, None, 'The peer limit for the torrents.'), + 'priority-high': ('array', 1, None, None, None, "A list of file id's that should have high priority."), + 'priority-low': ('array', 1, None, None, None, "A list of file id's that should have normal priority."), + 'priority-normal': ('array', 1, None, None, None, "A list of file id's that should have low priority."), + 'seedIdleLimit': ('number', 10, None, None, None, 'Seed inactivity limit in minutes.'), + 'seedIdleMode': ('number', 10, None, None, None, 'Seed inactivity mode. 0 = Use session limit, 1 = Use transfer limit, 2 = Disable limit.'), + 'seedRatioLimit': ('double', 5, None, None, None, 'Seeding ratio.'), + 'seedRatioMode': ('number', 5, None, None, None, 'Which ratio to use. 0 = Use session limit, 1 = Use transfer limit, 2 = Disable limit.'), + 'speed-limit-down': ('number', 1, 5, None, 'downloadLimit', 'Set the speed limit for download in Kib/s.'), + 'speed-limit-down-enabled': ('boolean', 1, 5, None, 'downloadLimited', 'Enable download speed limiter.'), + 'speed-limit-up': ('number', 1, 5, None, 'uploadLimit', 'Set the speed limit for upload in Kib/s.'), + 'speed-limit-up-enabled': ('boolean', 1, 5, None, 'uploadLimited', 'Enable upload speed limiter.'), + 'trackerAdd': ('array', 10, None, None, None, 'Array of string with announce URLs to add.'), + 'trackerRemove': ('array', 10, None, None, None, 'Array of ids of trackers to remove.'), + 'trackerReplace': ('array', 10, None, None, None, 'Array of (id, url) tuples where the announce URL should be replaced.'), + 'uploadLimit': ('number', 5, None, 'speed-limit-up', None, 'Set the speed limit for upload in Kib/s.'), + 'uploadLimited': ('boolean', 5, None, 'speed-limit-up-enabled', None, 'Enable upload speed limiter.'), + }, + 'add': { + 'bandwidthPriority': ('number', 8, None, None, None, 'Priority for this transfer.'), + 'download-dir': ('string', 1, None, None, None, 'The directory where the downloaded contents will be saved in.'), + 'filename': ('string', 1, None, None, None, "A filepath or URL to a torrent file or a magnet link."), + 'files-wanted': ('array', 1, None, None, None, "A list of file id's that should be downloaded."), + 'files-unwanted': ('array', 1, None, None, None, "A list of file id's that shouldn't be downloaded."), + 'metainfo': ('string', 1, None, None, None, 'The content of a torrent file, base64 encoded.'), + 'paused': ('boolean', 1, None, None, None, 'If True, does not start the transfer when added.'), + 'peer-limit': ('number', 1, None, None, None, 'Maximum number of peers allowed.'), + 'priority-high': ('array', 1, None, None, None, "A list of file id's that should have high priority."), + 'priority-low': ('array', 1, None, None, None, "A list of file id's that should have low priority."), + 'priority-normal': ('array', 1, None, None, None, "A list of file id's that should have normal priority."), + } +} + +# Arguments for session methods +SESSION_ARGS = { + 'get': { + "alt-speed-down": ('number', 5, None, None, None, ''), + "alt-speed-enabled": ('boolean', 5, None, None, None, ''), + "alt-speed-time-begin": ('number', 5, None, None, None, ''), + "alt-speed-time-enabled": ('boolean', 5, None, None, None, ''), + "alt-speed-time-end": ('number', 5, None, None, None, ''), + "alt-speed-time-day": ('number', 5, None, None, None, ''), + "alt-speed-up": ('number', 5, None, None, None, ''), + "blocklist-enabled": ('boolean', 5, None, None, None, ''), + "blocklist-size": ('number', 5, None, None, None, ''), + "blocklist-url": ('string', 11, None, None, None, ''), + "cache-size-mb": ('number', 10, None, None, None, ''), + "config-dir": ('string', 8, None, None, None, ''), + "dht-enabled": ('boolean', 6, None, None, None, ''), + "download-dir": ('string', 1, None, None, None, ''), + "encryption": ('string', 1, None, None, None, ''), + "idle-seeding-limit": ('number', 10, None, None, None, ''), + "idle-seeding-limit-enabled": ('boolean', 10, None, None, None, ''), + "incomplete-dir": ('string', 7, None, None, None, ''), + "incomplete-dir-enabled": ('boolean', 7, None, None, None, ''), + "lpd-enabled": ('boolean', 9, None, None, None, ''), + "peer-limit": ('number', 1, 5, None, None, ''), + "peer-limit-global": ('number', 5, None, None, None, ''), + "peer-limit-per-torrent": ('number', 5, None, None, None, ''), + "pex-allowed": ('boolean', 1, 5, None, None, ''), + "pex-enabled": ('boolean', 5, None, None, None, ''), + "port": ('number', 1, 5, None, None, ''), + "peer-port": ('number', 5, None, None, None, ''), + "peer-port-random-on-start": ('boolean', 5, None, None, None, ''), + "port-forwarding-enabled": ('boolean', 1, None, None, None, ''), + "rename-partial-files": ('boolean', 8, None, None, None, ''), + "rpc-version": ('number', 4, None, None, None, ''), + "rpc-version-minimum": ('number', 4, None, None, None, ''), + "script-torrent-done-enabled": ('boolean', 9, None, None, None, ''), + "script-torrent-done-filename": ('string', 9, None, None, None, ''), + "seedRatioLimit": ('double', 5, None, None, None, ''), + "seedRatioLimited": ('boolean', 5, None, None, None, ''), + "speed-limit-down": ('number', 1, None, None, None, ''), + "speed-limit-down-enabled": ('boolean', 1, None, None, None, ''), + "speed-limit-up": ('number', 1, None, None, None, ''), + "speed-limit-up-enabled": ('boolean', 1, None, None, None, ''), + "start-added-torrents": ('boolean', 9, None, None, None, ''), + "trash-original-torrent-files": ('boolean', 9, None, None, None, ''), + 'units': ('object', 10, None, None, None, ''), + "version": ('string', 3, None, None, None, ''), + }, + 'set': { + "alt-speed-down": ('number', 5, None, None, None, 'Alternate session download speed limit (in Kib/s).'), + "alt-speed-enabled": ('boolean', 5, None, None, None, 'Enables alternate global download speed limiter.'), + "alt-speed-time-begin": ('number', 5, None, None, None, 'Time when alternate speeds should be enabled. Minutes after midnight.'), + "alt-speed-time-enabled": ('boolean', 5, None, None, None, 'Enables alternate speeds scheduling.'), + "alt-speed-time-end": ('number', 5, None, None, None, 'Time when alternate speeds should be disabled. Minutes after midnight.'), + "alt-speed-time-day": ('number', 5, None, None, None, 'Enables alternate speeds scheduling these days.'), + "alt-speed-up": ('number', 5, None, None, None, 'Alternate session upload speed limit (in Kib/s).'), + "blocklist-enabled": ('boolean', 5, None, None, None, 'Enables the block list'), + "blocklist-url": ('string', 11, None, None, None, 'Location of the blocklist. Updated with blocklist-update.'), + "cache-size-mb": ('number', 10, None, None, None, 'The maximum size of the disk cache in MB'), + "dht-enabled": ('boolean', 6, None, None, None, 'Enables DHT.'), + "download-dir": ('string', 1, None, None, None, 'Set the session download directory.'), + "encryption": ('string', 1, None, None, None, 'Set the session encryption mode, one of ``required``, ``preferred`` or ``tolerated``.'), + "idle-seeding-limit": ('number', 10, None, None, None, 'The default seed inactivity limit in minutes.'), + "idle-seeding-limit-enabled": ('boolean', 10, None, None, None, 'Enables the default seed inactivity limit'), + "incomplete-dir": ('string', 7, None, None, None, 'The path to the directory of incomplete transfer data.'), + "incomplete-dir-enabled": ('boolean', 7, None, None, None, 'Enables the incomplete transfer data directory. Otherwise data for incomplete transfers are stored in the download target.'), + "lpd-enabled": ('boolean', 9, None, None, None, 'Enables local peer discovery for public torrents.'), + "peer-limit": ('number', 1, 5, None, 'peer-limit-global', 'Maximum number of peers'), + "peer-limit-global": ('number', 5, None, 'peer-limit', None, 'Maximum number of peers'), + "peer-limit-per-torrent": ('number', 5, None, None, None, 'Maximum number of peers per transfer'), + "pex-allowed": ('boolean', 1, 5, None, 'pex-enabled', 'Allowing PEX in public torrents.'), + "pex-enabled": ('boolean', 5, None, 'pex-allowed', None, 'Allowing PEX in public torrents.'), + "port": ('number', 1, 5, None, 'peer-port', 'Peer port.'), + "peer-port": ('number', 5, None, 'port', None, 'Peer port.'), + "peer-port-random-on-start": ('boolean', 5, None, None, None, 'Enables randomized peer port on start of Transmission.'), + "port-forwarding-enabled": ('boolean', 1, None, None, None, 'Enables port forwarding.'), + "rename-partial-files": ('boolean', 8, None, None, None, 'Appends ".part" to incomplete files'), + "script-torrent-done-enabled": ('boolean', 9, None, None, None, 'Whether or not to call the "done" script.'), + "script-torrent-done-filename": ('string', 9, None, None, None, 'Filename of the script to run when the transfer is done.'), + "seedRatioLimit": ('double', 5, None, None, None, 'Seed ratio limit. 1.0 means 1:1 download and upload ratio.'), + "seedRatioLimited": ('boolean', 5, None, None, None, 'Enables seed ration limit.'), + "speed-limit-down": ('number', 1, None, None, None, 'Download speed limit (in Kib/s).'), + "speed-limit-down-enabled": ('boolean', 1, None, None, None, 'Enables download speed limiting.'), + "speed-limit-up": ('number', 1, None, None, None, 'Upload speed limit (in Kib/s).'), + "speed-limit-up-enabled": ('boolean', 1, None, None, None, 'Enables upload speed limiting.'), + "start-added-torrents": ('boolean', 9, None, None, None, 'Added torrents will be started right away.'), + "trash-original-torrent-files": ('boolean', 9, None, None, None, 'The .torrent file of added torrents will be deleted.'), + }, +} diff --git a/libs/transmissionrpc/error.py b/libs/transmissionrpc/error.py new file mode 100755 index 00000000..dcac5e93 --- /dev/null +++ b/libs/transmissionrpc/error.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +class TransmissionError(Exception): + """ + This exception is raised when there has occured an error related to + communication with Transmission. It is a subclass of Exception. + """ + def __init__(self, message='', original=None): + Exception.__init__(self) + self.errormsg = message + self.original = original + + def __str__(self): + if self.original: + original_name = type(self.original).__name__ + return '%s Original exception: %s, "%s"' % (self.errormsg, original_name, str(self.original)) + else: + return self.errormsg + +class HTTPHandlerError(Exception): + """ + This exception is raised when there has occured an error related to + the HTTP handler. It is a subclass of Exception. + """ + def __init__(self, httpurl=None, httpcode=None, httpmsg=None, httpheaders=None, httpdata=None): + Exception.__init__(self) + self.url = '' + self.code = 600 + self.errormsg = '' + self.headers = {} + self.data = '' + if isinstance(httpurl, (str, unicode)): + self.url = httpurl + if isinstance(httpcode, (int, long)): + self.code = httpcode + if isinstance(httpmsg, (str, unicode)): + self.errormsg = httpmsg + if isinstance(httpheaders, (dict)): + self.headers = httpheaders + if isinstance(httpdata, (str, unicode)): + self.data = httpdata + + def __repr__(self): + return '' % (self.code, self.errormsg) + + def __str__(self): + return '' % (self.code, self.errormsg) + + def __unicode__(self): + return u'' % (self.code, self.errormsg) diff --git a/libs/transmissionrpc/httphandler.py b/libs/transmissionrpc/httphandler.py new file mode 100755 index 00000000..e4933d48 --- /dev/null +++ b/libs/transmissionrpc/httphandler.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Erik Svensson +# Licensed under the MIT license. + +import sys, httplib, urllib2 + +from transmissionrpc.error import HTTPHandlerError + +class HTTPHandler(object): + """ + Prototype for HTTP handling. + """ + def set_authentication(self, uri, login, password): + """ + Transmission use basic authentication in earlier versions and digest + authentication in later versions. + + * uri, the authentication realm URI. + * login, the authentication login. + * password, the authentication password. + """ + raise NotImplementedError("Bad HTTPHandler, failed to implement set_authentication.") + + def request(self, url, query, headers, timeout): + """ + Implement a HTTP POST request here. + + * url, The URL to request. + * query, The query data to send. This is a JSON data string. + * headers, a dictionary of headers to send. + * timeout, requested request timeout in seconds. + """ + raise NotImplementedError("Bad HTTPHandler, failed to implement request.") + +class DefaultHTTPHandler(HTTPHandler): + """ + The default HTTP handler provided with transmissionrpc. + """ + def __init__(self): + HTTPHandler.__init__(self) + + def set_authentication(self, uri, login, password): + password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password(realm=None, uri=uri, user=login, passwd=password) + opener = urllib2.build_opener( + urllib2.HTTPBasicAuthHandler(password_manager) + , urllib2.HTTPDigestAuthHandler(password_manager) + ) + urllib2.install_opener(opener) + + def request(self, url, query, headers, timeout): + request = urllib2.Request(url, query, headers) + try: + if (sys.version_info[0] == 2 and sys.version_info[1] > 5) or sys.version_info[0] > 2: + response = urllib2.urlopen(request, timeout=timeout) + else: + response = urllib2.urlopen(request) + except urllib2.HTTPError, error: + if error.fp == None: + raise HTTPHandlerError(error.filename, error.code, error.msg, dict(error.hdrs)) + else: + raise HTTPHandlerError(error.filename, error.code, error.msg, dict(error.hdrs), error.read()) + except urllib2.URLError, error: + # urllib2.URLError documentation is absymal! + # Try to get the tuple arguments of URLError + if hasattr(error.reason, 'args') and isinstance(error.reason.args, tuple) and len(error.reason.args) == 2: + raise HTTPHandlerError(httpcode=error.reason.args[0], httpmsg=error.reason.args[1]) + else: + raise HTTPHandlerError(httpmsg='urllib2.URLError: %s' % (error.reason)) + except httplib.BadStatusLine, error: + raise HTTPHandlerError(httpmsg='httplib.BadStatusLine: %s' % (error.line)) + return response.read() diff --git a/libs/transmissionrpc/session.py b/libs/transmissionrpc/session.py new file mode 100755 index 00000000..56e842cd --- /dev/null +++ b/libs/transmissionrpc/session.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +class Session(object): + """ + Session is a class holding the session data for a Transmission daemon. + + Access the session field can be done through attributes. + The attributes available are the same as the session arguments in the + Transmission RPC specification, but with underscore instead of hypen. + ``download-dir`` -> ``download_dir``. + """ + + def __init__(self, fields=None): + self.fields = {} + if fields != None: + self.update(fields) + + def update(self, other): + """Update the session data from a session arguments dictinary""" + + fields = None + if isinstance(other, dict): + fields = other + elif isinstance(other, Session): + fields = other.fields + else: + raise ValueError('Cannot update with supplied data') + + for key, value in fields.iteritems(): + self.fields[key.replace('-', '_')] = value + + def __getattr__(self, name): + try: + return self.fields[name] + except KeyError: + raise AttributeError('No attribute %s' % name) + + def __str__(self): + text = '' + for key in sorted(self.fields.keys()): + text += "% 32s: %s\n" % (key[-32:], self.fields[key]) + return text diff --git a/libs/transmissionrpc/torrent.py b/libs/transmissionrpc/torrent.py new file mode 100755 index 00000000..7b86bbf9 --- /dev/null +++ b/libs/transmissionrpc/torrent.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +import sys, datetime + +from transmissionrpc.constants import STATUS, PRIORITY +from transmissionrpc.utils import format_timedelta + +class Torrent(object): + """ + Torrent is a class holding the data raceived from Transmission regarding a bittorrent transfer. + All fetched torrent fields are accessable through this class using attributes. + This class has a few convenience properties using the torrent data. + """ + + def __init__(self, client, fields): + if 'id' not in fields: + raise ValueError('Torrent requires an id') + self.fields = {} + self.update(fields) + self.client = client + + def _getNameString(self, codec=None): + if codec == None: + codec = sys.getdefaultencoding() + name = None + # try to find name + if 'name' in self.fields: + name = self.fields['name'] + # if name is unicode, try to decode + if isinstance(name, unicode): + try: + name = name.encode(codec) + except UnicodeError: + name = None + return name + + def __repr__(self): + tid = self.fields['id'] + name = self._getNameString() + if isinstance(name, str): + return '' % (tid, name) + else: + return '' % (tid) + + def __str__(self): + name = self._getNameString() + if isinstance(name, str): + return 'Torrent \"%s\"' % (name) + else: + return 'Torrent' + + def __copy__(self): + return Torrent(self.client, self.fields) + + def update(self, other): + """ + Update the torrent data from a Transmission JSON-RPC arguments dictinary + """ + fields = None + if isinstance(other, dict): + fields = other + elif isinstance(other, Torrent): + fields = other.fields + else: + raise ValueError('Cannot update with supplied data') + for key, value in fields.iteritems(): + self.fields[key.replace('-', '_')] = value + + def files(self): + """ + Get list of files for this torrent. + + This function returns a dictionary with file information for each file. + The file information is has following fields: + :: + + { + : { + 'name': , + 'size': , + 'completed': , + 'priority': , + 'selected': + } + + ... + } + """ + result = {} + if 'files' in self.fields: + indicies = xrange(len(self.fields['files'])) + files = self.fields['files'] + priorities = self.fields['priorities'] + wanted = self.fields['wanted'] + for item in zip(indicies, files, priorities, wanted): + selected = True if item[3] else False + priority = PRIORITY[item[2]] + result[item[0]] = { + 'selected': selected, + 'priority': priority, + 'size': item[1]['length'], + 'name': item[1]['name'], + 'completed': item[1]['bytesCompleted']} + return result + + def __getattr__(self, name): + try: + return self.fields[name] + except KeyError: + raise AttributeError('No attribute %s' % name) + + @property + def status(self): + """ + Returns the torrent status. Is either one of 'check pending', 'checking', + 'downloading', 'seeding' or 'stopped'. The first two is related to + verification. + """ + return STATUS[self.fields['status']] + + @property + def progress(self): + """Get the download progress in percent.""" + try: + return 100.0 * (self.fields['sizeWhenDone'] - self.fields['leftUntilDone']) / float(self.fields['sizeWhenDone']) + except ZeroDivisionError: + return 0.0 + + @property + def ratio(self): + """Get the upload/download ratio.""" + try: + return self.fields['uploadedEver'] / float(self.fields['downloadedEver']) + except ZeroDivisionError: + return 0.0 + + @property + def eta(self): + """Get the "eta" as datetime.timedelta.""" + eta = self.fields['eta'] + if eta >= 0: + return datetime.timedelta(seconds=eta) + else: + ValueError('eta not valid') + + @property + def date_active(self): + """Get the attribute "activityDate" as datetime.datetime.""" + return datetime.datetime.fromtimestamp(self.fields['activityDate']) + + @property + def date_added(self): + """Get the attribute "addedDate" as datetime.datetime.""" + return datetime.datetime.fromtimestamp(self.fields['addedDate']) + + @property + def date_started(self): + """Get the attribute "startDate" as datetime.datetime.""" + return datetime.datetime.fromtimestamp(self.fields['startDate']) + + @property + def date_done(self): + """Get the attribute "doneDate" as datetime.datetime.""" + return datetime.datetime.fromtimestamp(self.fields['doneDate']) + + def format_eta(self): + """ + Returns the attribute *eta* formatted as a string. + + * If eta is -1 the result is 'not available' + * If eta is -2 the result is 'unknown' + * Otherwise eta is formatted as ::. + """ + eta = self.fields['eta'] + if eta == -1: + return 'not available' + elif eta == -2: + return 'unknown' + else: + return format_timedelta(self.eta) + + @property + def priority(self): + """ + Get the priority as string. + Can be one of 'low', 'normal', 'high'. + """ + return PRIORITY[self.fields['bandwidthPriority']] diff --git a/libs/transmissionrpc/utils.py b/libs/transmissionrpc/utils.py new file mode 100755 index 00000000..8bfd6920 --- /dev/null +++ b/libs/transmissionrpc/utils.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2008-2010 Erik Svensson +# Licensed under the MIT license. + +import socket, datetime, logging +import transmissionrpc.constants as constants +from transmissionrpc.constants import LOGGER + +UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] + +def format_size(size): + """ + Format byte size into IEC prefixes, B, KiB, MiB ... + """ + size = float(size) + i = 0 + while size >= 1024.0 and i < len(UNITS): + i += 1 + size /= 1024.0 + return (size, UNITS[i]) + +def format_speed(size): + """ + Format bytes per second speed into IEC prefixes, B/s, KiB/s, MiB/s ... + """ + (size, unit) = format_size(size) + return (size, unit + '/s') + +def format_timedelta(delta): + """ + Format datetime.timedelta into ::. + """ + minutes, seconds = divmod(delta.seconds, 60) + hours, minutes = divmod(minutes, 60) + return '%d %02d:%02d:%02d' % (delta.days, hours, minutes, seconds) + +def format_timestamp(timestamp): + """ + Format unix timestamp into ISO date format. + """ + if timestamp > 0: + dt_timestamp = datetime.datetime.fromtimestamp(timestamp) + return dt_timestamp.isoformat(' ') + else: + return '-' + +class INetAddressError(Exception): + """ + Error parsing / generating a internet address. + """ + pass + +def inet_address(address, default_port, default_address='localhost'): + """ + Parse internet address. + """ + addr = address.split(':') + if len(addr) == 1: + try: + port = int(addr[0]) + addr = default_address + except ValueError: + addr = addr[0] + port = default_port + elif len(addr) == 2: + try: + port = int(addr[1]) + except ValueError: + raise INetAddressError('Invalid address "%s".' % address) + if len(addr[0]) == 0: + addr = default_address + else: + addr = addr[0] + else: + raise INetAddressError('Invalid address "%s".' % address) + try: + socket.getaddrinfo(addr, port, socket.AF_INET, socket.SOCK_STREAM) + except socket.gaierror: + raise INetAddressError('Cannot look up address "%s".' % address) + return (addr, port) + +def rpc_bool(arg): + """ + Convert between Python boolean and Transmission RPC boolean. + """ + if isinstance(arg, (str, unicode)): + try: + arg = bool(int(arg)) + except ValueError: + arg = arg.lower() in [u'true', u'yes'] + return 1 if bool(arg) else 0 + +TR_TYPE_MAP = { + 'number' : int, + 'string' : str, + 'double': float, + 'boolean' : rpc_bool, + 'array': list, + 'object': dict +} + +def make_python_name(name): + """ + Convert Transmission RPC name to python compatible name. + """ + return name.replace('-', '_') + +def make_rpc_name(name): + """ + Convert python compatible name to Transmission RPC name. + """ + return name.replace('_', '-') + +def argument_value_convert(method, argument, value, rpc_version): + """ + Check and fix Transmission RPC issues with regards to methods, arguments and values. + """ + if method in ('torrent-add', 'torrent-get', 'torrent-set'): + args = constants.TORRENT_ARGS[method[-3:]] + elif method in ('session-get', 'session-set'): + args = constants.SESSION_ARGS[method[-3:]] + else: + return ValueError('Method "%s" not supported' % (method)) + if argument in args: + info = args[argument] + invalid_version = True + while invalid_version: + invalid_version = False + replacement = None + if rpc_version < info[1]: + invalid_version = True + replacement = info[3] + if info[2] and info[2] <= rpc_version: + invalid_version = True + replacement = info[4] + if invalid_version: + if replacement: + LOGGER.warning( + 'Replacing requested argument "%s" with "%s".' + % (argument, replacement)) + argument = replacement + info = args[argument] + else: + raise ValueError( + 'Method "%s" Argument "%s" does not exist in version %d.' + % (method, argument, rpc_version)) + return (argument, TR_TYPE_MAP[info[0]](value)) + else: + raise ValueError('Argument "%s" does not exists for method "%s".', + (argument, method)) + +def get_arguments(method, rpc_version): + """ + Get arguments for method in specified Transmission RPC version. + """ + if method in ('torrent-add', 'torrent-get', 'torrent-set'): + args = constants.TORRENT_ARGS[method[-3:]] + elif method in ('session-get', 'session-set'): + args = constants.SESSION_ARGS[method[-3:]] + else: + return ValueError('Method "%s" not supported' % (method)) + accessible = [] + for argument, info in args.iteritems(): + valid_version = True + if rpc_version < info[1]: + valid_version = False + if info[2] and info[2] <= rpc_version: + valid_version = False + if valid_version: + accessible.append(argument) + return accessible + +def add_stdout_logger(level='debug'): + """ + Add a stdout target for the transmissionrpc logging. + """ + levels = {'debug': logging.DEBUG, 'info': logging.INFO, 'warning': logging.WARNING, 'error': logging.ERROR} + + trpc_logger = logging.getLogger('transmissionrpc') + loghandler = logging.StreamHandler() + if level in levels.keys(): + loglevel = levels[level] + trpc_logger.setLevel(loglevel) + loghandler.setLevel(loglevel) + trpc_logger.addHandler(loghandler)