diff options
| -rw-r--r-- | NEWS | 15 | ||||
| -rwxr-xr-x | transmission-remote-cli | 231 | 
2 files changed, 200 insertions, 46 deletions
@@ -1,3 +1,18 @@ +1.6.0  2013-06-28 +    BUGFIXES: +        - Fix timestamps after year 2038 on 32-bit systems +        - Fix host placement in peer list +        - Fallback to UTF-8 on LookupError from getpreferredencoding() +          (as seen when ssh'ing into Mac OS) + +    - Jump to and select directories in file list +    - Sort torrents by tracker +    - Make blank line between torrents optional +    - Add preview for incomplete files +    - Move queue position by 10 with S-Left/Right +    - Show IP:PORT in peer list + +  1.5.0  2013-03-21      BUGFIXES:          - Torrents should no longer become 'isolated' for seemingly no reason diff --git a/transmission-remote-cli b/transmission-remote-cli index da494c7..bbb3ea9 100755 --- a/transmission-remote-cli +++ b/transmission-remote-cli @@ -16,7 +16,7 @@  # http://www.gnu.org/licenses/gpl-3.0.txt                              #  ######################################################################## -VERSION = '1.5.0' +VERSION = '1.6.0'  TRNSM_VERSION_MIN = '1.90'  TRNSM_VERSION_MAX = '2.80' @@ -42,6 +42,7 @@ except ImportError:          quit("Please install simplejson or Python 2.6 or higher.")  import time +import datetime  import re  import base64  import httplib @@ -61,6 +62,8 @@ from textwrap import wrap  from subprocess import call, Popen  import netrc  import glob +import operator +import urlparse  # optional features provided by non-standard modules @@ -114,8 +117,10 @@ config.set('Filtering', 'filter', '')  config.set('Filtering', 'invert', 'False')  config.add_section('Misc')  config.set('Misc', 'compact_list', 'False') +config.set('Misc', 'blank_lines', 'True')  config.set('Misc', 'torrentname_is_progressbar', 'True')  config.set('Misc', 'file_viewer', 'xdg-open %%s') +config.set('Misc', 'file_open_in_terminal', 'True')  config.add_section('Colors')  config.set('Colors', 'title_seed',       'bg:green,fg:black')  config.set('Colors', 'title_download',   'bg:blue,fg:black') @@ -397,6 +402,15 @@ class Transmission:      def parse_response(self, response): +        def get_main_tracker_domain(torrent): +            if torrent['trackerStats']: +                trackers = sorted(torrent['trackerStats'], +                                  key=operator.itemgetter('tier', 'id')) +                return urlparse.urlparse(trackers[0]['announce']).hostname +            else: +                # Trackerless torrents +                return None +          # 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']: @@ -412,6 +426,7 @@ class Transmission:                  except ValueError:                      t['seeders']  = t['leechers'] = -1                  t['isIsolated'] = not self.can_has_peers(t) +                t['mainTrackerDomain'] = get_main_tracker_domain(t)              if response['tag'] == self.TAG_TORRENT_LIST:                  self.torrent_cache = response['arguments']['torrents'] @@ -491,14 +506,15 @@ class Transmission:          return self.status_cache      def get_torrent_list(self, sort_orders): +        def sort_value(value): +            try: +                return value.lower() +            except AttributeError: +                return value          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']) +                self.torrent_cache.sort(key=lambda x: sort_value(x[sort_order['name']]), +                                        reverse=sort_order['reverse'])          except IndexError:              return []          return self.torrent_cache @@ -600,15 +616,17 @@ class Transmission:              request.send_request()              self.wait_for_torrentlist_update() -    def decrease_queue_position(self, torrent_id): -        request = TransmissionRequest(self.host, self.port, self.path, 'queue-move-up', 1, -                                          {'ids': [torrent_id]}) -        request.send_request() -        self.wait_for_torrentlist_update() +    def move_queue(self, torrent_id, new_position): +        args = {'ids': [ torrent_id ] } +        if new_position in ('up', 'down', 'top', 'bottom'): +            method_name = 'queue-move-' + new_position +        elif isinstance(new_position, int): +            method_name = 'torrent-set' +            args['queuePosition'] = min(max(new_position, 0), len(self.torrent_cache)-1) +        else: +            raise ValueError("Is not up/down/top/bottom/<number>: %s" % new_position) -    def increase_queue_position(self, torrent_id): -        request = TransmissionRequest(self.host, self.port, self.path, 'queue-move-down', 1, -                                          {'ids': [torrent_id]}) +        request = TransmissionRequest(self.host, self.port, self.path, method_name, 1, args)          request.send_request()          self.wait_for_torrentlist_update() @@ -821,8 +839,10 @@ class Interface:          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.blank_lines    = config.getboolean('Misc', 'blank_lines')          self.torrentname_is_progressbar = config.getboolean('Misc', 'torrentname_is_progressbar')          self.file_viewer    = config.get('Misc', 'file_viewer') +        self.file_open_in_terminal = config.getboolean('Misc', 'file_open_in_terminal')          self.torrents         = server.get_torrent_list(self.sort_orders)          self.stats            = server.get_global_stats() @@ -846,7 +866,7 @@ class Interface:          self.exit_now               = False          locale.setlocale(locale.LC_ALL, '') -        self.encoding = locale.getpreferredencoding() +        self.encoding = locale.getpreferredencoding() or 'UTF-8'          self.keybindings = {              ord('?'):               self.call_list_key_bindings, @@ -870,8 +890,8 @@ class Interface:              ord('t'):               self.t_key,              ord('+'):               self.bandwidth_priority,              ord('-'):               self.bandwidth_priority, -            ord('J'):               lambda c: self.move_queue('up'), -            ord('K'):               lambda c: self.move_queue('down'), +            ord('J'):               self.J_key, +            ord('K'):               self.K_key,              ord('p'):               self.pause_unpause_torrent,              ord('P'):               self.pause_unpause_all_torrent,              ord('v'):               self.verify_torrent, @@ -903,9 +923,14 @@ class Interface:              curses.KEY_LEFT:        self.file_pritority_or_switch_details,              ord(' '):               self.space_key,              ord('a'):               self.a_key, +            ord('A'):               self.A_key,              ord('m'):               self.move_torrent,              ord('n'):               self.reannounce_torrent, -            ord('/'):               self.dialog_search_torrentlist +            ord('/'):               self.dialog_search_torrentlist, +            curses.KEY_SEND:        lambda c: self.move_queue('bottom'), +            curses.KEY_SHOME:       lambda c: self.move_queue('top'), +            curses.KEY_SLEFT:       lambda c: self.move_queue('ppage'), +            curses.KEY_SRIGHT:      lambda c: self.move_queue('npage')          }          self.sort_options = [ @@ -914,7 +939,7 @@ class Interface:              ('status','S_tatus'), ('uploadedEver','Up_loaded'),              ('rateUpload','_Upload Speed'), ('rateDownload','_Download Speed'),              ('uploadRatio','_Ratio'), ('peersConnected','P_eers'), -            ('downloadDir', 'L_ocation') ] +            ('downloadDir', 'L_ocation'), ('mainTrackerDomain', 'Trac_ker') ]          # queue was implemmented in transmission 2.4          if server.get_rpc_version() >= 14: @@ -1024,7 +1049,8 @@ class Interface:          return new_width      def recalculate_torrents_per_page(self): -        self.tlist_item_height = 3 if not self.compact_list else 1 +        self.lines_per_entry   = 3 if self.blank_lines else 2 +        self.tlist_item_height = self.lines_per_entry if not self.compact_list else 1          self.mainview_height = self.height - 2          self.torrents_per_page = self.mainview_height / self.tlist_item_height @@ -1055,6 +1081,7 @@ class Interface:                  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', 'blank_lines', str(self.blank_lines))                  config.set('Misc', 'torrentname_is_progressbar', str(self.torrentname_is_progressbar))                  save_config(cmd_args.configfile)                  return @@ -1102,6 +1129,11 @@ class Interface:          elif self.selected_torrent == -1:              self.enter_key(c) +    def A_key(self, c): +        # File list +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.select_unselect_file(c) +      def a_key(self, c):          # File list          if self.selected_torrent > -1 and self.details_category_focus == 1: @@ -1148,6 +1180,18 @@ class Interface:          elif self.selected_torrent > -1 and self.details_category_focus == 3:              self.remove_tracker() +    def J_key(self, c): +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.move_to_next_directory_in_filelist() +        else: +            self.move_queue('down') + +    def K_key(self, c): +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.move_to_previous_directory_in_filelist() +        else: +            self.move_queue('up') +      def right_key(self, c):          if self.focus > -1 and self.selected_torrent == -1:              self.enter_key(c) @@ -1260,12 +1304,16 @@ class Interface:      def move_queue(self, direction):          # queue was implemmented in Transmission v2.4 -        if server.get_rpc_version() >= 14: -            if direction == 'up' and self.focus > -1: -                server.increase_queue_position(self.torrents[self.focus]['id']) -            elif direction == 'down' and self.focus > -1: -                server.decrease_queue_position(self.torrents[self.focus]['id']) - +        if server.get_rpc_version() >= 14 and self.focus > -1: +            if direction in ('ppage', 'npage'): +                new_position = self.torrents[self.focus]['queuePosition'] +                if direction == 'ppage': +                    new_position -= 10 +                else: +                    new_position += 10 +            else: +                new_position = direction +            server.move_queue(self.torrents[self.focus]['id'], new_position)      def pause_unpause_torrent(self, c):          if self.focus > -1: @@ -1476,6 +1524,25 @@ class Interface:                  except ValueError:                      self.selected_files.append(self.focus_detaillist)                  curses.ungetch(curses.KEY_DOWN) # move down +            # (un)select directory +            elif c == ord('A'): +                file_id = self.file_index_map[self.focus_detaillist] +                focused_dir = os.path.dirname(self.torrent_details['files'][file_id]['name']) +                if self.selected_files.count(self.focus_detaillist): +                    for focus in range(0, len(self.torrent_details['files'])): +                        file_id = self.file_index_map[focus] +                        if self.torrent_details['files'][file_id]['name'].startswith(focused_dir): +                            try: +                                while focus in self.selected_files: +                                    self.selected_files.remove(focus) +                            except ValueError: +                                pass +                else: +                    for focus in range(0, len(self.torrent_details['files'])): +                        file_id = self.file_index_map[focus] +                        if self.torrent_details['files'][file_id]['name'].startswith(focused_dir): +                            self.selected_files.append(focus) +                self.move_to_next_directory_in_filelist()              # (un)select all files              elif c == ord('a'):                  if self.selected_files: @@ -1483,20 +1550,67 @@ class Interface:                  else:                      self.selected_files = range(0, len(self.torrent_details['files'])) +    def move_to_next_directory_in_filelist(self): +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.focus_detaillist = max(self.focus_detaillist, 0) +            file_id = self.file_index_map[self.focus_detaillist] +            focused_dir = os.path.dirname(self.torrent_details['files'][file_id]['name']) +            while self.torrent_details['files'][file_id]['name'].startswith(focused_dir) \ +                    and self.focus_detaillist < len(self.torrent_details['files'])-1: +                self.movement_keys(curses.KEY_DOWN) +                file_id = self.file_index_map[self.focus_detaillist] + +    def move_to_previous_directory_in_filelist(self): +        if self.selected_torrent > -1 and self.details_category_focus == 1: +            self.focus_detaillist = max(self.focus_detaillist, 0) +            file_id = self.file_index_map[self.focus_detaillist] +            focused_dir = os.path.dirname(self.torrent_details['files'][file_id]['name']) +            while self.torrent_details['files'][file_id]['name'].startswith(focused_dir) \ +                    and self.focus_detaillist > 0: +                self.movement_keys(curses.KEY_UP) +                file_id = self.file_index_map[self.focus_detaillist] +      def open_torrent_file(self, c):          if self.focus_detaillist >= 0:              details = server.get_torrent_details() +            stats = server.get_global_stats() +              file_server_index = self.file_index_map[self.focus_detaillist]              file_name = details['files'][file_server_index]['name'] +              download_dir = details['downloadDir'] +            incomplete_dir = stats['incomplete-dir'] + '/' +         +            file_path = None +            possible_file_locations = [ +                    download_dir + file_name, +                    download_dir + file_name + '.part', +                    incomplete_dir + file_name, +                    incomplete_dir + file_name + '.part' +            ] + +            for f in possible_file_locations: +                if (os.path.isfile(f)): +                    file_path = f +                    break + +            if file_path is None: +                self.get_screen_size() +                self.dialog_ok("Could not find file:\n%s" % (file_name)) +                return              viewer_cmd=[]              for argstr in self.file_viewer.split(" "): -                viewer_cmd.append(argstr.replace('%s',download_dir + file_name)) +                viewer_cmd.append(argstr.replace('%s', file_path))              try: -                self.restore_screen() -                call(viewer_cmd) -                self.get_screen_size() +                if self.file_open_in_terminal: +                    self.restore_screen() +                    call(viewer_cmd) +                    self.get_screen_size() +                else: +                    devnull = open(os.devnull, 'wb') +                    Popen(viewer_cmd, stdout=devnull, stderr=devnull) +                    devnull.close()              except OSError, err:                  self.get_screen_size()                  self.dialog_ok("%s:\n%s" % (" ".join(viewer_cmd), err)) @@ -1649,7 +1763,7 @@ class Interface:              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 +            return self.lines_per_entry # 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']: @@ -2077,10 +2191,14 @@ class Interface:              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 self.blank_lines: +            for i in range(current_folder_len - same): +               current_depth -= 1 +               filelist.append('  '*current_depth + ' '*31 + '/') +               pos += 1 +        else: # code duplication, but less calculation +            for i in range(current_folder_len - same): +               current_depth -= 1          # Stepping out of a directory, but not into a new directory          if f_len < current_folder_len and f_len == same: @@ -2116,15 +2234,17 @@ class Interface:          # Find width of columns          clientname_width = 0          address_width = 0 +        port_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']) +            if len(str(peer['port'])) > port_width: port_width = len(str(peer['port']))          # 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 + 1) \ -            + 'Address'.ljust(address_width) +            + 'Address'.ljust(address_width+port_width+1)          if features['geoip']: column_names += 'Country'          if features['dns']: column_names += ' Host' @@ -2175,8 +2295,9 @@ class Interface:                      self.pad.addstr("             ")              # Client              self.pad.addstr(self.enc(peer['clientName'].ljust(clientname_width + 1))) -            # Address -            self.pad.addstr(peer['address'].ljust(address_width + 1)) +            # Address:Port +            self.pad.addstr(peer['address'].rjust(address_width) + \ +                                ':' + str(peer['port']).ljust(port_width) + ' ')              # Country              if features['geoip']: self.pad.addstr("  %2s   " % geo_ips[peer['address']])              # Host @@ -2493,8 +2614,8 @@ class Interface:      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" + \ +                  "            u/d  Adjust maximum global up-/download rate\n" + \ +                  "            U/D  Adjust maximum up-/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" + \ @@ -2507,8 +2628,12 @@ class Interface:                    "    Shift+Del/R  Remove torrent and delete content\n"          # Queue was implemented in Transmission v2.4 -        if server.get_rpc_version() >= 14: -            message += "            K/J  Increase/decrease Queue Position for focused torrent\n" +        if server.get_rpc_version() >= 14 and self.details_category_focus != 1: +            message += "            J/K  Move focused torrent in queue up/down\n" + \ +                       " Shift+Lft/Rght  Move focused torrent in queue up/down by 10\n" + \ +                       " Shift+Home/End  Move focused torrent to top/bottom of queue\n" +        else: +            message += "            J/K  Jump to next/previous directory\n"          # Torrent list          if self.selected_torrent == -1:              message += "              /  Search in torrent list\n" + \ @@ -2550,6 +2675,7 @@ class Interface:                      message += "        Up/Down  Select file\n" + \                                 "          Space  Select/Deselect focused file\n" + \                                 "              a  Select/Deselect all files\n" + \ +                               "              A  Select/Deselect directory\n" + \                                 "            Esc  Unfocus+Unselect or Back to torrent list\n" + \                                 "    q/Backspace  Back to torrent list"                  else: @@ -2798,7 +2924,7 @@ class Interface:              elif cursorkeys and c != -1:                  try: -                    if input == '': input = 0                         +                    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 @@ -2885,6 +3011,7 @@ class Interface:                  options.append(('Do_wnload Queue Size', "%s" % ('disabled',self.stats['download-queue-size'])[self.stats['download-queue-enabled']]))                  options.append(('S_eed Queue Size', "%s" % ('disabled',self.stats['seed-queue-size'])[self.stats['seed-queue-enabled']]))              options.append(('Title is Progress _Bar', ('no','yes')[self.torrentname_is_progressbar])) +            options.append(('Blan_k lines in non-compact', ('no','yes')[self.blank_lines]))              options.append(('File _Viewer', "%s" % self.file_viewer)) @@ -2991,6 +3118,9 @@ class Interface:                              server.set_option('seed-queue-enabled', True)                          server.set_option('seed-queue-size', queue_size) +            elif c == ord('k'): +		self.blank_lines = not self.blank_lines +              elif c == ord('v'):                  viewer = self.dialog_input_text('File Viewer\nExample: xdg-viewer %s', self.file_viewer)                  if viewer: @@ -3068,7 +3198,16 @@ def timestamp(timestamp, format="%x %X"):      if timestamp < 1:          return 'never' -    absolute = time.strftime(format, time.localtime(timestamp)) +    if timestamp > 2147483647:  # Max value of 32bit signed integer (2^31-1) +        # Timedelta objects do not fail on timestamps +        # resulting in a date later than 2038 +        date = (datetime.datetime.fromtimestamp(0) + +                datetime.timedelta(seconds=timestamp)) +        timeobj = date.timetuple() +    else: +        timeobj = time.localtime(timestamp) + +    absolute = time.strftime(format, timeobj)      if timestamp > time.time():          relative = 'in ' + scale_time(int(timestamp - time.time()), 'long')      else: @@ -3309,7 +3448,7 @@ def save_config(filepath, force=False):  def parse_sort_str(sort_str):      sort_orders = []      for i in sort_str.split(','): -        x = i.split(':')  +        x = i.split(':')          if len(x) > 1:              sort_orders.append( { 'name':x[1], 'reverse':True } )          else:  | 
