chiark / gitweb /
7582979ee14a5479062776358443731877d86589
[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[str(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     if os.path.exists(_configfile):
298       self._readfile(_configfile)
299     if os.path.exists(privconf):
300       self._readfile(privconf)
301     if os.path.exists(passfile) and _userconf:
302       self._readfile(passfile)
303     self.state = 'disconnected'
304
305   def debug(self, bits):
306     """Enable or disable protocol debugging.  Debug messages are written
307     to sys.stderr.
308
309     Arguments:
310     bits -- bitmap of operations that should generate debug information
311
312     Bitmap values:
313     debug_proto -- dump control protocol messages (excluding bodies)
314     debug_body -- dump control protocol message bodies
315     """
316     self.debugging = bits
317
318   def _debug(self, bit, s):
319     # debug output
320     if self.debugging & bit:
321       sys.stderr.write(_sanitize(s))
322       sys.stderr.write("\n")
323       sys.stderr.flush()
324
325   def connect(self):
326     """Connect to the DisOrder server and authenticate.
327
328     Raises communicationError if connection fails and operationError if
329     authentication fails (in which case disconnection is automatic).
330
331     May be called more than once to retry connections (e.g. when the
332     server is down).  If we are already connected and authenticated,
333     this is a no-op.
334
335     Other operations automatically connect if we're not already
336     connected, so it is not strictly necessary to call this method.
337     """
338     if self.state == 'disconnected':
339       try:
340         self.state = 'connecting'
341         if 'connect' in self.config and len(self.config['connect']) > 0:
342           c = self.config['connect']
343           self.who = repr(c)            # temporarily
344           if len(c) == 1:
345             a = socket.getaddrinfo(None, c[0],
346                                    socket.AF_INET,
347                                    socket.SOCK_STREAM,
348                                    0,
349                                    0)
350           else:
351             a = socket.getaddrinfo(c[0], c[1],
352                                    socket.AF_INET,
353                                    socket.SOCK_STREAM,
354                                    0,
355                                    0)
356           a = a[0]
357           s = socket.socket(a[0], a[1], a[2]);
358           s.connect(a[4])
359           self.who = "%s" % a[3]
360         else:
361           s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM);
362           self.who = self.config['home'] + os.sep + "socket"
363           s.connect(self.who)
364         self.w = s.makefile("wb")
365         self.r = s.makefile("rb")
366         (res, challenge) = self._simple()
367         h = sha.sha()
368         h.update(self.config['password'])
369         h.update(binascii.unhexlify(challenge))
370         self._simple("user", self.config['username'], h.hexdigest())
371         self.state = 'connected'
372       except socket.error, e:
373         self._disconnect()
374         raise communicationError(self.who, e)
375       except:
376         self._disconnect()
377         raise
378
379   def _disconnect(self):
380     # disconnect from the server, whatever state we are in
381     try:
382       del self.w
383       del self.r
384     except:
385       pass
386     self.state = 'disconnected'
387     
388   ########################################################################
389   # Operations
390
391   def become(self, who):
392     """Become another user.
393
394     Arguments:
395     who -- the user to become.
396
397     Only trusted users can perform this operation.
398     """
399     self._simple("become", who)
400
401   def play(self, track):
402     """Play a track.
403
404     Arguments:
405     track -- the path of the track to play.
406
407     Returns the ID of the new queue entry.
408     """
409     res, details = self._simple("play", track)
410     return unicode(details)             # because it's unicode in queue() output
411
412   def remove(self, track):
413     """Remove a track from the queue.
414
415     Arguments:
416     track -- the path or ID of the track to remove.
417     """
418     self._simple("remove", track)
419
420   def enable(self):
421     """Enable playing."""
422     self._simple("enable")
423
424   def disable(self, *now):
425     """Disable playing.
426
427     Arguments:
428     now -- if present (with any value), the current track is stopped
429            too.
430     """
431     if now:
432       self._simple("disable", "now")
433     else:
434       self._simple("disable")
435
436   def scratch(self, *id):
437     """Scratch the currently playing track.
438
439     Arguments:
440     id -- if present, the ID of the track to scratch.
441     """
442     if id:
443       self._simple("scratch", id[0])
444     else:
445       self._simple("scratch")
446
447   def shutdown(self):
448     """Shut down the server.
449
450     Only trusted users can perform this operation.
451     """
452     self._simple("shutdown")
453
454   def reconfigure(self):
455     """Make the server reload its configuration.
456
457     Only trusted users can perform this operation.
458     """
459     self._simple("reconfigure")
460
461   def rescan(self, pattern):
462     """Rescan one or more collections.
463
464     Arguments:
465     pattern -- glob pattern matching collections to rescan.
466
467     Only trusted users can perform this operation.
468     """
469     self._simple("rescan", pattern)
470
471   def version(self):
472     """Return the server's version number."""
473     return self._simple("version")[1]
474
475   def playing(self):
476     """Return the currently playing track.
477
478     If a track is playing then it is returned as a dictionary.
479     If no track is playing then None is returned."""
480     res, details = self._simple("playing")
481     if res % 10 != 9:
482       try:
483         return _queueEntry(details)
484       except _splitError, s:
485         raise protocolError(self.who, s.str())
486     else:
487       return None
488
489   def _somequeue(self, command):
490     self._simple(command)
491     try:
492       return map(lambda s: _queueEntry(s), self._body())
493     except _splitError, s:
494       raise protocolError(self.who, s.str())
495
496   def recent(self):
497     """Return a list of recently played tracks.
498
499     The return value is a list of dictionaries corresponding to
500     recently played tracks.  The oldest track comes first."""
501     return self._somequeue("recent")
502
503   def queue(self):
504     """Return the current queue.
505
506     The return value is a list of dictionaries corresponding to
507     recently played tracks.  The next track to be played comes first."""
508     return self._somequeue("queue")
509
510   def _somedir(self, command, dir, re):
511     if re:
512       self._simple(command, dir, re[0])
513     else:
514       self._simple(command, dir)
515     return self._body()
516
517   def directories(self, dir, *re):
518     """List subdirectories of a directory.
519
520     Arguments:
521     dir -- directory to list, or '' for the whole root.
522     re -- regexp that results must match.  Optional.
523
524     The return value is a list of the (nonempty) subdirectories of dir.
525     If dir is '' then a list of top-level directories is returned.
526
527     If a regexp is specified then the basename of each result must
528     match.  Matching is case-independent.  See pcrepattern(3).
529     """
530     return self._somedir("dirs", dir, re)
531   
532   def files(self, dir, *re):
533     """List files within a directory.
534
535     Arguments:
536     dir -- directory to list, or '' for the whole root.
537     re -- regexp that results must match.  Optional.
538
539     The return value is a list of playable files in dir.  If dir is ''
540     then a list of top-level files is returned.
541
542     If a regexp is specified then the basename of each result must
543     match.  Matching is case-independent.  See pcrepattern(3).
544     """
545     return self._somedir("files", dir, re)
546
547   def allfiles(self, dir, *re):
548     """List subdirectories and files within a directory.
549
550     Arguments:
551     dir -- directory to list, or '' for the whole root.
552     re -- regexp that results must match.  Optional.
553
554     The return value is a list of all (nonempty) subdirectories and
555     files within dir.  If dir is '' then a list of top-level files and
556     directories is returned.
557     
558     If a regexp is specified then the basename of each result must
559     match.  Matching is case-independent.  See pcrepattern(3).
560     """
561     return self._somedir("allfiles", dir, re)
562
563   def set(self, track, key, value):
564     """Set a preference value.
565
566     Arguments:
567     track -- the track to modify
568     key -- the preference name
569     value -- the new preference value
570     """
571     self._simple("set", track, key, value)
572
573   def unset(self, track, key):
574     """Unset a preference value.
575
576     Arguments:
577     track -- the track to modify
578     key -- the preference to remove
579     """
580     self._simple("set", track, key, value)
581
582   def get(self, track, key):
583     """Get a preference value.
584
585     Arguments:
586     track -- the track to query
587     key -- the preference to remove
588
589     The return value is the preference 
590     """
591     ret, details = self._simple("get", track, key)
592     if ret == 555:
593       return None
594     else:
595       return details
596
597   def prefs(self, track):
598     """Get all the preferences for a track.
599
600     Arguments:
601     track -- the track to query
602
603     The return value is a dictionary of all the track's preferences.
604     Note that even nominally numeric values remain encoded as strings.
605     """
606     self._simple("prefs", track)
607     r = {}
608     for line in self._body():
609       try:
610         kv = _split(line)
611       except _splitError, s:
612         raise protocolError(self.who, s.str())
613       if len(kv) != 2:
614         raise protocolError(self.who, "invalid prefs body line")
615       r[kv[0]] = kv[1]
616     return r
617
618   def _boolean(self, s):
619     return s[1] == 'yes'
620
621   def exists(self, track):
622     """Return true if a track exists
623
624     Arguments:
625     track -- the track to check for"""
626     return self._boolean(self._simple("exists", track))
627
628   def enabled(self):
629     """Return true if playing is enabled"""
630     return self._boolean(self._simple("enabled"))
631
632   def random_enabled(self):
633     """Return true if random play is enabled"""
634     return self._boolean(self._simple("random-enabled"))
635
636   def random_enable(self):
637     """Enable random play."""
638     self._simple("random-enable")
639
640   def random_disable(self):
641     """Disable random play."""
642     self._simple("random-disable")
643
644   def length(self, track):
645     """Return the length of a track in seconds.
646
647     Arguments:
648     track -- the track to query.
649     """
650     ret, details = self._simple("length", track)
651     return int(details)
652
653   def search(self, words):
654     """Search for tracks.
655
656     Arguments:
657     words -- the set of words to search for.
658
659     The return value is a list of track path names, all of which contain
660     all of the required words (in their path name, trackname
661     preferences, etc.)
662     """
663     self._simple("search", _quote(words))
664     return self._body()
665
666   def tags(self):
667     """List all tags
668
669     The return value is a list of all tags which apply to at least one
670     track."""
671     self._simple("tags")
672     return self._body()
673
674   def stats(self):
675     """Get server statistics.
676
677     The return value is list of statistics.
678     """
679     self._simple("stats")
680     return self._body()
681
682   def dump(self):
683     """Get all preferences.
684
685     The return value is an encoded dump of the preferences database.
686     """
687     self._simple("dump")
688     return self._body()
689
690   def set_volume(self, left, right):
691     """Set volume.
692
693     Arguments:
694     left -- volume for the left speaker.
695     right --  volume for the right speaker.
696     """
697     self._simple("volume", left, right)
698
699   def get_volume(self):
700     """Get volume.
701
702     The return value a tuple consisting of the left and right volumes.
703     """
704     ret, details = self._simple("volume")
705     return map(int,string.split(details))
706
707   def move(self, track, delta):
708     """Move a track in the queue.
709
710     Arguments:
711     track -- the name or ID of the track to move
712     delta -- the number of steps towards the head of the queue to move
713     """
714     ret, details = self._simple("move", track, str(delta))
715     return int(details)
716
717   def moveafter(self, target, tracks):
718     """Move a track in the queue
719
720     Arguments:
721     target -- target ID or None
722     tracks -- a list of IDs to move
723
724     If target is '' or is not in the queue then the tracks are moved to
725     the head of the queue.
726
727     Otherwise the tracks are moved to just after the target."""
728     if target is None:
729       target = ''
730     self._simple("moveafter", target, *tracks)
731
732   def log(self, callback):
733     """Read event log entries as they happen.
734
735     Each event log entry is handled by passing it to callback.
736
737     The callback takes two arguments, the first is the client and the
738     second the line from the event log.
739     
740     The callback should return True to continue or False to stop (don't
741     forget this, or your program will mysteriously misbehave).
742
743     It is suggested that you use the disorder.monitor class instead of
744     calling this method directly, but this is not mandatory.
745
746     See disorder_protocol(5) for the event log syntax.
747
748     Arguments:
749     callback -- function to call with log entry
750     """
751     ret, details = self._simple("log")
752     while True:
753       l = self._line()
754       self._debug(client.debug_body, "<<< %s" % l)
755       if l != '' and l[0] == '.':
756         if l == '.':
757           return
758         l = l[1:]
759       if not callback(self, l):
760         break
761     # tell the server to stop sending, eat the remains of the body,
762     # eat the response
763     self._send("version")
764     self._body()
765     self._response()
766
767   def pause(self):
768     """Pause the current track."""
769     self._simple("pause")
770
771   def resume(self):
772     """Resume after a pause."""
773     self._simple("resume")
774
775   def part(self, track, context, part):
776     """Get a track name part
777
778     Arguments:
779     track -- the track to query
780     context -- the context ('sort' or 'display')
781     part -- the desired part (usually 'artist', 'album' or 'title')
782
783     The return value is the preference 
784     """
785     ret, details = self._simple("part", track, context, part)
786     return details
787
788   def setglobal(self, key, value):
789     """Set a global preference value.
790
791     Arguments:
792     key -- the preference name
793     value -- the new preference value
794     """
795     self._simple("set-global", key, value)
796
797   def unsetglobal(self, key):
798     """Unset a global preference value.
799
800     Arguments:
801     key -- the preference to remove
802     """
803     self._simple("set-global", key, value)
804
805   def getglobal(self, key):
806     """Get a global preference value.
807
808     Arguments:
809     key -- the preference to look up
810
811     The return value is the preference 
812     """
813     ret, details = self._simple("get-global", key)
814     if ret == 555:
815       return None
816     else:
817       return details
818
819   ########################################################################
820   # I/O infrastructure
821
822   def _line(self):
823     # read one response line and return as some suitable string object
824     #
825     # If an I/O error occurs, disconnect from the server.
826     #
827     # XXX does readline() DTRT regarding character encodings?
828     try:
829       l = self.r.readline()
830       if not re.search("\n", l):
831         raise communicationError(self.who, "peer disconnected")
832       l = l[:-1]
833     except:
834       self._disconnect()
835       raise
836     return unicode(l, "UTF-8")
837
838   def _response(self):
839     # read a response as a (code, details) tuple
840     l = self._line()
841     self._debug(client.debug_proto, "<== %s" % l)
842     m = _response.match(l)
843     if m:
844       return int(m.group(1)), m.group(2)
845     else:
846       raise protocolError(self.who, "invalid response %s")
847
848   def _send(self, *command):
849     # Quote and send a command
850     #
851     # Returns the encoded command.
852     quoted = _quote(command)
853     self._debug(client.debug_proto, "==> %s" % quoted)
854     encoded = quoted.encode("UTF-8")
855     try:
856       self.w.write(encoded)
857       self.w.write("\n")
858       self.w.flush()
859       return encoded
860     except IOError, e:
861       # e.g. EPIPE
862       self._disconnect()
863       raise communicationError(self.who, e)
864     except:
865       self._disconnect()
866       raise
867
868   def _simple(self, *command):
869     # Issue a simple command, throw an exception on error
870     #
871     # If an I/O error occurs, disconnect from the server.
872     #
873     # On success or 'normal' errors returns response as a (code, details) tuple
874     #
875     # On error raise operationError
876     if self.state == 'disconnected':
877       self.connect()
878     if command:
879       cmd = self._send(*command)
880     else:
881       cmd = None
882     res, details = self._response()
883     if res / 100 == 2 or res == 555:
884       return res, details
885     raise operationError(res, details, cmd)
886
887   def _body(self):
888     # Fetch a dot-stuffed body
889     result = []
890     while True:
891       l = self._line()
892       self._debug(client.debug_body, "<<< %s" % l)
893       if l != '' and l[0] == '.':
894         if l == '.':
895           return result
896         l = l[1:]
897       result.append(l)
898
899   ########################################################################
900   # Configuration file parsing
901
902   def _readfile(self, path):
903     # Read a configuration file
904     #
905     # Arguments:
906     #
907     # path -- path of file to read
908
909     # handlers for various commands
910     def _collection(self, command, args):
911       if len(args) != 3:
912         return "'%s' takes three args" % command
913       self.config["collections"].append(args)
914       
915     def _unary(self, command, args):
916       if len(args) != 1:
917         return "'%s' takes only one arg" % command
918       self.config[command] = args[0]
919
920     def _include(self, command, args):
921       if len(args) != 1:
922         return "'%s' takes only one arg" % command
923       self._readfile(args[0])
924
925     def _any(self, command, args):
926       self.config[command] = args
927
928     # mapping of options to handlers
929     _options = { "collection": _collection,
930                  "username": _unary,
931                  "password": _unary,
932                  "home": _unary,
933                  "connect": _any,
934                  "include": _include }
935
936     # the parser
937     for lno, line in enumerate(file(path, "r")):
938       try:
939         fields = _split(line, 'comments')
940       except _splitError, s:
941         raise parseError(path, lno + 1, str(s))
942       if fields:
943         command = fields[0]
944         # we just ignore options we don't know about, so as to cope gracefully
945         # with version skew (and nothing to do with implementor laziness)
946         if command in _options:
947           e = _options[command](self, command, fields[1:])
948           if e:
949             self._parseError(path, lno + 1, e)
950
951   def _parseError(self, path, lno, s):
952     raise parseError(path, lno, s)
953
954 ########################################################################
955 # monitor class
956
957 class monitor:
958   """DisOrder event log monitor class
959
960   Intended to be subclassed with methods corresponding to event log messages
961   the implementor cares about over-ridden."""
962
963   def __init__(self, c=None):
964     """Constructor for the monitor class
965
966     Can be passed a client to use.  If none is specified then one
967     will be created specially for the purpose.
968
969     Arguments:
970     c -- client"""
971     if c == None:
972       c = client();
973     self.c = c
974
975   def run(self):
976     """Start monitoring logs.  Continues monitoring until one of the
977     message-specific methods returns False.  Can be called more than once
978     (but not recursively!)"""
979     self.c.log(self._callback)
980
981   def when(self):
982     """Return the timestamp of the current (or most recent) event log entry"""
983     return self.timestamp
984
985   def _callback(self, c, line):
986     try:
987       bits = _split(line)
988     except:
989       return self.invalid(line)
990     if(len(bits) < 2):
991       return self.invalid(line)
992     self.timestamp = int(bits[0], 16)
993     keyword = bits[1]
994     bits = bits[2:]
995     if keyword == 'completed':
996       if len(bits) == 1:
997         return self.completed(bits[0])
998     elif keyword == 'failed':
999       if len(bits) == 2:
1000         return self.failed(bits[0], bits[1])
1001     elif keyword == 'moved':
1002       if len(bits) == 3:
1003         try:
1004           n = int(bits[1])
1005         except:
1006           return self.invalid(line)
1007         return self.moved(bits[0], n, bits[2])
1008     elif keyword == 'playing':
1009       if len(bits) == 1:
1010         return self.playing(bits[0], None)
1011       elif len(bits) == 2:
1012         return self.playing(bits[0], bits[1])
1013     elif keyword == 'queue' or keyword == 'recent-added':
1014       try:
1015         q = _list2dict(bits)
1016       except:
1017         return self.invalid(line)
1018       if keyword == 'queue':
1019         return self.queue(q)
1020       if keyword == 'recent-added':
1021         return self.recent_added(q)
1022     elif keyword == 'recent-removed':
1023       if len(bits) == 1:
1024         return self.recent_removed(bits[0])
1025     elif keyword == 'removed':
1026       if len(bits) == 1:
1027         return self.removed(bits[0], None)
1028       elif len(bits) == 2:
1029         return self.removed(bits[0], bits[1])
1030     elif keyword == 'scratched':
1031       if len(bits) == 2:
1032         return self.scratched(bits[0], bits[1])
1033     return self.invalid(line)
1034
1035   def completed(self, track):
1036     """Called when a track completes.
1037
1038     Arguments:
1039     track -- track that completed"""
1040     return True
1041
1042   def failed(self, track, error):
1043     """Called when a player suffers an error.
1044
1045     Arguments:
1046     track -- track that failed
1047     error -- error indicator"""
1048     return True
1049
1050   def moved(self, id, offset, user):
1051     """Called when a track is moved in the queue.
1052
1053     Arguments:
1054     id -- queue entry ID
1055     offset -- distance moved
1056     user -- user responsible"""
1057     return True
1058
1059   def playing(self, track, user):
1060     """Called when a track starts playing.
1061
1062     Arguments:
1063     track -- track that has started
1064     user -- user that submitted track, or None"""
1065     return True
1066
1067   def queue(self, q):
1068     """Called when a track is added to the queue.
1069
1070     Arguments:
1071     q -- dictionary of new queue entry"""
1072     return True
1073
1074   def recent_added(self, q):
1075     """Called when a track is added to the recently played list
1076
1077     Arguments:
1078     q -- dictionary of new queue entry"""
1079     return True
1080
1081   def recent_removed(self, id):
1082     """Called when a track is removed from the recently played list
1083
1084     Arguments:
1085     id -- ID of removed entry (always the oldest)"""
1086     return True
1087
1088   def removed(self, id, user):
1089     """Called when a track is removed from the queue, either manually
1090     or in order to play it.
1091
1092     Arguments:
1093     id -- ID of removed entry
1094     user -- user responsible (or None if we're playing this track)"""
1095     return True
1096
1097   def scratched(self, track, user):
1098     """Called when a track is scratched
1099
1100     Arguments:
1101     track -- track that was scratched
1102     user -- user responsible"""
1103     return True
1104
1105   def invalid(self, line):
1106     """Called when an event log line cannot be interpreted
1107
1108     Arguments:
1109     line -- line that could not be understood"""
1110     return True
1111
1112 # Local Variables:
1113 # mode:python
1114 # py-indent-offset:2
1115 # comment-column:40
1116 # fill-column:72
1117 # End: