#!/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.4.1'
TRNSM_VERSION_MIN = '1.90'
TRNSM_VERSION_MAX = '2.61'
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
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', 'title_error', 'bg:red,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()
self.term_has_colors = curses.has_colors()
curses.start_color()
if self.term_has_colors:
curses.use_default_colors()
for name in config.keys():
self.config[name] = self._parse_color_pair(config[name])
if self.term_has_colors:
curses.init_pair(self.config[name]['id'],
self.config[name]['fg'],
self.config[name]['bg'])
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 id(self, name): return self.config[name]['id']
class Normalizer:
def __init__(self):
self.values = {}
def add(self, id, value, max):
if not id in self.values.keys():
self.values[id] = [ float(value) ]
else:
if len(self.values[id]) >= max:
self.values[id].pop(0)
self.values[id].append(float(value))
return self.get(id)
def get(self, id):
if not id in self.values.keys():
return 0.0
return sum(self.values[id]) / len(self.values[id])
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)
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))
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
STATUS_ISOLATED = 7 # Torrent can't find peers
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', 'isPrivate' ]
DETAIL_FIELDS = [ 'files', 'priorities', 'wanted', 'peers', 'trackers',
'activityDate', 'dateCreated', 'startDate', 'doneDate',
'totalSize', 'leftUntilDone', 'comment',
'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 not self.can_has_peers(t):
t['status'] = Transmission.STATUS_ISOLATED
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'
elif torrent['status'] == Transmission.STATUS_ISOLATED:
status = 'isolated'
else:
status = 'unknown state'
return status
def can_has_peers(self, torrent):
""" Will return True if at least one tracker was successfully queried
recently, or if DHT is enabled for this torrent and globally, False
otherwise. """
# torrent has trackers
if torrent['trackerStats']:
has_connected = any([tracker['hasAnnounced'] or tracker['hasScraped']
for tracker in torrent['trackerStats']])
if has_connected:
for tracker in torrent['trackerStats']:
if tracker['hasScraped'] and \
tracker['lastScrapeTime'] >= tracker['lastAnnounceTime'] and \
tracker['lastScrapeSucceeded']:
return True
elif tracker['hasAnnounced'] and \
tracker['lastAnnounceTime'] > tracker['lastScrapeTime'] and \
tracker['lastAnnounceSucceeded']:
return True
else:
# If no tracker has been queried (yet), assume at least one is online
return True
# torrent can make use of DHT
if not self.status_cache.has_key('dht-enabled') or \
(self.status_cache['dht-enabled'] and not torrent['isPrivate']):
return True
return False
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.file_index_map = {} # Maps local torrent's file indices to server file indices
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
locale.setlocale(locale.LC_ALL, '')
self.encoding = locale.getpreferredencoding()
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()
self.colors = ColorManager(dict(config.items('Colors')))
# 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 enc(self, text):
return text.encode(self.encoding, 'replace')
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.recalculate_torrents_per_page()
self.pad_height = max((len(self.torrents)+1) * self.tlist_item_height, self.height)
self.pad = curses.newpad(self.pad_height, self.width)
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 recalculate_torrents_per_page(self):
self.tlist_item_height = 3 if not self.compact_list else 1
self.mainview_height = self.height - 2
self.torrents_per_page = self.mainview_height / self.tlist_item_height
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'),
('private','P_rivate'), ('isolated', '_Isolated'),
('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'])
self.focus_next_after_delete()
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'])
self.focus_next_after_delete()
def focus_next_after_delete(self):
""" Focus next torrent after user deletes torrent """
new_focus = min(self.focus + 1, len(self.torrents) - 2)
self.focused_id = self.torrents[new_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.file_index_map[index] for index in self.selected_files])
server.increase_file_priority(files)
elif self.focus_detaillist > -1:
server.increase_file_priority([self.file_index_map[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.file_index_map[index] for index in self.selected_files])
server.decrease_file_priority(files)
elif self.focus_detaillist > -1:
server.decrease_file_priority([self.file_index_map[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
self.recalculate_torrents_per_page()
self.follow_list_focus()
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 == 'private':
self.torrents = [t for t in self.torrents if t['isPrivate']]
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]
elif self.filter_list == 'isolated':
self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_ISOLATED]
# 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.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.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.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.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.id('title_seed'))
elif torrent['status'] == Transmission.STATUS_STOPPED:
color = curses.color_pair(self.colors.id('title_paused'))
elif torrent['status'] == Transmission.STATUS_CHECK or \
torrent['status'] == Transmission.STATUS_CHECK_WAIT:
color = curses.color_pair(self.colors.id('title_verify'))
elif torrent['status'] == Transmission.STATUS_ISOLATED:
color = curses.color_pair(self.colors.id('title_error'))
elif torrent['rateDownload'] == 0:
color = curses.color_pair(self.colors.id('title_idle'))
elif torrent['percentDone'] < 100:
color = curses.color_pair(self.colors.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, self.enc(title[0:bar_width]), tag_done)
self.pad.addstr(ypos, len_columns(title[0:bar_width]), self.enc(title[bar_width:]), tag)
except:
pass
else:
self.pad.addstr(ypos, 0, self.enc(title), tag_done)
def draw_torrentlist_status(self, torrent, focused, ypos):
peers = ''
parts = [server.get_status(torrent)]
if torrent['status'] == Transmission.STATUS_ISOLATED and torrent['peersConnected'] <= 0:
if not torrent['trackerStats']:
parts[0] = "Unable to find peers without trackers and DHT disabled"
else:
tracker_errors = [tracker['lastAnnounceResult'] or tracker['lastScrapeResult']
for tracker in torrent['trackerStats']]
parts[0] = self.enc([te for te in tracker_errors if te][0])
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 (%d%%) transmitted; " %
(scale_bytes(t['uploadedEver'], 'long'), t['uploadRatio']*100)])
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(['Ratio: ', '%.2f copies distributed' % copies_distributed])
norm_upload_rate = norm.add('%s:rateUpload' % t['id'], t['rateUpload'], 15)
if norm_upload_rate > 0:
target_ratio = self.get_target_ratio()
bytes_left = (max(t['downloadedEver'],t['sizeWhenDone']) * target_ratio) - t['uploadedEver']
time_left = bytes_left / norm_upload_rate
info[-1][-1] += '; '
if time_left < 86400: # 1 day
info[-1].append('approaching %.2f at %s' % \
(target_ratio, timestamp(time.time() + time_left, "%R")))
else:
info[-1].append('approaching %.2f on %s' % \
(target_ratio, timestamp(time.time() + time_left, "%x")))
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: ', "