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