mirror of
https://github.com/beetbox/beets.git
synced 2026-01-04 06:53:27 +01:00
replaced command functions with generators; removed response objects
(ErrorResponse functionality is now in BPDError.) This was necessary because libmpdclient was timing out waiting for the results of long-running commands like listallinfo. By sending data as it is generated, we get data to the client more quickly. --HG-- extra : convert_revision : svn%3A41726ec3-264d-0410-9c23-a9f1637257cc/trunk%40150
This commit is contained in:
parent
83ad70565c
commit
5c8891a81c
1 changed files with 122 additions and 175 deletions
|
|
@ -60,12 +60,19 @@ class BPDError(Exception):
|
|||
def __init__(self, code, message):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.index = 0
|
||||
|
||||
template = Template('$resp [$code@$index] {$cmd_name} $message')
|
||||
def response(self, cmd):
|
||||
"""Returns an ErrorResponse for the exception as a response
|
||||
to the given command.
|
||||
"""Returns a string to be used as the response code for the
|
||||
erring command.
|
||||
"""
|
||||
return ErrorResponse(self.code, cmd.name, self.message)
|
||||
return self.template.substitute({'resp': RESP_ERR,
|
||||
'code': self.code,
|
||||
'index': self.index,
|
||||
'cmd_name': cmd,
|
||||
'message': self.message
|
||||
})
|
||||
|
||||
def make_bpd_error(s_code, s_message):
|
||||
"""Create a BPDError subclass for a static code and message.
|
||||
|
|
@ -149,12 +156,12 @@ def path_to_list(path):
|
|||
class Server(object):
|
||||
"""A MPD-compatible music player server.
|
||||
|
||||
The functions with the `cmd_` prefix are invoked in response to
|
||||
The generators with the `cmd_` prefix are invoked in response to
|
||||
client commands. For instance, if the client says `status`,
|
||||
`cmd_status` will be invoked. The arguments to the client's commands
|
||||
are used as function arguments (*args). The functions should return
|
||||
a `Response` object (or None to indicate an empty but successful
|
||||
response). They may also raise BPDError exceptions to report errors.
|
||||
are used as function arguments (*args). The generators should yield
|
||||
strings or sequences of strings as many times as necessary.
|
||||
They may also raise BPDError exceptions to report errors.
|
||||
|
||||
This is a generic superclass and doesn't support many commands.
|
||||
"""
|
||||
|
|
@ -230,8 +237,7 @@ class Server(object):
|
|||
out = []
|
||||
for key in dir(self):
|
||||
if key.startswith('cmd_'):
|
||||
out.append('command: ' + key[4:])
|
||||
return SuccessResponse(out)
|
||||
yield 'command: ' + key[4:]
|
||||
|
||||
def cmd_notcommands(self):
|
||||
"""Lists all unavailable commands. Because there's currently no
|
||||
|
|
@ -246,13 +252,13 @@ class Server(object):
|
|||
Gives a list of response-lines for: volume, repeat, random,
|
||||
playlist, playlistlength, and xfade.
|
||||
"""
|
||||
status_lines = ['volume: ' + str(self.volume),
|
||||
'repeat: ' + str(int(self.repeat)),
|
||||
'random: ' + str(int(self.random)),
|
||||
'playlist: ' + str(self.playlist_version),
|
||||
'playlistlength: ' + str(len(self.playlist)),
|
||||
'xfade: ' + str(self.crossfade),
|
||||
]
|
||||
yield ('volume: ' + str(self.volume),
|
||||
'repeat: ' + str(int(self.repeat)),
|
||||
'random: ' + str(int(self.random)),
|
||||
'playlist: ' + str(self.playlist_version),
|
||||
'playlistlength: ' + str(len(self.playlist)),
|
||||
'xfade: ' + str(self.crossfade),
|
||||
)
|
||||
|
||||
if self.current_index == -1:
|
||||
state = 'stop'
|
||||
|
|
@ -260,17 +266,14 @@ class Server(object):
|
|||
state = 'pause'
|
||||
else:
|
||||
state = 'play'
|
||||
status_lines.append('state: ' + state)
|
||||
yield 'state: ' + state
|
||||
|
||||
if self.current_index != -1: # i.e., paused or playing
|
||||
current_id = self._item_id(self.playlist[self.current_index])
|
||||
status_lines += ['song: ' + str(self.current_index),
|
||||
'songid: ' + str(current_id),
|
||||
]
|
||||
yield 'song: ' + str(self.current_index)
|
||||
yield 'songid: ' + str(current_id)
|
||||
|
||||
# Still missing: time, bitrate, audio, updating_db, error
|
||||
|
||||
return SuccessResponse(status_lines)
|
||||
#fixme Still missing: time, bitrate, audio, updating_db, error
|
||||
|
||||
def cmd_random(self, state):
|
||||
"""Set or unset random (shuffle) mode."""
|
||||
|
|
@ -308,7 +311,7 @@ class Server(object):
|
|||
self.playlist_version += 1
|
||||
|
||||
if self.current_index == index: # Deleted playing song.
|
||||
return self.cmd_stop()
|
||||
self.cmd_stop()
|
||||
elif index < self.current_index: # Deleted before playing.
|
||||
# Shift playing index down.
|
||||
self.current_index -= 1
|
||||
|
|
@ -327,7 +330,7 @@ class Server(object):
|
|||
raise ArgumentIndexError()
|
||||
def cmd_moveid(self, id_from, idx_to):
|
||||
idx_from = self._id_to_index(idx_from)
|
||||
return self.cmd_move(idx_from, idx_to)
|
||||
for l in self.cmd_move(idx_from, idx_to): yield l
|
||||
|
||||
def cmd_swap(self, i, j):
|
||||
"""Swaps two tracks in the playlist."""
|
||||
|
|
@ -343,69 +346,62 @@ class Server(object):
|
|||
def cmd_swapid(self, i_id, j_id):
|
||||
i = self._id_to_index(i_id)
|
||||
j = self._id_to_index(j_id)
|
||||
return self.cmd_swap(i, j)
|
||||
for l in self.cmd_swap(i, j): yield l
|
||||
|
||||
def cmd_urlhandlers(self):
|
||||
"""Indicates supported URL schemes. None by default."""
|
||||
pass
|
||||
|
||||
def _items_info(self, l):
|
||||
"""Gets info (using _item_info) for an entire list (e.g.,
|
||||
the playlist).
|
||||
"""
|
||||
info = []
|
||||
for track in l:
|
||||
info += self._item_info(track)
|
||||
return SuccessResponse(info)
|
||||
|
||||
def cmd_playlistinfo(self, index=-1):
|
||||
"""Gives metadata information about the entire playlist or a
|
||||
single track, given by its index.
|
||||
"""
|
||||
index = cast_arg(int, index)
|
||||
if index == -1:
|
||||
return self._items_info(self.playlist)
|
||||
for track in self.playlist:
|
||||
yield self._item_info(track)
|
||||
else:
|
||||
try:
|
||||
track = self.playlist[index]
|
||||
except IndexError:
|
||||
raise ArgumentIndexError()
|
||||
return SuccessResponse(self._item_info(track))
|
||||
yield self._item_info(track)
|
||||
def cmd_playlistid(self, track_id=-1):
|
||||
return self.cmd_playlistinfo(self._id_to_index(track_id))
|
||||
for l in self.cmd_playlistinfo(self._id_to_index(track_id)):
|
||||
yield l
|
||||
|
||||
def cmd_plchanges(self, version):
|
||||
"""Returns playlist changes since the given version.
|
||||
"""Yields playlist changes since the given version.
|
||||
|
||||
This is a "fake" implementation that ignores the version and
|
||||
just returns the entire playlist (rather like version=0). This
|
||||
seems to satisfy many clients.
|
||||
"""
|
||||
return self.cmd_playlistinfo()
|
||||
for l in self.cmd_playlistinfo(): yield l
|
||||
|
||||
def cmd_currentsong(self):
|
||||
"""Returns information about the currently-playing song.
|
||||
"""Yields information about the currently-playing song.
|
||||
"""
|
||||
if self.current_index != -1: # -1 means stopped.
|
||||
track = self.playlist[self.current_index]
|
||||
return SuccessResponse(self._item_info(track))
|
||||
yield self._item_info(track)
|
||||
|
||||
def cmd_next(self):
|
||||
"""Advance to the next song in the playlist."""
|
||||
self.current_index += 1
|
||||
if self.current_index >= len(self.playlist):
|
||||
# Fallen off the end. Just move to stopped state.
|
||||
return self.cmd_stop()
|
||||
self.cmd_stop()
|
||||
else:
|
||||
return self.cmd_play()
|
||||
self.cmd_play()
|
||||
|
||||
def cmd_previous(self):
|
||||
"""Step back to the last song."""
|
||||
self.current_index -= 1
|
||||
if self.current_index < 0:
|
||||
return self.cmd_stop()
|
||||
self.cmd_stop()
|
||||
else:
|
||||
return self.cmd_play()
|
||||
self.cmd_play()
|
||||
|
||||
def cmd_pause(self, state=None):
|
||||
"""Set the pause state playback."""
|
||||
|
|
@ -419,7 +415,7 @@ class Server(object):
|
|||
index = cast_arg(int, index)
|
||||
if index == -1: # No index specified: start where we are.
|
||||
if not self.playlist: # Empty playlist: stop immediately.
|
||||
return self.cmd_stop()
|
||||
self.cmd_stop()
|
||||
if self.current_index == -1: # No current song.
|
||||
self.current_index = 0 # Start at the beginning.
|
||||
# If we have a current song, just stay there.
|
||||
|
|
@ -434,7 +430,7 @@ class Server(object):
|
|||
index = -1
|
||||
else:
|
||||
index = self._id_to_index(track_id)
|
||||
return self.cmd_play(index)
|
||||
self.cmd_play(index)
|
||||
|
||||
def cmd_stop(self):
|
||||
"""Stop playback."""
|
||||
|
|
@ -447,10 +443,10 @@ class Server(object):
|
|||
if index < 0 or index >= len(self.playlist):
|
||||
raise ArgumentIndexError()
|
||||
self.current_index = index
|
||||
return self.cmd_play()
|
||||
self.cmd_play()
|
||||
def cmd_seekid(self, track_id, time):
|
||||
index = self._id_to_index(track_id)
|
||||
return self.cmd_seek(index, time)
|
||||
for l in self.cmd_seek(index, time): yield l
|
||||
|
||||
class Connection(object):
|
||||
"""A connection between a client and the server. Handles input and
|
||||
|
|
@ -462,17 +458,21 @@ class Connection(object):
|
|||
"""
|
||||
self.client, self.server = client, server
|
||||
|
||||
def send(self, data):
|
||||
def send(self, data=None):
|
||||
"""Send data, which is either a string or an iterable
|
||||
consisting of strings, to the client. A newline is added after
|
||||
every string.
|
||||
every string. `data` may be None, in which case nothing is
|
||||
sent.
|
||||
"""
|
||||
if data is None:
|
||||
return
|
||||
|
||||
if isinstance(data, basestring): # Passed a single string.
|
||||
out = data + NEWLINE
|
||||
else: # Passed an iterable of strings (for instance, a Response).
|
||||
out = NEWLINE.join(data) + NEWLINE
|
||||
|
||||
log.debug(out)
|
||||
log.debug(out[:-1]) # Don't log trailing newline.
|
||||
self.client.sendall(out.encode('utf-8'))
|
||||
|
||||
line_re = re.compile(r'([^\r\n]*)(?:\r\n|\n\r|\n|\r)')
|
||||
|
|
@ -494,6 +494,17 @@ class Connection(object):
|
|||
yield match.group(1)
|
||||
buf = buf[match.end():] # Remove line from buffer.
|
||||
|
||||
def do_command(self, command):
|
||||
"""Run the given command and give an appropriate response."""
|
||||
try:
|
||||
command.run(self)
|
||||
except BPDError, e:
|
||||
# Send the error.
|
||||
self.send(e.response(command.name))
|
||||
else:
|
||||
# Send success code.
|
||||
self.send(RESP_OK)
|
||||
|
||||
def run(self):
|
||||
"""Send a greeting to the client and begin processing commands
|
||||
as they arrive. Blocks until the client disconnects.
|
||||
|
|
@ -507,8 +518,8 @@ class Connection(object):
|
|||
if clist is not None:
|
||||
# Command list already opened.
|
||||
if line == CLIST_END:
|
||||
self.send(clist.run(self.server))
|
||||
clist = None
|
||||
self.do_command(clist)
|
||||
clist = None # Clear the command list.
|
||||
else:
|
||||
clist.append(Command(line))
|
||||
|
||||
|
|
@ -519,7 +530,7 @@ class Connection(object):
|
|||
else:
|
||||
# Ordinary command.
|
||||
try:
|
||||
self.send(Command(line).run(self.server))
|
||||
self.do_command(Command(line))
|
||||
except BPDClose:
|
||||
# Command indicates that the conn should close.
|
||||
self.client.close()
|
||||
|
|
@ -548,18 +559,21 @@ class Command(object):
|
|||
arg_matches = self.arg_re.findall(s[command_match.end():])
|
||||
self.args = [m[0] or m[1] for m in arg_matches]
|
||||
|
||||
def run(self, server):
|
||||
"""Executes the command on the given `Sever`, returning a
|
||||
`Response` object.
|
||||
def run(self, conn):
|
||||
"""Executes the command on the given connection.
|
||||
"""
|
||||
func_name = 'cmd_' + self.name
|
||||
if hasattr(server, func_name):
|
||||
if hasattr(conn.server, func_name):
|
||||
try:
|
||||
response = getattr(server, func_name)(*self.args)
|
||||
responses = getattr(conn.server, func_name)(*self.args)
|
||||
if responses is not None:
|
||||
# Yielding nothing is considered success.
|
||||
for response in responses:
|
||||
conn.send(response)
|
||||
|
||||
except BPDError, e:
|
||||
# An exposed error. Send it to the client.
|
||||
return e.response(self)
|
||||
except BPDError:
|
||||
# An exposed error. Let the Connection handle it.
|
||||
raise
|
||||
|
||||
except BPDClose:
|
||||
# An indication that the connection should close. Send
|
||||
|
|
@ -569,16 +583,10 @@ class Command(object):
|
|||
except Exception, e:
|
||||
# An "unintentional" error. Hide it from the client.
|
||||
log.error(traceback.format_exc(e))
|
||||
return ErrorResponse(ERROR_SYSTEM, self.name, 'server error')
|
||||
|
||||
if response is None:
|
||||
# Assume success if nothing is returned.
|
||||
return SuccessResponse()
|
||||
else:
|
||||
return response
|
||||
raise BPDError(ERROR_SYSTEM, 'server error')
|
||||
|
||||
else:
|
||||
return ErrorResponse(ERROR_UNKNOWN, self.name, 'unknown command')
|
||||
raise BPDError(ERROR_UNKNOWN, 'unknown command')
|
||||
|
||||
class CommandList(list):
|
||||
"""A list of commands issued by the client for processing by the
|
||||
|
|
@ -594,80 +602,22 @@ class CommandList(list):
|
|||
self.append(item)
|
||||
self.verbose = verbose
|
||||
|
||||
def run(self, server):
|
||||
"""Execute all the commands in this list, returning a list of
|
||||
strings to be sent as a response.
|
||||
def run(self, conn):
|
||||
"""Execute all the commands in this list.
|
||||
"""
|
||||
out = []
|
||||
|
||||
for i, command in enumerate(self):
|
||||
resp = command.run(server)
|
||||
out.extend(resp.items)
|
||||
|
||||
# If the command failed, stop executing and send the completion
|
||||
# code for this command.
|
||||
if isinstance(resp, ErrorResponse):
|
||||
resp.index = i # Give the error the correct index.
|
||||
break
|
||||
try:
|
||||
command.run(conn)
|
||||
except BPDError, e:
|
||||
# If the command failed, stop executing.
|
||||
e.index = i # Give the error the correct index.
|
||||
raise e
|
||||
|
||||
# Otherwise, possibly send the output delimeter if we're in a
|
||||
# verbose ("OK") command list.
|
||||
if self.verbose:
|
||||
out.append(RESP_CLIST_VERBOSE)
|
||||
|
||||
# Give a completion code matching that of the last command (correct
|
||||
# for both success and failure).
|
||||
out.append(resp.completion())
|
||||
|
||||
return out
|
||||
|
||||
class Response(object):
|
||||
"""A result of executing a single `Command`. A `Response` is
|
||||
iterable and consists of zero or more lines of response data
|
||||
(`items`) and a completion code. It is an abstract class.
|
||||
"""
|
||||
def __init__(self, items=None):
|
||||
"""Create a response consisting of the given lines of
|
||||
response messages.
|
||||
"""
|
||||
self.items = (items if items else [])
|
||||
def __iter__(self):
|
||||
"""Iterate through the `Response`'s items and then its
|
||||
completion code."""
|
||||
return iter(self.items + [self.completion()])
|
||||
def completion(self):
|
||||
"""Returns the completion code of the response."""
|
||||
raise NotImplementedError
|
||||
|
||||
class ErrorResponse(Response):
|
||||
"""A result of a command that fails.
|
||||
"""
|
||||
template = Template('$resp [$code@$index] {$cmd_name} $message')
|
||||
|
||||
def __init__(self, code, cmd_name, message, index=0, items=None):
|
||||
"""Create a new `ErrorResponse` for error code `code`
|
||||
resulting from command with name `cmd_name`. `message` is an
|
||||
explanatory error message, `index` is the index of a command
|
||||
in a command list, and `items` is the additional data to be
|
||||
send to the client.
|
||||
"""
|
||||
super(ErrorResponse, self).__init__(items)
|
||||
self.code, self.index, self.cmd_name, self.message = \
|
||||
code, index, cmd_name, message
|
||||
|
||||
def completion(self):
|
||||
return self.template.substitute({'resp': RESP_ERR,
|
||||
'code': self.code,
|
||||
'index': self.index,
|
||||
'cmd_name': self.cmd_name,
|
||||
'message': self.message
|
||||
})
|
||||
|
||||
class SuccessResponse(Response):
|
||||
"""A result of a command that succeeds.
|
||||
"""
|
||||
def completion(self):
|
||||
return RESP_OK
|
||||
conn.send(RESP_CLIST_VERBOSE)
|
||||
|
||||
|
||||
|
||||
|
|
@ -743,19 +693,18 @@ class BGServer(Server):
|
|||
return artist, album, track
|
||||
|
||||
def cmd_lsinfo(self, path="/"):
|
||||
"""Return info on all the items in the path."""
|
||||
"""Yields info on all the items in the path."""
|
||||
artist, album, track = self._parse_path(path)
|
||||
|
||||
if not artist: # List all artists.
|
||||
artists = self.lib.artists()
|
||||
return SuccessResponse(['directory: ' + a for a in artists])
|
||||
for artist in self.lib.artists():
|
||||
yield 'directory: ' + artist
|
||||
elif not album: # List all albums for an artist.
|
||||
albums = self.lib.albums(artist)
|
||||
contents = ['directory: ' + seq_to_path(alb) for alb in albums]
|
||||
return SuccessResponse(contents)
|
||||
for album in self.lib.albums(artist):
|
||||
yield 'directory: ' + seq_to_path(album)
|
||||
elif not track: # List all tracks on an album.
|
||||
items = self.lib.items(artist, album)
|
||||
return self._items_info(items)
|
||||
for item in self.lib.items(artist, album):
|
||||
yield self._item_info(item)
|
||||
else: # List a track. This isn't a directory.
|
||||
raise BPDError(ERROR_ARG, 'this is not a directory')
|
||||
|
||||
|
|
@ -768,44 +717,45 @@ class BGServer(Server):
|
|||
|
||||
# artists
|
||||
if not artist:
|
||||
artists = self.lib.artists()
|
||||
out += ['directory: ' + a for a in artists]
|
||||
for artist in self.lib.artists():
|
||||
yield 'directory: ' + artist
|
||||
|
||||
# albums
|
||||
if not album:
|
||||
albums = self.lib.albums(artist or None)
|
||||
out += ['directory: ' + seq_to_path(alb) for alb in albums]
|
||||
for album in self.lib.albums(artist or None):
|
||||
yield 'directory: ' + seq_to_path(album)
|
||||
|
||||
# tracks
|
||||
items = self.lib.items(artist or None, album or None)
|
||||
if info:
|
||||
for item in items:
|
||||
out += self._item_info(item)
|
||||
yield self._item_info(item)
|
||||
else:
|
||||
out += ['file: ' + self._item_path(i) for i in items]
|
||||
|
||||
return SuccessResponse(out)
|
||||
for item in items:
|
||||
yield 'file: ' + self._item_path(i)
|
||||
|
||||
def cmd_listall(self, path="/"):
|
||||
"""Return the paths all items in the directory, recursively."""
|
||||
return self._listall(path, False)
|
||||
for l in self._listall(path, False): yield l
|
||||
def cmd_listallinfo(self, path="/"):
|
||||
"""Return info on all the items in the directory, recursively."""
|
||||
return self._listall(path, True)
|
||||
for l in self._listall(path, True): yield l
|
||||
|
||||
def cmd_search(self, key, value):
|
||||
"""Perform a substring match in a specific column."""
|
||||
if key == 'filename':
|
||||
key = 'path'
|
||||
query = beets.library.SubstringQuery(key, value)
|
||||
return self._items_info(self.lib.get(query))
|
||||
for item in self.lib.get(query):
|
||||
yield self._item_info(item)
|
||||
|
||||
def cmd_find(self, key, value):
|
||||
"""Perform an exact match in a specific column."""
|
||||
if key == 'filename':
|
||||
key = 'path'
|
||||
query = beets.library.MatchQuery(key, value)
|
||||
return self._items_info(self.lib.get(query))
|
||||
for item in self.lib.get(query):
|
||||
yield self._item_info(item)
|
||||
|
||||
def _get_by_path(self, path):
|
||||
"""Helper function returning the item at a given path."""
|
||||
|
|
@ -820,33 +770,30 @@ class BGServer(Server):
|
|||
self.playlist.append(self._get_by_path(path))
|
||||
self.playlist_version += 1
|
||||
def cmd_addid(self, path):
|
||||
"""Same as cmd_add but returns an id."""
|
||||
"""Same as cmd_add but yields an id."""
|
||||
track = self._get_by_path(path)
|
||||
self.playlist.append(track)
|
||||
self.playlist_version += 1
|
||||
return SuccessResponse(['Id: ' + str(track.id)])
|
||||
yield 'Id: ' + str(track.id)
|
||||
|
||||
def cmd_status(self):
|
||||
response = super(BGServer, self).cmd_status()
|
||||
for l in super(BGServer, self).cmd_status(): yield l
|
||||
if self.current_index > -1:
|
||||
item = self.playlist[self.current_index]
|
||||
response.items += ['bitrate: ' + str(item.bitrate/1000),
|
||||
'time: 0:' + str(int(item.length)), #fixme
|
||||
]
|
||||
return response
|
||||
yield 'bitrate: ' + str(item.bitrate/1000)
|
||||
yield 'time: 0:' + str(int(item.length)) #fixme
|
||||
|
||||
def cmd_stats(self):
|
||||
# The first three items need to be done more efficiently. The
|
||||
# last three need to be implemented.
|
||||
out = ['artists: ' + str(len(self.lib.artists())),
|
||||
'albums: ' + str(len(self.lib.albums())),
|
||||
'songs: ' + str(len(list(self.lib.items()))),
|
||||
'uptime: ' + str(int(time.time() - self.startup_time)),
|
||||
'playtime: ' + '0',
|
||||
'db_playtime: ' + '0',
|
||||
'db_update: ' + str(int(self.startup_time)),
|
||||
]
|
||||
return SuccessResponse(out)
|
||||
yield ('artists: ' + str(len(self.lib.artists())),
|
||||
'albums: ' + str(len(self.lib.albums())),
|
||||
'songs: ' + str(len(list(self.lib.items()))),
|
||||
'uptime: ' + str(int(time.time() - self.startup_time)),
|
||||
'playtime: ' + '0',
|
||||
'db_playtime: ' + '0',
|
||||
'db_update: ' + str(int(self.startup_time)),
|
||||
)
|
||||
|
||||
# The functions below hook into the half-implementations provided
|
||||
# by the base class. Together, they're enough to implement all
|
||||
|
|
|
|||
Loading…
Reference in a new issue