#
-# 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
+# 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 2 of the License, or
+# 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.
-#
+# 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.
+#
# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
-# USA
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""Python support for DisOrder
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
import pwd
import socket
import binascii
-import sha
+import hashlib
import sys
import locale
# 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}) ?(.*)")
+# hashes
+_hashes = {
+ "sha1": hashlib.sha1,
+ "SHA1": hashlib.sha1,
+ "sha256": hashlib.sha256,
+ "SHA256": hashlib.sha256,
+ "sha384": hashlib.sha384,
+ "SHA384": hashlib.sha384,
+ "sha512": hashlib.sha512,
+ "SHA512": hashlib.sha512,
+};
+
version = "_version_"
########################################################################
Indicates that an operation failed (e.g. an attempt to play a
nonexistent track). The connection should still be usable.
"""
- def __init__(self, res, details):
+ def __init__(self, res, details, cmd=None):
self.res_ = int(res)
+ self.cmd_ = cmd
self.details_ = details
def __str__(self):
- """Return the complete response string from the server.
+ """Return the complete response string from the server, with the
+ command if available.
Excludes the final newline.
"""
- return "%d %s" % (self.res_, self.details_)
+ if self.cmd_ is None:
+ return "%d %s" % (self.res_, self.details_)
+ else:
+ return "%d %s [%s]" % (self.res_, self.details_, self.cmd_)
def response(self):
"""Return the response code from the server."""
return self.res_
while True:
k = i.next()
v = i.next()
- d[k] = v
+ d[str(k)] = v
except StopIteration:
pass
return d
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
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:
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).
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:
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
+ h = _hashes[algo]()
+ 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()
########################################################################
# Operations
- def become(self, who):
- """Become another user.
+ def play(self, track):
+ """Play a track.
Arguments:
- who -- the user to become.
+ track -- the path of the track to play.
- Only trusted users can perform this operation.
+ 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("become", who)
+ res, details = self._simple("play", track)
+ return unicode(details) # because it's unicode in queue() output
- def play(self, track):
- """Play a track.
+ def playafter(self, target, tracks):
+ """Insert tracks into a specific point in the queue.
Arguments:
- track -- the path of the track to play.
- """
- self._simple("play", track)
+ target -- target ID or None to insert at start of queue
+ tracks -- a list of tracks to play"""
+ if target is None:
+ target = ''
+ self._simple("playafter", target, *tracks)
def remove(self, track):
"""Remove a track from the queue.
"""
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:
"""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):
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)
- return details
+ if ret == 555:
+ return None
+ else:
+ return _split(details)[0]
def prefs(self, track):
"""Get all the preferences for a track.
all of the required words (in their path name, trackname
preferences, etc.)
"""
- self._simple("search", *words)
+ self._simple("search", _quote(words))
+ return self._body()
+
+ def tags(self):
+ """List all tags
+
+ The return value is a list of all tags which apply to at least one
+ track."""
+ self._simple("tags")
return self._body()
def stats(self):
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.
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.
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."""
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.
+
+ Arguments:
+ key -- the preference name
+ value -- the new preference value
+ """
+ self._simple("set-global", key, value)
+
+ def unsetglobal(self, key):
+ """Unset a global preference value.
+
+ Arguments:
+ key -- the preference to remove
+ """
+ self._simple("set-global", key, value)
+
+ def getglobal(self, key):
+ """Get a global preference value.
+
+ Arguments:
+ key -- the preference to look up
+
+ The return value is the preference
+ """
+ ret, details = self._simple("get-global", key)
+ if ret == 555:
+ return None
+ else:
+ 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 adopt(self, id):
+ """Adopt a randomly picked track"""
+ self._simple("adopt", id)
+
+ 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
else:
raise protocolError(self.who, "invalid response %s")
- def _send(self, *command):
+ def _send(self, body, *command):
+ # Quote and send a command and optional body
+ #
+ # Returns the encoded command.
quoted = _quote(command)
self._debug(client.debug_proto, "==> %s" % quoted)
encoded = quoted.encode("UTF-8")
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:
# e.g. EPIPE
self._disconnect()
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.
#
- # On success returns response as a (code, details) tuple
+ # 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:
- self._send(*command)
+ cmd = self._send(body, *command)
+ else:
+ cmd = None
res, details = self._response()
- if res / 100 == 2:
+ if res / 100 == 2 or res == 555:
return res, details
- raise operationError(res, details)
+ raise operationError(res, details, cmd)
def _body(self):
# Fetch a dot-stuffed body
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
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):
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):
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