chiark / gitweb /
325599bfad7393e989c5b3370c55bf785591ad5f
[disorder] / python / disorder.py.in
1 #
2 # Copyright (C) 2004, 2005, 2007 Richard Kettlewell
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12 # General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
17 # USA
18 #
19
20 """Python support for DisOrder
21
22 Provides disorder.client, a class for accessing a DisOrder server.
23
24 Example 1:
25
26   #! /usr/bin/env python
27   import disorder
28   d = disorder.client()
29   p = d.playing()
30   if p:
31     print p['track']
32
33 Example 2:
34
35   #! /usr/bin/env python
36   import disorder
37   import sys
38   d = disorder.client()
39   for path in sys.argv[1:]:
40     d.play(path)
41
42 See disorder_protocol(5) for details of the communication protocol.
43
44 NB that this code only supports servers configured to use SHA1-based
45 authentication.  If the server demands another hash then it will not be
46 possible to use this module.
47 """
48
49 import re
50 import string
51 import os
52 import pwd
53 import socket
54 import binascii
55 import sha
56 import sys
57 import locale
58
59 _configfile = "pkgconfdir/config"
60 _dbhome = "pkgstatedir"
61 _userconf = True
62
63 # various regexps we'll use
64 _ws = re.compile(r"^[ \t\n\r]+")
65 _squote = re.compile("'(([^\\\\']|\\\\[\\\\\"'n])+)'")
66 _dquote = re.compile("\"(([^\\\\\"]|\\\\[\\\\\"'n])+)\"")
67 _unquoted = re.compile("[^\"' \\t\\n\\r][^ \t\n\r]*")
68
69 _response = re.compile("([0-9]{3}) ?(.*)")
70
71 version = "_version_"
72
73 ########################################################################
74 # exception classes
75
76 class Error(Exception):
77   """Base class for DisOrder exceptions."""
78
79 class _splitError(Error):
80   # _split failed
81   def __init__(self, value):
82     self.value = value
83   def __str__(self):
84     return str(self.value)
85
86 class parseError(Error):
87   """Error parsing the configuration file."""
88   def __init__(self, path, line, details):
89     self.path = path
90     self.line = line
91     self.details = details
92   def __str__(self):
93     return "%s:%d: %s" % (self.path, self.line, self.details)
94
95 class protocolError(Error):
96   """DisOrder control protocol error.
97
98   Indicates a mismatch between the client and server's understanding of
99   the control protocol.
100   """
101   def __init__(self, who, error):
102     self.who = who
103     self.error = error
104   def __str__(self):
105     return "%s: %s" % (self.who, str(self.error))
106
107 class operationError(Error):
108   """DisOrder control protocol error response.
109
110   Indicates that an operation failed (e.g. an attempt to play a
111   nonexistent track).  The connection should still be usable.
112   """
113   def __init__(self, res, details, cmd=None):
114     self.res_ = int(res)
115     self.cmd_ = cmd
116     self.details_ = details
117   def __str__(self):
118     """Return the complete response string from the server, with the command
119     if available.
120
121     Excludes the final newline.
122     """
123     if self.cmd_ is None:
124       return "%d %s" % (self.res_, self.details_)
125     else:
126       return "%d %s [%s]" % (self.res_, self.details_, self.cmd_)
127   def response(self):
128     """Return the response code from the server."""
129     return self.res_
130   def details(self):
131     """Returns the detail string from the server."""
132     return self.details_
133
134 class communicationError(Error):
135   """DisOrder control protocol communication error.
136
137   Indicates that communication with the server went wrong, perhaps
138   because the server was restarted.  The caller could report an error to
139   the user and wait for further user instructions, or even automatically
140   retry the operation.
141   """
142   def __init__(self, who, error):
143     self.who = who
144     self.error = error
145   def __str__(self):
146     return "%s: %s" % (self.who, str(self.error))
147
148 ########################################################################
149 # DisOrder-specific text processing
150
151 def _unescape(s):
152   # Unescape the contents of a string
153   #
154   # Arguments:
155   #
156   # s -- string to unescape
157   #
158   s = re.sub("\\\\n", "\n", s)
159   s = re.sub("\\\\(.)", "\\1", s)
160   return s
161
162 def _split(s, *comments):
163   # Split a string into fields according to the usual Disorder string splitting
164   # conventions.
165   #
166   # Arguments:
167   #
168   # s        -- string to parse
169   # comments -- if present, parse comments
170   #
171   # Return values:
172   #
173   # On success, a list of fields is returned.
174   #
175   # On error, disorder.parseError is thrown.
176   #
177   fields = []
178   while s != "":
179     # discard comments
180     if comments and s[0] == '#':
181       break
182     # strip spaces
183     m = _ws.match(s)
184     if m:
185       s = s[m.end():]
186       continue
187     # pick of quoted fields of both kinds
188     m = _squote.match(s)
189     if not m:
190       m = _dquote.match(s)
191     if m:
192       fields.append(_unescape(m.group(1)))
193       s = s[m.end():]
194       continue
195     # and unquoted fields
196     m = _unquoted.match(s)
197     if m:
198       fields.append(m.group(0))
199       s = s[m.end():]
200       continue
201     # anything left must be in error
202     if s[0] == '"' or s[0] == '\'':
203       raise _splitError("invalid quoted string")
204     else:
205       raise _splitError("syntax error")
206   return fields
207
208 def _escape(s):
209   # Escape the contents of a string
210   #
211   # Arguments:
212   #
213   # s -- string to escape
214   #
215   if re.search("[\\\\\"'\n \t\r]", s) or s == '':
216     s = re.sub(r'[\\"]', r'\\\g<0>', s)
217     s = re.sub("\n", r"\\n", s)
218     return '"' + s + '"'
219   else:
220     return s
221
222 def _quote(list):
223   # Quote a list of values
224   return ' '.join(map(_escape, list))
225
226 def _sanitize(s):
227   # Return the value of s in a form suitable for writing to stderr
228   return s.encode(locale.nl_langinfo(locale.CODESET), 'replace')
229
230 def _list2dict(l):
231   # Convert a list of the form [k1, v1, k2, v2, ..., kN, vN]
232   # to a dictionary {k1:v1, k2:v2, ..., kN:vN}
233   d = {}
234   i = iter(l)
235   try:
236     while True:
237       k = i.next()
238       v = i.next()
239       d[str(k)] = v
240   except StopIteration:
241     pass
242   return d
243
244 def _queueEntry(s):
245   # parse a queue entry
246   return _list2dict(_split(s))
247
248 ########################################################################
249 # The client class
250
251 class client:
252   """DisOrder client class.
253
254   This class provides access to the DisOrder server either on this
255   machine or across the internet.
256
257   The server to connect to, and the username and password to use, are
258   determined from the configuration files as described in 'man
259   disorder_config'.
260
261   All methods will connect if necessary, as soon as you have a
262   disorder.client object you can start calling operational methods on
263   it.
264
265   However if the server is restarted then the next method called on a
266   connection will throw an exception.  This may be considered a bug.
267
268   All methods block until they complete.
269
270   Operation methods raise communicationError if the connection breaks,
271   protocolError if the response from the server is malformed, or
272   operationError if the response is valid but indicates that the
273   operation failed.
274   """
275
276   debug_proto = 0x0001
277   debug_body = 0x0002
278
279   def __init__(self, user=None, password=None):
280     """Constructor for DisOrder client class.
281
282     The constructor reads the configuration file, but does not connect
283     to the server.
284
285     If the environment variable DISORDER_PYTHON_DEBUG is set then the
286     debug flags are initialised to that value.  This can be overridden
287     with the debug() method below.
288
289     The constructor Raises parseError() if the configuration file is not
290     valid.
291     """
292     pw = pwd.getpwuid(os.getuid())
293     self.debugging = int(os.getenv("DISORDER_PYTHON_DEBUG", 0))
294     self.config = { 'collections': [],
295                     'username': pw.pw_name,
296                     'home': _dbhome }
297     self.user = user
298     self.password = password
299     home = os.getenv("HOME")
300     if not home:
301       home = pw.pw_dir
302     privconf = _configfile + "." + pw.pw_name
303     passfile = home + os.sep + ".disorder" + os.sep + "passwd"
304     if os.path.exists(_configfile):
305       self._readfile(_configfile)
306     if os.path.exists(privconf):
307       self._readfile(privconf)
308     if os.path.exists(passfile) and _userconf:
309       self._readfile(passfile)
310     self.state = 'disconnected'
311
312   def debug(self, bits):
313     """Enable or disable protocol debugging.  Debug messages are written
314     to sys.stderr.
315
316     Arguments:
317     bits -- bitmap of operations that should generate debug information
318
319     Bitmap values:
320     debug_proto -- dump control protocol messages (excluding bodies)
321     debug_body -- dump control protocol message bodies
322     """
323     self.debugging = bits
324
325   def _debug(self, bit, s):
326     # debug output
327     if self.debugging & bit:
328       sys.stderr.write(_sanitize(s))
329       sys.stderr.write("\n")
330       sys.stderr.flush()
331
332   def connect(self, cookie=None):
333     """c.connect(cookie=None)
334
335     Connect to the DisOrder server and authenticate.
336
337     Raises communicationError if connection fails and operationError if
338     authentication fails (in which case disconnection is automatic).
339
340     May be called more than once to retry connections (e.g. when the
341     server is down).  If we are already connected and authenticated,
342     this is a no-op.
343
344     Other operations automatically connect if we're not already
345     connected, so it is not strictly necessary to call this method.
346
347     If COOKIE is specified then that is used to log in instead of
348     the username/password.
349     """
350     if self.state == 'disconnected':
351       try:
352         self.state = 'connecting'
353         if 'connect' in self.config and len(self.config['connect']) > 0:
354           c = self.config['connect']
355           self.who = repr(c)            # temporarily
356           if len(c) == 1:
357             a = socket.getaddrinfo(None, c[0],
358                                    socket.AF_INET,
359                                    socket.SOCK_STREAM,
360                                    0,
361                                    0)
362           else:
363             a = socket.getaddrinfo(c[0], c[1],
364                                    socket.AF_INET,
365                                    socket.SOCK_STREAM,
366                                    0,
367                                    0)
368           a = a[0]
369           s = socket.socket(a[0], a[1], a[2]);
370           s.connect(a[4])
371           self.who = "%s" % a[3]
372         else:
373           s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM);
374           self.who = self.config['home'] + os.sep + "socket"
375           s.connect(self.who)
376         self.w = s.makefile("wb")
377         self.r = s.makefile("rb")
378         (res, challenge) = self._simple()
379         if cookie is None:
380           if self.user is None:
381             user = self.config['username']
382           else:
383             user = self.user
384           if self.password is None:
385             password = self.config['password']
386           else:
387             password = self.password
388           h = sha.sha()
389           h.update(password)
390           h.update(binascii.unhexlify(challenge))
391           self._simple("user", user, h.hexdigest())
392         else:
393           self._simple("cookie", cookie)
394         self.state = 'connected'
395       except socket.error, e:
396         self._disconnect()
397         raise communicationError(self.who, e)
398       except:
399         self._disconnect()
400         raise
401
402   def _disconnect(self):
403     # disconnect from the server, whatever state we are in
404     try:
405       del self.w
406       del self.r
407     except:
408       pass
409     self.state = 'disconnected'
410     
411   ########################################################################
412   # Operations
413
414   def become(self, who):
415     """Become another user.
416
417     Arguments:
418     who -- the user to become.
419
420     Only trusted users can perform this operation.
421     """
422     self._simple("become", who)
423
424   def play(self, track):
425     """Play a track.
426
427     Arguments:
428     track -- the path of the track to play.
429
430     Returns the ID of the new queue entry.
431
432     Note that queue IDs are unicode strings (because all track information
433     values are unicode strings).
434     """
435     res, details = self._simple("play", track)
436     return unicode(details)             # because it's unicode in queue() output
437
438   def remove(self, track):
439     """Remove a track from the queue.
440
441     Arguments:
442     track -- the path or ID of the track to remove.
443     """
444     self._simple("remove", track)
445
446   def enable(self):
447     """Enable playing."""
448     self._simple("enable")
449
450   def disable(self, *now):
451     """Disable playing.
452
453     Arguments:
454     now -- if present (with any value), the current track is stopped
455            too.
456     """
457     if now:
458       self._simple("disable", "now")
459     else:
460       self._simple("disable")
461
462   def scratch(self, *id):
463     """Scratch the currently playing track.
464
465     Arguments:
466     id -- if present, the ID of the track to scratch.
467     """
468     if id:
469       self._simple("scratch", id[0])
470     else:
471       self._simple("scratch")
472
473   def shutdown(self):
474     """Shut down the server.
475
476     Only trusted users can perform this operation.
477     """
478     self._simple("shutdown")
479
480   def reconfigure(self):
481     """Make the server reload its configuration.
482
483     Only trusted users can perform this operation.
484     """
485     self._simple("reconfigure")
486
487   def rescan(self, pattern):
488     """Rescan one or more collections.
489
490     Arguments:
491     pattern -- glob pattern matching collections to rescan.
492
493     Only trusted users can perform this operation.
494     """
495     self._simple("rescan", pattern)
496
497   def version(self):
498     """Return the server's version number."""
499     return self._simple("version")[1]
500
501   def playing(self):
502     """Return the currently playing track.
503
504     If a track is playing then it is returned as a dictionary.  See
505     disorder_protocol(5) for the meanings of the keys.  All keys are
506     plain strings but the values will be unicode strings.
507     
508     If no track is playing then None is returned."""
509     res, details = self._simple("playing")
510     if res % 10 != 9:
511       try:
512         return _queueEntry(details)
513       except _splitError, s:
514         raise protocolError(self.who, s.str())
515     else:
516       return None
517
518   def _somequeue(self, command):
519     self._simple(command)
520     try:
521       return map(lambda s: _queueEntry(s), self._body())
522     except _splitError, s:
523       raise protocolError(self.who, s.str())
524
525   def recent(self):
526     """Return a list of recently played tracks.
527
528     The return value is a list of dictionaries corresponding to
529     recently played tracks.  The oldest track comes first.
530
531     See disorder_protocol(5) for the meanings of the keys.  All keys are
532     plain strings but the values will be unicode strings."""
533     return self._somequeue("recent")
534
535   def queue(self):
536     """Return the current queue.
537
538     The return value is a list of dictionaries corresponding to
539     recently played tracks.  The next track to be played comes first.
540
541     See disorder_protocol(5) for the meanings of the keys.  All keys are
542     plain strings but the values will be unicode strings."""
543     return self._somequeue("queue")
544
545   def _somedir(self, command, dir, re):
546     if re:
547       self._simple(command, dir, re[0])
548     else:
549       self._simple(command, dir)
550     return self._body()
551
552   def directories(self, dir, *re):
553     """List subdirectories of a directory.
554
555     Arguments:
556     dir -- directory to list, or '' for the whole root.
557     re -- regexp that results must match.  Optional.
558
559     The return value is a list of the (nonempty) subdirectories of dir.
560     If dir is '' then a list of top-level directories is returned.
561
562     If a regexp is specified then the basename of each result must
563     match.  Matching is case-independent.  See pcrepattern(3).
564     """
565     return self._somedir("dirs", dir, re)
566   
567   def files(self, dir, *re):
568     """List files within a directory.
569
570     Arguments:
571     dir -- directory to list, or '' for the whole root.
572     re -- regexp that results must match.  Optional.
573
574     The return value is a list of playable files in dir.  If dir is ''
575     then a list of top-level files is returned.
576
577     If a regexp is specified then the basename of each result must
578     match.  Matching is case-independent.  See pcrepattern(3).
579     """
580     return self._somedir("files", dir, re)
581
582   def allfiles(self, dir, *re):
583     """List subdirectories and files within a directory.
584
585     Arguments:
586     dir -- directory to list, or '' for the whole root.
587     re -- regexp that results must match.  Optional.
588
589     The return value is a list of all (nonempty) subdirectories and
590     files within dir.  If dir is '' then a list of top-level files and
591     directories is returned.
592     
593     If a regexp is specified then the basename of each result must
594     match.  Matching is case-independent.  See pcrepattern(3).
595     """
596     return self._somedir("allfiles", dir, re)
597
598   def set(self, track, key, value):
599     """Set a preference value.
600
601     Arguments:
602     track -- the track to modify
603     key -- the preference name
604     value -- the new preference value
605     """
606     self._simple("set", track, key, value)
607
608   def unset(self, track, key):
609     """Unset a preference value.
610
611     Arguments:
612     track -- the track to modify
613     key -- the preference to remove
614     """
615     self._simple("set", track, key, value)
616
617   def get(self, track, key):
618     """Get a preference value.
619
620     Arguments:
621     track -- the track to query
622     key -- the preference to remove
623
624     The return value is the preference.
625     """
626     ret, details = self._simple("get", track, key)
627     if ret == 555:
628       return None
629     else:
630       return details
631
632   def prefs(self, track):
633     """Get all the preferences for a track.
634
635     Arguments:
636     track -- the track to query
637
638     The return value is a dictionary of all the track's preferences.
639     Note that even nominally numeric values remain encoded as strings.
640     """
641     self._simple("prefs", track)
642     r = {}
643     for line in self._body():
644       try:
645         kv = _split(line)
646       except _splitError, s:
647         raise protocolError(self.who, s.str())
648       if len(kv) != 2:
649         raise protocolError(self.who, "invalid prefs body line")
650       r[kv[0]] = kv[1]
651     return r
652
653   def _boolean(self, s):
654     return s[1] == 'yes'
655
656   def exists(self, track):
657     """Return true if a track exists
658
659     Arguments:
660     track -- the track to check for"""
661     return self._boolean(self._simple("exists", track))
662
663   def enabled(self):
664     """Return true if playing is enabled"""
665     return self._boolean(self._simple("enabled"))
666
667   def random_enabled(self):
668     """Return true if random play is enabled"""
669     return self._boolean(self._simple("random-enabled"))
670
671   def random_enable(self):
672     """Enable random play."""
673     self._simple("random-enable")
674
675   def random_disable(self):
676     """Disable random play."""
677     self._simple("random-disable")
678
679   def length(self, track):
680     """Return the length of a track in seconds.
681
682     Arguments:
683     track -- the track to query.
684     """
685     ret, details = self._simple("length", track)
686     return int(details)
687
688   def search(self, words):
689     """Search for tracks.
690
691     Arguments:
692     words -- the set of words to search for.
693
694     The return value is a list of track path names, all of which contain
695     all of the required words (in their path name, trackname
696     preferences, etc.)
697     """
698     self._simple("search", _quote(words))
699     return self._body()
700
701   def tags(self):
702     """List all tags
703
704     The return value is a list of all tags which apply to at least one
705     track."""
706     self._simple("tags")
707     return self._body()
708
709   def stats(self):
710     """Get server statistics.
711
712     The return value is list of statistics.
713     """
714     self._simple("stats")
715     return self._body()
716
717   def dump(self):
718     """Get all preferences.
719
720     The return value is an encoded dump of the preferences database.
721     """
722     self._simple("dump")
723     return self._body()
724
725   def set_volume(self, left, right):
726     """Set volume.
727
728     Arguments:
729     left -- volume for the left speaker.
730     right --  volume for the right speaker.
731     """
732     self._simple("volume", left, right)
733
734   def get_volume(self):
735     """Get volume.
736
737     The return value a tuple consisting of the left and right volumes.
738     """
739     ret, details = self._simple("volume")
740     return map(int,string.split(details))
741
742   def move(self, track, delta):
743     """Move a track in the queue.
744
745     Arguments:
746     track -- the name or ID of the track to move
747     delta -- the number of steps towards the head of the queue to move
748     """
749     ret, details = self._simple("move", track, str(delta))
750     return int(details)
751
752   def moveafter(self, target, tracks):
753     """Move a track in the queue
754
755     Arguments:
756     target -- target ID or None
757     tracks -- a list of IDs to move
758
759     If target is '' or is not in the queue then the tracks are moved to
760     the head of the queue.
761
762     Otherwise the tracks are moved to just after the target."""
763     if target is None:
764       target = ''
765     self._simple("moveafter", target, *tracks)
766
767   def log(self, callback):
768     """Read event log entries as they happen.
769
770     Each event log entry is handled by passing it to callback.
771
772     The callback takes two arguments, the first is the client and the
773     second the line from the event log.
774     
775     The callback should return True to continue or False to stop (don't
776     forget this, or your program will mysteriously misbehave).
777
778     It is suggested that you use the disorder.monitor class instead of
779     calling this method directly, but this is not mandatory.
780
781     See disorder_protocol(5) for the event log syntax.
782
783     Arguments:
784     callback -- function to call with log entry
785     """
786     ret, details = self._simple("log")
787     while True:
788       l = self._line()
789       self._debug(client.debug_body, "<<< %s" % l)
790       if l != '' and l[0] == '.':
791         if l == '.':
792           return
793         l = l[1:]
794       if not callback(self, l):
795         break
796     # tell the server to stop sending, eat the remains of the body,
797     # eat the response
798     self._send("version")
799     self._body()
800     self._response()
801
802   def pause(self):
803     """Pause the current track."""
804     self._simple("pause")
805
806   def resume(self):
807     """Resume after a pause."""
808     self._simple("resume")
809
810   def part(self, track, context, part):
811     """Get a track name part
812
813     Arguments:
814     track -- the track to query
815     context -- the context ('sort' or 'display')
816     part -- the desired part (usually 'artist', 'album' or 'title')
817
818     The return value is the preference 
819     """
820     ret, details = self._simple("part", track, context, part)
821     return details
822
823   def setglobal(self, key, value):
824     """Set a global preference value.
825
826     Arguments:
827     key -- the preference name
828     value -- the new preference value
829     """
830     self._simple("set-global", key, value)
831
832   def unsetglobal(self, key):
833     """Unset a global preference value.
834
835     Arguments:
836     key -- the preference to remove
837     """
838     self._simple("set-global", key, value)
839
840   def getglobal(self, key):
841     """Get a global preference value.
842
843     Arguments:
844     key -- the preference to look up
845
846     The return value is the preference 
847     """
848     ret, details = self._simple("get-global", key)
849     if ret == 555:
850       return None
851     else:
852       return details
853
854   def make_cookie(self):
855     """Create a login cookie"""
856     ret, details = self._simple("make-cookie")
857     return details
858   
859   def revoke(self):
860     """Revoke a login cookie"""
861     self._simple("revoke")
862
863   def adduser(self, user, password):
864     """Create a user"""
865     self._simple("adduser", user, password)
866
867   def deluser(self, user):
868     """Delete a user"""
869     self._simple("deluser", user)
870
871   def userinfo(self, user, key):
872     """Get user information"""
873     res, details = self._simple("userinfo", user, key)
874     if res == 555:
875       return None
876     return _split(details)[0]
877
878   def edituser(self, user, key, value):
879     """Set user information"""
880     self._simple("edituser", user, key, value)
881
882   ########################################################################
883   # I/O infrastructure
884
885   def _line(self):
886     # read one response line and return as some suitable string object
887     #
888     # If an I/O error occurs, disconnect from the server.
889     #
890     # XXX does readline() DTRT regarding character encodings?
891     try:
892       l = self.r.readline()
893       if not re.search("\n", l):
894         raise communicationError(self.who, "peer disconnected")
895       l = l[:-1]
896     except:
897       self._disconnect()
898       raise
899     return unicode(l, "UTF-8")
900
901   def _response(self):
902     # read a response as a (code, details) tuple
903     l = self._line()
904     self._debug(client.debug_proto, "<== %s" % l)
905     m = _response.match(l)
906     if m:
907       return int(m.group(1)), m.group(2)
908     else:
909       raise protocolError(self.who, "invalid response %s")
910
911   def _send(self, *command):
912     # Quote and send a command
913     #
914     # Returns the encoded command.
915     quoted = _quote(command)
916     self._debug(client.debug_proto, "==> %s" % quoted)
917     encoded = quoted.encode("UTF-8")
918     try:
919       self.w.write(encoded)
920       self.w.write("\n")
921       self.w.flush()
922       return encoded
923     except IOError, e:
924       # e.g. EPIPE
925       self._disconnect()
926       raise communicationError(self.who, e)
927     except:
928       self._disconnect()
929       raise
930
931   def _simple(self, *command):
932     # Issue a simple command, throw an exception on error
933     #
934     # If an I/O error occurs, disconnect from the server.
935     #
936     # On success or 'normal' errors returns response as a (code, details) tuple
937     #
938     # On error raise operationError
939     if self.state == 'disconnected':
940       self.connect()
941     if command:
942       cmd = self._send(*command)
943     else:
944       cmd = None
945     res, details = self._response()
946     if res / 100 == 2 or res == 555:
947       return res, details
948     raise operationError(res, details, cmd)
949
950   def _body(self):
951     # Fetch a dot-stuffed body
952     result = []
953     while True:
954       l = self._line()
955       self._debug(client.debug_body, "<<< %s" % l)
956       if l != '' and l[0] == '.':
957         if l == '.':
958           return result
959         l = l[1:]
960       result.append(l)
961
962   ########################################################################
963   # Configuration file parsing
964
965   def _readfile(self, path):
966     # Read a configuration file
967     #
968     # Arguments:
969     #
970     # path -- path of file to read
971
972     # handlers for various commands
973     def _collection(self, command, args):
974       if len(args) != 3:
975         return "'%s' takes three args" % command
976       self.config["collections"].append(args)
977       
978     def _unary(self, command, args):
979       if len(args) != 1:
980         return "'%s' takes only one arg" % command
981       self.config[command] = args[0]
982
983     def _include(self, command, args):
984       if len(args) != 1:
985         return "'%s' takes only one arg" % command
986       self._readfile(args[0])
987
988     def _any(self, command, args):
989       self.config[command] = args
990
991     # mapping of options to handlers
992     _options = { "collection": _collection,
993                  "username": _unary,
994                  "password": _unary,
995                  "home": _unary,
996                  "connect": _any,
997                  "include": _include }
998
999     # the parser
1000     for lno, line in enumerate(file(path, "r")):
1001       try:
1002         fields = _split(line, 'comments')
1003       except _splitError, s:
1004         raise parseError(path, lno + 1, str(s))
1005       if fields:
1006         command = fields[0]
1007         # we just ignore options we don't know about, so as to cope gracefully
1008         # with version skew (and nothing to do with implementor laziness)
1009         if command in _options:
1010           e = _options[command](self, command, fields[1:])
1011           if e:
1012             self._parseError(path, lno + 1, e)
1013
1014   def _parseError(self, path, lno, s):
1015     raise parseError(path, lno, s)
1016
1017 ########################################################################
1018 # monitor class
1019
1020 class monitor:
1021   """DisOrder event log monitor class
1022
1023   Intended to be subclassed with methods corresponding to event log messages
1024   the implementor cares about over-ridden."""
1025
1026   def __init__(self, c=None):
1027     """Constructor for the monitor class
1028
1029     Can be passed a client to use.  If none is specified then one
1030     will be created specially for the purpose.
1031
1032     Arguments:
1033     c -- client"""
1034     if c == None:
1035       c = client();
1036     self.c = c
1037
1038   def run(self):
1039     """Start monitoring logs.  Continues monitoring until one of the
1040     message-specific methods returns False.  Can be called more than once
1041     (but not recursively!)"""
1042     self.c.log(self._callback)
1043
1044   def when(self):
1045     """Return the timestamp of the current (or most recent) event log entry"""
1046     return self.timestamp
1047
1048   def _callback(self, c, line):
1049     try:
1050       bits = _split(line)
1051     except:
1052       return self.invalid(line)
1053     if(len(bits) < 2):
1054       return self.invalid(line)
1055     self.timestamp = int(bits[0], 16)
1056     keyword = bits[1]
1057     bits = bits[2:]
1058     if keyword == 'completed':
1059       if len(bits) == 1:
1060         return self.completed(bits[0])
1061     elif keyword == 'failed':
1062       if len(bits) == 2:
1063         return self.failed(bits[0], bits[1])
1064     elif keyword == 'moved':
1065       if len(bits) == 3:
1066         try:
1067           n = int(bits[1])
1068         except:
1069           return self.invalid(line)
1070         return self.moved(bits[0], n, bits[2])
1071     elif keyword == 'playing':
1072       if len(bits) == 1:
1073         return self.playing(bits[0], None)
1074       elif len(bits) == 2:
1075         return self.playing(bits[0], bits[1])
1076     elif keyword == 'queue' or keyword == 'recent-added':
1077       try:
1078         q = _list2dict(bits)
1079       except:
1080         return self.invalid(line)
1081       if keyword == 'queue':
1082         return self.queue(q)
1083       if keyword == 'recent-added':
1084         return self.recent_added(q)
1085     elif keyword == 'recent-removed':
1086       if len(bits) == 1:
1087         return self.recent_removed(bits[0])
1088     elif keyword == 'removed':
1089       if len(bits) == 1:
1090         return self.removed(bits[0], None)
1091       elif len(bits) == 2:
1092         return self.removed(bits[0], bits[1])
1093     elif keyword == 'scratched':
1094       if len(bits) == 2:
1095         return self.scratched(bits[0], bits[1])
1096     return self.invalid(line)
1097
1098   def completed(self, track):
1099     """Called when a track completes.
1100
1101     Arguments:
1102     track -- track that completed"""
1103     return True
1104
1105   def failed(self, track, error):
1106     """Called when a player suffers an error.
1107
1108     Arguments:
1109     track -- track that failed
1110     error -- error indicator"""
1111     return True
1112
1113   def moved(self, id, offset, user):
1114     """Called when a track is moved in the queue.
1115
1116     Arguments:
1117     id -- queue entry ID
1118     offset -- distance moved
1119     user -- user responsible"""
1120     return True
1121
1122   def playing(self, track, user):
1123     """Called when a track starts playing.
1124
1125     Arguments:
1126     track -- track that has started
1127     user -- user that submitted track, or None"""
1128     return True
1129
1130   def queue(self, q):
1131     """Called when a track is added to the queue.
1132
1133     Arguments:
1134     q -- dictionary of new queue entry"""
1135     return True
1136
1137   def recent_added(self, q):
1138     """Called when a track is added to the recently played list
1139
1140     Arguments:
1141     q -- dictionary of new queue entry"""
1142     return True
1143
1144   def recent_removed(self, id):
1145     """Called when a track is removed from the recently played list
1146
1147     Arguments:
1148     id -- ID of removed entry (always the oldest)"""
1149     return True
1150
1151   def removed(self, id, user):
1152     """Called when a track is removed from the queue, either manually
1153     or in order to play it.
1154
1155     Arguments:
1156     id -- ID of removed entry
1157     user -- user responsible (or None if we're playing this track)"""
1158     return True
1159
1160   def scratched(self, track, user):
1161     """Called when a track is scratched
1162
1163     Arguments:
1164     track -- track that was scratched
1165     user -- user responsible"""
1166     return True
1167
1168   def invalid(self, line):
1169     """Called when an event log line cannot be interpreted
1170
1171     Arguments:
1172     line -- line that could not be understood"""
1173     return True
1174
1175 # Local Variables:
1176 # mode:python
1177 # py-indent-offset:2
1178 # comment-column:40
1179 # fill-column:72
1180 # End: