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