aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--NEWS17
-rw-r--r--README.md6
-rwxr-xr-xtransmission-remote-cli200
3 files changed, 156 insertions, 67 deletions
diff --git a/NEWS b/NEWS
index b59b25e..1e5054d 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,20 @@
+1.4 2012-07-30
+ BUGFIXES:
+ - Fix crash upon opening help window for small terminals
+ - Consider wide characters in dialogs
+ - Don't draw dialogs that are bigger than the terminal
+ - Fix file list indentation for multiple same-level directories
+
+ - Support for Transmission 2.61
+ - Highlight torrents that can't discover new peers
+ - Use terminal's default background/foreground colors, making
+ pseudo-transparency and black-on-white color schemes possible
+ - Use locale to find out preferred output encoding instead of forcing
+ UTF-8
+ - Peer and tracker list are a little more compressed to fit into small
+ terminals
+
+
1.3.1 2012-06-12
BUGFIXES:
- Fix wrong progress bar placement calculation for two-column
diff --git a/README.md b/README.md
index a923139..f4d14f9 100644
--- a/README.md
+++ b/README.md
@@ -78,11 +78,11 @@ torrent files with transmission-remote-cli.
## Screenshots
-![Main window - full, v1.3](transmission-remote-cli/blob/master/screenshots/screenshot-mainfull-v1.3.png)
+![Main window - full, v1.3](https://github.com/fagga/transmission-remote-cli/raw/master/screenshots/screenshot-mainfull-v1.3.png)
-![Main window - compact, v1.3](transmission-remote-cli/blob/master/screenshots/screenshot-maincompact-v1.3.png)
+![Main window - compact, v1.3](https://github.com/fagga/transmission-remote-cli/raw/master/screenshots/screenshot-maincompact-v1.3.png)
-![Info window, v1.3](transmission-remote-cli/blob/master/screenshots/screenshot-details-v1.3.png)
+![Info window, v1.3](https://github.com/fagga/transmission-remote-cli/raw/master/screenshots/screenshot-details-v1.3.png)
## Copyright
diff --git a/transmission-remote-cli b/transmission-remote-cli
index d9896e1..4bfe38d 100755
--- a/transmission-remote-cli
+++ b/transmission-remote-cli
@@ -16,10 +16,10 @@
# http://www.gnu.org/licenses/gpl-3.0.txt #
########################################################################
-VERSION = '1.3.1'
+VERSION = '1.4'
TRNSM_VERSION_MIN = '1.90'
-TRNSM_VERSION_MAX = '2.52'
+TRNSM_VERSION_MAX = '2.61'
RPC_VERSION_MIN = 8
RPC_VERSION_MAX = 14
@@ -55,7 +55,6 @@ import os
import signal
import unicodedata
import locale
-locale.setlocale(locale.LC_ALL, '')
import curses
import curses.ascii
from textwrap import wrap
@@ -121,6 +120,7 @@ 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')
@@ -257,6 +257,7 @@ class Transmission:
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
@@ -392,6 +393,8 @@ class Transmission:
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']
@@ -723,10 +726,40 @@ class Transmission:
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 '-'
@@ -774,6 +807,9 @@ class Interface:
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,
@@ -864,6 +900,7 @@ class Interface:
# enable colors if available
try:
curses.start_color()
+ curses.use_default_colors()
self.colors = ColorManager(dict(config.items('Colors')))
for name in sorted(self.colors.get_names()):
curses.init_pair(self.colors.get_id(name),
@@ -892,6 +929,9 @@ class Interface:
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:
@@ -1110,7 +1150,8 @@ class Interface:
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'),
+ ('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)
@@ -1456,6 +1497,8 @@ class Interface:
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]
@@ -1587,14 +1630,16 @@ class Interface:
size = '| ' + size
title = ljust_columns(torrent['name'], width - len(size)) + size
- if torrent['status'] == Transmission.STATUS_SEED \
- or torrent['status'] == Transmission.STATUS_SEED_WAIT:
+ 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:
+ 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['status'] == Transmission.STATUS_ISOLATED:
+ color = curses.color_pair(self.colors.get_id('title_error'))
elif torrent['rateDownload'] == 0:
color = curses.color_pair(self.colors.get_id('title_idle'))
elif torrent['percentDone'] < 100:
@@ -1612,24 +1657,25 @@ class Interface:
# 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, len_columns(title[0:bar_width]), title[bar_width:].encode('utf-8'), tag)
+ 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, title.encode('utf-8'), tag_done)
+ self.pad.addstr(ypos, 0, self.enc(title), 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')
-
+ 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)
@@ -1861,12 +1907,12 @@ class Interface:
for i, line in enumerate(comment):
if(ypos+i > self.height-1):
break
- self.pad.addstr(ypos+i, 50, line.encode('utf8'))
+ self.pad.addstr(ypos+i, 50, self.enc(line))
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'))
+ self.pad.addstr(ypos+6+i, 2, self.enc(line))
def draw_filelist(self, ypos):
column_names = ' # Progress Size Priority Filename'
@@ -1891,21 +1937,21 @@ class Interface:
xpos = 0
for part in re.split('(high|normal|low|off)', line[0:30], 1):
if part == 'high':
- self.pad.addstr(ypos, xpos, part,
+ self.pad.addstr(ypos, xpos, self.enc(part),
curses_tags + curses.color_pair(self.colors.get_id('file_prio_high')))
elif part == 'normal':
- self.pad.addstr(ypos, xpos, part,
+ self.pad.addstr(ypos, xpos, self.enc(part),
curses_tags + curses.color_pair(self.colors.get_id('file_prio_normal')))
elif part == 'low':
- self.pad.addstr(ypos, xpos, part,
+ self.pad.addstr(ypos, xpos, self.enc(part),
curses_tags + curses.color_pair(self.colors.get_id('file_prio_low')))
elif part == 'off':
- self.pad.addstr(ypos, xpos, part,
+ self.pad.addstr(ypos, xpos, self.enc(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)
+ self.pad.addstr(ypos, xpos, self.enc(part), curses_tags)
xpos += len(part)
- self.pad.addstr(ypos, xpos, line[30:].encode('utf-8'), curses_tags)
+ self.pad.addstr(ypos, xpos, self.enc(line[30:]), curses_tags)
ypos += 1
if ypos > self.height:
break
@@ -1939,17 +1985,36 @@ class Interface:
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)
+ """ Create directory transition from <current_folder> to <f>,
+ both of which are an array of strings, each one representing one
+ subdirectory in their path (e.g. /tmp/a/c would result in
+ [temp, a, c]). <filelist> is a list of strings that will later be drawn
+ to screen. This function only creates directory strings, and is
+ responsible for managing depth (i.e. indentation) between different
+ directories.
+ """
+ f_len = len(f) - 1 # Amount of subdirectories in f
+ current_folder_len = len(current_folder) # Amount of subdirectories in
+ # current_folder
+ # Number of directory parts from f and current_directory that are identical
same = 0
- while same < current_folder_len and same < f_len and f[same] == current_folder[same]:
+ while (same < current_folder_len and
+ same < f_len and
+ f[same] == current_folder[same]):
same += 1
+
+ # Reduce depth for each directory f has less than current_folder
for i in range(current_folder_len - same):
current_depth -= 1
filelist.append(' '*current_depth + ' '*31 + '/')
pos += 1
- if f_len < current_folder_len:
+
+ # Stepping out of a directory, but not into a new directory
+ if f_len < current_folder_len and f_len == same:
return [current_depth, pos]
+
+ # Increase depth for each new directory that appears in f,
+ # but not in current_directory
while current_depth < f_len:
filelist.append('%s\\ %s' % (' '*current_depth + ' '*31 , f[current_depth]))
current_depth += 1
@@ -1983,12 +2048,12 @@ class Interface:
if len(peer['address']) > address_width: address_width = len(peer['address'])
# Column names
- column_names = "Flags %3d Down %3d Up Progress ETA " % \
+ 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"
+ column_names += 'Client'.ljust(clientname_width + 1) \
+ + 'Address'.ljust(address_width)
+ 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
@@ -2019,7 +2084,7 @@ class Interface:
# Down
self.pad.addstr("%5s " % scale_bytes(peer['rateToClient']), download_tag)
# Up
- self.pad.addstr("%5s " % scale_bytes(peer['rateToPeer']), upload_tag)
+ self.pad.addstr("%5s " % scale_bytes(peer['rateToPeer']), upload_tag)
# Progress
if peer['progress'] < 1: self.pad.addstr("%3d%%" % (float(peer['progress'])*100))
@@ -2027,25 +2092,24 @@ class Interface:
# 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']))
+ self.pad.addstr(" %6s %4s " % \
+ ('~' + scale_bytes(peer['download_speed']),
+ '~' + scale_time(peer['time_left'])))
else:
- self.pad.addstr(" ")
+ if peer['progress'] < 1:
+ self.pad.addstr(" <guessing> ")
+ else:
+ self.pad.addstr(" ")
# Client
- self.pad.addstr(peer['clientName'].ljust(clientname_width + 2).encode('utf-8'))
+ self.pad.addstr(self.enc(peer['clientName'].ljust(clientname_width + 1)))
# Address
- self.pad.addstr(peer['address'].ljust(address_width + 2))
+ self.pad.addstr(peer['address'].ljust(address_width + 1))
# Country
- if features['geoip']: self.pad.addstr(" %2s " % geo_ips[peer['address']])
+ 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)
+ if features['dns']: self.pad.addstr(self.enc(host_name), 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):
@@ -2083,7 +2147,7 @@ class Interface:
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']))
+ addstr(ypos+1, 54, "Last scrape: %s" % timestamp(t['lastScrapeTime']))
if t['lastAnnounceSucceeded']:
peers = "%s peer%s" % (num2str(t['lastAnnouncePeerCount']), ('s', '')[t['lastAnnouncePeerCount']==1])
@@ -2093,21 +2157,21 @@ class Interface:
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'))
+ announce_msg_size = self.wrap_and_draw_result(top, ypos+2, 19, self.enc(t['lastAnnounceResult']))
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)
+ addstr(ypos+2, 52, "Tracker knows:")
+ addstr(ypos+2, 67, "%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'])
+ addstr(ypos+2, 57, "Response:")
+ scrape_msg_size += self.wrap_and_draw_result(top, ypos+2, 67, 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']))
+ addstr(ypos+3, 52, " Next scrape: %s" % timestamp(t['nextScrapeTime']))
ypos += 5
@@ -2158,14 +2222,14 @@ class Interface:
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
+ self.pad.addstr(ypos, 1, self.enc(i[0].rjust(key_width))) # 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'))
+ self.pad.addstr(self.enc(v))
ypos += 1
return ypos
@@ -2326,7 +2390,7 @@ class Interface:
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)
+ self.screen.addstr(0, 0, self.enc(status), curses.A_REVERSE)
def draw_quick_help(self):
help = [('?','Show Keybindings')]
@@ -2442,23 +2506,29 @@ class Interface:
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
+ if len_columns(line) > width:
+ line = ljust_columns(line, width-7) + '...'
+
+ if ypos < height - 1: # ypos == height-1 is frame border
+ win.addstr(ypos, 2, self.enc(line))
+ ypos += 1
+ else:
+ # Do not write outside of frame border
+ win.addstr(ypos, 2, " More... ")
+ break
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
+ width = max(max(map(lambda x: len_columns(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
+ width = max(len_columns(message), 8) + 4
win = self.window(height, width, message=message)
win.keypad(True)
@@ -2963,6 +3033,8 @@ def ljust_columns(text, max_width, padchar=' '):
def len_columns(text):
""" Returns the amount of columns that <text> would occupy. """
+ if type(text) == type(str()):
+ text = unicode(text)
columns = 0
for character in text:
columns += 2 if unicodedata.east_asian_width(character) in ('W', 'F') else 1
@@ -2985,7 +3057,7 @@ def debug(data):
if cmd_args.DEBUG:
file = open("debug.log", 'a')
if type(data) == type(str()):
- file.write(data.encode('utf-8'))
+ file.write(self.enc(data))
else:
import pprint
pp = pprint.PrettyPrinter(indent=4)