diff options
Diffstat (limited to 'transmission-remote-cli.py')
| -rwxr-xr-x | transmission-remote-cli.py | 3123 | 
1 files changed, 3123 insertions, 0 deletions
| diff --git a/transmission-remote-cli.py b/transmission-remote-cli.py new file mode 100755 index 0000000..e7dfb9e --- /dev/null +++ b/transmission-remote-cli.py @@ -0,0 +1,3123 @@ +#!/usr/bin/env python +######################################################################## +# This is transmission-remote-cli, whereas 'cli' stands for 'Curses    # +# Luminous Interface', a client for the daemon of the BitTorrent       # +# client Transmission.                                                 # +#                                                                      # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or    # +# (at your option) any later version.                                  # +#                                                                      # +# This program is distributed in the hope that it will be useful,      # +# but WITHOUT ANY WARRANTY; without even the implied warranty of       # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the        # +# GNU General Public License for more details:                         # +# http://www.gnu.org/licenses/gpl-3.0.txt                              # +######################################################################## + +VERSION = '1.1.1' + +TRNSM_VERSION_MIN = '1.90' +TRNSM_VERSION_MAX = '2.50' +RPC_VERSION_MIN = 8 +RPC_VERSION_MAX = 14 + +# error codes +CONNECTION_ERROR = 1 +JSON_ERROR       = 2 +CONFIGFILE_ERROR = 3 + + +# use simplejson if available because it seems to be faster +try: +    import simplejson as json +except ImportError: +    try: +        # Python 2.6 comes with a json module ... +        import json +        # ...but there is also an old json module that doesn't support .loads/.dumps. +        json.dumps ; json.dumps +    except (ImportError,AttributeError): +        quit("Please install simplejson or Python 2.6 or higher.") + +import time +import re +import base64 +import httplib +import urllib2 +import socket +socket.setdefaulttimeout(None) +import ConfigParser +from optparse import OptionParser, SUPPRESS_HELP +import sys +import os +import signal +import unicodedata +import locale +locale.setlocale(locale.LC_ALL, '') +import curses +import curses.ascii +from textwrap import wrap +from subprocess import call +import netrc + + +# optional features provided by non-standard modules +features = {'dns':False, 'geoip':False, 'ipy':False} +try:   import adns; features['dns'] = True     # resolve IP to host name +except ImportError: features['dns'] = False + +try:   import GeoIP; features['geoip'] = True  # show country peer seems to be in +except ImportError:  features['geoip'] = False + +try:   import IPy;  features['ipy'] = True  # extract ipv4 from ipv6 addresses +except ImportError: features['ipy'] = False + + +if features['ipy']: +    IPV6_RANGE_6TO4 = IPy.IP('2002::/16') +    IPV6_RANGE_TEREDO = IPy.IP('2001::/32') +    IPV4_ONES = 0xffffffff + +if features['geoip']: +    def country_code_by_addr_vany(geo_ip, geo_ip6, addr): +        if '.' in addr: +            return geo_ip.country_code_by_addr(addr) +        if not ':' in addr: +            return None +        if features['ipy']: +            ip = IPy.IP(addr) +            if ip in IPV6_RANGE_6TO4: +              addr = str(IPy.IP(ip.int() >> 80 & IPV4_ONES)) +              return geo_ip.country_code_by_addr(addr) +            elif ip in IPV6_RANGE_TEREDO: +              addr = str(IPy.IP(ip.int() & IPV4_ONES ^ IPV4_ONES)) +              return geo_ip.country_code_by_addr(addr) +        if hasattr(geo_ip6, 'country_code_by_addr_v6'): +            return geo_ip6.country_code_by_addr_v6(addr) + + +# define config defaults +config = ConfigParser.SafeConfigParser() +config.add_section('Connection') +config.set('Connection', 'password', '') +config.set('Connection', 'username', '') +config.set('Connection', 'port', '9091') +config.set('Connection', 'host', 'localhost') +config.set('Connection', 'path', '/transmission/rpc') +config.set('Connection', 'ssl', 'False') +config.add_section('Sorting') +config.set('Sorting', 'order', 'name') +config.add_section('Filtering') +config.set('Filtering', 'filter', '') +config.set('Filtering', 'invert', 'False') +config.add_section('Misc') +config.set('Misc', 'compact_list', 'False') +config.set('Misc', 'torrentname_is_progressbar', 'True') +config.add_section('Colors') +config.set('Colors', 'title_seed',       'bg:green,fg:black') +config.set('Colors', 'title_download',   'bg:blue,fg:black') +config.set('Colors', 'title_idle',       'bg:cyan,fg:black') +config.set('Colors', 'title_verify',     'bg:magenta,fg:black') +config.set('Colors', 'title_paused',     'bg:black,fg:white') +config.set('Colors', 'download_rate',    'bg:black,fg:blue') +config.set('Colors', 'upload_rate',      'bg:black,fg:red') +config.set('Colors', 'eta+ratio',        'bg:black,fg:white') +config.set('Colors', 'filter_status',    'bg:red,fg:black') +config.set('Colors', 'dialog',           'bg:black,fg:white') +config.set('Colors', 'dialog_important', 'bg:red,fg:black') +config.set('Colors', 'button',           'bg:white,fg:black') +config.set('Colors', 'button_focused',   'bg:black,fg:white') +config.set('Colors', 'file_prio_high',   'bg:red,fg:black') +config.set('Colors', 'file_prio_normal', 'bg:white,fg:black') +config.set('Colors', 'file_prio_low',    'bg:yellow,fg:black') +config.set('Colors', 'file_prio_off',    'bg:blue,fg:black') + + +class ColorManager: +    def __init__(self, config): +        self.config = dict() +        for name in config.keys(): +            self.config[name] = self._parse_color_pair(config[name]) + +    def _parse_color_pair(self, pair): +        # BG and FG are intentionally switched here because colors are always +        # used with curses.A_REVERSE. (To be honest, I forgot why, probably +        # has something to do with how highlighting focus works.) +        bg_name = pair.split(',')[1].split(':')[1].upper() +        fg_name = pair.split(',')[0].split(':')[1].upper() +        return { 'id': len(self.config.keys()) + 1, +                 'bg':eval('curses.COLOR_' + bg_name), +                 'fg':eval('curses.COLOR_' + fg_name) } + +    def get_id(self, name): return self.config[name]['id'] +    def get_bg(self, name): return self.config[name]['bg'] +    def get_fg(self, name): return self.config[name]['fg'] +    def get_names(self): return self.config.keys() + + + +authhandler = None +session_id = 0 + +# Handle communication with Transmission server. +class TransmissionRequest: +    def __init__(self, host, port, path, method=None, tag=None, arguments=None): +        self.url           = create_url(host, port, path) +        self.open_request  = None +        self.last_update   = 0 +        if method and tag: +            self.set_request_data(method, tag, arguments) + +    def set_request_data(self, method, tag, arguments=None): +        request_data = {'method':method, 'tag':tag} +        if arguments: request_data['arguments'] = arguments +        self.http_request = urllib2.Request(url=self.url, data=json.dumps(request_data)) + +    def send_request(self): +        """Ask for information from server OR submit command.""" + +        global session_id +        try: +            if session_id: +                self.http_request.add_header('X-Transmission-Session-Id', session_id) +            self.open_request = urllib2.urlopen(self.http_request) +            debug(self.http_request.get_data() + "\n\n") +        except AttributeError: +            # request data (http_request) isn't specified yet -- data will be available on next call +            pass + +        # authentication +        except urllib2.HTTPError, e: +            try: +                msg = html2text(str(e.read())) +            except: +                msg = str(e) + +            # extract session id and send request again +            m = re.search('X-Transmission-Session-Id:\s*(\w+)', msg) +            try: +                session_id = m.group(1) +                self.send_request() +            except AttributeError: +                quit(str(msg) + "\n", CONNECTION_ERROR) + +        except urllib2.URLError, msg: +            try: +                reason = msg.reason[1] +            except IndexError: +                reason = str(msg.reason) +            quit("Cannot connect to %s: %s\n" % (self.http_request.host, reason), CONNECTION_ERROR) + +    def get_response(self): +        """Get response to previously sent request.""" + +        if self.open_request == None: +            return {'result': 'no open request'} +        response = self.open_request.read() +        # work around regression in Python 2.6.5, caused by http://bugs.python.org/issue8797 +        if authhandler: +            authhandler.retried = 0 +        try: +            data = json.loads(unicode(response)) +            debug(data) +        except ValueError: +            quit("Cannot parse response: %s\n" % response, JSON_ERROR) +        self.open_request = None +        return data + + +# End of Class TransmissionRequest + + +# Higher level of data exchange +class Transmission: +    STATUS_STOPPED       = 0   # Torrent is stopped +    STATUS_CHECK_WAIT    = 1   # Queued to check files +    STATUS_CHECK         = 2   # Checking files +    STATUS_DOWNLOAD_WAIT = 3   # Queued to download +    STATUS_DOWNLOAD      = 4   # Downloading +    STATUS_SEED_WAIT     = 5   # Queued to seed +    STATUS_SEED          = 6   # Seeding + +    TAG_TORRENT_LIST    = 7 +    TAG_TORRENT_DETAILS = 77 +    TAG_SESSION_STATS   = 21 +    TAG_SESSION_GET     = 22 + +    LIST_FIELDS = [ 'id', 'name', 'downloadDir', 'status', 'trackerStats', 'desiredAvailable', +                    'rateDownload', 'rateUpload', 'eta', 'uploadRatio', +                    'sizeWhenDone', 'haveValid', 'haveUnchecked', 'addedDate', +                    'uploadedEver', 'errorString', 'recheckProgress', +                    'peersConnected', 'uploadLimit', 'downloadLimit', +                    'uploadLimited', 'downloadLimited', 'bandwidthPriority', +                    'peersSendingToUs', 'peersGettingFromUs', +                    'seedRatioLimit', 'seedRatioMode' ] + +    DETAIL_FIELDS = [ 'files', 'priorities', 'wanted', 'peers', 'trackers', +                      'activityDate', 'dateCreated', 'startDate', 'doneDate', +                      'totalSize', 'leftUntilDone', 'comment', 'isPrivate', +                      'hashString', 'pieceCount', 'pieceSize', 'pieces', +                      'downloadedEver', 'corruptEver', 'peersFrom' ] + LIST_FIELDS + +    def __init__(self, host, port, path, username, password): +        self.host = host +        self.port = port +        self.path = path + +        if username and password: +            password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm() +            password_mgr.add_password(None, create_url(host, port, path), username, password) +            global authhandler +            authhandler = urllib2.HTTPBasicAuthHandler(password_mgr) +            opener = urllib2.build_opener(authhandler) +            urllib2.install_opener(opener) + +        # check rpc version +        request = TransmissionRequest(host, port, path, 'session-get', self.TAG_SESSION_GET) +        request.send_request() +        response = request.get_response() + +        self.rpc_version = response['arguments']['rpc-version'] + +        # rpc version too old? +        version_error = "Unsupported Transmission version: " + str(response['arguments']['version']) + \ +            " -- RPC protocol version: " + str(response['arguments']['rpc-version']) + "\n" + +        min_msg = "Please install Transmission version " + TRNSM_VERSION_MIN + " or higher.\n" +        try: +            if response['arguments']['rpc-version'] < RPC_VERSION_MIN: +                quit(version_error + min_msg) +        except KeyError: +            quit(version_error + min_msg) + +        # rpc version too new? +        if response['arguments']['rpc-version'] > RPC_VERSION_MAX: +            quit(version_error + "Please install Transmission version " + TRNSM_VERSION_MAX + " or lower.\n") + +        # setup compatibility to Transmission <2.40 +        if self.rpc_version < 14: +            Transmission.STATUS_CHECK_WAIT    = 1 << 0 +            Transmission.STATUS_CHECK         = 1 << 1 +            Transmission.STATUS_DOWNLOAD_WAIT = 1 << 2 +            Transmission.STATUS_DOWNLOAD      = 1 << 2 +            Transmission.STATUS_SEED_WAIT     = 1 << 3 +            Transmission.STATUS_SEED          = 1 << 3 +            Transmission.STATUS_STOPPED       = 1 << 4 + +        # set up request list +        self.requests = {'torrent-list': +                             TransmissionRequest(host, port, path, 'torrent-get', self.TAG_TORRENT_LIST, {'fields': self.LIST_FIELDS}), +                         'session-stats': +                             TransmissionRequest(host, port, path, 'session-stats', self.TAG_SESSION_STATS, 21), +                         'session-get': +                             TransmissionRequest(host, port, path, 'session-get', self.TAG_SESSION_GET), +                         'torrent-details': +                             TransmissionRequest(host, port, path)} + +        self.torrent_cache = [] +        self.status_cache  = dict() +        self.torrent_details_cache = dict() +        self.peer_progress_cache   = dict() +        self.hosts_cache   = dict() +        self.geo_ips_cache = dict() +        if features['dns']:   self.resolver = adns.init() +        if features['geoip']: +            self.geo_ip = GeoIP.new(GeoIP.GEOIP_MEMORY_CACHE) +            try: +                self.geo_ip6 = GeoIP.open_type(GeoIP.GEOIP_COUNTRY_EDITION_V6, GeoIP.GEOIP_MEMORY_CACHE); +            except AttributeError: self.geo_ip6 = None +            except GeoIP.error: self.geo_ip6 = None + +        # make sure there are no undefined values +        self.wait_for_torrentlist_update() +        self.requests['torrent-details'] = TransmissionRequest(self.host, self.port, self.path) + + +    def update(self, delay, tag_waiting_for=0): +        """Maintain up-to-date data.""" + +        tag_waiting_for_occurred = False + +        for request in self.requests.values(): +            if time.time() - request.last_update >= delay: +                request.last_update = time.time() +                response = request.get_response() + +                if response['result'] == 'no open request': +                    request.send_request() + +                elif response['result'] == 'success': +                    tag = self.parse_response(response) +                    if tag == tag_waiting_for: +                        tag_waiting_for_occurred = True + +        if tag_waiting_for: +            return tag_waiting_for_occurred +        else: +            return None + + + +    def parse_response(self, response): +        # response is a reply to torrent-get +        if response['tag'] == self.TAG_TORRENT_LIST or response['tag'] == self.TAG_TORRENT_DETAILS: +            for t in response['arguments']['torrents']: +                t['uploadRatio'] = round(float(t['uploadRatio']), 2) +                t['percentDone'] = percent(float(t['sizeWhenDone']), +                                           float(t['haveValid'] + t['haveUnchecked'])) +                t['available'] = t['desiredAvailable'] + t['haveValid'] + t['haveUnchecked'] +                if t['downloadDir'][-1] != '/': +                    t['downloadDir'] += '/' +                try: +                    t['seeders']  = max(map(lambda x: x['seederCount'],  t['trackerStats'])) +                    t['leechers'] = max(map(lambda x: x['leecherCount'], t['trackerStats'])) +                except ValueError: +                    t['seeders']  = t['leechers'] = -1 + +            if response['tag'] == self.TAG_TORRENT_LIST: +                self.torrent_cache = response['arguments']['torrents'] + +            elif response['tag'] == self.TAG_TORRENT_DETAILS: +                # torrent list may be empty sometimes after deleting +                # torrents.  no idea why and why the server sends us +                # TAG_TORRENT_DETAILS, but just passing seems to help.(?) +                try: +                    torrent_details = response['arguments']['torrents'][0] +                    torrent_details['pieces'] = base64.decodestring(torrent_details['pieces']) +                    self.torrent_details_cache = torrent_details +                    self.upgrade_peerlist() +                except IndexError: +                    pass + +        elif response['tag'] == self.TAG_SESSION_STATS: +            self.status_cache.update(response['arguments']) + +        elif response['tag'] == self.TAG_SESSION_GET: +            self.status_cache.update(response['arguments']) + +        return response['tag'] + +    def upgrade_peerlist(self): +        for index,peer in enumerate(self.torrent_details_cache['peers']): +            ip = peer['address'] +            peerid = ip + self.torrent_details_cache['hashString'] + +            # make sure peer cache exists +            if not self.peer_progress_cache.has_key(peerid): +                self.peer_progress_cache[peerid] = {'last_progress':peer['progress'], 'last_update':time.time(), +                                                    'download_speed':0, 'time_left':0} + +            # estimate how fast a peer is downloading +            if peer['progress'] < 1: +                this_time = time.time() +                time_diff = this_time - self.peer_progress_cache[peerid]['last_update'] +                progress_diff = peer['progress'] - self.peer_progress_cache[peerid]['last_progress'] +                if self.peer_progress_cache[peerid]['last_progress'] and progress_diff > 0 and time_diff > 5: +                    downloaded = self.torrent_details_cache['totalSize'] * progress_diff +                    avg_speed  = downloaded / time_diff + +                    if self.peer_progress_cache[peerid]['download_speed'] > 0:  # make it less jumpy +                        avg_speed = (self.peer_progress_cache[peerid]['download_speed'] + avg_speed) /2 + +                    download_left = self.torrent_details_cache['totalSize'] - \ +                        (self.torrent_details_cache['totalSize']*peer['progress']) +                    time_left  = download_left / avg_speed + +                    self.peer_progress_cache[peerid]['last_update']    = this_time  # remember update time +                    self.peer_progress_cache[peerid]['download_speed'] = avg_speed +                    self.peer_progress_cache[peerid]['time_left']      = time_left + +                self.peer_progress_cache[peerid]['last_progress'] = peer['progress']  # remember progress +            self.torrent_details_cache['peers'][index].update(self.peer_progress_cache[peerid]) + +            # resolve and locate peer's ip +            if features['dns'] and not self.hosts_cache.has_key(ip): +                try: +                    self.hosts_cache[ip] = self.resolver.submit_reverse(ip, adns.rr.PTR) +                except adns.Error: +                    pass +            if features['geoip'] and not self.geo_ips_cache.has_key(ip): +                self.geo_ips_cache[ip] = country_code_by_addr_vany(self.geo_ip, self.geo_ip6, ip) +                if self.geo_ips_cache[ip] == None: +                    self.geo_ips_cache[ip] = '?' + +    def get_rpc_version(self): +        return self.rpc_version + +    def get_global_stats(self): +        return self.status_cache + +    def get_torrent_list(self, sort_orders): +        try: +            for sort_order in sort_orders: +                if isinstance(self.torrent_cache[0][sort_order['name']], (str, unicode)): +                    self.torrent_cache.sort(key=lambda x: x[sort_order['name']].lower(), +                                            reverse=sort_order['reverse']) +                else: +                    self.torrent_cache.sort(key=lambda x: x[sort_order['name']], +                                            reverse=sort_order['reverse']) +        except IndexError: +            return [] +        return self.torrent_cache + +    def get_torrent_by_id(self, id): +        i = 0 +        while self.torrent_cache[i]['id'] != id:  i += 1 +        if self.torrent_cache[i]['id'] == id: +            return self.torrent_cache[i] +        else: +            return None + + +    def get_torrent_details(self): +        return self.torrent_details_cache +    def set_torrent_details_id(self, id): +        if id < 0: +            self.requests['torrent-details'] = TransmissionRequest(self.host, self.port, self.path) +        else: +            self.requests['torrent-details'].set_request_data('torrent-get', self.TAG_TORRENT_DETAILS, +                                                              {'ids':id, 'fields': self.DETAIL_FIELDS}) + +    def get_hosts(self): +        return self.hosts_cache + +    def get_geo_ips(self): +        return self.geo_ips_cache + + +    def set_option(self, option_name, option_value): +        request = TransmissionRequest(self.host, self.port, self.path, 'session-set', 1, {option_name: option_value}) +        request.send_request() +        self.wait_for_status_update() + + +    def set_rate_limit(self, direction, new_limit, torrent_id=-1): +        data = dict() +        if new_limit <= -1: +            new_limit     = None +            limit_enabled = False +        else: +            limit_enabled = True + +        if torrent_id < 0: +            type = 'session-set' +            data['speed-limit-'+direction]            = new_limit +            data['speed-limit-'+direction+'-enabled'] = limit_enabled +        else: +            type = 'torrent-set' +            data['ids'] = [torrent_id] +            data[direction+'loadLimit']   = new_limit +            data[direction+'loadLimited'] = limit_enabled + +        request = TransmissionRequest(self.host, self.port, self.path, type, 1, data) +        request.send_request() +        self.wait_for_torrentlist_update() + + +    def set_seed_ratio(self, ratio, torrent_id=-1): +        data = dict() +        if ratio == -1: +            ratio = None +            mode  = 0   # Use global settings +        elif ratio == 0: +            ratio = None +            mode  = 2   # Seed regardless of ratio +        elif ratio >= 0: +            mode  = 1   # Stop seeding at seedRatioLimit +        else: +            return + +        data['ids']            = [torrent_id] +        data['seedRatioLimit'] = ratio +        data['seedRatioMode']  = mode +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, data) +        request.send_request() +        self.wait_for_torrentlist_update() + + +    def increase_bandwidth_priority(self, torrent_id): +        torrent = self.get_torrent_by_id(torrent_id) +        if torrent == None or torrent['bandwidthPriority'] >= 1: +            return False +        else: +            new_priority = torrent['bandwidthPriority'] + 1 +            request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, +                                          {'ids': [torrent_id], 'bandwidthPriority':new_priority}) +            request.send_request() +            self.wait_for_torrentlist_update() + +    def decrease_bandwidth_priority(self, torrent_id): +        torrent = self.get_torrent_by_id(torrent_id) +        if torrent == None or torrent['bandwidthPriority'] <= -1: +            return False +        else: +            new_priority = torrent['bandwidthPriority'] - 1 +            request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, +                                          {'ids': [torrent_id], 'bandwidthPriority':new_priority}) +            request.send_request() +            self.wait_for_torrentlist_update() + + +    def toggle_turtle_mode(self): +        self.set_option('alt-speed-enabled', not self.status_cache['alt-speed-enabled']) + + +    def add_torrent(self, location): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-add', 1, {'filename': location}) +        request.send_request() +        response = request.get_response() +        if response['result'] != 'success': +            return response['result'] +        else: +            return '' + +    def stop_torrent(self, id): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-stop', 1, {'ids': [id]}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def start_torrent(self, id): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-start', 1, {'ids': [id]}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def verify_torrent(self, id): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-verify', 1, {'ids': [id]}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def reannounce_torrent(self, id): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-reannounce', 1, {'ids': [id]}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def move_torrent(self, torrent_id, new_location): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set-location', 1, +                                      {'ids': torrent_id, 'location': new_location, 'move': True}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def remove_torrent(self, id): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-remove', 1, {'ids': [id]}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def remove_torrent_local_data(self, id): +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-remove', 1, {'ids': [id], 'delete-local-data':True}) +        request.send_request() +        self.wait_for_torrentlist_update() + +    def add_torrent_tracker(self, id, tracker): +        data = { 'ids' : [id], +                 'trackerAdd' : [tracker] } +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, data) +        request.send_request() +        response = request.get_response() +        return response['result'] if response['result'] != 'success' else '' + +    def remove_torrent_tracker(self, id, tracker): +        data = { 'ids' : [id], +                 'trackerRemove' : [tracker] } +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, data) +        request.send_request() +        response = request.get_response() +        self.wait_for_torrentlist_update() +        return response['result'] if response['result'] != 'success' else '' + +    def increase_file_priority(self, file_nums): +        file_nums = list(file_nums) +        ref_num = file_nums[0] +        for num in file_nums: +            if not self.torrent_details_cache['wanted'][num]: +                ref_num = num +                break +            elif self.torrent_details_cache['priorities'][num] < \ +                    self.torrent_details_cache['priorities'][ref_num]: +                ref_num = num +        current_priority = self.torrent_details_cache['priorities'][ref_num] +        if not self.torrent_details_cache['wanted'][ref_num]: +            self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'low') +        elif current_priority == -1: +            self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'normal') +        elif current_priority == 0: +            self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'high') + +    def decrease_file_priority(self, file_nums): +        file_nums = list(file_nums) +        ref_num = file_nums[0] +        for num in file_nums: +            if self.torrent_details_cache['priorities'][num] > \ +                    self.torrent_details_cache['priorities'][ref_num]: +                ref_num = num +        current_priority = self.torrent_details_cache['priorities'][ref_num] +        if current_priority >= 1: +            self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'normal') +        elif current_priority == 0: +            self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'low') +        elif current_priority == -1: +            self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'off') + + +    def set_file_priority(self, torrent_id, file_nums, priority): +        request_data = {'ids': [torrent_id]} +        if priority == 'off': +            request_data['files-unwanted'] = file_nums +        else: +            request_data['files-wanted'] = file_nums +            request_data['priority-' + priority] = file_nums +        request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, request_data) +        request.send_request() +        self.wait_for_details_update() + +    def get_file_priority(self, torrent_id, file_num): +        priority = self.torrent_details_cache['priorities'][file_num] +        if not self.torrent_details_cache['wanted'][file_num]: return 'off' +        elif priority <= -1: return 'low' +        elif priority == 0:  return 'normal' +        elif priority >= 1:  return 'high' +        return '?' + +    def wait_for_torrentlist_update(self): +        self.wait_for_update(7) +    def wait_for_details_update(self): +        self.wait_for_update(77) +    def wait_for_status_update(self): +        self.wait_for_update(22) +    def wait_for_update(self, update_id): +        self.update(0) # send request +        while True:    # wait for response +            if self.update(0, update_id): break +            time.sleep(0.1) + +    def get_status(self, torrent): +        if torrent['status'] == Transmission.STATUS_STOPPED: +            status = 'paused' +        elif torrent['status'] == Transmission.STATUS_CHECK: +            status = 'verifying' +        elif torrent['status'] == Transmission.STATUS_CHECK_WAIT: +            status = 'will verify' +        elif torrent['status'] == Transmission.STATUS_DOWNLOAD: +            status = ('idle','downloading')[torrent['rateDownload'] > 0] +        elif torrent['status'] == Transmission.STATUS_DOWNLOAD_WAIT: +            status = 'will download' +        elif torrent['status'] == Transmission.STATUS_SEED: +            status = 'seeding' +        elif torrent['status'] == Transmission.STATUS_SEED_WAIT: +            status = 'will seed' +        else: +            status = 'unknown state' +        return status + +    def get_bandwidth_priority(self, torrent): +        if torrent['bandwidthPriority'] == -1: +            return '-' +        elif torrent['bandwidthPriority'] == 0: +            return ' ' +        elif torrent['bandwidthPriority'] == 1: +            return '+' +        else: +            return '?' + +# End of Class Transmission + + + + + +# User Interface +class Interface: +    TRACKER_ITEM_HEIGHT = 6 + +    def __init__(self): +        self.filter_list    = config.get('Filtering', 'filter') +        self.filter_inverse = config.getboolean('Filtering', 'invert') +        self.sort_orders    = parse_sort_str(config.get('Sorting', 'order')) +        self.compact_list   = config.getboolean('Misc', 'compact_list') +        self.torrentname_is_progressbar = config.getboolean('Misc', 'torrentname_is_progressbar') + +        self.torrents         = server.get_torrent_list(self.sort_orders) +        self.stats            = server.get_global_stats() +        self.torrent_details  = [] +        self.selected_torrent = -1  # changes to >-1 when focus >-1 & user hits return +        self.all_paused       = False +        self.highlight_dialog = False +        self.search_focus = 0   # like self.focus but for searches in torrent list +        self.focused_id   = -1  # the id (provided by Transmission) of self.torrents[self.focus] +        self.focus        = -1  # -1: nothing focused; 0: top of list; <# of torrents>-1: bottom of list +        self.scrollpos    = 0   # start of torrentlist +        self.torrents_per_page  = 0 # will be set by manage_layout() +        self.rateDownload_width = self.rateUpload_width = 2 + +        self.details_category_focus = 0  # overview/files/peers/tracker in details +        self.focus_detaillist       = -1 # same as focus but for details +        self.selected_files         = [] # marked files in details +        self.scrollpos_detaillist   = 0  # same as scrollpos but for details +        self.compact_torrentlist    = False # draw only one line for each torrent in compact mode +        self.exit_now               = False + +        self.keybindings = { +            ord('?'):               self.call_list_key_bindings, +            curses.KEY_F1:          self.call_list_key_bindings, +            27:                     self.go_back_or_unfocus, +            curses.KEY_BREAK:       self.go_back_or_unfocus, +            12:                     self.go_back_or_unfocus, +            curses.KEY_BACKSPACE:   self.leave_details, +            ord('q'):               self.go_back_or_quit, +            ord('o'):               self.o_key, +            ord('\n'):              self.select_torrent_detail_view, +            curses.KEY_RIGHT:       self.right_key, +            ord('l'):               self.l_key, +            ord('s'):               self.show_sort_order_menu, +            ord('f'):               self.f_key, +            ord('u'):               self.global_upload, +            ord('d'):               self.global_download, +            ord('U'):               self.torrent_upload, +            ord('D'):               self.torrent_download, +            ord('L'):               self.seed_ratio, +            ord('t'):               self.t_key, +            ord('+'):               self.bandwidth_priority, +            ord('-'):               self.bandwidth_priority, +            ord('p'):               self.pause_unpause_torrent, +            ord('P'):               self.pause_unpause_all_torrent, +            ord('v'):               self.verify_torrent, +            ord('y'):               self.verify_torrent, +            ord('r'):               self.r_key, +            curses.KEY_DC:          self.r_key, +            ord('R'):               self.remove_torrent_local_data, +            curses.KEY_SDC:         self.remove_torrent_local_data, +            curses.KEY_UP:          self.movement_keys, +            ord('k'):               self.movement_keys, +            curses.KEY_DOWN:        self.movement_keys, +            ord('j'):               self.movement_keys, +            curses.KEY_PPAGE:       self.movement_keys, +            curses.KEY_NPAGE:       self.movement_keys, +            curses.KEY_HOME:        self.movement_keys, +            curses.KEY_END:         self.movement_keys, +            ord('g'):               self.movement_keys, +            ord('G'):               self.movement_keys, +            curses.ascii.ctrl(ord('f')): self.movement_keys, +            curses.ascii.ctrl(ord('b')): self.movement_keys, +            curses.ascii.ctrl(ord('n')): self.movement_keys, +            curses.ascii.ctrl(ord('p')): self.movement_keys, +            ord("\t"):              self.move_in_details, +            curses.KEY_BTAB:        self.move_in_details, +            ord('e'):               self.move_in_details, +            ord('c'):               self.move_in_details, +            ord('C'):               self.toggle_compact_torrentlist, +            ord('h'):               self.file_pritority_or_switch_details, +            curses.KEY_LEFT:        self.file_pritority_or_switch_details, +            ord(' '):               self.space_key, +            ord('a'):               self.a_key, +            ord('m'):               self.move_torrent, +            ord('n'):               self.reannounce_torrent, +            ord('/'):               self.dialog_search_torrentlist +        } + +        self.sort_options = [ +            ('name','_Name'), ('addedDate','_Age'), ('percentDone','_Progress'), +            ('seeders','_Seeds'), ('leechers','Lee_ches'), ('sizeWhenDone', 'Si_ze'), +            ('status','S_tatus'), ('uploadedEver','Up_loaded'), +            ('rateUpload','_Upload Speed'), ('rateDownload','_Download Speed'), +            ('uploadRatio','_Ratio'), ('peersConnected','P_eers'), +            ('downloadDir', 'L_ocation'), ('reverse','Re_verse') +        ] + + +        try: +            self.init_screen() +            self.run() +        except: +            self.restore_screen() +            (exc_type, exc_value, exc_traceback) = sys.exc_info() +            raise exc_type, exc_value, exc_traceback +        else: +            self.restore_screen() + + +    def init_screen(self): +        os.environ['ESCDELAY'] = '0' # make escape usable +        self.screen = curses.initscr() +        curses.noecho() ; curses.cbreak() ; self.screen.keypad(1) +        curses.halfdelay(10) # STDIN timeout + +        hide_cursor() + +        # enable colors if available +        try: +            curses.start_color() +            self.colors = ColorManager(dict(config.items('Colors'))) +            for name in sorted(self.colors.get_names()): +                curses.init_pair(self.colors.get_id(name), +                                 self.colors.get_fg(name), +                                 self.colors.get_bg(name)) +        except: +            pass + +        # http://bugs.python.org/issue2675 +        try: +            del os.environ['LINES'] +            del os.environ['COLUMNS'] +        except: +            pass + +        # http://bugs.python.org/issue2675 +        try: +            del os.environ['LINES'] +            del os.environ['COLUMNS'] +        except: +            pass + +        signal.signal(signal.SIGWINCH, lambda y,frame: self.get_screen_size()) +        self.get_screen_size() + +    def restore_screen(self): +        curses.endwin() + +    def get_screen_size(self): +        time.sleep(0.1) # prevents curses.error on rapid resizing +        while True: +            curses.endwin() +            self.screen.refresh() +            self.height, self.width = self.screen.getmaxyx() +            # Tracker list breaks if width smaller than 73 +            if self.width < 73 or self.height < 16: +                self.screen.erase() +                self.screen.addstr(0,0, "Terminal too small", curses.A_REVERSE + curses.A_BOLD) +                time.sleep(1) +            else: +                break +        self.manage_layout() + +    def manage_layout(self): +        self.tlist_item_height = 3 if not self.compact_list else 1 +        self.pad_height = max((len(self.torrents)+1) * self.tlist_item_height, self.height) +        self.pad = curses.newpad(self.pad_height, self.width) +        self.mainview_height = self.height - 2 +        self.torrents_per_page = self.mainview_height / self.tlist_item_height +        self.detaillistitems_per_page = self.height - 8 + +        if self.selected_torrent > -1: +            self.rateDownload_width = self.get_rateDownload_width([self.torrent_details]) +            self.rateUpload_width   = self.get_rateUpload_width([self.torrent_details]) +            self.torrent_title_width = self.width - self.rateUpload_width - 2 +            # show downloading column only if torrents is downloading +            if self.torrent_details['status'] == Transmission.STATUS_DOWNLOAD: +                self.torrent_title_width -= self.rateDownload_width + 2 + +        elif self.torrents: +            visible_torrents = self.torrents[self.scrollpos/self.tlist_item_height : self.scrollpos/self.tlist_item_height + self.torrents_per_page + 1] +            self.rateDownload_width = self.get_rateDownload_width(visible_torrents) +            self.rateUpload_width   = self.get_rateUpload_width(visible_torrents) + +            self.torrent_title_width = self.width - self.rateUpload_width - 2 +            # show downloading column only if any downloading torrents are visible +            if filter(lambda x: x['status']==Transmission.STATUS_DOWNLOAD, visible_torrents): +                self.torrent_title_width -= self.rateDownload_width + 2 +        else: +            self.torrent_title_width = 80 + +    def get_rateDownload_width(self, torrents): +        new_width = max(map(lambda x: len(scale_bytes(x['rateDownload'])), torrents)) +        new_width = max(max(map(lambda x: len(scale_time(x['eta'])), torrents)), new_width) +        new_width = max(len(scale_bytes(self.stats['downloadSpeed'])), new_width) +        new_width = max(self.rateDownload_width, new_width) # don't shrink +        return new_width + +    def get_rateUpload_width(self, torrents): +        new_width = max(map(lambda x: len(scale_bytes(x['rateUpload'])), torrents)) +        new_width = max(max(map(lambda x: len(num2str(x['uploadRatio'], '%.02f')), torrents)), new_width) +        new_width = max(len(scale_bytes(self.stats['uploadSpeed'])), new_width) +        new_width = max(self.rateUpload_width, new_width) # don't shrink +        return new_width + + +    def run(self): +        self.draw_title_bar() +        self.draw_stats() +        self.draw_torrent_list() + +        while True: +            server.update(1) + +            # display torrentlist +            if self.selected_torrent == -1: +                self.draw_torrent_list() + +            # display some torrent's details +            else: +                self.draw_details() + +            self.stats = server.get_global_stats() +            self.draw_title_bar()  # show shortcuts and stuff +            self.draw_stats()      # show global states +            self.screen.move(0,0)  # in case cursor can't be invisible +            self.handle_user_input() +            if self.exit_now: +                sort_str = ','.join(map(lambda x: ('','reverse:')[x['reverse']] + x['name'], self.sort_orders)) +                config.set('Sorting', 'order',   sort_str) +                config.set('Filtering', 'filter', self.filter_list) +                config.set('Filtering', 'invert', str(self.filter_inverse)) +                config.set('Misc', 'compact_list', str(self.compact_list)) +                config.set('Misc', 'torrentname_is_progressbar', str(self.torrentname_is_progressbar)) +                save_config(cmd_args.configfile) +                return + +    def go_back_or_unfocus(self, c): +        if self.focus_detaillist > -1:   # unfocus and deselect file +            self.focus_detaillist     = -1 +            self.scrollpos_detaillist = 0 +            self.selected_files       = [] +        elif self.selected_torrent > -1: # return from details +            self.details_category_focus = 0 +            self.selected_torrent = -1 +            self.selected_files   = [] +        else: +            if self.focus > -1: +                self.scrollpos = 0    # unfocus main list +                self.focus     = -1 +            elif self.filter_list: +                self.filter_list = '' # reset filter + +    def leave_details(self, c): +        if self.selected_torrent > -1: +            server.set_torrent_details_id(-1) +            self.selected_torrent       = -1 +            self.details_category_focus = 0 +            self.scrollpos_detaillist   = 0 +            self.selected_files         = [] + +    def go_back_or_quit(self, c): +        if self.selected_torrent == -1: +            self.exit_now = True +        else: # return to list view +            server.set_torrent_details_id(-1) +            self.selected_torrent       = -1 +            self.details_category_focus = 0 +            self.focus_detaillist       = -1 +            self.scrollpos_detaillist   = 0 +            self.selected_files         = [] + +    def space_key(self, c): +        # File list +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.select_unselect_file(c) +        # Torrent list +        elif self.selected_torrent == -1: +            self.select_torrent_detail_view(c) + +    def a_key(self, c): +        # File list +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.select_unselect_file(c) +        # Trackers +        elif self.selected_torrent > -1 and self.details_category_focus == 3: +            self.add_tracker() + +        # Do nothing in other detail tabs +        elif self.selected_torrent > -1: +            pass +        else: +            self.add_torrent() + +    def o_key(self, c): +        if self.selected_torrent == -1: +            self.draw_options_dialog() +        elif self.selected_torrent > -1: +            self.details_category_focus = 0 + +    def l_key(self, c): +        if self.focus > -1 and self.selected_torrent == -1: +            self.select_torrent_detail_view(c) +        elif self.selected_torrent > -1: +            self.file_pritority_or_switch_details(c) + +    def t_key(self, c): +        if self.selected_torrent == -1: +            server.toggle_turtle_mode() +        elif self.selected_torrent > -1: +            self.details_category_focus = 3 + +    def f_key(self, c): +        if self.selected_torrent == -1: +            self.show_state_filter_menu(c) +        elif self.selected_torrent > -1: +            self.details_category_focus = 1 + +    def r_key(self, c): +        # Torrent list +        if self.selected_torrent == -1: +            self.remove_torrent(c) +        # Trackers +        elif self.selected_torrent > -1 and self.details_category_focus == 3: +            self.remove_tracker() + +    def right_key(self, c): +        if self.focus > -1 and self.selected_torrent == -1: +            self.select_torrent_detail_view(c) +        else: +            self.file_pritority_or_switch_details(c) + +    def add_torrent(self): +        location = self.dialog_input_text("Add torrent from file or URL", os.getcwd()) +        if location: +            error = server.add_torrent(location) +            if error: +                msg = wrap("Couldn't add torrent \"%s\":" % location) +                msg.extend(wrap(error, self.width-4)) +                self.dialog_ok("\n".join(msg)) + +    def select_torrent_detail_view(self, c): +        if self.focus > -1 and self.selected_torrent == -1: +            self.screen.clear() +            self.selected_torrent = self.focus +            server.set_torrent_details_id(self.torrents[self.focus]['id']) +            server.wait_for_details_update() + +    def show_sort_order_menu(self, c): +        if self.selected_torrent == -1: +           choice = self.dialog_menu('Sort order', self.sort_options, +                                     map(lambda x: x[0]==self.sort_orders[-1]['name'], self.sort_options).index(True)+1) +           if choice != -128: +               if choice == 'reverse': +                   self.sort_orders[-1]['reverse'] = not self.sort_orders[-1]['reverse'] +               else: +                   self.sort_orders.append({'name':choice, 'reverse':False}) +                   while len(self.sort_orders) > 2: +                       self.sort_orders.pop(0) + +    def show_state_filter_menu(self, c): +        if self.selected_torrent == -1: +            options = [('uploading','_Uploading'), ('downloading','_Downloading'), +                       ('active','Ac_tive'), ('paused','_Paused'), ('seeding','_Seeding'), +                       ('incomplete','In_complete'), ('verifying','Verif_ying'), +                       ('invert','In_vert'), ('','_All')] +            choice = self.dialog_menu(('Show only','Filter all')[self.filter_inverse], options, +                                      map(lambda x: x[0]==self.filter_list, options).index(True)+1) +            if choice != -128: +                if choice == 'invert': +                    self.filter_inverse = not self.filter_inverse +                else: +                    if choice == '': +                        self.filter_inverse = False +                    self.filter_list = choice + +    def global_upload(self, c): +       current_limit = (-1,self.stats['speed-limit-up'])[self.stats['speed-limit-up-enabled']] +       limit = self.dialog_input_number("Global upload limit in kilobytes per second", current_limit) +       if limit == -128: +           return  +       server.set_rate_limit('up', limit) + +    def global_download(self, c): +       current_limit = (-1,self.stats['speed-limit-down'])[self.stats['speed-limit-down-enabled']] +       limit = self.dialog_input_number("Global download limit in kilobytes per second", current_limit) +       if limit == -128: +           return  +       server.set_rate_limit('down', limit) + +    def torrent_upload(self, c): +        if self.focus > -1: +            current_limit = (-1,self.torrents[self.focus]['uploadLimit'])[self.torrents[self.focus]['uploadLimited']] +            limit = self.dialog_input_number("Upload limit in kilobytes per second for\n%s" % \ +                                                 self.torrents[self.focus]['name'], current_limit) +            if limit == -128: +                return  +            server.set_rate_limit('up', limit, self.torrents[self.focus]['id']) + +    def torrent_download(self, c): +        if self.focus > -1: +            current_limit = (-1,self.torrents[self.focus]['downloadLimit'])[self.torrents[self.focus]['downloadLimited']] +            limit = self.dialog_input_number("Download limit in Kilobytes per second for\n%s" % \ +                                                 self.torrents[self.focus]['name'], current_limit) +            if limit == -128: +                return  +            server.set_rate_limit('down', limit, self.torrents[self.focus]['id']) + +    def seed_ratio(self, c): +        if self.focus > -1: +            if self.torrents[self.focus]['seedRatioMode'] == 0:   # Use global settings +                current_limit = '' +            elif self.torrents[self.focus]['seedRatioMode'] == 1: # Stop seeding at seedRatioLimit +                current_limit = self.torrents[self.focus]['seedRatioLimit'] +            elif self.torrents[self.focus]['seedRatioMode'] == 2: # Seed regardless of ratio +                current_limit = -1 +            limit = self.dialog_input_number("Seed ratio limit for\n%s" % self.torrents[self.focus]['name'], +                                             current_limit, floating_point=True, allow_empty=True) +            if limit == -1: +                limit = 0 +            if limit == -2: # -2 means 'empty' in dialog_input_number return codes +                limit = -1 +            server.set_seed_ratio(float(limit), self.torrents[self.focus]['id']) + +    def bandwidth_priority(self, c): +        if c == ord('-') and self.focus > -1: +            server.decrease_bandwidth_priority(self.torrents[self.focus]['id']) +        elif c == ord('+') and self.focus > -1: +            server.increase_bandwidth_priority(self.torrents[self.focus]['id']) + +    def pause_unpause_torrent(self, c): +        if self.focus > -1: +            if self.selected_torrent > -1: +                t = self.torrent_details +            else: +                t = self.torrents[self.focus] +            if t['status'] == Transmission.STATUS_STOPPED: +                server.start_torrent(t['id']) +            else: +                server.stop_torrent(t['id']) + +    def pause_unpause_all_torrent(self, c): +        if self.all_paused: +            for t in self.torrents: +                server.start_torrent(t['id']) +            self.all_paused = False +        else: +            for t in self.torrents: +                server.stop_torrent(t['id']) +            self.all_paused = True + +    def verify_torrent(self, c): +        if self.focus > -1: +            if self.torrents[self.focus]['status'] != Transmission.STATUS_CHECK \ +           and self.torrents[self.focus]['status'] != Transmission.STATUS_CHECK_WAIT: +                server.verify_torrent(self.torrents[self.focus]['id']) + +    def reannounce_torrent(self, c): +        if self.focus > -1: +            server.reannounce_torrent(self.torrents[self.focus]['id']) + +    def remove_torrent(self, c): +        if self.focus > -1: +            name = self.torrents[self.focus]['name'][0:self.width - 15] +            if self.dialog_yesno("Remove %s?" % name) == True: +                if self.selected_torrent > -1:  # leave details +                    server.set_torrent_details_id(-1) +                    self.selected_torrent = -1 +                    self.details_category_focus = 0 +                server.remove_torrent(self.torrents[self.focus]['id']) + +    def remove_torrent_local_data(self, c): +        if self.focus > -1: +            name = self.torrents[self.focus]['name'][0:self.width - 15] +            if self.dialog_yesno("Remove and delete %s?" % name, important=True) == True: +                if self.selected_torrent > -1:  # leave details +                    server.set_torrent_details_id(-1) +                    self.selected_torrent = -1 +                    self.details_category_focus = 0 +                server.remove_torrent_local_data(self.torrents[self.focus]['id']) + +    def add_tracker(self): +        if server.get_rpc_version() < 10: +            self.dialog_ok("You need Transmission v2.10 or higher to add trackers.") +            return + +        tracker = self.dialog_input_text('Add tracker URL:') +        if tracker: +            t = self.torrent_details +            response = server.add_torrent_tracker(t['id'], tracker) + +            if response: +                msg = wrap("Couldn't add tracker: %s" % response) +                self.dialog_ok("\n".join(msg)) + +    def remove_tracker(self): +        if server.get_rpc_version() < 10: +            self.dialog_ok("You need Transmission v2.10 or higher to remove trackers.") +            return + +        t = self.torrent_details +        if (self.scrollpos_detaillist >= 0 and \ +            self.scrollpos_detaillist < len(t['trackerStats']) and \ +            self.dialog_yesno("Do you want to remove this tracker?") is True): + +            tracker = t['trackerStats'][self.scrollpos_detaillist] +            response = server.remove_torrent_tracker(t['id'], tracker['id']) + +            if response: +                msg = wrap("Couldn't remove tracker: %s" % response) +                self.dialog_ok("\n".join(msg)) + +    def movement_keys(self, c): +        if self.selected_torrent == -1 and len(self.torrents) > 0: +            if   c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')): +                self.focus, self.scrollpos = self.move_up(self.focus, self.scrollpos, self.tlist_item_height) +            elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')): +                self.focus, self.scrollpos = self.move_down(self.focus, self.scrollpos, self.tlist_item_height, +                                                            self.torrents_per_page, len(self.torrents)) +            elif c == curses.KEY_PPAGE or c == curses.ascii.ctrl(ord('b')): +                self.focus, self.scrollpos = self.move_page_up(self.focus, self.scrollpos, self.tlist_item_height, +                                                               self.torrents_per_page) +            elif c == curses.KEY_NPAGE or c == curses.ascii.ctrl(ord('f')): +                self.focus, self.scrollpos = self.move_page_down(self.focus, self.scrollpos, self.tlist_item_height, +                                                                 self.torrents_per_page, len(self.torrents)) +            elif c == curses.KEY_HOME or c == ord('g'): +                self.focus, self.scrollpos = self.move_to_top() +            elif c == curses.KEY_END or c == ord('G'): +                self.focus, self.scrollpos = self.move_to_end(self.tlist_item_height, self.torrents_per_page, len(self.torrents)) +            self.focused_id = self.torrents[self.focus]['id'] +        elif self.selected_torrent > -1: +            # file list +            if self.details_category_focus == 1: +                # focus/movement +                if c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')): +                    self.focus_detaillist, self.scrollpos_detaillist = \ +                        self.move_up(self.focus_detaillist, self.scrollpos_detaillist, 1) +                elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')): +                    self.focus_detaillist, self.scrollpos_detaillist = \ +                        self.move_down(self.focus_detaillist, self.scrollpos_detaillist, 1, +                                       self.detaillistitems_per_page, len(self.torrent_details['files'])) +                elif c == curses.KEY_PPAGE or c == curses.ascii.ctrl(ord('b')): +                    self.focus_detaillist, self.scrollpos_detaillist = \ +                        self.move_page_up(self.focus_detaillist, self.scrollpos_detaillist, 1, +                                          self.detaillistitems_per_page) +                elif c == curses.KEY_NPAGE or c == curses.ascii.ctrl(ord('f')): +                    self.focus_detaillist, self.scrollpos_detaillist = \ +                        self.move_page_down(self.focus_detaillist, self.scrollpos_detaillist, 1, +                                            self.detaillistitems_per_page, len(self.torrent_details['files'])) +                elif c == curses.KEY_HOME or c == ord('g'): +                    self.focus_detaillist, self.scrollpos_detaillist = self.move_to_top() +                elif c == curses.KEY_END or c == ord('G'): +                    self.focus_detaillist, self.scrollpos_detaillist = \ +                        self.move_to_end(1, self.detaillistitems_per_page, len(self.torrent_details['files'])) +            list_len = 0 + +            # peer list movement +            if self.details_category_focus == 2: +                list_len = len(self.torrent_details['peers']) + +            # tracker list movement +            elif self.details_category_focus == 3: +                list_len = len(self.torrent_details['trackerStats']) + +            # pieces list movement +            elif self.details_category_focus == 4: +                piece_count = self.torrent_details['pieceCount'] +                margin = len(str(piece_count)) + 2 +                map_width = int(str(self.width-margin-1)[0:-1] + '0') +                list_len = int(piece_count / map_width) + 1 + +            if list_len: +                if c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')): +                    if self.scrollpos_detaillist > 0: +                        self.scrollpos_detaillist -= 1 +                elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')): +                    if self.scrollpos_detaillist < list_len - 1: +                        self.scrollpos_detaillist += 1 +                elif c == curses.KEY_PPAGE or c == curses.ascii.ctrl(ord('b')): +                    self.scrollpos_detaillist = \ +                        max(self.scrollpos_detaillist - self.detaillistitems_per_page - 1, 0) +                elif c == curses.KEY_NPAGE or c == curses.ascii.ctrl(ord('f')): +                    if self.scrollpos_detaillist + self.detaillistitems_per_page >= list_len: +                        self.scrollpos_detaillist = list_len - 1 +                    else: +                        self.scrollpos_detaillist += self.detaillistitems_per_page +                elif c == curses.KEY_HOME or c == ord('g'): +                    self.scrollpos_detaillist = 0 +                elif c == curses.KEY_END or c == ord('G'): +                    self.scrollpos_detaillist = list_len - 1 + +            # Disallow scrolling past the last item that would cause blank +            # space to be displayed in pieces and peer lists. +            if self.details_category_focus in (2, 4): +                self.scrollpos_detaillist = min(self.scrollpos_detaillist, +                    max(0, list_len - self.detaillistitems_per_page)) + +    def file_pritority_or_switch_details(self, c): +        if self.selected_torrent > -1: +            # file priority OR walk through details +            if c == curses.KEY_RIGHT or c == ord('l'): +                if self.details_category_focus == 1 and \ +                        (self.selected_files or self.focus_detaillist > -1): +                    if self.selected_files: +                        files = set(self.selected_files) +                        server.increase_file_priority(files) +                    elif self.focus_detaillist > -1: +                        server.increase_file_priority([self.focus_detaillist]) +                else: +                    self.scrollpos_detaillist = 0 +                    self.next_details() +            elif c == curses.KEY_LEFT or c == ord('h'): +                if self.details_category_focus == 1 and \ +                        (self.selected_files or self.focus_detaillist > -1): +                    if self.selected_files: +                        files = set(self.selected_files) +                        server.decrease_file_priority(files) +                    elif self.focus_detaillist > -1: +                        server.decrease_file_priority([self.focus_detaillist]) +                else: +                    self.scrollpos_detaillist = 0 +                    self.prev_details() + +    def select_unselect_file(self, c): +        if self.selected_torrent > -1 and self.details_category_focus == 1 and self.focus_detaillist >= 0: +            # file selection with space +            if c == ord(' '): +                try: +                    self.selected_files.pop(self.selected_files.index(self.focus_detaillist)) +                except ValueError: +                    self.selected_files.append(self.focus_detaillist) +                curses.ungetch(curses.KEY_DOWN) # move down +            # (un)select all files +            elif c == ord('a'): +                if self.selected_files: +                    self.selected_files = [] +                else: +                    self.selected_files = range(0, len(self.torrent_details['files'])) + +    def move_in_details(self, c): +        if self.selected_torrent > -1: +            if c == ord("\t"): +                self.next_details() +            elif c == curses.KEY_BTAB: +                self.prev_details() +            elif c == ord('e'): +                self.details_category_focus = 2 +            elif c == ord('c'): +                self.details_category_focus = 4 + +    def call_list_key_bindings(self, c): +        self.list_key_bindings() + +    def toggle_compact_torrentlist(self, c): +        self.compact_list = not self.compact_list + +    def move_torrent(self, c): +        if self.focus > -1: +            location = homedir2tilde(self.torrents[self.focus]['downloadDir']) +            msg = 'Move "%s" from\n%s to' % (self.torrents[self.focus]['name'], location) +            path = self.dialog_input_text(msg, location) +            if path: +                server.move_torrent(self.torrents[self.focus]['id'], tilde2homedir(path)) + +    def handle_user_input(self): +        c = self.screen.getch() +        if c == -1: +            return 0 + +        f = self.keybindings.get(c, None) +        if f: +            f(c) + +        # update view +        if self.selected_torrent == -1: +            self.draw_torrent_list() +        else: +            self.draw_details() + +    def filter_torrent_list(self): +        unfiltered = self.torrents +        if self.filter_list == 'downloading': +            self.torrents = [t for t in self.torrents if t['rateDownload'] > 0] +        elif self.filter_list == 'uploading': +            self.torrents = [t for t in self.torrents if t['rateUpload'] > 0] +        elif self.filter_list == 'paused': +            self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_STOPPED] +        elif self.filter_list == 'seeding': +            self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_SEED \ +                                 or t['status'] == Transmission.STATUS_SEED_WAIT] +        elif self.filter_list == 'incomplete': +            self.torrents = [t for t in self.torrents if t['percentDone'] < 100] +        elif self.filter_list == 'active': +            self.torrents = [t for t in self.torrents if t['peersGettingFromUs'] > 0 \ +                                 or t['peersSendingToUs'] > 0 \ +                                 or t['status'] == Transmission.STATUS_CHECK] +        elif self.filter_list == 'verifying': +            self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_CHECK \ +                                 or t['status'] == Transmission.STATUS_CHECK_WAIT] +        # invert list? +        if self.filter_inverse: +            self.torrents = [t for t in unfiltered if t not in self.torrents] + +    def follow_list_focus(self): +        if self.focus == -1: +            return + +        # check if list is empty or id to look for isn't in list +        ids = [t['id'] for t in self.torrents] +        if len(self.torrents) == 0 or self.focused_id not in ids: +            self.focus, self.scrollpos = -1, 0 +            return + +        # find focused_id +        self.focus = min(self.focus, len(self.torrents)-1) +        if self.torrents[self.focus]['id'] != self.focused_id: +            for i,t in enumerate(self.torrents): +                if t['id'] == self.focused_id: +                    self.focus = i +                    break + +        # make sure the focus is not above the visible area +        while self.focus < (self.scrollpos/self.tlist_item_height): +            self.scrollpos -= self.tlist_item_height +        # make sure the focus is not below the visible area +        while self.focus > (self.scrollpos/self.tlist_item_height) + self.torrents_per_page-1: +            self.scrollpos += self.tlist_item_height +        # keep min and max bounds +        self.scrollpos = min(self.scrollpos, (len(self.torrents) - self.torrents_per_page) * self.tlist_item_height) +        self.scrollpos = max(0, self.scrollpos) + +    def draw_torrent_list(self, search_keyword=''): +        self.torrents = server.get_torrent_list(self.sort_orders) +        self.filter_torrent_list() + +        if search_keyword: +            matched_torrents = [t for t in self.torrents if search_keyword.lower() in t['name'].lower()] +            if matched_torrents: +                self.focus = 0 +                if self.search_focus >= len(matched_torrents): +                    self.search_focus = 0 +                self.focused_id = matched_torrents[self.search_focus]['id'] +                self.highlight_dialog = False +            else: +                self.highlight_dialog = True +                curses.beep() +        else: +            self.search_focus = 0 + +        self.follow_list_focus() +        self.manage_layout() + +        ypos = 0 +        for i in range(len(self.torrents)): +            ypos += self.draw_torrentlist_item(self.torrents[i], +                                               (i == self.focus), +                                               self.compact_list, +                                               ypos) + +        self.pad.refresh(self.scrollpos,0, 1,0, self.mainview_height,self.width-1) +        self.screen.refresh() + + +    def draw_torrentlist_item(self, torrent, focused, compact, y): +        # the torrent name is also a progress bar +        self.draw_torrentlist_title(torrent, focused, self.torrent_title_width, y) + +        rates = '' +        if torrent['status'] == Transmission.STATUS_DOWNLOAD: +            self.draw_downloadrate(torrent, y) +        if torrent['status'] == Transmission.STATUS_DOWNLOAD or torrent['status'] == Transmission.STATUS_SEED: +            self.draw_uploadrate(torrent, y) + +        if not compact: +            # the line below the title/progress +            if torrent['percentDone'] < 100 and torrent['status'] == Transmission.STATUS_DOWNLOAD: +                self.draw_eta(torrent, y) + +            self.draw_ratio(torrent, y) +            self.draw_torrentlist_status(torrent, focused, y) + +            return 3 # number of lines that were used for drawing the list item +        else: +            # Draw ratio in place of upload rate if upload rate = 0 +            if not torrent['rateUpload']: +                self.draw_ratio(torrent, y - 1) + +            return 1 + +    def draw_downloadrate(self, torrent, ypos): +        self.pad.move(ypos, self.width-self.rateDownload_width-self.rateUpload_width-3) +        self.pad.addch(curses.ACS_DARROW, (0,curses.A_BOLD)[torrent['downloadLimited']]) +        rate = ('',scale_bytes(torrent['rateDownload']))[torrent['rateDownload']>0] +        self.pad.addstr(rate.rjust(self.rateDownload_width), +                        curses.color_pair(self.colors.get_id('download_rate')) + curses.A_BOLD + curses.A_REVERSE) +    def draw_uploadrate(self, torrent, ypos): +        self.pad.move(ypos, self.width-self.rateUpload_width-1) +        self.pad.addch(curses.ACS_UARROW, (0,curses.A_BOLD)[torrent['uploadLimited']]) +        rate = ('',scale_bytes(torrent['rateUpload']))[torrent['rateUpload']>0] +        self.pad.addstr(rate.rjust(self.rateUpload_width), +                        curses.color_pair(self.colors.get_id('upload_rate')) + curses.A_BOLD + curses.A_REVERSE) +    def draw_ratio(self, torrent, ypos): +        self.pad.addch(ypos+1, self.width-self.rateUpload_width-1, curses.ACS_DIAMOND, +                       (0,curses.A_BOLD)[torrent['uploadRatio'] < 1 and torrent['uploadRatio'] >= 0]) +        self.pad.addstr(ypos+1, self.width-self.rateUpload_width, +                        num2str(torrent['uploadRatio'], '%.02f').rjust(self.rateUpload_width), +                        curses.color_pair(self.colors.get_id('eta+ratio')) + curses.A_BOLD + curses.A_REVERSE) +    def draw_eta(self, torrent, ypos): +        self.pad.addch(ypos+1, self.width-self.rateDownload_width-self.rateUpload_width-3, curses.ACS_PLMINUS) +        self.pad.addstr(ypos+1, self.width-self.rateDownload_width-self.rateUpload_width-2, +                        scale_time(torrent['eta']).rjust(self.rateDownload_width), +                        curses.color_pair(self.colors.get_id('eta+ratio')) + curses.A_BOLD + curses.A_REVERSE) + + +    def draw_torrentlist_title(self, torrent, focused, width, ypos): +        if torrent['status'] == Transmission.STATUS_CHECK: +            percentDone = float(torrent['recheckProgress']) * 100 +        else: +            percentDone = torrent['percentDone'] + +        bar_width = int(float(width) * (float(percentDone)/100)) + +        size = "%6s" % scale_bytes(torrent['sizeWhenDone']) +        if torrent['percentDone'] < 100: +            if torrent['seeders'] <= 0 and torrent['status'] != Transmission.STATUS_CHECK: +                size = "%6s / " % scale_bytes(torrent['available']) + size +            size = "%6s / " % scale_bytes(torrent['haveValid'] + torrent['haveUnchecked']) + size +        size = '| ' + size +        title = ljust_columns(torrent['name'], width - len(size)) + size + +        if torrent['status'] == Transmission.STATUS_SEED \ +        or torrent['status'] == Transmission.STATUS_SEED_WAIT: +            color = curses.color_pair(self.colors.get_id('title_seed')) +        elif torrent['status'] == Transmission.STATUS_STOPPED: +            color = curses.color_pair(self.colors.get_id('title_paused')) +        elif torrent['status'] == Transmission.STATUS_CHECK \ +          or torrent['status'] == Transmission.STATUS_CHECK_WAIT: +            color = curses.color_pair(self.colors.get_id('title_verify')) +        elif torrent['rateDownload'] == 0: +            color = curses.color_pair(self.colors.get_id('title_idle')) +        elif torrent['percentDone'] < 100: +            color = curses.color_pair(self.colors.get_id('title_download')) +        else: +            color = 0 + +        tag = curses.A_REVERSE +        tag_done = tag + color +        if focused: +            tag += curses.A_BOLD +            tag_done += curses.A_BOLD + +        if self.torrentname_is_progressbar: +            # addstr() dies when you tell it to draw on the last column of the +            # terminal, so we have to catch this exception. +            try: +                self.pad.addstr(ypos, 0, title[0:bar_width].encode('utf-8'), tag_done) +                self.pad.addstr(ypos, bar_width, title[bar_width:].encode('utf-8'), tag) +            except: +                pass +        else: +            self.pad.addstr(ypos, 0, title.encode('utf-8'), tag_done) + + +    def draw_torrentlist_status(self, torrent, focused, ypos): +        peers = '' +        parts = [server.get_status(torrent)] + +        # show tracker error if appropriate +        if torrent['errorString'] and \ +                not torrent['seeders'] and not torrent['leechers'] and \ +                not torrent['status'] == Transmission.STATUS_STOPPED: +            parts[0] = torrent['errorString'].encode('utf-8') + +        else: +            if torrent['status'] == Transmission.STATUS_CHECK: +                parts[0] += " (%d%%)" % int(float(torrent['recheckProgress']) * 100) +            elif torrent['status'] == Transmission.STATUS_DOWNLOAD: +                parts[0] += " (%d%%)" % torrent['percentDone'] +            parts[0] = parts[0].ljust(20) + +            # seeds and leeches will be appended right justified later +            peers  = "%5s seed%s " % (num2str(torrent['seeders']), ('s', ' ')[torrent['seeders']==1]) +            peers += "%5s leech%s" % (num2str(torrent['leechers']), ('es', '  ')[torrent['leechers']==1]) + +            # show additional information if enough room +            if self.torrent_title_width - sum(map(lambda x: len(x), parts)) - len(peers) > 18: +                uploaded = scale_bytes(torrent['uploadedEver']) +                parts.append("%7s uploaded" % ('nothing',uploaded)[uploaded != '0B']) + +            if self.torrent_title_width - sum(map(lambda x: len(x), parts)) - len(peers) > 22: +                parts.append("%4s peer%s connected" % (torrent['peersConnected'], +                                                       ('s',' ')[torrent['peersConnected'] == 1])) + +        if focused: tags = curses.A_REVERSE + curses.A_BOLD +        else:       tags = 0 + +        remaining_space = self.torrent_title_width - sum(map(lambda x: len(x), parts), len(peers)) - 2 +        delimiter = ' ' * int(remaining_space / (len(parts))) + +        line = server.get_bandwidth_priority(torrent) + ' ' + delimiter.join(parts) + +        # make sure the peers element is always right justified +        line += ' ' * int(self.torrent_title_width - len(line) - len(peers)) + peers +        self.pad.addstr(ypos+1, 0, line, tags) + + +    def draw_details(self): +        self.torrent_details = server.get_torrent_details() +        self.manage_layout() + +        # details could need more space than the torrent list +        self.pad_height = max(50, len(self.torrent_details['files'])+10, (len(self.torrents)+1)*3, self.height) +        self.pad = curses.newpad(self.pad_height, self.width) + +        # torrent name + progress bar +        self.draw_torrentlist_item(self.torrent_details, False, False, 0) + +        # divider + menu +        menu_items = ['_Overview', "_Files", 'P_eers', '_Trackers', 'Pie_ces' ] +        xpos = int((self.width - sum(map(lambda x: len(x), menu_items))-len(menu_items)) / 2) +        for item in menu_items: +            self.pad.move(3, xpos) +            tags = curses.A_BOLD +            if menu_items.index(item) == self.details_category_focus: +                tags += curses.A_REVERSE +            title = item.split('_') +            self.pad.addstr(title[0], tags) +            self.pad.addstr(title[1][0], tags + curses.A_UNDERLINE) +            self.pad.addstr(title[1][1:], tags) +            xpos += len(item)+1 + +        # which details to display +        if self.details_category_focus == 0: +            self.draw_details_overview(5) +        elif self.details_category_focus == 1: +            self.draw_filelist(5) +        elif self.details_category_focus == 2: +            self.draw_peerlist(5) +        elif self.details_category_focus == 3: +            self.draw_trackerlist(5) +        elif self.details_category_focus == 4: +            self.draw_pieces_map(5) + +        self.pad.refresh(0,0, 1,0, self.height-2,self.width) +        self.screen.refresh() + + +    def draw_details_overview(self, ypos): +        t = self.torrent_details +        info = [] +        info.append(['Hash: ', "%s" % t['hashString']]) +        info.append(['ID: ',   "%s" % t['id']]) + +        wanted = 0 +        for i, file_info in enumerate(t['files']): +            if t['wanted'][i] == True: wanted += t['files'][i]['length'] + +        sizes = ['Size: ', "%s;  " % scale_bytes(t['totalSize'], 'long'), +                 "%s wanted;  " % (scale_bytes(wanted, 'long'),'everything') [t['totalSize'] == wanted]] +        if t['available'] < t['totalSize']: +            sizes.append("%s available;  " % scale_bytes(t['available'], 'long')) +        sizes.extend(["%s left" % scale_bytes(t['leftUntilDone'], 'long')]) +        info.append(sizes) + +        info.append(['Files: ', "%d;  " % len(t['files'])]) +        complete     = map(lambda x: x['bytesCompleted'] == x['length'], t['files']).count(True) +        not_complete = filter(lambda x: x['bytesCompleted'] != x['length'], t['files']) +        partial      = map(lambda x: x['bytesCompleted'] > 0, not_complete).count(True) +        if complete == len(t['files']): +            info[-1].append("all complete") +        else: +            info[-1].append("%d complete;  " % complete) +            info[-1].append("%d commenced" % partial) + +        info.append(['Pieces: ', "%s;  " % t['pieceCount'], +                     "%s each" % scale_bytes(t['pieceSize'], 'long')]) + +        info.append(['Download: ']) +        info[-1].append("%s" % scale_bytes(t['downloadedEver'], 'long') + \ +                        " (%d%%) received;  " % int(percent(t['sizeWhenDone'], t['downloadedEver']))) +        info[-1].append("%s" % scale_bytes(t['haveValid'], 'long') + \ +                        " (%d%%) verified;  " % int(percent(t['sizeWhenDone'], t['haveValid']))) +        info[-1].append("%s corrupt"  % scale_bytes(t['corruptEver'], 'long')) +        if t['percentDone'] < 100: +            info[-1][-1] += ';  ' +            if t['rateDownload']: +                info[-1].append("receiving %s per second" % scale_bytes(t['rateDownload'], 'long')) +                if t['downloadLimited']: +                    info[-1][-1] += " (throttled to %s)" % scale_bytes(t['downloadLimit']*1024, 'long') +            else: +                info[-1].append("no reception in progress") + +        try: +            copies_distributed = (float(t['uploadedEver']) / float(t['sizeWhenDone'])) +        except ZeroDivisionError: +            copies_distributed = 0 +        info.append(['Upload: ', "%s " % scale_bytes(t['uploadedEver'], 'long') + \ +                         "(%.2f copies) distributed;  " % copies_distributed]) +        if t['rateUpload']: +            info[-1].append("sending %s per second" % scale_bytes(t['rateUpload'], 'long')) +            if t['uploadLimited']: +                info[-1][-1] += " (throttled to %s)" % scale_bytes(t['uploadLimit']*1024, 'long') +        else: +            info[-1].append("no transmission in progress") + +        info.append(['Seed limit: ']) +        if t['seedRatioMode'] == 0: +            if self.stats['seedRatioLimited']: +                info[-1].append('default (pause torrent after distributing %s copies)' % self.stats['seedRatioLimit']) +            else: +                info[-1].append('default (unlimited)') +        elif t['seedRatioMode'] == 1: +            info[-1].append('pause torrent after distributing %s copies' % t['seedRatioLimit']) +        elif t['seedRatioMode'] == 2: +            info[-1].append('unlimited (ignore global limits)') + +        info.append(['Peers: ', +                     "connected to %d;  "     % t['peersConnected'], +                     "downloading from %d;  " % t['peersSendingToUs'], +                     "uploading to %d"        % t['peersGettingFromUs']]) + +        # average peer speed +        incomplete_peers = [peer for peer in self.torrent_details['peers'] if peer['progress'] < 1] +        if incomplete_peers: +            # use at least 2/3 or 10 of incomplete peers to make an estimation +            active_peers = [peer for peer in incomplete_peers if peer['download_speed']] +            min_active_peers = min(10, max(1, round(len(incomplete_peers)*0.666))) +            if 1 <= len(active_peers) >= min_active_peers: +                swarm_speed  = sum([peer['download_speed'] for peer in active_peers]) / len(active_peers) +                info.append(['Swarm speed: ', "%s on average;  " % scale_bytes(swarm_speed), +                             "distribution of 1 copy takes %s" % \ +                                 scale_time(int(t['totalSize'] / swarm_speed), 'long')]) +            else: +                info.append(['Swarm speed: ', "<gathering info from %d peers, %d done>" % \ +                                 (min_active_peers, len(active_peers))]) +        else: +            info.append(['Swarm speed: ', "<no downloading peers connected>"]) + + +        info.append(['Privacy: ']) +        if t['isPrivate']: +            info[-1].append('Private to this tracker -- DHT and PEX disabled') +        else: +            info[-1].append('Public torrent') + +        info.append(['Location: ',"%s" % homedir2tilde(t['downloadDir'])]) + +        ypos = self.draw_details_list(ypos, info) + +        self.draw_details_eventdates(ypos+1) +        return ypos+1 + +    def draw_details_eventdates(self, ypos): +        t = self.torrent_details + +        self.pad.addstr(ypos,   1, '  Created: ' + timestamp(t['dateCreated'])) +        self.pad.addstr(ypos+1, 1, '    Added: ' + timestamp(t['addedDate'])) +        self.pad.addstr(ypos+2, 1, '  Started: ' + timestamp(t['startDate'])) +        self.pad.addstr(ypos+3, 1, ' Activity: ' + timestamp(t['activityDate'])) + +        if t['percentDone'] < 100 and t['eta'] > 0: +            self.pad.addstr(ypos+4, 1, 'Finishing: ' + timestamp(time.time() + t['eta'])) +        elif t['doneDate'] <= 0: +            self.pad.addstr(ypos+4, 1, 'Finishing: sometime') +        else: +            self.pad.addstr(ypos+4, 1, ' Finished: ' + timestamp(t['doneDate'])) + +        if t['comment']: +            if self.width >= 90: +                width = self.width - 50 +                comment = wrap_multiline(t['comment'], width, initial_indent='Comment: ') +                for i, line in enumerate(comment): +                    if(ypos+i > self.height-1): +                        break +                    self.pad.addstr(ypos+i, 50, line.encode('utf8')) +            else: +                width = self.width - 2 +                comment = wrap_multiline(t['comment'], width, initial_indent='Comment: ') +                for i, line in enumerate(comment): +                    self.pad.addstr(ypos+6+i, 2, line.encode('utf8')) + +    def draw_filelist(self, ypos): +        column_names = '  #  Progress  Size  Priority  Filename' +        self.pad.addstr(ypos, 0, column_names.ljust(self.width), curses.A_UNDERLINE) +        ypos += 1 + +        for line in self.create_filelist(): +            curses_tags = 0 +            # highlight focused/selected line(s) +            while line.startswith('_'): +                if line[1] == 'S': +                    curses_tags  = curses.A_BOLD +                    line = line[2:] +                if line[1] == 'F': +                    curses_tags += curses.A_REVERSE +                    line = line[2:] +                try: +                    self.pad.addstr(ypos, 0, ' '*self.width, curses_tags) +                except: pass + +            # colored priority (only in the first 30 chars, the rest is filename) +            xpos = 0 +            for part in re.split('(high|normal|low|off)', line[0:30], 1): +                if part == 'high': +                    self.pad.addstr(ypos, xpos, part, +                                    curses_tags + curses.color_pair(self.colors.get_id('file_prio_high'))) +                elif part == 'normal': +                    self.pad.addstr(ypos, xpos, part, +                                    curses_tags + curses.color_pair(self.colors.get_id('file_prio_normal'))) +                elif part == 'low': +                    self.pad.addstr(ypos, xpos, part, +                                    curses_tags + curses.color_pair(self.colors.get_id('file_prio_low'))) +                elif part == 'off': +                    self.pad.addstr(ypos, xpos, part, +                                    curses_tags + curses.color_pair(self.colors.get_id('file_prio_off'))) +                else: +                    self.pad.addstr(ypos, xpos, part.encode('utf-8'), curses_tags) +                xpos += len(part) +            self.pad.addstr(ypos, xpos, line[30:].encode('utf-8'), curses_tags) +            ypos += 1 +            if ypos > self.height: +                break + +    def create_filelist(self): +        filelist = [] +        files = self.torrent_details['files'] +        current_folder = [] +        current_depth = 0 +        index = 0 +        pos = 0 +        pos_before_focus = 0 +        for file in files: +            f = file['name'].split('/') +            f_len = len(f) - 1 +            if f[:f_len] != current_folder: +                [current_depth, pos] = self.create_filelist_transition(f, current_folder, filelist, current_depth, pos) +                current_folder = f[:f_len] +            filelist.append(self.create_filelist_line(f[-1], index, percent(file['length'], file['bytesCompleted']), +                file['length'], current_depth)) +            index += 1 +            if self.focus_detaillist == index - 1: +                pos_before_focus = pos +            if index + pos >= self.focus_detaillist + 1 + pos + self.detaillistitems_per_page/2 \ +            and index + pos >= self.detaillistitems_per_page: +                if self.focus_detaillist + 1 + pos_before_focus < self.detaillistitems_per_page / 2: +                    return filelist +                return filelist[self.focus_detaillist + 1 + pos_before_focus - self.detaillistitems_per_page / 2 +                        : self.focus_detaillist + 1 + pos_before_focus + self.detaillistitems_per_page / 2] +        begin = len(filelist) - self.detaillistitems_per_page +        return filelist[begin > 0 and begin or 0:] + +    def create_filelist_transition(self, f, current_folder, filelist, current_depth, pos): +        f_len = len(f) - 1 +        current_folder_len = len(current_folder) +        same = 0 +        while same < current_folder_len and same  < f_len and f[same] == current_folder[same]: +            same += 1 +        for i in range(current_folder_len - same): +            current_depth -= 1 +            filelist.append('  '*current_depth + ' '*31 + '/') +            pos += 1 +        if f_len < current_folder_len: +            return [current_depth, pos] +        while current_depth < f_len: +            filelist.append('%s\\ %s' % ('  '*current_depth + ' '*31 , f[current_depth])) +            current_depth += 1 +            pos += 1 +        return [current_depth, pos] + +    def create_filelist_line(self, name, index, percent, length, current_depth): +        line = "%s  %6.1f%%" % (str(index+1).rjust(3), percent) + \ +            '  '+scale_bytes(length).rjust(5) + \ +            '  '+server.get_file_priority(self.torrent_details['id'], index).center(8) + \ +            " %s| %s" % ('  '*current_depth, name[0:self.width-31-current_depth]) +        if index == self.focus_detaillist: +            line = '_F' + line +        if index in self.selected_files: +            line = '_S' + line +        return line + +    def draw_peerlist(self, ypos): +        # Start drawing list either at the "selected" index, or at the index +        # that is required to display all remaining items without further scrolling. +        last_possible_index = max(0, len(self.torrent_details['peers']) - self.detaillistitems_per_page) +        start = min(self.scrollpos_detaillist, last_possible_index) +        end = start + self.detaillistitems_per_page +        peers = self.torrent_details['peers'][start:end] + +        # Find width of columns +        clientname_width = 0 +        address_width = 0 +        for peer in peers: +            if len(peer['clientName']) > clientname_width: clientname_width = len(peer['clientName']) +            if len(peer['address']) > address_width: address_width = len(peer['address']) + +        # Column names +        column_names = "Flags %3d Down %3d Up  Progress      ETA " % \ +            (self.torrent_details['peersSendingToUs'], self.torrent_details['peersGettingFromUs']) +        column_names += '  Client'.ljust(clientname_width + 2) \ +            + "  Address".ljust(address_width + 2) +        if features['geoip']: column_names += "  Country" +        if features['dns']: column_names += "  Host" + +        self.pad.addstr(ypos, 0, column_names.ljust(self.width), curses.A_UNDERLINE) +        ypos += 1 + +        # Peers +        hosts = server.get_hosts() +        geo_ips = server.get_geo_ips() +        for index, peer in enumerate(peers): +            if features['dns']: +                try: +                    try: +                        host = hosts[peer['address']].check() +                        host_name = host[3][0] +                    except (IndexError, KeyError): +                        host_name = "<not resolvable>" +                except adns.NotReady: +                    host_name = "<resolving>" +                except adns.Error, msg: +                    host_name = msg + +            upload_tag = download_tag = line_tag = 0 +            if peer['rateToPeer']:   upload_tag   = curses.A_BOLD +            if peer['rateToClient']: download_tag = curses.A_BOLD + +            self.pad.move(ypos, 0) +            # Flags +            self.pad.addstr("%-6s   " % peer['flagStr']) +            # Down +            self.pad.addstr("%5s  " % scale_bytes(peer['rateToClient']), download_tag) +            # Up +            self.pad.addstr("%5s  " % scale_bytes(peer['rateToPeer']), upload_tag) + +            # Progress +            if peer['progress'] < 1: self.pad.addstr("%3d%%" % (float(peer['progress'])*100)) +            else: self.pad.addstr("%3d%%" % (float(peer['progress'])*100), curses.A_BOLD) + +            # ETA +            if peer['progress'] < 1 and peer['download_speed'] > 1024: +                self.pad.addstr(" @ ") +                self.pad.addch(curses.ACS_PLMINUS) +                self.pad.addstr("%-5s " % scale_bytes(peer['download_speed'])) +                self.pad.addch(curses.ACS_PLMINUS) +                self.pad.addstr("%-4s " % scale_time(peer['time_left'])) +            else: +                self.pad.addstr("                ") +            # Client +            self.pad.addstr(peer['clientName'].ljust(clientname_width + 2).encode('utf-8')) +            # Address +            self.pad.addstr(peer['address'].ljust(address_width + 2)) +            # Country +            if features['geoip']: self.pad.addstr("  %2s     " % geo_ips[peer['address']]) +            # Host +            if features['dns']: self.pad.addstr(host_name.encode('utf-8'), curses.A_DIM) +            ypos += 1 + +#TODO +# 1. Issue #14 on GitHub is asking for feature to be able to modify trackers. +    def draw_trackerlist(self, ypos): +        top = ypos - 1 +        def addstr(ypos, xpos, *args): +            if ypos > top and ypos < self.height - 2: +                self.pad.addstr(ypos, xpos, *args) + +        tracker_per_page = self.detaillistitems_per_page // self.TRACKER_ITEM_HEIGHT +        page = self.scrollpos_detaillist // tracker_per_page +        start = tracker_per_page * page +        end = tracker_per_page * (page + 1) +        tlist = self.torrent_details['trackerStats'][start:end] + +        # keep position in range when last tracker gets deleted +        self.scrollpos_detaillist = min(self.scrollpos_detaillist, +                                        len(self.torrent_details['trackerStats'])-1) +        # show newly added tracker when list was empty before +        if self.torrent_details['trackerStats']: +            self.scrollpos_detaillist = max(0, self.scrollpos_detaillist) + +        current_tier = -1 +        for index, t in enumerate(tlist): +            announce_msg_size = scrape_msg_size = 0 +            selected = t == self.torrent_details['trackerStats'][self.scrollpos_detaillist] + +            if current_tier != t['tier']: +                current_tier = t['tier'] + +                tiercolor = curses.A_BOLD + curses.A_REVERSE \ +                            if selected else curses.A_REVERSE +                addstr(ypos, 0, ("Tier %d" % (current_tier+1)).ljust(self.width), tiercolor) +                ypos += 1 + +            if selected: +                for i in range(4): +                    addstr(ypos+i, 0, ' ', curses.A_BOLD + curses.A_REVERSE) + +            addstr(ypos+1, 4,  "Last announce: %s" % timestamp(t['lastAnnounceTime'])) +            addstr(ypos+1, 57, "  Last scrape: %s" % timestamp(t['lastScrapeTime'])) + +            if t['lastAnnounceSucceeded']: +                peers = "%s peer%s" % (num2str(t['lastAnnouncePeerCount']), ('s', '')[t['lastAnnouncePeerCount']==1]) +                addstr(ypos,   2, t['announce'], curses.A_BOLD + curses.A_UNDERLINE) +                addstr(ypos+2, 11, "Result: ") +                addstr(ypos+2, 19, "%s received" % peers, curses.A_BOLD) +            else: +                addstr(ypos,   2, t['announce'], curses.A_UNDERLINE) +                addstr(ypos+2, 9, "Response:") +                announce_msg_size = self.wrap_and_draw_result(top, ypos+2, 19, t['lastAnnounceResult'].encode('utf-8')) + +            if t['lastScrapeSucceeded']: +                seeds   = "%s seed%s" % (num2str(t['seederCount']), ('s', '')[t['seederCount']==1]) +                leeches = "%s leech%s" % (num2str(t['leecherCount']), ('es', '')[t['leecherCount']==1]) +                addstr(ypos+2, 57, "Tracker knows: ") +                addstr(ypos+2, 72, "%s and %s" % (seeds, leeches), curses.A_BOLD) +            else: +                addstr(ypos+2, 62, "Response:") +                scrape_msg_size += self.wrap_and_draw_result(top, ypos+2, 72, t['lastScrapeResult']) + +            ypos += max(announce_msg_size, scrape_msg_size) + +            addstr(ypos+3, 4,  "Next announce: %s" % timestamp(t['nextAnnounceTime'])) +            addstr(ypos+3, 57, "  Next scrape: %s" % timestamp(t['nextScrapeTime'])) + +            ypos += 5 + +    def wrap_and_draw_result(self, top, ypos, xpos, result): +        result = wrap(result, 30) +        i = 0 +        for i, line in enumerate(result): +            if ypos+i > top and ypos+i < self.height - 2: +                self.pad.addstr(ypos+i, xpos, line, curses.A_UNDERLINE) +        return i + + +    def draw_pieces_map(self, ypos): +        pieces = self.torrent_details['pieces'] +        piece_count = self.torrent_details['pieceCount'] +        margin = len(str(piece_count)) + 2 + +        map_width = int(str(self.width-margin-1)[0:-1] + '0') +        for x in range(10, map_width, 10): +            self.pad.addstr(ypos, x+margin-1, str(x), curses.A_BOLD) + +        start = self.scrollpos_detaillist * map_width +        end = min(start + (self.height - ypos - 3) * map_width, piece_count) +        if end <= start: return +        block = ord(pieces[start >> 3]) << (start & 7) + +        format = "%%%dd" % (margin - 2) +        for counter in xrange(start, end): +            if counter % map_width == 0: +                ypos += 1 ; xpos = margin +                self.pad.addstr(ypos, 1, format % counter, curses.A_BOLD) +            else: +                xpos += 1 + +            if counter & 7 == 0: +                block = ord(pieces[counter >> 3]) +            piece = block & 0x80 +            if piece: self.pad.addch(ypos, xpos, ' ', curses.A_REVERSE) +            else:     self.pad.addch(ypos, xpos, '_') +            block <<= 1 + +        missing_pieces = piece_count - counter - 1 +        if missing_pieces: +            line = "%d further piece%s" % (missing_pieces, ('','s')[missing_pieces>1]) +            xpos = (self.width - len(line)) / 2 +            self.pad.addstr(self.height-3, xpos, line, curses.A_REVERSE) + +    def draw_details_list(self, ypos, info): +        key_width = max(map(lambda x: len(x[0]), info)) +        for i in info: +            self.pad.addstr(ypos, 1, i[0].rjust(key_width).encode('utf-8')) # key +            # value part may be wrapped if it gets too long +            for v in i[1:]: +                y, x = self.pad.getyx() +                if x + len(v) >= self.width: +                    ypos += 1 +                    self.pad.move(ypos, key_width+1) +                self.pad.addstr(v.encode('utf-8')) +            ypos += 1 +        return ypos + +    def next_details(self): +        if self.details_category_focus >= 4: +            self.details_category_focus = 0 +        else: +            self.details_category_focus += 1 +        self.focus_detaillist     = -1 +        self.scrollpos_detaillist = 0 +        self.pad.erase() + +    def prev_details(self): +        if self.details_category_focus <= 0: +            self.details_category_focus = 4 +        else: +            self.details_category_focus -= 1 +        self.pad.erase() + + + + +    def move_up(self, focus, scrollpos, step_size): +        if focus < 0: focus = -1 +        else: +            focus -= 1 +            if scrollpos/step_size - focus > 0: +                scrollpos -= step_size +                scrollpos = max(0, scrollpos) +            while scrollpos % step_size: +                scrollpos -= 1 +        return focus, scrollpos + +    def move_down(self, focus, scrollpos, step_size, elements_per_page, list_height): +        if focus < list_height - 1: +            focus += 1 +            if focus+1 - scrollpos/step_size > elements_per_page: +                scrollpos += step_size +        return focus, scrollpos + +    def move_page_up(self, focus, scrollpos, step_size, elements_per_page): +        for x in range(elements_per_page - 1): +            focus, scrollpos = self.move_up(focus, scrollpos, step_size) +        if focus < 0: focus = 0 +        return focus, scrollpos + +    def move_page_down(self, focus, scrollpos, step_size, elements_per_page, list_height): +        if focus < 0: focus = 0 +        for x in range(elements_per_page - 1): +            focus, scrollpos = self.move_down(focus, scrollpos, step_size, elements_per_page, list_height) +        return focus, scrollpos + +    def move_to_top(self): +        return 0, 0 + +    def move_to_end(self, step_size, elements_per_page, list_height): +        focus     = list_height - 1 +        scrollpos = max(0, (list_height - elements_per_page) * step_size) +        return focus, scrollpos + + + + + +    def draw_stats(self): +        self.screen.insstr(self.height-1, 0, ' '.center(self.width), curses.A_REVERSE) +        self.draw_torrents_stats() +        self.draw_global_rates() + +    def draw_torrents_stats(self): +        if self.selected_torrent > -1 and self.details_category_focus == 2: +            self.screen.insstr((self.height-1), 0, +                               "%d peer%s connected (" % (self.torrent_details['peersConnected'], +                                                         ('s','')[self.torrent_details['peersConnected'] == 1]) + \ +                                   "Trackers:%d " % self.torrent_details['peersFrom']['fromTracker'] + \ +                                   "DHT:%d " % self.torrent_details['peersFrom']['fromDht'] + \ +                                   "LTEP:%d " % self.torrent_details['peersFrom']['fromLtep'] + \ +                                   "PEX:%d " % self.torrent_details['peersFrom']['fromPex'] + \ +                                   "Incoming:%d " % self.torrent_details['peersFrom']['fromIncoming'] + \ +                                   "Cache:%d)" % self.torrent_details['peersFrom']['fromCache'], +                               curses.A_REVERSE) +        else: +            self.screen.addstr((self.height-1), 0, "Torrent%s:" % ('s','')[len(self.torrents) == 1], +                                   curses.A_REVERSE) +            self.screen.addstr("%d (" % len(self.torrents), curses.A_REVERSE) + +            downloading = len(filter(lambda x: x['status']==Transmission.STATUS_DOWNLOAD, self.torrents)) +            seeding = len(filter(lambda x: x['status']==Transmission.STATUS_SEED, self.torrents)) +            paused = self.stats['pausedTorrentCount'] + +            self.screen.addstr("Downloading:", curses.A_REVERSE) +            self.screen.addstr("%d " % downloading, curses.A_REVERSE) +            self.screen.addstr("Seeding:", curses.A_REVERSE) +            self.screen.addstr("%d " % seeding, curses.A_REVERSE) +            self.screen.addstr("Paused:", curses.A_REVERSE) +            self.screen.addstr("%d) " % paused, curses.A_REVERSE) + +            if self.filter_list: +                self.screen.addstr("Filter:", curses.A_REVERSE) +                self.screen.addstr("%s%s" % (('','not ')[self.filter_inverse], self.filter_list), +                                   curses.color_pair(self.colors.get_id('filter_status')) +                                   + curses.A_REVERSE) + +            # show last sort order (if terminal size permits it) +            curpos_y, curpos_x = self.screen.getyx() +            if self.sort_orders and self.width - curpos_x > 20: +                self.screen.addstr(" Sort by:", curses.A_REVERSE) +                name = [name[1] for name in self.sort_options if name[0] == self.sort_orders[-1]['name']][0] +                name = name.replace('_', '').lower() +                curses_tags = curses.color_pair(self.colors.get_id('filter_status')) + curses.A_REVERSE +                if self.sort_orders[-1]['reverse']: +                    self.screen.addch(curses.ACS_DARROW, curses_tags) +                else: +                    self.screen.addch(curses.ACS_UARROW, curses_tags) +                try:  # 'name' may be too long +                    self.screen.addstr(name, curses_tags) +                except curses.error: +                    pass + +    def draw_global_rates(self): +        rates_width = self.rateDownload_width + self.rateUpload_width + 3 + +        if self.stats['alt-speed-enabled']: +            upload_limit   = "/%dK" % self.stats['alt-speed-up'] +            download_limit = "/%dK" % self.stats['alt-speed-down'] +        else: +            upload_limit   = ('', "/%dK" % self.stats['speed-limit-up'])[self.stats['speed-limit-up-enabled']] +            download_limit = ('', "/%dK" % self.stats['speed-limit-down'])[self.stats['speed-limit-down-enabled']] + +        limits = {'dn_limit' : download_limit, 'up_limit' : upload_limit} +        limits_width = len(limits['dn_limit']) + len(limits['up_limit']) + +        if self.stats['alt-speed-enabled']: +            self.screen.move(self.height-1, self.width-rates_width - limits_width - len('Turtle mode ')) +            self.screen.addstr('Turtle mode', curses.A_REVERSE + curses.A_BOLD) +            self.screen.addch(' ', curses.A_REVERSE) + +        self.screen.move(self.height - 1, self.width - rates_width - limits_width) +        self.screen.addch(curses.ACS_DARROW, curses.A_REVERSE) +        self.screen.addstr(scale_bytes(self.stats['downloadSpeed']).rjust(self.rateDownload_width), +                           curses.color_pair(self.colors.get_id('download_rate')) +                           + curses.A_REVERSE + curses.A_BOLD) +        self.screen.addstr(limits['dn_limit'], curses.A_REVERSE) +        self.screen.addch(' ', curses.A_REVERSE) +        self.screen.addch(curses.ACS_UARROW, curses.A_REVERSE) +        self.screen.insstr(limits['up_limit'], curses.A_REVERSE) +        self.screen.insstr(scale_bytes(self.stats['uploadSpeed']).rjust(self.rateUpload_width), +                           curses.color_pair(self.colors.get_id('upload_rate')) +                           + curses.A_REVERSE + curses.A_BOLD) + + + +    def draw_title_bar(self): +        self.screen.insstr(0, 0, ' '.center(self.width), curses.A_REVERSE) +        self.draw_connection_status() +        self.draw_quick_help() +    def draw_connection_status(self): +        status = "Transmission @ %s:%s" % (server.host, server.port) +        if cmd_args.DEBUG: +            status = "%d x %d " % (self.width, self.height) + status +        self.screen.addstr(0, 0, status.encode('utf-8'), curses.A_REVERSE) + +    def draw_quick_help(self): +        help = [('?','Show Keybindings')] + +        if self.selected_torrent == -1: +            if self.focus >= 0: +                help = [('enter','View Details'), ('p','Pause/Unpause'), ('r','Remove'), ('v','Verify')] +            else: +                help = [('/','Search'), ('f','Filter'), ('s','Sort')] + help + [('o','Options'), ('q','Quit')] +        else: +            help = [('Move with','cursor keys'), ('q','Back to List')] +            if self.details_category_focus == 1 and self.focus_detaillist > -1: +                help = [('space','(De)Select File'), +                        ('left/right','De-/Increase Priority'), +                        ('escape','Unfocus/-select')] + help +            elif self.details_category_focus == 2: +                help = [('F1/?','Explain flags')] + help +            elif self.details_category_focus == 3: +                help = [('a','Add Tracker'),('r','Remove Tracker')] + help + +        line = ' | '.join(map(lambda x: "%s %s" % (x[0], x[1]), help)) +        line = line[0:self.width] +        self.screen.insstr(0, self.width-len(line), line, curses.A_REVERSE) + + +    def list_key_bindings(self): +        title = 'Help Menu' +        message = "           F1/?  Show this help\n" + \ +                  "            u/d  Adjust maximum global upload/download rate\n" + \ +                  "            U/D  Adjust maximum upload/download rate for focused torrent\n" + \ +                  "              L  Set seed ratio limit for focused torrent\n" + \ +                  "            +/-  Adjust bandwidth priority for focused torrent\n" + \ +                  "              p  Pause/Unpause torrent\n" + \ +                  "              P  Pause/Unpause all torrents\n" + \ +                  "            v/y  Verify torrent\n" + \ +                  "              m  Move torrent\n" + \ +                  "              n  Reannounce torrent\n" + \ +                  "              a  Add torrent\n" + \ +                  "          Del/r  Remove torrent and keep content\n" + \ +                  "    Shift+Del/R  Remove torrent and delete content\n" +        # Torrent list +        if self.selected_torrent == -1: +            message += "              /  Search in torrent list\n" + \ +                       "              f  Filter torrent list\n" + \ +                       "              s  Sort torrent list\n" \ +                       "    Enter/Right  View torrent's details\n" + \ +                       "              o  Configuration options\n" + \ +                       "              t  Toggle turtle mode\n" + \ +                       "              C  Toggle compact list mode\n" + \ +                       "            Esc  Unfocus\n" + \ +                       "              q  Quit" +        else: +            # Peer list +            if self.details_category_focus == 2: +                title = 'Peer status flags' +                message = " O  Optimistic unchoke\n" + \ +                          " D  Downloading from this peer\n" + \ +                          " d  We would download from this peer if they'd let us\n" + \ +                          " U  Uploading to peer\n" + \ +                          " u  We would upload to this peer if they'd ask\n" + \ +                          " K  Peer has unchoked us, but we're not interested\n" + \ +                          " ?  We unchoked this peer, but they're not interested\n" + \ +                          " E  Encrypted Connection\n" + \ +                          " H  Peer was discovered through DHT\n" + \ +                          " X  Peer was discovered through Peer Exchange (PEX)\n" + \ +                          " I  Peer is an incoming connection\n" + \ +                          " T  Peer is connected via uTP" +            else: +                # Viewing torrent details +                message += "              o  Jump to overview\n" + \ +                           "              f  Jump to file list\n" + \ +                           "              e  Jump to peer list\n" + \ +                           "              t  Jump to tracker information\n" + \ +                           "      Tab/Right  Jump to next view\n" + \ +                           " Shift+Tab/Left  Jump to previous view\n" +                if self.details_category_focus == 1:  # files +                    if self.focus_detaillist > -1: +                        message += "     Left/Right  Decrease/Increase file priority\n" +                    message += "        Up/Down  Select file\n" + \ +                               "          Space  Select/Deselect focused file\n" + \ +                               "              a  Select/Deselect all files\n" + \ +                               "            Esc  Unfocus+Unselect or Back to torrent list\n" + \ +                               "    q/Backspace  Back to torrent list" +                else: +                    message += "q/Backspace/Esc  Back to torrent list" + +        width  = max(map(lambda x: len(x), message.split("\n"))) + 4 +        width  = min(self.width, width) +        height = min(self.height, message.count("\n")+3) +        win = self.window(height, width, message=message, title=title) +        while True: +            if win.getch() >= 0: return + + +    def window(self, height, width, message='', title=''): +        height = min(self.height, height) +        width  = min(self.width, width) +        ypos = int( (self.height - height) / 2 ) +        xpos = int( (self.width  - width) / 2 ) +        win = curses.newwin(height, width, ypos, xpos) +        win.box() +        win.bkgd(' ', curses.A_REVERSE + curses.A_BOLD) + +        if width >= 20: +            win.addch(height-1, width-19, curses.ACS_RTEE) +            win.addstr(height-1, width-18, " Close with Esc ") +            win.addch(height-1, width-2, curses.ACS_LTEE) + +        if width >= (len(title) + 6) and title != '': +            win.addch(0, 1, curses.ACS_RTEE) +            win.addstr(0, 2, " " + title + " ") +            win.addch(0, len(title) + 4 , curses.ACS_LTEE) + +        ypos = 1 +        for line in message.split("\n"): +            if len(line) > width: +                line = line[0:width-7] + '...' +            win.addstr(ypos, 2, line.encode('utf-8')) +            ypos += 1 +        return win + + +    def dialog_ok(self, message): +        height = 3 + message.count("\n") +        width  = max(max(map(lambda x: len(x), message.split("\n"))), 40) + 4 +        win = self.window(height, width, message=message) +        while True: +            if win.getch() >= 0: return + +    def dialog_yesno(self, message, important=False): +        height = 5 + message.count("\n") +        width  = max(len(message), 8) + 4 +        win = self.window(height, width, message=message) +        win.keypad(True) + +        if important: +            win.bkgd(' ', curses.color_pair(self.colors.get_id('dialog_important')) +                          + curses.A_REVERSE) + +        focus_tags   = curses.color_pair(self.colors.get_id('button_focused')) +        unfocus_tags = 0 + +        input = False +        while True: +            win.move(height-2, (width/2)-4) +            if input: +                win.addstr('Y',  focus_tags + curses.A_UNDERLINE) +                win.addstr('es', focus_tags) +                win.addstr('   ') +                win.addstr('N',  curses.A_UNDERLINE) +                win.addstr('o') +            else: +                win.addstr('Y', curses.A_UNDERLINE) +                win.addstr('es') +                win.addstr('   ') +                win.addstr('N',  focus_tags + curses.A_UNDERLINE) +                win.addstr('o', focus_tags) + +            c = win.getch() +            if c == ord('y'): +                return True +            elif c == ord('n'): +                return False +            elif c == ord("\t"): +                input = not input +            elif c == curses.KEY_LEFT or c == ord('h'): +                input = True +            elif c == curses.KEY_RIGHT or c == ord('l'): +                input = False +            elif c == ord("\n") or c == ord(' '): +                return input +            elif c == 27 or c == curses.KEY_BREAK: +                return -1 + +    def dialog_input_text(self, message, input='', on_change=None, on_enter=None): +        width  = self.width - 4 +        textwidth = self.width - 8 +        height = message.count("\n") + 4 + +        win = self.window(height, width, message=message) +        win.keypad(True) +        show_cursor() +        index = len(input) +        while True: +            # Cut the text into pages, each as long as the text field +            # The current page is determined by index position +            page = index // textwidth +            displaytext = input[textwidth*page:textwidth*(page + 1)] +            displayindex = index - textwidth*page + +            color = (curses.color_pair(self.colors.get_id('dialog_important')) if self.highlight_dialog +                     else curses.color_pair(self.colors.get_id('dialog'))) +            win.addstr(height - 2, 2, displaytext.ljust(textwidth), color) +            win.move(height - 2, displayindex + 2) +            c = win.getch() +            if c == 27 or c == curses.KEY_BREAK: +                hide_cursor() +                return '' +            elif index < len(input) and ( c == curses.KEY_RIGHT or c == curses.ascii.ctrl(ord('f')) ): +                index += 1 +            elif index > 0 and ( c == curses.KEY_LEFT or c == curses.ascii.ctrl(ord('b')) ): +                index -= 1 +            elif (c == curses.KEY_BACKSPACE or c == 127) and index > 0: +                input = input[:index - 1] + (index < len(input) and input[index:] or '') +                index -= 1 +                if on_change: on_change(input) +            elif index < len(input) and ( c == curses.KEY_DC or c == curses.ascii.ctrl(ord('d')) ): +                input = input[:index] + input[index + 1:] +                if on_change: on_change(input) +            elif index < len(input) and c == curses.ascii.ctrl(ord('k')): +                input = input[:index] +                if on_change: on_change(input) +            elif c == curses.KEY_HOME or c == curses.ascii.ctrl(ord('a')): +                index = 0 +            elif c == curses.KEY_END or c == curses.ascii.ctrl(ord('e')): +                index = len(input) +            elif c == ord('\n'): +                if on_enter: +                    on_enter(input) +                else: +                    hide_cursor() +                    return input +            elif c >= 32 and c < 127: +                input = input[:index] + chr(c) + (index < len(input) and input[index:] or '') +                index += 1 +                if on_change: on_change(input) +            if on_change: win.redrawwin() + +    def dialog_search_torrentlist(self, c): +        self.dialog_input_text('Search torrent by title:', +                               on_change=self.draw_torrent_list, +                               on_enter=self.increment_search) + +    def increment_search(self, input): +        self.search_focus += 1 +        self.draw_torrent_list(input) + + +    def dialog_input_number(self, message, current_value, +                            cursorkeys=True, floating_point=False, allow_empty=False, +                            allow_zero=True, allow_negative_one=True): +        if not allow_zero: +            allow_negative_one = False + +        width  = max(max(map(lambda x: len(x), message.split("\n"))), 40) + 4 +        width  = min(self.width, width) +        height = message.count("\n") + (4,6)[cursorkeys] + +        show_cursor() +        win = self.window(height, width, message=message) +        win.keypad(True) +        input = str(current_value) +        if cursorkeys: +            if floating_point: +                bigstep   = 1 +                smallstep = 0.1 +            else: +                bigstep   = 100 +                smallstep = 10 +            win.addstr(height-4, 2, ("   up/down +/- %-3s" % bigstep).rjust(width-4)) +            win.addstr(height-3, 2, ("left/right +/- %3s" % smallstep).rjust(width-4)) +            if allow_negative_one: +                win.addstr(height-3, 2, "-1 means unlimited") +            if allow_empty: +                win.addstr(height-4, 2, "leave empty for default") + +        while True: +            win.addstr(height-2, 2, input.ljust(width-4), curses.color_pair(self.colors.get_id('dialog'))) +            win.move(height - 2, len(input) + 2) +            c = win.getch() +            if c == 27 or c == ord('q') or c == curses.KEY_BREAK: +                hide_cursor() +                return -128 +            elif c == ord("\n"): +                try: +                    if allow_empty and len(input) <= 0: +                        hide_cursor() +                        return -2 +                    elif floating_point: +                        hide_cursor() +                        return float(input) +                    else: +                        hide_cursor() +                        return int(input) +                except ValueError: +                    hide_cursor() +                    return -1 + +            elif c == curses.KEY_BACKSPACE or c == curses.KEY_DC or c == 127 or c == 8: +                input = input[:-1] +            elif len(input) >= width-5: +                curses.beep() +            elif c >= ord('1') and c <= ord('9'): +                input += chr(c) +            elif allow_zero and c == ord('0') and input != '-' and not input.startswith('0'): +                input += chr(c) +            elif allow_negative_one and c == ord('-') and len(input) == 0: +                input += chr(c) +            elif floating_point and c == ord('.') and not '.' in input: +                input += chr(c) + +            elif cursorkeys and c != -1: +                try: +                    if input == '': input = 0                         +                    if floating_point: number = float(input) +                    else:              number = int(input) +                    if c == curses.KEY_LEFT or c == ord('h'):    number -= smallstep +                    elif c == curses.KEY_RIGHT or c == ord('l'): number += smallstep +                    elif c == curses.KEY_DOWN or c == ord('j'):  number -= bigstep +                    elif c == curses.KEY_UP or c == ord('k'):    number += bigstep +                    if not allow_zero and number <= 0: +                        number = 1 +                    elif not allow_negative_one and number < 0: +                        number = 0 +                    elif number < 0:  # input like -0.6 isn't useful +                        number = -1 +                    input = str(number) +                except ValueError: +                    pass + +    def dialog_menu(self, title, options, focus=1): +        height = len(options) + 2 +        width  = max(max(map(lambda x: len(x[1])+3, options)), len(title)+3) +        win = self.window(height, width) + +        win.addstr(0,1, title) +        win.keypad(True) + +        old_focus = focus +        while True: +            keymap = self.dialog_list_menu_options(win, width, options, focus) +            c = win.getch() + +            if c > 96 and c < 123 and chr(c) in keymap: +                return options[keymap[chr(c)]][0] +            elif c == 27 or c == ord('q'): +                return -128 +            elif c == ord("\n"): +                return options[focus-1][0] +            elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')): +                focus += 1 +                if focus > len(options): focus = 1 +            elif c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')): +                focus -= 1 +                if focus < 1: focus = len(options) +            elif c == curses.KEY_HOME or c == ord('g'): +                focus = 1 +            elif c == curses.KEY_END or c == ord('G'): +                focus = len(options) + +    def dialog_list_menu_options(self, win, width, options, focus): +        keys = dict() +        i = 1 +        for option in options: +            title = option[1].split('_') +            if i == focus: tag = curses.color_pair(self.colors.get_id('dialog')) +            else:          tag = 0 +            win.addstr(i,2, title[0], tag) +            win.addstr(title[1][0], tag + curses.A_UNDERLINE) +            win.addstr(title[1][1:], tag) +            win.addstr(''.ljust(width - len(option[1]) - 3), tag) + +            keys[title[1][0].lower()] = i-1 +            i+=1 +        return keys + +    def draw_options_dialog(self): +        enc_options = [('required','_required'), ('preferred','_preferred'), ('tolerated','_tolerated')] +        seed_ratio = self.stats['seedRatioLimit'] +        while True: +            options = [] +            options.append(('Peer _Port', "%d" % self.stats['peer-port'])) +            options.append(('UP_nP/NAT-PMP', ('disabled','enabled ')[self.stats['port-forwarding-enabled']])) +            options.append(('Peer E_xchange', ('disabled','enabled ')[self.stats['pex-enabled']])) +            options.append(('_Distributed Hash Table', ('disabled','enabled ')[self.stats['dht-enabled']])) +            options.append(('_Local Peer Discovery', ('disabled','enabled ')[self.stats['lpd-enabled']])) +            options.append(('Protocol En_cryption', "%s" % self.stats['encryption'])) +            # uTP support was added in Transmission v2.3 +            if server.get_rpc_version() >= 13: +                options.append(('_Micro Transport Protocol', ('disabled','enabled')[self.stats['utp-enabled']])) +            options.append(('_Global Peer Limit', "%d" % self.stats['peer-limit-global'])) +            options.append(('Peer Limit per _Torrent', "%d" % self.stats['peer-limit-per-torrent'])) +            options.append(('_Seed Ratio Limit', "%s" % ('unlimited',self.stats['seedRatioLimit'])[self.stats['seedRatioLimited']])) +            options.append(('T_urtle Mode UL Limit', "%dK" % self.stats['alt-speed-up'])) +            options.append(('Tu_rtle Mode DL Limit', "%dK" % self.stats['alt-speed-down'])) +            options.append(('Title is Progress _Bar', ('no','yes')[self.torrentname_is_progressbar])) + +            max_len = max([sum([len(re.sub('_', '', x)) for x in y[0]]) for y in options]) +            win = self.window(len(options)+2, max_len+15, '', "Global Options") + +            line_num = 1 +            for option in options: +                parts = re.split('_', option[0]) +                parts_len = sum([len(x) for x in parts]) + +                win.addstr(line_num, max_len-parts_len+2, parts.pop(0)) +                for part in parts: +                    win.addstr(part[0], curses.A_UNDERLINE) +                    win.addstr(part[1:] + ': ' + option[1]) +                line_num += 1 + +            c = win.getch() +            if c == 27 or c == ord('q') or c == ord("\n"): +                return +            elif c == ord('p'): +                port = self.dialog_input_number("Port for incoming connections", +                                                self.stats['peer-port'], +                                                cursorkeys=False) +                if port >= 0 and port <= 65535: +                    server.set_option('peer-port', port) +                elif port != -128:  # user hit ESC +                    self.dialog_ok('Port must be in the range of 0 - 65535') +            elif c == ord('n'): +                server.set_option('port-forwarding-enabled', +                                       (1,0)[self.stats['port-forwarding-enabled']]) +            elif c == ord('x'): +                server.set_option('pex-enabled', (1,0)[self.stats['pex-enabled']]) +            elif c == ord('d'): +                server.set_option('dht-enabled', (1,0)[self.stats['dht-enabled']]) +            elif c == ord('l'): +                server.set_option('lpd-enabled', (1,0)[self.stats['lpd-enabled']]) +            # uTP support was added in Transmission v2.3 +            elif c == ord('m') and server.get_rpc_version() >= 13: +                server.set_option('utp-enabled', (1,0)[self.stats['utp-enabled']]) +            elif c == ord('g'): +                limit = self.dialog_input_number("Maximum number of connected peers", +                                                 self.stats['peer-limit-global'], +                                                 allow_negative_one=False) +                if limit >= 0: +                    server.set_option('peer-limit-global', limit) +            elif c == ord('t'): +                limit = self.dialog_input_number("Maximum number of connected peers per torrent", +                                                 self.stats['peer-limit-per-torrent'], +                                                 allow_negative_one=False) +                if limit >= 0: +                    server.set_option('peer-limit-per-torrent', limit) +            elif c == ord('s'): +                limit = self.dialog_input_number('Stop seeding with upload/download ratio', +                                                 (-1,self.stats['seedRatioLimit'])[self.stats['seedRatioLimited']], +                                                 floating_point=True) +                if limit >= 0: +                    server.set_option('seedRatioLimit', limit) +                    server.set_option('seedRatioLimited', True) +                elif limit < 0 and limit != -128: +                    server.set_option('seedRatioLimited', False) +            elif c == ord('c'): +                choice = self.dialog_menu('Encryption', enc_options, +                                          map(lambda x: x[0]==self.stats['encryption'], enc_options).index(True)+1) +                if choice != -128: +                    server.set_option('encryption', choice) +            elif c == ord('u'): +                limit = self.dialog_input_number('Upload limit for Turtle Mode in kilobytes per second', +                                                 self.stats['alt-speed-up'], +                                                 allow_negative_one=False) +                server.set_option('alt-speed-up', limit) +            elif c == ord('r'): +                limit = self.dialog_input_number('Download limit for Turtle Mode in kilobytes per second', +                                                 self.stats['alt-speed-down'], +                                                 allow_negative_one=False) +                server.set_option('alt-speed-down', limit) +            elif c == ord('b'): +                self.torrentname_is_progressbar = not self.torrentname_is_progressbar + +            self.draw_torrent_list() + +# End of class Interface + + + +def percent(full, part): +    try: percent = 100/(float(full) / float(part)) +    except ZeroDivisionError: percent = 0.0 +    return percent + + +def scale_time(seconds, type='short'): +    minute_in_sec = float(60) +    hour_in_sec   = float(3600) +    day_in_sec    = float(86400) +    month_in_sec  = 27.321661 * day_in_sec # from wikipedia +    year_in_sec   = 365.25    * day_in_sec # from wikipedia + +    if seconds < 0: +        return ('?', 'some time')[type=='long'] + +    elif seconds < minute_in_sec: +        if type == 'long': +            if seconds < 5: +                return 'now' +            else: +                return "%d second%s" % (seconds, ('', 's')[seconds>1]) +        else: +            return "%ds" % seconds + +    elif seconds < hour_in_sec: +        minutes = round(seconds / minute_in_sec, 0) +        if type == 'long': +            return "%d minute%s" % (minutes, ('', 's')[minutes>1]) +        else: +            return "%dm" % minutes + +    elif seconds < day_in_sec: +        hours = round(seconds / hour_in_sec, 0) +        if type == 'long': +            return "%d hour%s" % (hours, ('', 's')[hours>1]) +        else: +            return "%dh" % hours + +    elif seconds < month_in_sec: +        days = round(seconds / day_in_sec, 0) +        if type == 'long': +            return "%d day%s" % (days, ('', 's')[days>1]) +        else: +            return "%dd" % days + +    elif seconds < year_in_sec: +        months = round(seconds / month_in_sec, 0) +        if type == 'long': +            return "%d month%s" % (months, ('', 's')[months>1]) +        else: +            return "%dM" % months + +    else: +        years = round(seconds / year_in_sec, 0) +        if type == 'long': +            return "%d year%s" % (years, ('', 's')[years>1]) +        else: +            return "%dy" % years + + +def timestamp(timestamp): +    if timestamp < 1: +        return 'never' + +    date_format = "%x %X" +    absolute = time.strftime(date_format, time.localtime(timestamp)) +    if timestamp > time.time(): +        relative = 'in ' + scale_time(int(timestamp - time.time()), 'long') +    else: +        relative = scale_time(int(time.time() - timestamp), 'long') + ' ago' + +    if relative.startswith('now') or relative.endswith('now'): +        relative = 'now' +    return "%s (%s)" % (absolute, relative) + + +def scale_bytes(bytes, type='short'): +    if bytes >= 1073741824: +        scaled_bytes = round((bytes / 1073741824.0), 2) +        unit = 'G' +    elif bytes >= 1048576: +        scaled_bytes = round((bytes / 1048576.0), 1) +        if scaled_bytes >= 100: +            scaled_bytes = int(scaled_bytes) +        unit = 'M' +    elif bytes >= 1024: +        scaled_bytes = int(bytes / 1024) +        unit = 'K' +    else: +        scaled_bytes = round((bytes / 1024.0), 1) +        unit = 'K' + + +    # handle 0 bytes special +    if bytes == 0 and type == 'long': +        return 'nothing' + +    # convert to integer if .0 +    if int(scaled_bytes) == float(scaled_bytes): +        scaled_bytes = str(int(scaled_bytes)) +    else: +        scaled_bytes = str(scaled_bytes).rstrip('0') + +    if type == 'long': +        return num2str(bytes) + ' [' + scaled_bytes + unit + ']' +    else: +        return scaled_bytes + unit + + +def homedir2tilde(path): +    return re.sub(r'^'+os.environ['HOME'], '~', path) +def tilde2homedir(path): +    return re.sub(r'^~', os.environ['HOME'], path) + +def html2text(str): +    str = re.sub(r'</h\d+>', "\n", str) +    str = re.sub(r'</p>', ' ', str) +    str = re.sub(r'<[^>]*?>', '', str) +    return str + +def hide_cursor(): +    try: curses.curs_set(0)   # hide cursor if possible +    except curses.error: pass # some terminals seem to have problems with that +def show_cursor(): +    try: curses.curs_set(1) +    except curses.error: pass + +def wrap_multiline(text, width, initial_indent='', subsequent_indent=None): +    if subsequent_indent is None: +        subsequent_indent = ' ' * len(initial_indent) +    for line in text.splitlines(): +        # this is required because wrap() strips empty lines +        if not line.strip(): +            yield line +            continue +        for line in wrap(line, width, replace_whitespace=False, +                initial_indent=initial_indent, subsequent_indent=subsequent_indent): +            yield line +        initial_indent = subsequent_indent + +def ljust_columns(text, max_width, padchar=' '): +    """ Returns a string that is exactly <max_width> display columns wide, +    padded with <padchar> if necessary. Accounts for characters that are +    displayed two columns wide, i.e. kanji. """ + +    chars = [] +    columns = 0 +    max_width = max(0, max_width) +    for character in text: +        width = len_columns(character) +        if columns + width <= max_width: +            chars.append(character) +            columns += width +        else: +            break + +    # Fill up any remaining space +    while columns < max_width: +        assert len(padchar) == 1 +        chars.append(padchar) +        columns += 1 +    return ''.join(chars) + +def len_columns(text): +    """ Returns the amount of columns that <text> would occupy. """ +    columns = 0 +    for character in text: +        columns += 2 if unicodedata.east_asian_width(character) in ('W', 'F') else 1 +    return columns + + +def num2str(num, format='%s'): +    if int(num) == -1: +        return '?' +    elif int(num) == -2: +        return 'oo' +    else: +        if num > 999: +            return (re.sub(r'(\d{3})', '\g<1>,', str(num)[::-1])[::-1]).lstrip(',') +        else: +            return format % num + + +def debug(data): +    if cmd_args.DEBUG: +        file = open("debug.log", 'a') +        if type(data) == type(str()): +            file.write(data.encode('utf-8')) +        else: +            import pprint +            pp = pprint.PrettyPrinter(indent=4) +            file.write("\n====================\n" + pp.pformat(data) + "\n====================\n\n") +        file.close + +def quit(msg='', exitcode=0): +    try: +        curses.endwin() +    except curses.error: +        pass + +    # if this is a graceful exit and config file is present +    if not msg and not exitcode: +        save_config(cmd_args.configfile) +    else: +        print >> sys.stderr, msg, +    os._exit(exitcode) + + +def explode_connection_string(connection): +    host, port, path = \ +        config.get('Connection', 'host'), \ +        config.getint('Connection', 'port'),  \ +        config.get('Connection', 'path') +    username, password = \ +        config.get('Connection', 'username'), \ +        config.get('Connection', 'password') +    try: +        if connection.count('@') == 1: +            auth, connection = connection.split('@') +            if auth.count(':') == 1: +                username, password = auth.split(':') +        if connection.count(':') == 1: +            host, port = connection.split(':') +            if port.count('/') >= 1: +                port, path = port.split('/', 1) +            port = int(port) +        else: +            host = connection +    except ValueError: +        quit("Wrong connection pattern: %s\n" % connection) +    return host, port, path, username, password + +def create_url(host, port, path): +    url = '%s:%d/%s' % (host, port, path) +    url = url.replace('//', '/')   # double-/ doesn't work for some reason +    if config.getboolean('Connection', 'ssl'): +        return 'https://%s' % url +    else: +        return 'http://%s' % url + +def read_netrc(file=os.environ['HOME'] + '/.netrc', hostname=None): +    try: +        login = password = '' +        try: +            login, account, password = netrc.netrc(file).authenticators(hostname) +        except TypeError: +            pass +        try: +            netrc.netrc(file).hosts[hostname] +        except KeyError: +            if hostname != 'localhost': +                print "Unknown machine in %s: %s" % (file, hostname) +                if login and password: +                    print "Using default login: %s" % login +                else: +                    exit(CONFIGFILE_ERROR) +    except netrc.NetrcParseError, e: +        quit("Error in %s at line %s: %s\n" % (e.filename, e.lineno, e.msg)) +    except IOError, msg: +        quit("Cannot read %s: %s\n" % (file, msg)) +    return login, password + + +# create initial config file +def create_config(option, opt_str, value, parser): +    configfile = parser.values.configfile +    config.read(configfile) +    if parser.values.connection: +        host, port, path, username, password = explode_connection_string(parser.values.connection) +        config.set('Connection', 'host', host) +        config.set('Connection', 'port', str(port)) +        config.set('Connection', 'path', path) +        config.set('Connection', 'username', username) +        config.set('Connection', 'password', password) + +    # create directory if necessary +    dir = os.path.dirname(configfile) +    if dir != '' and not os.path.isdir(dir): +        try: +            os.makedirs(dir) +        except OSError, msg: +            print msg +            exit(CONFIGFILE_ERROR) + +    # write file +    if not save_config(configfile, force=True): +        exit(CONFIGFILE_ERROR) +    print "Wrote config file: %s" % configfile +    exit(0) + +def save_config(filepath, force=False): +    if force or os.path.isfile(filepath): +        try: +            config.write(open(filepath, 'w')) +            os.chmod(filepath, 0600)  # config may contain password +            return 1 +        except IOError, msg: +            print >> sys.stderr, "Cannot write config file %s:\n%s" % (filepath, msg) +            return 0 +    return -1 + +def parse_sort_str(sort_str): +    sort_orders = [] +    for i in sort_str.split(','): +        x = i.split(':')  +        if len(x) > 1: +            sort_orders.append( { 'name':x[1], 'reverse':True } ) +        else: +            sort_orders.append( { 'name':x[0], 'reverse':False } ) +    return sort_orders + + +# command line parameters +default_config_path = os.environ['HOME'] + '/.config/transmission-remote-cli/settings.cfg' +parser = OptionParser(usage="%prog [options] [-- transmission-remote options]", +                      version="%%prog %s" % VERSION, +                      description="%%prog %s" % VERSION) +parser.add_option("-c", "--connect", action="store", dest="connection", default="", +                  help="Point to the server using pattern [username:password@]host[:port]/[path]") +parser.add_option("-s", "--ssl", action="store_true", dest="ssl", default=False, +                  help="Connect to transmission using SSL.") +parser.add_option("-f", "--config", action="store", dest="configfile", default=default_config_path, +                  help="Path to configuration file.") +parser.add_option("--create-config", action="callback", callback=create_config, +                  help="Create configuration file CONFIGFILE with default values.") +parser.add_option("-n", "--netrc", action="store_true", dest="use_netrc", default=False, +                  help="Get authentication info from your ~/.netrc file.") +parser.add_option("--debug", action="store_true", dest="DEBUG", default=False, +                  help="Everything passed to the debug() function will be added to the file debug.log.") +(cmd_args, transmissionremote_args) = parser.parse_args() + + +# read config from config file +config.read(cmd_args.configfile) + +# command line connection data can override config file +if cmd_args.connection: +    host, port, path, username, password = explode_connection_string(cmd_args.connection) +    config.set('Connection', 'host', host) +    config.set('Connection', 'port', str(port)) +    config.set('Connection', 'path', path) +    config.set('Connection', 'username', username) +    config.set('Connection', 'password', password) +if cmd_args.use_netrc: +    username, password = read_netrc(hostname=config.get('Connection','host')) +    config.set('Connection', 'username', username) +    config.set('Connection', 'password', password) +if cmd_args.ssl: +    config.set('Connection', 'ssl', 'True') + + + +# forward arguments after '--' to transmission-remote +if transmissionremote_args: +    cmd = ['transmission-remote', '%s:%s' % +           (config.get('Connection', 'host'), config.get('Connection', 'port'))] + +    # one argument and it doesn't start with '-' --> treat it like it's a torrent link/url +    if len(transmissionremote_args) == 1 and not transmissionremote_args[0].startswith('-'): +        cmd.extend(['-a', transmissionremote_args[0]]) + +    if config.get('Connection', 'username') and config.get('Connection', 'password'): +        cmd_print = cmd +        cmd_print.extend(['--auth', '%s:PASSWORD' % config.get('Connection', 'username')]) +        print "EXECUTING:\n%s\nRESPONSE:" % ' '.join(cmd_print) +        cmd.extend(['--auth', '%s:%s' % (config.get('Connection', 'username'), config.get('Connection', 'password'))]) +        cmd.extend(transmissionremote_args) +    else: +        print "EXECUTING:\n%s\nRESPONSE:" % ' '.join(cmd) + +    try: +        retcode = call(cmd) +    except OSError, msg: +        quit("Could not execute the above command: %s\n" % msg, 128) +    quit('', retcode) + + +server = Transmission(config.get('Connection', 'host'), +                      config.getint('Connection', 'port'), +                      config.get('Connection', 'path'), +                      config.get('Connection', 'username'), +                      config.get('Connection', 'password')) +Interface() + | 
