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