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