#!/usr/bin/env python2.4 """ Version 1.7 Copyright (C) 2011 Matthias Radig 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 """ import optparse, sys, os, datetime, time, re, pyPgSQL import pyPgSQL.PgSQL as db allowedInUrl = r"[\w|\.|\-|!|?|/|#|&|%|\+|=]" # command line options **************************************************** option_conf = [ ('--host', '-s', 'localhost'), ('--port', '-p', '5432'), ('--target', '-t', ''), ('--user', '-u', None), ('--order', '-o', 'task_id'), ('--expression', '-e', '*'), ('--direction', '-d', 'asc') ] # general helpers ********************************************************* # these are not available on string objects in python 2.4 def partition(s, n, reverse=False): if reverse: i = s.rfind(n) else: i = s.find(n) if i < 0: return s, '', '' return s[:i], s[i:i+1], s[i+1:] def rpartition(s, n): return partition(s, n, True) # formatting results ****************************************************** states = {1:'WAITING', 2:'ACTIVE', 3:'PAUSED', 4:'COMPLETING', 5:'COMPLETE', 6:'CHECKING', 8:'SEEDING', 107:'TIMEOUT'} def noFormat(object): return str(object) def formatTime(millis): if millis != None: time = datetime.datetime.fromtimestamp(millis) return str(time) return '-' def formatSize(bytes): if bytes != None: mb = bytes / (1024.0 * 1024) return '%.2f MB' % mb return '-' def formatProgress(curr, total): if curr != None and total != None: return '%.2f %%' % (100.0 * curr / total) return '-' def formatStatus(status): if status in states: return states[status] return 'UNKNOWN' def formatSmartStatus(status, curr, total): if status == 2: return formatProgress(curr, total) return formatStatus(status) def formatRate(bps): if bps != None and bps > 0: kbps = bps / 1024.0 return '%.0f KB/s' % kbps return '-'; def formatRatio(ratio): if ratio != None: return '%.2f' % ratio formatters = { 'task' : (('task_id',), noFormat), 'user' : (('username',), noFormat), 'created': (('created_time',), formatTime), 'started': (('started_time',), formatTime), 'size': (('total_size',), formatSize), 'part_size': (('current_size',), formatSize), 'progress': (('current_size', 'total_size'), formatProgress), 'status': (('status', 'current_size', 'total_size'), \ formatSmartStatus), 'simple_status':(('status',), formatStatus), 'rate': (('current_rate',), formatRate), 'upload_rate': (('upload_rate',), formatRate), 'upload_size': (('total_upload',), formatSize), 'seeding_ratio':(('seeding_ratio',), formatRatio) } default_columns = ('task', 'filename', 'status', 'rate') default_torrent_columns = ('task', 'filename', 'status', 'rate', 'upload_rate', 'connected_peers') def format(columns, table): index = range(len(columns)) result = [] result.append(columns) for line in table: formatted = [] for i in index: col = columns[i] if col in formatters: #formatted.append(formatters[columns[i]][1](line[i])) db_colums = formatters[col][0] formatter = formatters[col][1] args = [] for db_col in db_colums: args.append(line[db_col]) formatted.append(formatter(*args)) else: formatted.append(noFormat(line[col])) result.append(formatted) return result def printTable(table): chars_per_col = [] index = range(len(table[0])) lines = [] for i in index: chars_per_col.append(0) for line in table: for i in index: chars_per_col[i] = max(len(line[i]), chars_per_col[i]) for line in table: string = [] for i in index: string.append(line[i] + ' ' * (chars_per_col[i]-len(line[i]))) lines.append(' '.join(string)) return lines # database query helpers ************************************************** def userClause(options): if options.user != None: return "AND username = '"+options.user+"'" return '' def idClause(ids): if ids[0] != 'all': return 'AND task_id IN ('+','.join(ids)+')' return '' def createSelection(columns): selection = set() for col in columns: if col in formatters: for db_col in formatters[col][0]: selection.add(db_col) else: selection.add(col) return ', '.join(selection) # URL parsing helpers ***************************************************** def parseFilename(url): return rpartition(url, '/')[2] def parseURLs(pattern, string): pattern = pattern.replace('.', r'\.') pattern = pattern.replace('+', r'\+') regex = '(?:http|https|ftp)://'+pattern.replace('*', allowedInUrl+'*') result = re.findall(regex, string) # remove duplicates, keep order unique = [] [unique.append(x) for x in result if x not in unique] return unique # commands **************************************************************** def lineList(conn, options, columns, filter='1=1'): selection = createSelection(columns) query = 'SELECT '+selection+' FROM download_queue WHERE '+filter+' '+userClause(options)+' ORDER BY '+options.order+' '+options.direction cursor = conn.cursor() cursor.execute(query) result = cursor.fetchall() cursor.close() #format results formatted = format(columns, result) return printTable(formatted) def list(conn, options, columns, filter='1=1'): if len(columns) == 0: columns = default_columns lines = lineList(conn, options, columns, filter) for line in lines: print line def torrentlist(conn, options, columns): if len(columns) == 0: columns = default_torrent_columns list(conn, options, columns, 'torrent IS NOT NULL') def monitor(conn, options, columns, filter='1=1'): import curses, time if len(columns) == 0: columns = default_columns def hook(stdscr): in_down = set([ord('j'), curses.KEY_DOWN]) in_up = set([ord('k'), curses.KEY_UP]) in_left = set([ord('h'), curses.KEY_LEFT]) in_right = set([ord('l'), curses.KEY_RIGHT]) in_quit = set([ord('q'), ord('x'), 27]) in_all = in_down.union(in_up).union(in_quit).union(in_left).union(in_right) maxy, maxx = stdscr.getmaxyx() y, x = 0, 0 t = time.time() run = True stdscr.nodelay(True) stdscr.keypad(True) changed = True input = None lines = ['Initializing ...'] while(run): input = stdscr.getch() if input in in_all: changed = True if input in in_down: y = min(y+1, max(len(lines)-maxy+1, 0)) elif input in in_up: y = max(y-1, 0) elif input in in_left: x = max(x-1, 0) elif input in in_right: x = min(x+1, max(len(lines[0])-maxx+1, 0)) elif input in in_quit: return if time.time() - t > 1: lines = lineList(conn, options, columns, filter) changed = True t = time.time() if changed and len(lines) > 0: stdscr.clear() pos = 1 stdscr.addstr(0, 0, lines[0][x:x+maxx]) for line in lines[y+1:y+maxy-1]: line = line[x:x+maxx] # curses uses coordinates y,x stdscr.addstr(pos, 0, line) pos += 1 rangey, rangex = len(lines)-maxy+1, len(lines[0])-maxx+1 message = [] rely, relx = float(y) / rangey, float(x) / rangex if rangey >= 0: message.append('Vertical:') if rely == 0: message.append('Top') elif rely >= 1: message.append('Bottom') else: message.append('%.0f %%' % (rely * 100)) if rangex >= 0: message.append(' Horizontal:') if relx == 0: message.append('Left') elif relx >= 1: message.append('Right') else: message.append('%.0f %%' % (relx * 100)) stdscr.addstr(maxy-1, 0, ' '.join(message)) stdscr.refresh() changed = False time.sleep(0.1) curses.wrapper(hook) def torrentmonitor(conn, options, columns): if len(columns) == 0: columns = default_torrent_columns monitor(conn, options, columns, 'torrent IS NOT NULL') def clean(conn, options, args): sql = 'DELETE FROM download_queue WHERE status = 5 '+userClause(options) cursor = conn.cursor() cursor.execute(sql) cursor.close() conn.commit() def pause(conn, options, ids): sql = 'UPDATE download_queue SET status = 3 WHERE (status = 1 OR status = 2) '+userClause(options)+" "+idClause(ids) cursor = conn.cursor() cursor.execute(sql) cursor.close() conn.commit() def resume(conn, options, ids): sql = 'UPDATE download_queue SET status = 1 WHERE (status = 3) '+userClause(options)+" "+idClause(ids) cursor = conn.cursor() cursor.execute(sql) cursor.close() conn.commit() def remove(conn, options, ids): sql = 'DELETE FROM download_queue WHERE 1=1 '+userClause(options)+" "+idClause(ids) cursor = conn.cursor() cursor.execute(sql) cursor.close() conn.commit() def add(conn, options, urls): if options.user == None: user = 'admin' else: user = options.user if len(urls) == 0: urls = [] input = '\n'.join(sys.stdin.readlines()) urls = parseURLs(options.expression, input) # earlier versions of DiskStation don't have a target column # use only if necessary useTarget = options.target != '' if useTarget: sql = "INSERT INTO download_queue (username, url, status, filename, pid, created_time, destination) VALUES (%s, %s, 1, %s, -1, %s, %s)" else: sql = "INSERT INTO download_queue (username, url, status, filename, pid, created_time) VALUES (%s, %s, 1, %s, -1, %s)" params = [] now = int(time.time()) cursor = conn.cursor() ids = [] for url in urls: if useTarget: tupel = (user, url, parseFilename(url), now, options.target) else: tupel = (user, url, parseFilename(url), now) params.append(tupel) cursor.execute(sql, tupel) cursor.execute("SELECT lastval()") ids.append(cursor.fetchone()[0]) cursor.close() print 'Adding URLs:' for e in params: print e[1] conn.commit() return ids def torrent(conn, options, files): if options.user == None: user = 'admin' else: user = options.user params = [] useTarget = options.target != '' for file in files: # try: catch: finally: is buggy in python 2.4 # using separate try blocks as workaround try: try: stream = open(file, 'rb') content = db.PgBytea(stream.read()) now = int(time.time()) name = parseFilename(file) if useTarget: tupel = (user, name, name, now, content, options.target) else: tupel = (user, name, name, now, content) params.append(tupel) except IOError: sys.stderr.write('could not read file %s\n' % file) finally: stream.close() if useTarget: sql = "INSERT INTO download_queue (username, url, status, filename, pid, created_time, torrent, task_flags, seeding_interval, destination) VALUES (%s, %s, 1, %s, -1, %s, %s, 4, -1, %s)" else: sql = "INSERT INTO download_queue (username, url, status, filename, pid, created_time, torrent, task_flags, seeding_interval) VALUES (%s, %s, 1, %s, -1, %s, %s, 4, -1)" cursor = conn.cursor() cursor.executemany(sql, params) cursor.close() print 'Adding Torrent Files:' for e in params: print e[2] conn.commit() def interactive_mode(options): connection = getConnection(options) print '\n', '*'*32 print 'Downloadstation Interactive Mode' print '*'*32, '\n' print "type 'exit' or EOF (CTRL-D) to quit" print "type 'set ' to set an option" print "type 'reconnect' to create a new connection after changing the connection related options" print while 1: sys.stdout.write('>>> ') line = sys.stdin.readline() part = partition(line, ' ') command = part[0].strip() args = [] for arg in part[2].split(): args.append(arg.strip()) if command in commands: commands[command](connection, options, args) elif command == 'set': options.__dict__[args[0]] = args[1] elif command == 'reconnect': connection.close() connection = getConnection(options) elif command == 'exit' or command == 'quit' or command == '': break else: print 'Command %s not found.' % command # package management ****************************************************** class StaticMethod: def __init__(self, function): self.__call__ = function class Group: EXTRACTED = 'x' REMOVED = 'r' def __init__(self, jobs, flags=[]): self.jobs = jobs self.flags = flags def write(self, file): file.write('Group: ') file.write(', '.join(self.flags)) file.write('\n') for url in self.jobs: file.write(str(url)) file.write('\n') def files(self, conn): cursor = conn.cursor() sql = "SELECT filename FROM download_queue WHERE task_id = %s ORDER BY task_id" % ' OR task_id='.join(self.jobs) cursor.execute(sql) result = cursor.fetchall() cursor.close() return map(lambda t: t[0], result) def remaining(self, conn): #f = (lambda x: os.path.exists(Package.DOWNLOAD_DIR+x)) def f(x): return not os.path.exists(Package.DOWNLOAD_DIR+x) return filter(f, self.files(conn)) def complete(self, conn): return len(self.remaining(conn)) == 0 def processed(self): return Group.EXTRACTED in self.flags def removed(self): return Group.REMOVED in self.flags class Package: STORE_DIR = os.path.expanduser('~/.downloadstationcli/packages/') DOWNLOAD_DIR = os.path.expanduser('/volume1/downloads/') multiPartRegex = re.compile(r'(.+)(:?\.part\d+\.rar|.r\d+)$') def __init__(self, name, groups, password=None): self.name = name self.groups = groups self.password = password def jobs(self): list = [] for g in self.groups: list.extend(g.jobs) return list def files(self, conn): result = [] for g in self.groups: result.extend(g.files(conn)) return result def removed(self): for g in self.groups: if not g.removed(): return False return True def open(name): file = open(Package.STORE_DIR+name, 'r') try: groups = [] groupURLs = None group = None flags= None password = None for line in file.readlines(): if line.startswith('Group:'): if groupURLs != None: groups.append(Group(groupURLs, flags)) flags = map(str.strip, line[6:].split(',')) groupURLs = [] elif line.startswith('Password:'): password = line[9:].strip() else: groupURLs.append(line.strip()) if groupURLs != None: groups.append(Group(groupURLs, flags)) return Package(name, groups, password) finally: file.close() def save(self): if not os.path.isdir(Package.STORE_DIR): os.makedirs(Package.STORE_DIR) file = open(Package.STORE_DIR+self.name, 'w') try: for group in self.groups: group.write(file) if self.password != None: file.write('Password: ') file.write(self.password) file.write('\n') print 'Package "%s" saved to "%s%s"' % \ (self.name, Package.STORE_DIR, self.name) finally: file.close() def remove(self): os.remove(Package.STORE_DIR+self.name) print 'Package "%s" removed.' % self.name def getPrefix(file): m = Package.multiPartRegex.match(file) if m: return m.group(1) else: return None open = StaticMethod(open) getPrefix = StaticMethod(getPrefix) def for_each_package(conn, options, command): for name in os.listdir(Package.STORE_DIR): command(conn, options, name) def pkg_list(conn, options, name=None): if name == None: for file in os.listdir(Package.STORE_DIR): print file else: pkg = Package.open(name) i = 1 for g in pkg.groups: print 'Group %d:' % i i += 1 filter = 'task_id = ' + 'OR task_id = '.join(g.jobs) list(conn, options, default_columns, filter) print def pkg_create(conn, options, name): def parse(string): urls = parseURLs(options.expression, string) groups = [] group = None groupPrefix = None for url in urls: file = parseFilename(url) prefix = Package.getPrefix(file) if groupPrefix == None or groupPrefix != prefix: groupPrefix = prefix group = [] groups.append(group) group.append(url) password = string.strip().splitlines()[-1].strip() return groups, password # override -t option, not supported for packages if options.target != '': print 'option --target not supported for packages' sys.exit(2) if name == 'all': raise '"all" is not allowed as a package name' groupedURLs, password = parse('\n'.join(sys.stdin.readlines())) groups = [] for urls in groupedURLs: ids = add(conn, options, urls) groups.append(Group(ids)) pkg = Package(name, groups, password) pkg.save() extractors = {"rar":"unrar x '-p%s' '%s'", "zip":"unzip -P '%s' '%s'"} def do_process(conn, options, pkg, grp): path = Package.DOWNLOAD_DIR+pkg.name+'/' if not os.path.exists(path): os.makedirs(path) oldpath = os.getcwdu() try: os.chdir(path) files = grp.files(conn) ext = files[0][-3:] if ext in extractors: import subprocess cmd = extractors[ext] % (pkg.password, '../'+files[0]) p = subprocess.Popen(cmd, shell=True) p.communicate() if p.returncode != 0: raise "Error during extracting" else: for f in files: os.rename('../'+f, f) grp.flags.append(Group.EXTRACTED) finally: os.chdir(oldpath) def pkg_process(conn, options, name): pkg = Package.open(name) i = 0 try: for g in pkg.groups: try: i += 1 if g.processed(): print "Group %d already processed." % i elif g.removed(): print "Group %d was removed." % i elif not g.complete(conn): print "Group %d is missing files:" % i for f in g.remaining(conn): print Package.DOWNLOAD_DIR+f else: print "Extracting Group %d." % i do_process(conn, options, pkg, g) except: print "Group %d could not be processed." % i finally: pkg.save() def pkg_clean(conn, options, name): pkg = Package.open(name) i = 0 for g in pkg.groups: i += 1 if not g.processed(): print "Group %d not processed yet." % i elif g.removed(): print "Group %d was already removed." % i else: print "Cleaning up Group %d." % i for f in g.files(conn): path = Package.DOWNLOAD_DIR+f if os.path.isfile(path): os.remove(path) remove(conn, options, g.jobs) g.flags.append(Group.REMOVED) if pkg.removed(): pkg.remove() else: pkg.save() def pkg_pac(conn, options, name): pkg_process(conn, options, name) pkg_clean(conn, options, name) def pkg_remove(conn, options, name): pkg = Package.open(name) print "Removing package %s." % name for f in pkg.files(conn): path = Package.DOWNLOAD_DIR+f if os.path.isfile(path): os.remove(path) jobs = pkg.jobs() if len(jobs) > 0: remove(conn, options, jobs) pkg.remove() def pkg_pause(conn, options, name): pause(conn, options, Package.open(name).jobs()) def pkg_resume(conn, options, name): resume(conn, options, Package.open(name).jobs()) pkg_commands = { 'list': pkg_list, 'create': pkg_create, 'process': pkg_process, 'clean': pkg_clean, 'pac': pkg_pac, 'remove': pkg_remove, 'pause': pkg_pause, 'resume': pkg_resume } def pkg(conn, options, args): command = pkg_commands[args[0]] if len(args) > 1 and args[1] == 'all': for_each_package(conn, options, command) else: command(conn, options, *args[1:]) # command mapping ********************************************************* commands = { 'list': list, 'clean': clean, 'pause': pause, 'resume': resume, 'remove': remove, 'add': add, 'monitor': monitor, 'torrent': torrent, 'tlist': torrentlist, 'tmonitor': torrentmonitor, 'pkg': pkg } # connection and command selection **************************************** def getConnection(options): return db.connect(user = 'admin', password = 'dd@awylds', host = options.host, port = options.port, database = 'download') def createOptionParser(): p = optparse.OptionParser( description='CLI for Synology Downloadstation', prog='downloadstation', version='downloadstationcli 1.7', usage='%prog \nCommands:\n'+ 'list'+' '*18+'print a list of all jobs\n'+ 'tlist'+' '*17+'same as list, for torrents only\n'+ 'monitor'+' '*15+'view the list of all jobs in real time\n'+ 'tmonitor'+' '*14+'same as monitor, for torrents only\n'+ 'clean'+' '*17+ 'remove completed jobs\n'+ 'add'+' '*19+'add urls\n'+ 'torrent'+' '*15+'add torrents\n'+ 'remove' +' '*16+'remove specified download jobs\n'+ 'pause' +' '*17+'pause specified download jobs\n'+ 'resume' +' '*16+'continue specified download jobs') for o in option_conf: p.add_option(o[0], o[1], default=o[2]) return p def main(): p = createOptionParser() # check for command; if none given, start interactive mode if len(sys.argv) < 2 or sys.argv[1].startswith('-'): options, args = p.parse_args() interactive_mode(options) else: command = sys.argv[1] options, args = p.parse_args(sys.argv[2:]) connection = getConnection(options) commands[command](connection, options, args) connection.close() if __name__ == '__main__': try: main() except KeyboardInterrupt: print 'User Abort'