X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~mdw/git/disorder/blobdiff_plain/31773020eedbe29daff0a407619df9607e569e22..6acdbba4d904b8ee764f0d564675357a46d43fa8:/python/disorder.py.in diff --git a/python/disorder.py.in b/python/disorder.py.in index a8afcba..47d7090 100644 --- a/python/disorder.py.in +++ b/python/disorder.py.in @@ -1,5 +1,5 @@ # -# Copyright (C) 2004, 2005 Richard Kettlewell +# Copyright (C) 2004, 2005, 2007, 2008 Richard Kettlewell # # 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 @@ -39,6 +39,11 @@ Example 2: for path in sys.argv[1:]: d.play(path) +See disorder_protocol(5) for details of the communication protocol. + +NB that this code only supports servers configured to use SHA1-based +authentication. If the server demands another hash then it will not be +possible to use this module. """ import re @@ -57,8 +62,8 @@ _userconf = True # various regexps we'll use _ws = re.compile(r"^[ \t\n\r]+") -_squote = re.compile("'(([^\\\\']|\\\\[\\\\\"'n])+)'") -_dquote = re.compile("\"(([^\\\\\"]|\\\\[\\\\\"'n])+)\"") +_squote = re.compile("'(([^\\\\']|\\\\[\\\\\"'n])*)'") +_dquote = re.compile("\"(([^\\\\\"]|\\\\[\\\\\"'n])*)\"") _unquoted = re.compile("[^\"' \\t\\n\\r][^ \t\n\r]*") _response = re.compile("([0-9]{3}) ?(.*)") @@ -110,8 +115,8 @@ class operationError(Error): self.cmd_ = cmd self.details_ = details def __str__(self): - """Return the complete response string from the server, with the command - if available. + """Return the complete response string from the server, with the + command if available. Excludes the final newline. """ @@ -231,7 +236,7 @@ def _list2dict(l): while True: k = i.next() v = i.next() - d[k] = v + d[str(k)] = v except StopIteration: pass return d @@ -271,7 +276,7 @@ class client: debug_proto = 0x0001 debug_body = 0x0002 - def __init__(self): + def __init__(self, user=None, password=None): """Constructor for DisOrder client class. The constructor reads the configuration file, but does not connect @@ -289,12 +294,15 @@ class client: self.config = { 'collections': [], 'username': pw.pw_name, 'home': _dbhome } + self.user = user + self.password = password home = os.getenv("HOME") if not home: home = pw.pw_dir privconf = _configfile + "." + pw.pw_name passfile = home + os.sep + ".disorder" + os.sep + "passwd" - self._readfile(_configfile) + if os.path.exists(_configfile): + self._readfile(_configfile) if os.path.exists(privconf): self._readfile(privconf) if os.path.exists(passfile) and _userconf: @@ -321,8 +329,10 @@ class client: sys.stderr.write("\n") sys.stderr.flush() - def connect(self): - """Connect to the DisOrder server and authenticate. + def connect(self, cookie=None): + """c.connect(cookie=None) + + Connect to the DisOrder server and authenticate. Raises communicationError if connection fails and operationError if authentication fails (in which case disconnection is automatic). @@ -333,6 +343,9 @@ class client: Other operations automatically connect if we're not already connected, so it is not strictly necessary to call this method. + + If COOKIE is specified then that is used to log in instead of + the username/password. """ if self.state == 'disconnected': try: @@ -362,11 +375,27 @@ class client: s.connect(self.who) self.w = s.makefile("wb") self.r = s.makefile("rb") - (res, challenge) = self._simple() - h = sha.sha() - h.update(self.config['password']) - h.update(binascii.unhexlify(challenge)) - self._simple("user", self.config['username'], h.hexdigest()) + (res, details) = self._simple() + (protocol, algo, challenge) = _split(details) + if protocol != '2': + raise communicationError(self.who, + "unknown protocol version %s" % protocol) + if cookie is None: + if self.user is None: + user = self.config['username'] + else: + user = self.user + if self.password is None: + password = self.config['password'] + else: + password = self.password + # TODO support algorithms other than SHA-1 + h = sha.sha() + h.update(password) + h.update(binascii.unhexlify(challenge)) + self._simple("user", user, h.hexdigest()) + else: + self._simple("cookie", cookie) self.state = 'connected' except socket.error, e: self._disconnect() @@ -387,23 +416,19 @@ class client: ######################################################################## # Operations - def become(self, who): - """Become another user. - - Arguments: - who -- the user to become. - - Only trusted users can perform this operation. - """ - self._simple("become", who) - def play(self, track): """Play a track. Arguments: track -- the path of the track to play. + + Returns the ID of the new queue entry. + + Note that queue IDs are unicode strings (because all track + information values are unicode strings). """ - self._simple("play", track) + res, details = self._simple("play", track) + return unicode(details) # because it's unicode in queue() output def remove(self, track): """Remove a track from the queue. @@ -454,24 +479,24 @@ class client: """ self._simple("reconfigure") - def rescan(self, pattern): + def rescan(self, *flags): """Rescan one or more collections. - Arguments: - pattern -- glob pattern matching collections to rescan. - Only trusted users can perform this operation. """ - self._simple("rescan", pattern) + self._simple("rescan", *flags) def version(self): """Return the server's version number.""" - return self._simple("version")[1] + return _split(self._simple("version")[1])[0] def playing(self): """Return the currently playing track. - If a track is playing then it is returned as a dictionary. + If a track is playing then it is returned as a dictionary. See + disorder_protocol(5) for the meanings of the keys. All keys are + plain strings but the values will be unicode strings. + If no track is playing then None is returned.""" res, details = self._simple("playing") if res % 10 != 9: @@ -493,14 +518,20 @@ class client: """Return a list of recently played tracks. The return value is a list of dictionaries corresponding to - recently played tracks. The oldest track comes first.""" + recently played tracks. The oldest track comes first. + + See disorder_protocol(5) for the meanings of the keys. All keys are + plain strings but the values will be unicode strings.""" return self._somequeue("recent") def queue(self): """Return the current queue. The return value is a list of dictionaries corresponding to - recently played tracks. The next track to be played comes first.""" + recently played tracks. The next track to be played comes first. + + See disorder_protocol(5) for the meanings of the keys. + All keys are plain strings but the values will be unicode strings.""" return self._somequeue("queue") def _somedir(self, command, dir, re): @@ -582,13 +613,13 @@ class client: track -- the track to query key -- the preference to remove - The return value is the preference + The return value is the preference. """ ret, details = self._simple("get", track, key) if ret == 555: return None else: - return details + return _split(details)[0] def prefs(self, track): """Get all the preferences for a track. @@ -710,6 +741,21 @@ class client: ret, details = self._simple("move", track, str(delta)) return int(details) + def moveafter(self, target, tracks): + """Move a track in the queue + + Arguments: + target -- target ID or None + tracks -- a list of IDs to move + + If target is '' or is not in the queue then the tracks are moved to + the head of the queue. + + Otherwise the tracks are moved to just after the target.""" + if target is None: + target = '' + self._simple("moveafter", target, *tracks) + def log(self, callback): """Read event log entries as they happen. @@ -719,7 +765,9 @@ class client: second the line from the event log. The callback should return True to continue or False to stop (don't - forget this, or your program will mysteriously misbehave). + forget this, or your program will mysteriously misbehave). Once you + stop reading the log the connection is useless and should be + deleted. It is suggested that you use the disorder.monitor class instead of calling this method directly, but this is not mandatory. @@ -739,11 +787,6 @@ class client: l = l[1:] if not callback(self, l): break - # tell the server to stop sending, eat the remains of the body, - # eat the response - self._send("version") - self._body() - self._response() def pause(self): """Pause the current track.""" @@ -764,7 +807,7 @@ class client: The return value is the preference """ ret, details = self._simple("part", track, context, part) - return details + return _split(details)[0] def setglobal(self, key, value): """Set a global preference value. @@ -795,7 +838,124 @@ class client: if ret == 555: return None else: - return details + return _split(details)[0] + + def make_cookie(self): + """Create a login cookie""" + ret, details = self._simple("make-cookie") + return _split(details)[0] + + def revoke(self): + """Revoke a login cookie""" + self._simple("revoke") + + def adduser(self, user, password): + """Create a user""" + self._simple("adduser", user, password) + + def deluser(self, user): + """Delete a user""" + self._simple("deluser", user) + + def userinfo(self, user, key): + """Get user information""" + res, details = self._simple("userinfo", user, key) + if res == 555: + return None + return _split(details)[0] + + def edituser(self, user, key, value): + """Set user information""" + self._simple("edituser", user, key, value) + + def users(self): + """List all users + + The return value is a list of all users.""" + self._simple("users") + return self._body() + + def register(self, username, password, email): + """Register a user""" + res, details = self._simple("register", username, password, email) + return _split(details)[0] + + def confirm(self, confirmation): + """Confirm a user registration""" + res, details = self._simple("confirm", confirmation) + + def schedule_list(self): + """Get a list of scheduled events """ + self._simple("schedule-list") + return self._body() + + def schedule_del(self, event): + """Delete a scheduled event""" + self._simple("schedule-del", event) + + def schedule_get(self, event): + """Get the details for an event as a dict (returns None if + event not found)""" + res, details = self._simple("schedule-get", event) + if res == 555: + return None + d = {} + for line in self._body(): + bits = _split(line) + d[bits[0]] = bits[1] + return d + + def schedule_add(self, when, priority, action, *rest): + """Add a scheduled event""" + self._simple("schedule-add", str(when), priority, action, *rest) + + def playlist_delete(self, playlist): + """Delete a playlist""" + res, details = self._simple("playlist-delete", playlist) + if res == 555: + raise operationError(res, details, "playlist-delete") + + def playlist_get(self, playlist): + """Get the contents of a playlist + + The return value is an array of track names, or None if there is no + such playlist.""" + res, details = self._simple("playlist-get", playlist) + if res == 555: + return None + return self._body() + + def playlist_lock(self, playlist): + """Lock a playlist. Playlists can only be modified when locked.""" + self._simple("playlist-lock", playlist) + + def playlist_unlock(self): + """Unlock the locked playlist.""" + self._simple("playlist-unlock") + + def playlist_set(self, playlist, tracks): + """Set the contents of a playlist. The playlist must be locked. + + Arguments: + playlist -- Playlist to set + tracks -- Array of tracks""" + self._simple_body(tracks, "playlist-set", playlist) + + def playlist_set_share(self, playlist, share): + """Set the sharing status of a playlist""" + self._simple("playlist-set-share", playlist, share) + + def playlist_get_share(self, playlist): + """Returns the sharing status of a playlist""" + res, details = self._simple("playlist-get-share", playlist) + if res == 555: + return None + return _split(details)[0] + + def playlists(self): + """Returns the list of visible playlists""" + self._simple("playlists") + return self._body() ######################################################################## # I/O infrastructure @@ -826,8 +986,8 @@ class client: else: raise protocolError(self.who, "invalid response %s") - def _send(self, *command): - # Quote and send a command + def _send(self, body, *command): + # Quote and send a command and optional body # # Returns the encoded command. quoted = _quote(command) @@ -836,6 +996,13 @@ class client: try: self.w.write(encoded) self.w.write("\n") + if body != None: + for l in body: + if l[0] == ".": + self.w.write(".") + self.w.write(l) + self.w.write("\n") + self.w.write(".\n") self.w.flush() return encoded except IOError, e: @@ -846,7 +1013,7 @@ class client: self._disconnect() raise - def _simple(self, *command): + def _simple(self, *command): # Issue a simple command, throw an exception on error # # If an I/O error occurs, disconnect from the server. @@ -854,10 +1021,20 @@ class client: # On success or 'normal' errors returns response as a (code, details) tuple # # On error raise operationError + return self._simple_body(None, *command) + + def _simple_body(self, body, *command): + # Issue a simple command with optional body, throw an exception on error + # + # If an I/O error occurs, disconnect from the server. + # + # On success or 'normal' errors returns response as a (code, details) tuple + # + # On error raise operationError if self.state == 'disconnected': self.connect() if command: - cmd = self._send(*command) + cmd = self._send(body, *command) else: cmd = None res, details = self._response() @@ -938,8 +1115,8 @@ class client: class monitor: """DisOrder event log monitor class - Intended to be subclassed with methods corresponding to event log messages - the implementor cares about over-ridden.""" + Intended to be subclassed with methods corresponding to event log + messages the implementor cares about over-ridden.""" def __init__(self, c=None): """Constructor for the monitor class @@ -955,8 +1132,8 @@ class monitor: def run(self): """Start monitoring logs. Continues monitoring until one of the - message-specific methods returns False. Can be called more than once - (but not recursively!)""" + message-specific methods returns False. Can be called more than + once (but not recursively!)""" self.c.log(self._callback) def when(self): @@ -1011,6 +1188,8 @@ class monitor: elif keyword == 'scratched': if len(bits) == 2: return self.scratched(bits[0], bits[1]) + elif keyword == 'rescanned': + return self.rescanned() return self.invalid(line) def completed(self, track): @@ -1090,6 +1269,10 @@ class monitor: line -- line that could not be understood""" return True + def rescanned(self): + """Called when a rescan completes""" + return True + # Local Variables: # mode:python # py-indent-offset:2