chiark / gitweb /
a8afcba9401dfaef6995eeb0755b56edac62808e
[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 tags(self):
663     """List all tags
664
665     The return value is a list of all tags which apply to at least one
666     track."""
667     self._simple("tags")
668     return self._body()
669
670   def stats(self):
671     """Get server statistics.
672
673     The return value is list of statistics.
674     """
675     self._simple("stats")
676     return self._body()
677
678   def dump(self):
679     """Get all preferences.
680
681     The return value is an encoded dump of the preferences database.
682     """
683     self._simple("dump")
684     return self._body()
685
686   def set_volume(self, left, right):
687     """Set volume.
688
689     Arguments:
690     left -- volume for the left speaker.
691     right --  volume for the right speaker.
692     """
693     self._simple("volume", left, right)
694
695   def get_volume(self):
696     """Get volume.
697
698     The return value a tuple consisting of the left and right volumes.
699     """
700     ret, details = self._simple("volume")
701     return map(int,string.split(details))
702
703   def move(self, track, delta):
704     """Move a track in the queue.
705
706     Arguments:
707     track -- the name or ID of the track to move
708     delta -- the number of steps towards the head of the queue to move
709     """
710     ret, details = self._simple("move", track, str(delta))
711     return int(details)
712
713   def log(self, callback):
714     """Read event log entries as they happen.
715
716     Each event log entry is handled by passing it to callback.
717
718     The callback takes two arguments, the first is the client and the
719     second the line from the event log.
720     
721     The callback should return True to continue or False to stop (don't
722     forget this, or your program will mysteriously misbehave).
723
724     It is suggested that you use the disorder.monitor class instead of
725     calling this method directly, but this is not mandatory.
726
727     See disorder_protocol(5) for the event log syntax.
728
729     Arguments:
730     callback -- function to call with log entry
731     """
732     ret, details = self._simple("log")
733     while True:
734       l = self._line()
735       self._debug(client.debug_body, "<<< %s" % l)
736       if l != '' and l[0] == '.':
737         if l == '.':
738           return
739         l = l[1:]
740       if not callback(self, l):
741         break
742     # tell the server to stop sending, eat the remains of the body,
743     # eat the response
744     self._send("version")
745     self._body()
746     self._response()
747
748   def pause(self):
749     """Pause the current track."""
750     self._simple("pause")
751
752   def resume(self):
753     """Resume after a pause."""
754     self._simple("resume")
755
756   def part(self, track, context, part):
757     """Get a track name part
758
759     Arguments:
760     track -- the track to query
761     context -- the context ('sort' or 'display')
762     part -- the desired part (usually 'artist', 'album' or 'title')
763
764     The return value is the preference 
765     """
766     ret, details = self._simple("part", track, context, part)
767     return details
768
769   def setglobal(self, key, value):
770     """Set a global preference value.
771
772     Arguments:
773     key -- the preference name
774     value -- the new preference value
775     """
776     self._simple("set-global", key, value)
777
778   def unsetglobal(self, key):
779     """Unset a global preference value.
780
781     Arguments:
782     key -- the preference to remove
783     """
784     self._simple("set-global", key, value)
785
786   def getglobal(self, key):
787     """Get a global preference value.
788
789     Arguments:
790     key -- the preference to look up
791
792     The return value is the preference 
793     """
794     ret, details = self._simple("get-global", key)
795     if ret == 555:
796       return None
797     else:
798       return details
799
800   ########################################################################
801   # I/O infrastructure
802
803   def _line(self):
804     # read one response line and return as some suitable string object
805     #
806     # If an I/O error occurs, disconnect from the server.
807     #
808     # XXX does readline() DTRT regarding character encodings?
809     try:
810       l = self.r.readline()
811       if not re.search("\n", l):
812         raise communicationError(self.who, "peer disconnected")
813       l = l[:-1]
814     except:
815       self._disconnect()
816       raise
817     return unicode(l, "UTF-8")
818
819   def _response(self):
820     # read a response as a (code, details) tuple
821     l = self._line()
822     self._debug(client.debug_proto, "<== %s" % l)
823     m = _response.match(l)
824     if m:
825       return int(m.group(1)), m.group(2)
826     else:
827       raise protocolError(self.who, "invalid response %s")
828
829   def _send(self, *command):
830     # Quote and send a command
831     #
832     # Returns the encoded command.
833     quoted = _quote(command)
834     self._debug(client.debug_proto, "==> %s" % quoted)
835     encoded = quoted.encode("UTF-8")
836     try:
837       self.w.write(encoded)
838       self.w.write("\n")
839       self.w.flush()
840       return encoded
841     except IOError, e:
842       # e.g. EPIPE
843       self._disconnect()
844       raise communicationError(self.who, e)
845     except:
846       self._disconnect()
847       raise
848
849   def _simple(self, *command):
850     # Issue a simple command, throw an exception on error
851     #
852     # If an I/O error occurs, disconnect from the server.
853     #
854     # On success or 'normal' errors returns response as a (code, details) tuple
855     #
856     # On error raise operationError
857     if self.state == 'disconnected':
858       self.connect()
859     if command:
860       cmd = self._send(*command)
861     else:
862       cmd = None
863     res, details = self._response()
864     if res / 100 == 2 or res == 555:
865       return res, details
866     raise operationError(res, details, cmd)
867
868   def _body(self):
869     # Fetch a dot-stuffed body
870     result = []
871     while True:
872       l = self._line()
873       self._debug(client.debug_body, "<<< %s" % l)
874       if l != '' and l[0] == '.':
875         if l == '.':
876           return result
877         l = l[1:]
878       result.append(l)
879
880   ########################################################################
881   # Configuration file parsing
882
883   def _readfile(self, path):
884     # Read a configuration file
885     #
886     # Arguments:
887     #
888     # path -- path of file to read
889
890     # handlers for various commands
891     def _collection(self, command, args):
892       if len(args) != 3:
893         return "'%s' takes three args" % command
894       self.config["collections"].append(args)
895       
896     def _unary(self, command, args):
897       if len(args) != 1:
898         return "'%s' takes only one arg" % command
899       self.config[command] = args[0]
900
901     def _include(self, command, args):
902       if len(args) != 1:
903         return "'%s' takes only one arg" % command
904       self._readfile(args[0])
905
906     def _any(self, command, args):
907       self.config[command] = args
908
909     # mapping of options to handlers
910     _options = { "collection": _collection,
911                  "username": _unary,
912                  "password": _unary,
913                  "home": _unary,
914                  "connect": _any,
915                  "include": _include }
916
917     # the parser
918     for lno, line in enumerate(file(path, "r")):
919       try:
920         fields = _split(line, 'comments')
921       except _splitError, s:
922         raise parseError(path, lno + 1, str(s))
923       if fields:
924         command = fields[0]
925         # we just ignore options we don't know about, so as to cope gracefully
926         # with version skew (and nothing to do with implementor laziness)
927         if command in _options:
928           e = _options[command](self, command, fields[1:])
929           if e:
930             self._parseError(path, lno + 1, e)
931
932   def _parseError(self, path, lno, s):
933     raise parseError(path, lno, s)
934
935 ########################################################################
936 # monitor class
937
938 class monitor:
939   """DisOrder event log monitor class
940
941   Intended to be subclassed with methods corresponding to event log messages
942   the implementor cares about over-ridden."""
943
944   def __init__(self, c=None):
945     """Constructor for the monitor class
946
947     Can be passed a client to use.  If none is specified then one
948     will be created specially for the purpose.
949
950     Arguments:
951     c -- client"""
952     if c == None:
953       c = client();
954     self.c = c
955
956   def run(self):
957     """Start monitoring logs.  Continues monitoring until one of the
958     message-specific methods returns False.  Can be called more than once
959     (but not recursively!)"""
960     self.c.log(self._callback)
961
962   def when(self):
963     """Return the timestamp of the current (or most recent) event log entry"""
964     return self.timestamp
965
966   def _callback(self, c, line):
967     try:
968       bits = _split(line)
969     except:
970       return self.invalid(line)
971     if(len(bits) < 2):
972       return self.invalid(line)
973     self.timestamp = int(bits[0], 16)
974     keyword = bits[1]
975     bits = bits[2:]
976     if keyword == 'completed':
977       if len(bits) == 1:
978         return self.completed(bits[0])
979     elif keyword == 'failed':
980       if len(bits) == 2:
981         return self.failed(bits[0], bits[1])
982     elif keyword == 'moved':
983       if len(bits) == 3:
984         try:
985           n = int(bits[1])
986         except:
987           return self.invalid(line)
988         return self.moved(bits[0], n, bits[2])
989     elif keyword == 'playing':
990       if len(bits) == 1:
991         return self.playing(bits[0], None)
992       elif len(bits) == 2:
993         return self.playing(bits[0], bits[1])
994     elif keyword == 'queue' or keyword == 'recent-added':
995       try:
996         q = _list2dict(bits)
997       except:
998         return self.invalid(line)
999       if keyword == 'queue':
1000         return self.queue(q)
1001       if keyword == 'recent-added':
1002         return self.recent_added(q)
1003     elif keyword == 'recent-removed':
1004       if len(bits) == 1:
1005         return self.recent_removed(bits[0])
1006     elif keyword == 'removed':
1007       if len(bits) == 1:
1008         return self.removed(bits[0], None)
1009       elif len(bits) == 2:
1010         return self.removed(bits[0], bits[1])
1011     elif keyword == 'scratched':
1012       if len(bits) == 2:
1013         return self.scratched(bits[0], bits[1])
1014     return self.invalid(line)
1015
1016   def completed(self, track):
1017     """Called when a track completes.
1018
1019     Arguments:
1020     track -- track that completed"""
1021     return True
1022
1023   def failed(self, track, error):
1024     """Called when a player suffers an error.
1025
1026     Arguments:
1027     track -- track that failed
1028     error -- error indicator"""
1029     return True
1030
1031   def moved(self, id, offset, user):
1032     """Called when a track is moved in the queue.
1033
1034     Arguments:
1035     id -- queue entry ID
1036     offset -- distance moved
1037     user -- user responsible"""
1038     return True
1039
1040   def playing(self, track, user):
1041     """Called when a track starts playing.
1042
1043     Arguments:
1044     track -- track that has started
1045     user -- user that submitted track, or None"""
1046     return True
1047
1048   def queue(self, q):
1049     """Called when a track is added to the queue.
1050
1051     Arguments:
1052     q -- dictionary of new queue entry"""
1053     return True
1054
1055   def recent_added(self, q):
1056     """Called when a track is added to the recently played list
1057
1058     Arguments:
1059     q -- dictionary of new queue entry"""
1060     return True
1061
1062   def recent_removed(self, id):
1063     """Called when a track is removed from the recently played list
1064
1065     Arguments:
1066     id -- ID of removed entry (always the oldest)"""
1067     return True
1068
1069   def removed(self, id, user):
1070     """Called when a track is removed from the queue, either manually
1071     or in order to play it.
1072
1073     Arguments:
1074     id -- ID of removed entry
1075     user -- user responsible (or None if we're playing this track)"""
1076     return True
1077
1078   def scratched(self, track, user):
1079     """Called when a track is scratched
1080
1081     Arguments:
1082     track -- track that was scratched
1083     user -- user responsible"""
1084     return True
1085
1086   def invalid(self, line):
1087     """Called when an event log line cannot be interpreted
1088
1089     Arguments:
1090     line -- line that could not be understood"""
1091     return True
1092
1093 # Local Variables:
1094 # mode:python
1095 # py-indent-offset:2
1096 # comment-column:40
1097 # fill-column:72
1098 # End: