1# -*- coding: utf-8 -*- 2# This file is part of beets. 3# Copyright 2016, Adrian Sampson. 4# 5# Permission is hereby granted, free of charge, to any person obtaining 6# a copy of this software and associated documentation files (the 7# "Software"), to deal in the Software without restriction, including 8# without limitation the rights to use, copy, modify, merge, publish, 9# distribute, sublicense, and/or sell copies of the Software, and to 10# permit persons to whom the Software is furnished to do so, subject to 11# the following conditions: 12# 13# The above copyright notice and this permission notice shall be 14# included in all copies or substantial portions of the Software. 15 16"""A clone of the Music Player Daemon (MPD) that plays music from a 17Beets library. Attempts to implement a compatible protocol to allow 18use of the wide range of MPD clients. 19""" 20 21from __future__ import division, absolute_import, print_function 22 23import re 24from string import Template 25import traceback 26import random 27import time 28import math 29import inspect 30import socket 31 32import beets 33from beets.plugins import BeetsPlugin 34import beets.ui 35from beets import vfs 36from beets.util import bluelet 37from beets.library import Item 38from beets import dbcore 39from beets.mediafile import MediaFile 40import six 41 42PROTOCOL_VERSION = '0.14.0' 43BUFSIZE = 1024 44 45HELLO = u'OK MPD %s' % PROTOCOL_VERSION 46CLIST_BEGIN = u'command_list_begin' 47CLIST_VERBOSE_BEGIN = u'command_list_ok_begin' 48CLIST_END = u'command_list_end' 49RESP_OK = u'OK' 50RESP_CLIST_VERBOSE = u'list_OK' 51RESP_ERR = u'ACK' 52 53NEWLINE = u"\n" 54 55ERROR_NOT_LIST = 1 56ERROR_ARG = 2 57ERROR_PASSWORD = 3 58ERROR_PERMISSION = 4 59ERROR_UNKNOWN = 5 60ERROR_NO_EXIST = 50 61ERROR_PLAYLIST_MAX = 51 62ERROR_SYSTEM = 52 63ERROR_PLAYLIST_LOAD = 53 64ERROR_UPDATE_ALREADY = 54 65ERROR_PLAYER_SYNC = 55 66ERROR_EXIST = 56 67 68VOLUME_MIN = 0 69VOLUME_MAX = 100 70 71SAFE_COMMANDS = ( 72 # Commands that are available when unauthenticated. 73 u'close', u'commands', u'notcommands', u'password', u'ping', 74) 75 76# List of subsystems/events used by the `idle` command. 77SUBSYSTEMS = [ 78 u'update', u'player', u'mixer', u'options', u'playlist', u'database', 79 # Related to unsupported commands: 80 # u'stored_playlist', u'output', u'subscription', u'sticker', u'message', 81 # u'partition', 82] 83 84ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys()) 85 86 87# Gstreamer import error. 88class NoGstreamerError(Exception): 89 pass 90 91 92# Error-handling, exceptions, parameter parsing. 93 94class BPDError(Exception): 95 """An error that should be exposed to the client to the BPD 96 server. 97 """ 98 def __init__(self, code, message, cmd_name='', index=0): 99 self.code = code 100 self.message = message 101 self.cmd_name = cmd_name 102 self.index = index 103 104 template = Template(u'$resp [$code@$index] {$cmd_name} $message') 105 106 def response(self): 107 """Returns a string to be used as the response code for the 108 erring command. 109 """ 110 return self.template.substitute({ 111 'resp': RESP_ERR, 112 'code': self.code, 113 'index': self.index, 114 'cmd_name': self.cmd_name, 115 'message': self.message, 116 }) 117 118 119def make_bpd_error(s_code, s_message): 120 """Create a BPDError subclass for a static code and message. 121 """ 122 123 class NewBPDError(BPDError): 124 code = s_code 125 message = s_message 126 cmd_name = '' 127 index = 0 128 129 def __init__(self): 130 pass 131 return NewBPDError 132 133ArgumentTypeError = make_bpd_error(ERROR_ARG, u'invalid type for argument') 134ArgumentIndexError = make_bpd_error(ERROR_ARG, u'argument out of range') 135ArgumentNotFoundError = make_bpd_error(ERROR_NO_EXIST, u'argument not found') 136 137 138def cast_arg(t, val): 139 """Attempts to call t on val, raising a ArgumentTypeError 140 on ValueError. 141 142 If 't' is the special string 'intbool', attempts to cast first 143 to an int and then to a bool (i.e., 1=True, 0=False). 144 """ 145 if t == 'intbool': 146 return cast_arg(bool, cast_arg(int, val)) 147 else: 148 try: 149 return t(val) 150 except ValueError: 151 raise ArgumentTypeError() 152 153 154class BPDClose(Exception): 155 """Raised by a command invocation to indicate that the connection 156 should be closed. 157 """ 158 159 160class BPDIdle(Exception): 161 """Raised by a command to indicate the client wants to enter the idle state 162 and should be notified when a relevant event happens. 163 """ 164 def __init__(self, subsystems): 165 super(BPDIdle, self).__init__() 166 self.subsystems = set(subsystems) 167 168 169# Generic server infrastructure, implementing the basic protocol. 170 171 172class BaseServer(object): 173 """A MPD-compatible music player server. 174 175 The functions with the `cmd_` prefix are invoked in response to 176 client commands. For instance, if the client says `status`, 177 `cmd_status` will be invoked. The arguments to the client's commands 178 are used as function arguments following the connection issuing the 179 command. The functions may send data on the connection. They may 180 also raise BPDError exceptions to report errors. 181 182 This is a generic superclass and doesn't support many commands. 183 """ 184 185 def __init__(self, host, port, password, ctrl_port, log, ctrl_host=None): 186 """Create a new server bound to address `host` and listening 187 on port `port`. If `password` is given, it is required to do 188 anything significant on the server. 189 A separate control socket is established listening to `ctrl_host` on 190 port `ctrl_port` which is used to forward notifications from the player 191 and can be sent debug commands (e.g. using netcat). 192 """ 193 self.host, self.port, self.password = host, port, password 194 self.ctrl_host, self.ctrl_port = ctrl_host or host, ctrl_port 195 self.ctrl_sock = None 196 self._log = log 197 198 # Default server values. 199 self.random = False 200 self.repeat = False 201 self.consume = False 202 self.single = False 203 self.volume = VOLUME_MAX 204 self.crossfade = 0 205 self.mixrampdb = 0.0 206 self.mixrampdelay = float('nan') 207 self.replay_gain_mode = 'off' 208 self.playlist = [] 209 self.playlist_version = 0 210 self.current_index = -1 211 self.paused = False 212 self.error = None 213 214 # Current connections 215 self.connections = set() 216 217 # Object for random numbers generation 218 self.random_obj = random.Random() 219 220 def connect(self, conn): 221 """A new client has connected. 222 """ 223 self.connections.add(conn) 224 225 def disconnect(self, conn): 226 """Client has disconnected; clean up residual state. 227 """ 228 self.connections.remove(conn) 229 230 def run(self): 231 """Block and start listening for connections from clients. An 232 interrupt (^C) closes the server. 233 """ 234 self.startup_time = time.time() 235 236 def start(): 237 yield bluelet.spawn( 238 bluelet.server(self.ctrl_host, self.ctrl_port, 239 ControlConnection.handler(self))) 240 yield bluelet.server(self.host, self.port, 241 MPDConnection.handler(self)) 242 bluelet.run(start()) 243 244 def dispatch_events(self): 245 """If any clients have idle events ready, send them. 246 """ 247 # We need a copy of `self.connections` here since clients might 248 # disconnect once we try and send to them, changing `self.connections`. 249 for conn in list(self.connections): 250 yield bluelet.spawn(conn.send_notifications()) 251 252 def _ctrl_send(self, message): 253 """Send some data over the control socket. 254 If it's our first time, open the socket. The message should be a 255 string without a terminal newline. 256 """ 257 if not self.ctrl_sock: 258 self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 259 self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port)) 260 self.ctrl_sock.sendall((message + u'\n').encode('utf-8')) 261 262 def _send_event(self, event): 263 """Notify subscribed connections of an event.""" 264 for conn in self.connections: 265 conn.notify(event) 266 267 def _item_info(self, item): 268 """An abstract method that should response lines containing a 269 single song's metadata. 270 """ 271 raise NotImplementedError 272 273 def _item_id(self, item): 274 """An abstract method returning the integer id for an item. 275 """ 276 raise NotImplementedError 277 278 def _id_to_index(self, track_id): 279 """Searches the playlist for a song with the given id and 280 returns its index in the playlist. 281 """ 282 track_id = cast_arg(int, track_id) 283 for index, track in enumerate(self.playlist): 284 if self._item_id(track) == track_id: 285 return index 286 # Loop finished with no track found. 287 raise ArgumentNotFoundError() 288 289 def _random_idx(self): 290 """Returns a random index different from the current one. 291 If there are no songs in the playlist it returns -1. 292 If there is only one song in the playlist it returns 0. 293 """ 294 if len(self.playlist) < 2: 295 return len(self.playlist) - 1 296 new_index = self.random_obj.randint(0, len(self.playlist) - 1) 297 while new_index == self.current_index: 298 new_index = self.random_obj.randint(0, len(self.playlist) - 1) 299 return new_index 300 301 def _succ_idx(self): 302 """Returns the index for the next song to play. 303 It also considers random, single and repeat flags. 304 No boundaries are checked. 305 """ 306 if self.repeat and self.single: 307 return self.current_index 308 if self.random: 309 return self._random_idx() 310 return self.current_index + 1 311 312 def _prev_idx(self): 313 """Returns the index for the previous song to play. 314 It also considers random and repeat flags. 315 No boundaries are checked. 316 """ 317 if self.repeat and self.single: 318 return self.current_index 319 if self.random: 320 return self._random_idx() 321 return self.current_index - 1 322 323 def cmd_ping(self, conn): 324 """Succeeds.""" 325 pass 326 327 def cmd_idle(self, conn, *subsystems): 328 subsystems = subsystems or SUBSYSTEMS 329 for system in subsystems: 330 if system not in SUBSYSTEMS: 331 raise BPDError(ERROR_ARG, 332 u'Unrecognised idle event: {}'.format(system)) 333 raise BPDIdle(subsystems) # put the connection into idle mode 334 335 def cmd_kill(self, conn): 336 """Exits the server process.""" 337 exit(0) 338 339 def cmd_close(self, conn): 340 """Closes the connection.""" 341 raise BPDClose() 342 343 def cmd_password(self, conn, password): 344 """Attempts password authentication.""" 345 if password == self.password: 346 conn.authenticated = True 347 else: 348 conn.authenticated = False 349 raise BPDError(ERROR_PASSWORD, u'incorrect password') 350 351 def cmd_commands(self, conn): 352 """Lists the commands available to the user.""" 353 if self.password and not conn.authenticated: 354 # Not authenticated. Show limited list of commands. 355 for cmd in SAFE_COMMANDS: 356 yield u'command: ' + cmd 357 358 else: 359 # Authenticated. Show all commands. 360 for func in dir(self): 361 if func.startswith('cmd_'): 362 yield u'command: ' + func[4:] 363 364 def cmd_notcommands(self, conn): 365 """Lists all unavailable commands.""" 366 if self.password and not conn.authenticated: 367 # Not authenticated. Show privileged commands. 368 for func in dir(self): 369 if func.startswith('cmd_'): 370 cmd = func[4:] 371 if cmd not in SAFE_COMMANDS: 372 yield u'command: ' + cmd 373 374 else: 375 # Authenticated. No commands are unavailable. 376 pass 377 378 def cmd_status(self, conn): 379 """Returns some status information for use with an 380 implementation of cmd_status. 381 382 Gives a list of response-lines for: volume, repeat, random, 383 playlist, playlistlength, and xfade. 384 """ 385 yield ( 386 u'repeat: ' + six.text_type(int(self.repeat)), 387 u'random: ' + six.text_type(int(self.random)), 388 u'consume: ' + six.text_type(int(self.consume)), 389 u'single: ' + six.text_type(int(self.single)), 390 u'playlist: ' + six.text_type(self.playlist_version), 391 u'playlistlength: ' + six.text_type(len(self.playlist)), 392 u'mixrampdb: ' + six.text_type(self.mixrampdb), 393 ) 394 395 if self.volume > 0: 396 yield u'volume: ' + six.text_type(self.volume) 397 398 if not math.isnan(self.mixrampdelay): 399 yield u'mixrampdelay: ' + six.text_type(self.mixrampdelay) 400 if self.crossfade > 0: 401 yield u'xfade: ' + six.text_type(self.crossfade) 402 403 if self.current_index == -1: 404 state = u'stop' 405 elif self.paused: 406 state = u'pause' 407 else: 408 state = u'play' 409 yield u'state: ' + state 410 411 if self.current_index != -1: # i.e., paused or playing 412 current_id = self._item_id(self.playlist[self.current_index]) 413 yield u'song: ' + six.text_type(self.current_index) 414 yield u'songid: ' + six.text_type(current_id) 415 416 if self.error: 417 yield u'error: ' + self.error 418 419 def cmd_clearerror(self, conn): 420 """Removes the persistent error state of the server. This 421 error is set when a problem arises not in response to a 422 command (for instance, when playing a file). 423 """ 424 self.error = None 425 426 def cmd_random(self, conn, state): 427 """Set or unset random (shuffle) mode.""" 428 self.random = cast_arg('intbool', state) 429 self._send_event('options') 430 431 def cmd_repeat(self, conn, state): 432 """Set or unset repeat mode.""" 433 self.repeat = cast_arg('intbool', state) 434 self._send_event('options') 435 436 def cmd_consume(self, conn, state): 437 """Set or unset consume mode.""" 438 self.consume = cast_arg('intbool', state) 439 self._send_event('options') 440 441 def cmd_single(self, conn, state): 442 """Set or unset single mode.""" 443 # TODO support oneshot in addition to 0 and 1 [MPD 0.20] 444 self.single = cast_arg('intbool', state) 445 self._send_event('options') 446 447 def cmd_setvol(self, conn, vol): 448 """Set the player's volume level (0-100).""" 449 vol = cast_arg(int, vol) 450 if vol < VOLUME_MIN or vol > VOLUME_MAX: 451 raise BPDError(ERROR_ARG, u'volume out of range') 452 self.volume = vol 453 self._send_event('mixer') 454 455 def cmd_volume(self, conn, vol_delta): 456 """Deprecated command to change the volume by a relative amount.""" 457 raise BPDError(ERROR_SYSTEM, u'No mixer') 458 459 def cmd_crossfade(self, conn, crossfade): 460 """Set the number of seconds of crossfading.""" 461 crossfade = cast_arg(int, crossfade) 462 if crossfade < 0: 463 raise BPDError(ERROR_ARG, u'crossfade time must be nonnegative') 464 self._log.warning(u'crossfade is not implemented in bpd') 465 self.crossfade = crossfade 466 self._send_event('options') 467 468 def cmd_mixrampdb(self, conn, db): 469 """Set the mixramp normalised max volume in dB.""" 470 db = cast_arg(float, db) 471 if db > 0: 472 raise BPDError(ERROR_ARG, u'mixrampdb time must be negative') 473 self._log.warning('mixramp is not implemented in bpd') 474 self.mixrampdb = db 475 self._send_event('options') 476 477 def cmd_mixrampdelay(self, conn, delay): 478 """Set the mixramp delay in seconds.""" 479 delay = cast_arg(float, delay) 480 if delay < 0: 481 raise BPDError(ERROR_ARG, u'mixrampdelay time must be nonnegative') 482 self._log.warning('mixramp is not implemented in bpd') 483 self.mixrampdelay = delay 484 self._send_event('options') 485 486 def cmd_replay_gain_mode(self, conn, mode): 487 """Set the replay gain mode.""" 488 if mode not in ['off', 'track', 'album', 'auto']: 489 raise BPDError(ERROR_ARG, u'Unrecognised replay gain mode') 490 self._log.warning('replay gain is not implemented in bpd') 491 self.replay_gain_mode = mode 492 self._send_event('options') 493 494 def cmd_replay_gain_status(self, conn): 495 """Get the replaygain mode.""" 496 yield u'replay_gain_mode: ' + six.text_type(self.replay_gain_mode) 497 498 def cmd_clear(self, conn): 499 """Clear the playlist.""" 500 self.playlist = [] 501 self.playlist_version += 1 502 self.cmd_stop(conn) 503 self._send_event('playlist') 504 505 def cmd_delete(self, conn, index): 506 """Remove the song at index from the playlist.""" 507 index = cast_arg(int, index) 508 try: 509 del(self.playlist[index]) 510 except IndexError: 511 raise ArgumentIndexError() 512 self.playlist_version += 1 513 514 if self.current_index == index: # Deleted playing song. 515 self.cmd_stop(conn) 516 elif index < self.current_index: # Deleted before playing. 517 # Shift playing index down. 518 self.current_index -= 1 519 self._send_event('playlist') 520 521 def cmd_deleteid(self, conn, track_id): 522 self.cmd_delete(conn, self._id_to_index(track_id)) 523 524 def cmd_move(self, conn, idx_from, idx_to): 525 """Move a track in the playlist.""" 526 idx_from = cast_arg(int, idx_from) 527 idx_to = cast_arg(int, idx_to) 528 try: 529 track = self.playlist.pop(idx_from) 530 self.playlist.insert(idx_to, track) 531 except IndexError: 532 raise ArgumentIndexError() 533 534 # Update currently-playing song. 535 if idx_from == self.current_index: 536 self.current_index = idx_to 537 elif idx_from < self.current_index <= idx_to: 538 self.current_index -= 1 539 elif idx_from > self.current_index >= idx_to: 540 self.current_index += 1 541 542 self.playlist_version += 1 543 self._send_event('playlist') 544 545 def cmd_moveid(self, conn, idx_from, idx_to): 546 idx_from = self._id_to_index(idx_from) 547 return self.cmd_move(conn, idx_from, idx_to) 548 549 def cmd_swap(self, conn, i, j): 550 """Swaps two tracks in the playlist.""" 551 i = cast_arg(int, i) 552 j = cast_arg(int, j) 553 try: 554 track_i = self.playlist[i] 555 track_j = self.playlist[j] 556 except IndexError: 557 raise ArgumentIndexError() 558 559 self.playlist[j] = track_i 560 self.playlist[i] = track_j 561 562 # Update currently-playing song. 563 if self.current_index == i: 564 self.current_index = j 565 elif self.current_index == j: 566 self.current_index = i 567 568 self.playlist_version += 1 569 self._send_event('playlist') 570 571 def cmd_swapid(self, conn, i_id, j_id): 572 i = self._id_to_index(i_id) 573 j = self._id_to_index(j_id) 574 return self.cmd_swap(conn, i, j) 575 576 def cmd_urlhandlers(self, conn): 577 """Indicates supported URL schemes. None by default.""" 578 pass 579 580 def cmd_playlistinfo(self, conn, index=-1): 581 """Gives metadata information about the entire playlist or a 582 single track, given by its index. 583 """ 584 index = cast_arg(int, index) 585 if index == -1: 586 for track in self.playlist: 587 yield self._item_info(track) 588 else: 589 try: 590 track = self.playlist[index] 591 except IndexError: 592 raise ArgumentIndexError() 593 yield self._item_info(track) 594 595 def cmd_playlistid(self, conn, track_id=-1): 596 return self.cmd_playlistinfo(conn, self._id_to_index(track_id)) 597 598 def cmd_plchanges(self, conn, version): 599 """Sends playlist changes since the given version. 600 601 This is a "fake" implementation that ignores the version and 602 just returns the entire playlist (rather like version=0). This 603 seems to satisfy many clients. 604 """ 605 return self.cmd_playlistinfo(conn) 606 607 def cmd_plchangesposid(self, conn, version): 608 """Like plchanges, but only sends position and id. 609 610 Also a dummy implementation. 611 """ 612 for idx, track in enumerate(self.playlist): 613 yield u'cpos: ' + six.text_type(idx) 614 yield u'Id: ' + six.text_type(track.id) 615 616 def cmd_currentsong(self, conn): 617 """Sends information about the currently-playing song. 618 """ 619 if self.current_index != -1: # -1 means stopped. 620 track = self.playlist[self.current_index] 621 yield self._item_info(track) 622 623 def cmd_next(self, conn): 624 """Advance to the next song in the playlist.""" 625 old_index = self.current_index 626 self.current_index = self._succ_idx() 627 self._send_event('playlist') 628 if self.consume: 629 # TODO how does consume interact with single+repeat? 630 self.playlist.pop(old_index) 631 if self.current_index > old_index: 632 self.current_index -= 1 633 if self.current_index >= len(self.playlist): 634 # Fallen off the end. Move to stopped state or loop. 635 if self.repeat: 636 self.current_index = -1 637 return self.cmd_play(conn) 638 return self.cmd_stop(conn) 639 elif self.single and not self.repeat: 640 return self.cmd_stop(conn) 641 else: 642 return self.cmd_play(conn) 643 644 def cmd_previous(self, conn): 645 """Step back to the last song.""" 646 old_index = self.current_index 647 self.current_index = self._prev_idx() 648 self._send_event('playlist') 649 if self.consume: 650 self.playlist.pop(old_index) 651 if self.current_index < 0: 652 if self.repeat: 653 self.current_index = len(self.playlist) - 1 654 else: 655 self.current_index = 0 656 return self.cmd_play(conn) 657 658 def cmd_pause(self, conn, state=None): 659 """Set the pause state playback.""" 660 if state is None: 661 self.paused = not self.paused # Toggle. 662 else: 663 self.paused = cast_arg('intbool', state) 664 self._send_event('player') 665 666 def cmd_play(self, conn, index=-1): 667 """Begin playback, possibly at a specified playlist index.""" 668 index = cast_arg(int, index) 669 670 if index < -1 or index >= len(self.playlist): 671 raise ArgumentIndexError() 672 673 if index == -1: # No index specified: start where we are. 674 if not self.playlist: # Empty playlist: stop immediately. 675 return self.cmd_stop(conn) 676 if self.current_index == -1: # No current song. 677 self.current_index = 0 # Start at the beginning. 678 # If we have a current song, just stay there. 679 680 else: # Start with the specified index. 681 self.current_index = index 682 683 self.paused = False 684 self._send_event('player') 685 686 def cmd_playid(self, conn, track_id=0): 687 track_id = cast_arg(int, track_id) 688 if track_id == -1: 689 index = -1 690 else: 691 index = self._id_to_index(track_id) 692 return self.cmd_play(conn, index) 693 694 def cmd_stop(self, conn): 695 """Stop playback.""" 696 self.current_index = -1 697 self.paused = False 698 self._send_event('player') 699 700 def cmd_seek(self, conn, index, pos): 701 """Seek to a specified point in a specified song.""" 702 index = cast_arg(int, index) 703 if index < 0 or index >= len(self.playlist): 704 raise ArgumentIndexError() 705 self.current_index = index 706 self._send_event('player') 707 708 def cmd_seekid(self, conn, track_id, pos): 709 index = self._id_to_index(track_id) 710 return self.cmd_seek(conn, index, pos) 711 712 # Additions to the MPD protocol. 713 714 def cmd_crash_TypeError(self, conn): # noqa: N802 715 """Deliberately trigger a TypeError for testing purposes. 716 We want to test that the server properly responds with ERROR_SYSTEM 717 without crashing, and that this is not treated as ERROR_ARG (since it 718 is caused by a programming error, not a protocol error). 719 """ 720 'a' + 2 721 722 723class Connection(object): 724 """A connection between a client and the server. 725 """ 726 def __init__(self, server, sock): 727 """Create a new connection for the accepted socket `client`. 728 """ 729 self.server = server 730 self.sock = sock 731 self.address = u'{}:{}'.format(*sock.sock.getpeername()) 732 733 def debug(self, message, kind=' '): 734 """Log a debug message about this connection. 735 """ 736 self.server._log.debug(u'{}[{}]: {}', kind, self.address, message) 737 738 def run(self): 739 pass 740 741 def send(self, lines): 742 """Send lines, which which is either a single string or an 743 iterable consisting of strings, to the client. A newline is 744 added after every string. Returns a Bluelet event that sends 745 the data. 746 """ 747 if isinstance(lines, six.string_types): 748 lines = [lines] 749 out = NEWLINE.join(lines) + NEWLINE 750 for l in out.split(NEWLINE)[:-1]: 751 self.debug(l, kind='>') 752 if isinstance(out, six.text_type): 753 out = out.encode('utf-8') 754 return self.sock.sendall(out) 755 756 @classmethod 757 def handler(cls, server): 758 def _handle(sock): 759 """Creates a new `Connection` and runs it. 760 """ 761 return cls(server, sock).run() 762 return _handle 763 764 765class MPDConnection(Connection): 766 """A connection that receives commands from an MPD-compatible client. 767 """ 768 def __init__(self, server, sock): 769 """Create a new connection for the accepted socket `client`. 770 """ 771 super(MPDConnection, self).__init__(server, sock) 772 self.authenticated = False 773 self.notifications = set() 774 self.idle_subscriptions = set() 775 776 def do_command(self, command): 777 """A coroutine that runs the given command and sends an 778 appropriate response.""" 779 try: 780 yield bluelet.call(command.run(self)) 781 except BPDError as e: 782 # Send the error. 783 yield self.send(e.response()) 784 else: 785 # Send success code. 786 yield self.send(RESP_OK) 787 788 def disconnect(self): 789 """The connection has closed for any reason. 790 """ 791 self.server.disconnect(self) 792 self.debug('disconnected', kind='*') 793 794 def notify(self, event): 795 """Queue up an event for sending to this client. 796 """ 797 self.notifications.add(event) 798 799 def send_notifications(self, force_close_idle=False): 800 """Send the client any queued events now. 801 """ 802 pending = self.notifications.intersection(self.idle_subscriptions) 803 try: 804 for event in pending: 805 yield self.send(u'changed: {}'.format(event)) 806 if pending or force_close_idle: 807 self.idle_subscriptions = set() 808 self.notifications = self.notifications.difference(pending) 809 yield self.send(RESP_OK) 810 except bluelet.SocketClosedError: 811 self.disconnect() # Client disappeared. 812 813 def run(self): 814 """Send a greeting to the client and begin processing commands 815 as they arrive. 816 """ 817 self.debug('connected', kind='*') 818 self.server.connect(self) 819 yield self.send(HELLO) 820 821 clist = None # Initially, no command list is being constructed. 822 while True: 823 line = yield self.sock.readline() 824 if not line: 825 self.disconnect() # Client disappeared. 826 break 827 line = line.strip() 828 if not line: 829 err = BPDError(ERROR_UNKNOWN, u'No command given') 830 yield self.send(err.response()) 831 self.disconnect() # Client sent a blank line. 832 break 833 line = line.decode('utf8') # MPD protocol uses UTF-8. 834 for l in line.split(NEWLINE): 835 self.debug(l, kind='<') 836 837 if self.idle_subscriptions: 838 # The connection is in idle mode. 839 if line == u'noidle': 840 yield bluelet.call(self.send_notifications(True)) 841 else: 842 err = BPDError(ERROR_UNKNOWN, 843 u'Got command while idle: {}'.format(line)) 844 yield self.send(err.response()) 845 break 846 continue 847 848 if clist is not None: 849 # Command list already opened. 850 if line == CLIST_END: 851 yield bluelet.call(self.do_command(clist)) 852 clist = None # Clear the command list. 853 yield bluelet.call(self.server.dispatch_events()) 854 else: 855 clist.append(Command(line)) 856 857 elif line == CLIST_BEGIN or line == CLIST_VERBOSE_BEGIN: 858 # Begin a command list. 859 clist = CommandList([], line == CLIST_VERBOSE_BEGIN) 860 861 else: 862 # Ordinary command. 863 try: 864 yield bluelet.call(self.do_command(Command(line))) 865 except BPDClose: 866 # Command indicates that the conn should close. 867 self.sock.close() 868 self.disconnect() # Client explicitly closed. 869 return 870 except BPDIdle as e: 871 self.idle_subscriptions = e.subsystems 872 self.debug('awaiting: {}'.format(' '.join(e.subsystems)), 873 kind='z') 874 yield bluelet.call(self.server.dispatch_events()) 875 876 877class ControlConnection(Connection): 878 """A connection used to control BPD for debugging and internal events. 879 """ 880 def __init__(self, server, sock): 881 """Create a new connection for the accepted socket `client`. 882 """ 883 super(ControlConnection, self).__init__(server, sock) 884 885 def debug(self, message, kind=' '): 886 self.server._log.debug(u'CTRL {}[{}]: {}', kind, self.address, message) 887 888 def run(self): 889 """Listen for control commands and delegate to `ctrl_*` methods. 890 """ 891 self.debug('connected', kind='*') 892 while True: 893 line = yield self.sock.readline() 894 if not line: 895 break # Client disappeared. 896 line = line.strip() 897 if not line: 898 break # Client sent a blank line. 899 line = line.decode('utf8') # Protocol uses UTF-8. 900 for l in line.split(NEWLINE): 901 self.debug(l, kind='<') 902 command = Command(line) 903 try: 904 func = command.delegate('ctrl_', self) 905 yield bluelet.call(func(*command.args)) 906 except (AttributeError, TypeError) as e: 907 yield self.send('ERROR: {}'.format(e.args[0])) 908 except Exception: 909 yield self.send(['ERROR: server error', 910 traceback.format_exc().rstrip()]) 911 912 def ctrl_play_finished(self): 913 """Callback from the player signalling a song finished playing. 914 """ 915 yield bluelet.call(self.server.dispatch_events()) 916 917 def ctrl_profile(self): 918 """Memory profiling for debugging. 919 """ 920 from guppy import hpy 921 heap = hpy().heap() 922 yield self.send(heap) 923 924 def ctrl_nickname(self, oldlabel, newlabel): 925 """Rename a client in the log messages. 926 """ 927 for c in self.server.connections: 928 if c.address == oldlabel: 929 c.address = newlabel 930 break 931 else: 932 yield self.send(u'ERROR: no such client: {}'.format(oldlabel)) 933 934 935class Command(object): 936 """A command issued by the client for processing by the server. 937 """ 938 939 command_re = re.compile(r'^([^ \t]+)[ \t]*') 940 arg_re = re.compile(r'"((?:\\"|[^"])+)"|([^ \t"]+)') 941 942 def __init__(self, s): 943 """Creates a new `Command` from the given string, `s`, parsing 944 the string for command name and arguments. 945 """ 946 command_match = self.command_re.match(s) 947 self.name = command_match.group(1) 948 949 self.args = [] 950 arg_matches = self.arg_re.findall(s[command_match.end():]) 951 for match in arg_matches: 952 if match[0]: 953 # Quoted argument. 954 arg = match[0] 955 arg = arg.replace(u'\\"', u'"').replace(u'\\\\', u'\\') 956 else: 957 # Unquoted argument. 958 arg = match[1] 959 self.args.append(arg) 960 961 def delegate(self, prefix, target, extra_args=0): 962 """Get the target method that corresponds to this command. 963 The `prefix` is prepended to the command name and then the resulting 964 name is used to search `target` for a method with a compatible number 965 of arguments. 966 """ 967 # Attempt to get correct command function. 968 func_name = prefix + self.name 969 if not hasattr(target, func_name): 970 raise AttributeError(u'unknown command "{}"'.format(self.name)) 971 func = getattr(target, func_name) 972 973 if six.PY2: 974 # caution: the fields of the namedtuple are slightly different 975 # between the results of getargspec and getfullargspec. 976 argspec = inspect.getargspec(func) 977 else: 978 argspec = inspect.getfullargspec(func) 979 980 # Check that `func` is able to handle the number of arguments sent 981 # by the client (so we can raise ERROR_ARG instead of ERROR_SYSTEM). 982 # Maximum accepted arguments: argspec includes "self". 983 max_args = len(argspec.args) - 1 - extra_args 984 # Minimum accepted arguments: some arguments might be optional. 985 min_args = max_args 986 if argspec.defaults: 987 min_args -= len(argspec.defaults) 988 wrong_num = (len(self.args) > max_args) or (len(self.args) < min_args) 989 # If the command accepts a variable number of arguments skip the check. 990 if wrong_num and not argspec.varargs: 991 raise TypeError(u'wrong number of arguments for "{}"' 992 .format(self.name), self.name) 993 994 return func 995 996 def run(self, conn): 997 """A coroutine that executes the command on the given 998 connection. 999 """ 1000 try: 1001 # `conn` is an extra argument to all cmd handlers. 1002 func = self.delegate('cmd_', conn.server, extra_args=1) 1003 except AttributeError as e: 1004 raise BPDError(ERROR_UNKNOWN, e.args[0]) 1005 except TypeError as e: 1006 raise BPDError(ERROR_ARG, e.args[0], self.name) 1007 1008 # Ensure we have permission for this command. 1009 if conn.server.password and \ 1010 not conn.authenticated and \ 1011 self.name not in SAFE_COMMANDS: 1012 raise BPDError(ERROR_PERMISSION, u'insufficient privileges') 1013 1014 try: 1015 args = [conn] + self.args 1016 results = func(*args) 1017 if results: 1018 for data in results: 1019 yield conn.send(data) 1020 1021 except BPDError as e: 1022 # An exposed error. Set the command name and then let 1023 # the Connection handle it. 1024 e.cmd_name = self.name 1025 raise e 1026 1027 except BPDClose: 1028 # An indication that the connection should close. Send 1029 # it on the Connection. 1030 raise 1031 1032 except BPDIdle: 1033 raise 1034 1035 except Exception: 1036 # An "unintentional" error. Hide it from the client. 1037 conn.server._log.error('{}', traceback.format_exc()) 1038 raise BPDError(ERROR_SYSTEM, u'server error', self.name) 1039 1040 1041class CommandList(list): 1042 """A list of commands issued by the client for processing by the 1043 server. May be verbose, in which case the response is delimited, or 1044 not. Should be a list of `Command` objects. 1045 """ 1046 1047 def __init__(self, sequence=None, verbose=False): 1048 """Create a new `CommandList` from the given sequence of 1049 `Command`s. If `verbose`, this is a verbose command list. 1050 """ 1051 if sequence: 1052 for item in sequence: 1053 self.append(item) 1054 self.verbose = verbose 1055 1056 def run(self, conn): 1057 """Coroutine executing all the commands in this list. 1058 """ 1059 for i, command in enumerate(self): 1060 try: 1061 yield bluelet.call(command.run(conn)) 1062 except BPDError as e: 1063 # If the command failed, stop executing. 1064 e.index = i # Give the error the correct index. 1065 raise e 1066 1067 # Otherwise, possibly send the output delimiter if we're in a 1068 # verbose ("OK") command list. 1069 if self.verbose: 1070 yield conn.send(RESP_CLIST_VERBOSE) 1071 1072 1073# A subclass of the basic, protocol-handling server that actually plays 1074# music. 1075 1076class Server(BaseServer): 1077 """An MPD-compatible server using GStreamer to play audio and beets 1078 to store its library. 1079 """ 1080 1081 def __init__(self, library, host, port, password, ctrl_port, log): 1082 try: 1083 from beetsplug.bpd import gstplayer 1084 except ImportError as e: 1085 # This is a little hacky, but it's the best I know for now. 1086 if e.args[0].endswith(' gst'): 1087 raise NoGstreamerError() 1088 else: 1089 raise 1090 log.info(u'Starting server...') 1091 super(Server, self).__init__(host, port, password, ctrl_port, log) 1092 self.lib = library 1093 self.player = gstplayer.GstPlayer(self.play_finished) 1094 self.cmd_update(None) 1095 log.info(u'Server ready and listening on {}:{}'.format( 1096 host, port)) 1097 1098 def run(self): 1099 self.player.run() 1100 super(Server, self).run() 1101 1102 def play_finished(self): 1103 """A callback invoked every time our player finishes a track. 1104 """ 1105 self.cmd_next(None) 1106 self._ctrl_send(u'play_finished') 1107 1108 # Metadata helper functions. 1109 1110 def _item_info(self, item): 1111 info_lines = [ 1112 u'file: ' + item.destination(fragment=True), 1113 u'Time: ' + six.text_type(int(item.length)), 1114 u'Title: ' + item.title, 1115 u'Artist: ' + item.artist, 1116 u'Album: ' + item.album, 1117 u'Genre: ' + item.genre, 1118 ] 1119 1120 track = six.text_type(item.track) 1121 if item.tracktotal: 1122 track += u'/' + six.text_type(item.tracktotal) 1123 info_lines.append(u'Track: ' + track) 1124 1125 info_lines.append(u'Date: ' + six.text_type(item.year)) 1126 1127 try: 1128 pos = self._id_to_index(item.id) 1129 info_lines.append(u'Pos: ' + six.text_type(pos)) 1130 except ArgumentNotFoundError: 1131 # Don't include position if not in playlist. 1132 pass 1133 1134 info_lines.append(u'Id: ' + six.text_type(item.id)) 1135 1136 return info_lines 1137 1138 def _item_id(self, item): 1139 return item.id 1140 1141 # Database updating. 1142 1143 def cmd_update(self, conn, path=u'/'): 1144 """Updates the catalog to reflect the current database state. 1145 """ 1146 # Path is ignored. Also, the real MPD does this asynchronously; 1147 # this is done inline. 1148 self._log.debug(u'Building directory tree...') 1149 self.tree = vfs.libtree(self.lib) 1150 self._log.debug(u'Finished building directory tree.') 1151 self.updated_time = time.time() 1152 self._send_event('update') 1153 self._send_event('database') 1154 1155 # Path (directory tree) browsing. 1156 1157 def _resolve_path(self, path): 1158 """Returns a VFS node or an item ID located at the path given. 1159 If the path does not exist, raises a 1160 """ 1161 components = path.split(u'/') 1162 node = self.tree 1163 1164 for component in components: 1165 if not component: 1166 continue 1167 1168 if isinstance(node, int): 1169 # We're trying to descend into a file node. 1170 raise ArgumentNotFoundError() 1171 1172 if component in node.files: 1173 node = node.files[component] 1174 elif component in node.dirs: 1175 node = node.dirs[component] 1176 else: 1177 raise ArgumentNotFoundError() 1178 1179 return node 1180 1181 def _path_join(self, p1, p2): 1182 """Smashes together two BPD paths.""" 1183 out = p1 + u'/' + p2 1184 return out.replace(u'//', u'/').replace(u'//', u'/') 1185 1186 def cmd_lsinfo(self, conn, path=u"/"): 1187 """Sends info on all the items in the path.""" 1188 node = self._resolve_path(path) 1189 if isinstance(node, int): 1190 # Trying to list a track. 1191 raise BPDError(ERROR_ARG, u'this is not a directory') 1192 else: 1193 for name, itemid in iter(sorted(node.files.items())): 1194 item = self.lib.get_item(itemid) 1195 yield self._item_info(item) 1196 for name, _ in iter(sorted(node.dirs.items())): 1197 dirpath = self._path_join(path, name) 1198 if dirpath.startswith(u"/"): 1199 # Strip leading slash (libmpc rejects this). 1200 dirpath = dirpath[1:] 1201 yield u'directory: %s' % dirpath 1202 1203 def _listall(self, basepath, node, info=False): 1204 """Helper function for recursive listing. If info, show 1205 tracks' complete info; otherwise, just show items' paths. 1206 """ 1207 if isinstance(node, int): 1208 # List a single file. 1209 if info: 1210 item = self.lib.get_item(node) 1211 yield self._item_info(item) 1212 else: 1213 yield u'file: ' + basepath 1214 else: 1215 # List a directory. Recurse into both directories and files. 1216 for name, itemid in sorted(node.files.items()): 1217 newpath = self._path_join(basepath, name) 1218 # "yield from" 1219 for v in self._listall(newpath, itemid, info): 1220 yield v 1221 for name, subdir in sorted(node.dirs.items()): 1222 newpath = self._path_join(basepath, name) 1223 yield u'directory: ' + newpath 1224 for v in self._listall(newpath, subdir, info): 1225 yield v 1226 1227 def cmd_listall(self, conn, path=u"/"): 1228 """Send the paths all items in the directory, recursively.""" 1229 return self._listall(path, self._resolve_path(path), False) 1230 1231 def cmd_listallinfo(self, conn, path=u"/"): 1232 """Send info on all the items in the directory, recursively.""" 1233 return self._listall(path, self._resolve_path(path), True) 1234 1235 # Playlist manipulation. 1236 1237 def _all_items(self, node): 1238 """Generator yielding all items under a VFS node. 1239 """ 1240 if isinstance(node, int): 1241 # Could be more efficient if we built up all the IDs and 1242 # then issued a single SELECT. 1243 yield self.lib.get_item(node) 1244 else: 1245 # Recurse into a directory. 1246 for name, itemid in sorted(node.files.items()): 1247 # "yield from" 1248 for v in self._all_items(itemid): 1249 yield v 1250 for name, subdir in sorted(node.dirs.items()): 1251 for v in self._all_items(subdir): 1252 yield v 1253 1254 def _add(self, path, send_id=False): 1255 """Adds a track or directory to the playlist, specified by the 1256 path. If `send_id`, write each item's id to the client. 1257 """ 1258 for item in self._all_items(self._resolve_path(path)): 1259 self.playlist.append(item) 1260 if send_id: 1261 yield u'Id: ' + six.text_type(item.id) 1262 self.playlist_version += 1 1263 self._send_event('playlist') 1264 1265 def cmd_add(self, conn, path): 1266 """Adds a track or directory to the playlist, specified by a 1267 path. 1268 """ 1269 return self._add(path, False) 1270 1271 def cmd_addid(self, conn, path): 1272 """Same as `cmd_add` but sends an id back to the client.""" 1273 return self._add(path, True) 1274 1275 # Server info. 1276 1277 def cmd_status(self, conn): 1278 for line in super(Server, self).cmd_status(conn): 1279 yield line 1280 if self.current_index > -1: 1281 item = self.playlist[self.current_index] 1282 1283 yield ( 1284 u'bitrate: ' + six.text_type(item.bitrate / 1000), 1285 u'audio: {}:{}:{}'.format( 1286 six.text_type(item.samplerate), 1287 six.text_type(item.bitdepth), 1288 six.text_type(item.channels), 1289 ), 1290 ) 1291 1292 (pos, total) = self.player.time() 1293 yield ( 1294 u'time: {}:{}'.format( 1295 six.text_type(int(pos)), 1296 six.text_type(int(total)), 1297 ), 1298 u'elapsed: ' + u'{:.3f}'.format(pos), 1299 u'duration: ' + u'{:.3f}'.format(total), 1300 ) 1301 1302 # Also missing 'updating_db'. 1303 1304 def cmd_stats(self, conn): 1305 """Sends some statistics about the library.""" 1306 with self.lib.transaction() as tx: 1307 statement = 'SELECT COUNT(DISTINCT artist), ' \ 1308 'COUNT(DISTINCT album), ' \ 1309 'COUNT(id), ' \ 1310 'SUM(length) ' \ 1311 'FROM items' 1312 artists, albums, songs, totaltime = tx.query(statement)[0] 1313 1314 yield ( 1315 u'artists: ' + six.text_type(artists), 1316 u'albums: ' + six.text_type(albums), 1317 u'songs: ' + six.text_type(songs), 1318 u'uptime: ' + six.text_type(int(time.time() - self.startup_time)), 1319 u'playtime: ' + u'0', # Missing. 1320 u'db_playtime: ' + six.text_type(int(totaltime)), 1321 u'db_update: ' + six.text_type(int(self.updated_time)), 1322 ) 1323 1324 def cmd_decoders(self, conn): 1325 """Send list of supported decoders and formats.""" 1326 decoders = self.player.get_decoders() 1327 for name, (mimes, exts) in decoders.items(): 1328 yield u'plugin: {}'.format(name) 1329 for ext in exts: 1330 yield u'suffix: {}'.format(ext) 1331 for mime in mimes: 1332 yield u'mime_type: {}'.format(mime) 1333 1334 # Searching. 1335 1336 tagtype_map = { 1337 u'Artist': u'artist', 1338 u'Album': u'album', 1339 u'Title': u'title', 1340 u'Track': u'track', 1341 u'AlbumArtist': u'albumartist', 1342 u'AlbumArtistSort': u'albumartist_sort', 1343 # Name? 1344 u'Genre': u'genre', 1345 u'Date': u'year', 1346 u'Composer': u'composer', 1347 # Performer? 1348 u'Disc': u'disc', 1349 u'filename': u'path', # Suspect. 1350 } 1351 1352 def cmd_tagtypes(self, conn): 1353 """Returns a list of the metadata (tag) fields available for 1354 searching. 1355 """ 1356 for tag in self.tagtype_map: 1357 yield u'tagtype: ' + tag 1358 1359 def _tagtype_lookup(self, tag): 1360 """Uses `tagtype_map` to look up the beets column name for an 1361 MPD tagtype (or throw an appropriate exception). Returns both 1362 the canonical name of the MPD tagtype and the beets column 1363 name. 1364 """ 1365 for test_tag, key in self.tagtype_map.items(): 1366 # Match case-insensitively. 1367 if test_tag.lower() == tag.lower(): 1368 return test_tag, key 1369 raise BPDError(ERROR_UNKNOWN, u'no such tagtype') 1370 1371 def _metadata_query(self, query_type, any_query_type, kv): 1372 """Helper function returns a query object that will find items 1373 according to the library query type provided and the key-value 1374 pairs specified. The any_query_type is used for queries of 1375 type "any"; if None, then an error is thrown. 1376 """ 1377 if kv: # At least one key-value pair. 1378 queries = [] 1379 # Iterate pairwise over the arguments. 1380 it = iter(kv) 1381 for tag, value in zip(it, it): 1382 if tag.lower() == u'any': 1383 if any_query_type: 1384 queries.append(any_query_type(value, 1385 ITEM_KEYS_WRITABLE, 1386 query_type)) 1387 else: 1388 raise BPDError(ERROR_UNKNOWN, u'no such tagtype') 1389 else: 1390 _, key = self._tagtype_lookup(tag) 1391 queries.append(query_type(key, value)) 1392 return dbcore.query.AndQuery(queries) 1393 else: # No key-value pairs. 1394 return dbcore.query.TrueQuery() 1395 1396 def cmd_search(self, conn, *kv): 1397 """Perform a substring match for items.""" 1398 query = self._metadata_query(dbcore.query.SubstringQuery, 1399 dbcore.query.AnyFieldQuery, 1400 kv) 1401 for item in self.lib.items(query): 1402 yield self._item_info(item) 1403 1404 def cmd_find(self, conn, *kv): 1405 """Perform an exact match for items.""" 1406 query = self._metadata_query(dbcore.query.MatchQuery, 1407 None, 1408 kv) 1409 for item in self.lib.items(query): 1410 yield self._item_info(item) 1411 1412 def cmd_list(self, conn, show_tag, *kv): 1413 """List distinct metadata values for show_tag, possibly 1414 filtered by matching match_tag to match_term. 1415 """ 1416 show_tag_canon, show_key = self._tagtype_lookup(show_tag) 1417 if len(kv) == 1: 1418 if show_tag_canon == 'Album': 1419 # If no tag was given, assume artist. This is because MPD 1420 # supports a short version of this command for fetching the 1421 # albums belonging to a particular artist, and some clients 1422 # rely on this behaviour (e.g. MPDroid, M.A.L.P.). 1423 kv = ('Artist', kv[0]) 1424 else: 1425 raise BPDError(ERROR_ARG, u'should be "Album" for 3 arguments') 1426 elif len(kv) % 2 != 0: 1427 raise BPDError(ERROR_ARG, u'Incorrect number of filter arguments') 1428 query = self._metadata_query(dbcore.query.MatchQuery, None, kv) 1429 1430 clause, subvals = query.clause() 1431 statement = 'SELECT DISTINCT ' + show_key + \ 1432 ' FROM items WHERE ' + clause + \ 1433 ' ORDER BY ' + show_key 1434 self._log.debug(statement) 1435 with self.lib.transaction() as tx: 1436 rows = tx.query(statement, subvals) 1437 1438 for row in rows: 1439 if not row[0]: 1440 # Skip any empty values of the field. 1441 continue 1442 yield show_tag_canon + u': ' + six.text_type(row[0]) 1443 1444 def cmd_count(self, conn, tag, value): 1445 """Returns the number and total time of songs matching the 1446 tag/value query. 1447 """ 1448 _, key = self._tagtype_lookup(tag) 1449 songs = 0 1450 playtime = 0.0 1451 for item in self.lib.items(dbcore.query.MatchQuery(key, value)): 1452 songs += 1 1453 playtime += item.length 1454 yield u'songs: ' + six.text_type(songs) 1455 yield u'playtime: ' + six.text_type(int(playtime)) 1456 1457 # Persistent playlist manipulation. In MPD this is an optional feature so 1458 # these dummy implementations match MPD's behaviour with the feature off. 1459 1460 def cmd_listplaylist(self, conn, playlist): 1461 raise BPDError(ERROR_NO_EXIST, u'No such playlist') 1462 1463 def cmd_listplaylistinfo(self, conn, playlist): 1464 raise BPDError(ERROR_NO_EXIST, u'No such playlist') 1465 1466 def cmd_listplaylists(self, conn): 1467 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1468 1469 def cmd_load(self, conn, playlist): 1470 raise BPDError(ERROR_NO_EXIST, u'Stored playlists are disabled') 1471 1472 def cmd_playlistadd(self, conn, playlist, uri): 1473 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1474 1475 def cmd_playlistclear(self, conn, playlist): 1476 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1477 1478 def cmd_playlistdelete(self, conn, playlist, index): 1479 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1480 1481 def cmd_playlistmove(self, conn, playlist, from_index, to_index): 1482 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1483 1484 def cmd_rename(self, conn, playlist, new_name): 1485 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1486 1487 def cmd_rm(self, conn, playlist): 1488 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1489 1490 def cmd_save(self, conn, playlist): 1491 raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') 1492 1493 # "Outputs." Just a dummy implementation because we don't control 1494 # any outputs. 1495 1496 def cmd_outputs(self, conn): 1497 """List the available outputs.""" 1498 yield ( 1499 u'outputid: 0', 1500 u'outputname: gstreamer', 1501 u'outputenabled: 1', 1502 ) 1503 1504 def cmd_enableoutput(self, conn, output_id): 1505 output_id = cast_arg(int, output_id) 1506 if output_id != 0: 1507 raise ArgumentIndexError() 1508 1509 def cmd_disableoutput(self, conn, output_id): 1510 output_id = cast_arg(int, output_id) 1511 if output_id == 0: 1512 raise BPDError(ERROR_ARG, u'cannot disable this output') 1513 else: 1514 raise ArgumentIndexError() 1515 1516 # Playback control. The functions below hook into the 1517 # half-implementations provided by the base class. Together, they're 1518 # enough to implement all normal playback functionality. 1519 1520 def cmd_play(self, conn, index=-1): 1521 new_index = index != -1 and index != self.current_index 1522 was_paused = self.paused 1523 super(Server, self).cmd_play(conn, index) 1524 1525 if self.current_index > -1: # Not stopped. 1526 if was_paused and not new_index: 1527 # Just unpause. 1528 self.player.play() 1529 else: 1530 self.player.play_file(self.playlist[self.current_index].path) 1531 1532 def cmd_pause(self, conn, state=None): 1533 super(Server, self).cmd_pause(conn, state) 1534 if self.paused: 1535 self.player.pause() 1536 elif self.player.playing: 1537 self.player.play() 1538 1539 def cmd_stop(self, conn): 1540 super(Server, self).cmd_stop(conn) 1541 self.player.stop() 1542 1543 def cmd_seek(self, conn, index, pos): 1544 """Seeks to the specified position in the specified song.""" 1545 index = cast_arg(int, index) 1546 pos = cast_arg(int, pos) 1547 super(Server, self).cmd_seek(conn, index, pos) 1548 self.player.seek(pos) 1549 1550 # Volume control. 1551 1552 def cmd_setvol(self, conn, vol): 1553 vol = cast_arg(int, vol) 1554 super(Server, self).cmd_setvol(conn, vol) 1555 self.player.volume = float(vol) / 100 1556 1557 1558# Beets plugin hooks. 1559 1560class BPDPlugin(BeetsPlugin): 1561 """Provides the "beet bpd" command for running a music player 1562 server. 1563 """ 1564 def __init__(self): 1565 super(BPDPlugin, self).__init__() 1566 self.config.add({ 1567 'host': u'', 1568 'port': 6600, 1569 'control_port': 6601, 1570 'password': u'', 1571 'volume': VOLUME_MAX, 1572 }) 1573 self.config['password'].redact = True 1574 1575 def start_bpd(self, lib, host, port, password, volume, ctrl_port): 1576 """Starts a BPD server.""" 1577 try: 1578 server = Server(lib, host, port, password, ctrl_port, self._log) 1579 server.cmd_setvol(None, volume) 1580 server.run() 1581 except NoGstreamerError: 1582 self._log.error(u'Gstreamer Python bindings not found.') 1583 self._log.error(u'Install "gstreamer1.0" and "python-gi"' 1584 u'or similar package to use BPD.') 1585 1586 def commands(self): 1587 cmd = beets.ui.Subcommand( 1588 'bpd', help=u'run an MPD-compatible music player server' 1589 ) 1590 1591 def func(lib, opts, args): 1592 host = self.config['host'].as_str() 1593 host = args.pop(0) if args else host 1594 port = args.pop(0) if args else self.config['port'].get(int) 1595 if args: 1596 ctrl_port = args.pop(0) 1597 else: 1598 ctrl_port = self.config['control_port'].get(int) 1599 if args: 1600 raise beets.ui.UserError(u'too many arguments') 1601 password = self.config['password'].as_str() 1602 volume = self.config['volume'].get(int) 1603 self.start_bpd(lib, host, int(port), password, volume, 1604 int(ctrl_port)) 1605 1606 cmd.func = func 1607 return [cmd] 1608