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