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