From f912164d399051ce5f0a9538460a92737c2fb975 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 17 Jul 2011 03:31:02 +0200 Subject: [PATCH 001/177] Quick rewrite to tee-less design --- mopidy/gstreamer.py | 34 ++++++++++++++-------------------- mopidy/outputs/__init__.py | 2 +- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index b5e38b92..1de6e000 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -36,7 +36,6 @@ class GStreamer(ThreadingActor): def __init__(self): self._pipeline = None self._source = None - self._tee = None self._uridecodebin = None self._volume = None self._outputs = [] @@ -54,13 +53,11 @@ class GStreamer(ThreadingActor): description = ' ! '.join([ 'uridecodebin name=uri', 'audioconvert name=convert', - 'volume name=volume', - 'tee name=tee']) + 'volume name=volume']) logger.debug(u'Setting up base GStreamer pipeline: %s', description) self._pipeline = gst.parse_launch(description) - self._tee = self._pipeline.get_by_name('tee') self._volume = self._pipeline.get_by_name('volume') self._uridecodebin = self._pipeline.get_by_name('uri') @@ -70,7 +67,8 @@ class GStreamer(ThreadingActor): def _setup_outputs(self): for output in settings.OUTPUTS: - get_class(output)(self).connect() + self._outputs.append(get_class(output)(self)) + self._outputs[0].connect() def _setup_message_processor(self): bus = self._pipeline.get_bus() @@ -304,9 +302,8 @@ class GStreamer(ThreadingActor): """ self._pipeline.add(output) output.sync_state_with_parent() # Required to add to running pipe - gst.element_link_many(self._tee, output) - self._outputs.append(output) - logger.debug('GStreamer added %s', output.get_name()) + gst.element_link_many(self._volume, output) + logger.debug('Output set to %s', output.get_name()) def list_outputs(self): """ @@ -323,26 +320,23 @@ class GStreamer(ThreadingActor): :param output: output to remove from the pipeline :type output: :class:`gst.Bin` """ - if output not in self._outputs: - raise LookupError('Ouput %s not present in pipeline' - % output.get_name) - teesrc = output.get_pad('sink').get_peer() - handler = teesrc.add_event_probe(self._handle_event_probe) + peersrc = output.get_pad('sink').get_peer() + handler = peersrc.add_event_probe(self._handle_event_probe) - struct = gst.Structure('mopidy-unlink-tee') + struct = gst.Structure('mopidy-unlink') struct.set_value('handler', handler) event = gst.event_new_custom(gst.EVENT_CUSTOM_DOWNSTREAM, struct) - self._tee.send_event(event) + self._volume.send_event(event) - def _handle_event_probe(self, teesrc, event): - if event.type == gst.EVENT_CUSTOM_DOWNSTREAM and event.has_name('mopidy-unlink-tee'): + def _handle_event_probe(self, srcpad, event): + if event.type == gst.EVENT_CUSTOM_DOWNSTREAM and event.has_name('mopidy-unlink'): data = self._get_structure_data(event.get_structure()) - output = teesrc.get_peer().get_parent() + output = srcpad.get_peer().get_parent() - teesrc.unlink(teesrc.get_peer()) - teesrc.remove_event_probe(data['handler']) + srcpad.unlink(srcpad.get_peer()) + srcpad.remove_event_probe(data['handler']) output.set_state(gst.STATE_NULL) self._pipeline.remove(output) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index ba242c4b..d94c0727 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -21,7 +21,7 @@ class BaseOutput(object): self.modify_bin() def _build_bin(self): - description = 'queue ! %s' % self.describe_bin() + description = self.describe_bin() logger.debug('Creating new output: %s', description) return gst.parse_bin_from_description(description, True) From b2ccdec9603a26e5efeb557db69d8aab23aa2e29 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 17 Jul 2011 04:14:21 +0200 Subject: [PATCH 002/177] Rip out rest of code that supported simulatnous outputs --- docs/changes.rst | 9 +++- docs/installation/gstreamer.rst | 4 +- docs/settings.rst | 2 +- mopidy/gstreamer.py | 96 ++------------------------------- mopidy/outputs/__init__.py | 29 +--------- mopidy/outputs/shoutcast.py | 16 ------ mopidy/settings.py | 8 +-- 7 files changed, 19 insertions(+), 145 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5506bfb0..b0d320eb 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -17,10 +17,17 @@ v0.6.0 (in development) - Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with :attr:`mopidy.backends.base.Backend.uri_schemes`, which just takes the part up to the colon of an URI, and not any prefix. + - Add Listener API, :mod:`mopidy.listeners`, to be implemented by actors wanting to receive events from the backend. This is a formalization of the ad hoc events the Last.fm scrobbler has already been using for some time. -- Fix metadata update in Shoutcast streaming (Fixes: :issue:`122`) + +- Fix metadata update in Shoutcast streaming (Fixes: :issue:`122`) + +- Multiple simultaneously playing outputs was considered more trouble than what + it is worth maintnance wise. Thus, this feature has been axed for now. + Switching outputs is still posible, but only one can be active at a time, and + it is still the case that switching during playback does not funtion. v0.5.0 (2011-06-15) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 08e16378..8f2ea07e 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -73,8 +73,8 @@ Using a custom audio sink ========================= If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can add ``mopidy.outputs.custom.CustomOutput`` to the -:attr:`mopidy.settings.OUTPUTS` setting, and set the +``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUTS` to +``mopidy.outputs.custom.CustomOutput``, and set the :attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline description describing the GStreamer sink you want to use. diff --git a/docs/settings.rst b/docs/settings.rst index 68adfd55..d3c9015e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -131,7 +131,7 @@ server simultaneously. To use the SHOUTcast output, do the following: #. Install, configure and start the Icecast server. It can be found in the ``icecast2`` package in Debian/Ubuntu. -#. Add ``mopidy.outputs.shoutcast.ShoutcastOutput`` output to the +#. Set ``mopidy.outputs.shoutcast.ShoutcastOutput`` as the first output in the :attr:`mopidy.settings.OUTPUTS` setting. #. Check the default values for the following settings, and alter them to match diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 1de6e000..b43089e0 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -29,7 +29,7 @@ class GStreamer(ThreadingActor): **Settings:** - - :attr:`mopidy.settings.OUTPUTS` + - :attr:`mopidy.settings.OUTPUT` """ @@ -66,9 +66,9 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) def _setup_outputs(self): - for output in settings.OUTPUTS: - self._outputs.append(get_class(output)(self)) - self._outputs[0].connect() + for klass in settings.OUTPUTS: + self._outputs.append(get_class(klass)()) + self.connect_output(self._outputs[0].bin) def _setup_message_processor(self): bus = self._pipeline.get_bus() @@ -87,10 +87,6 @@ class GStreamer(ThreadingActor): pad.link(target_pad) def _on_message(self, bus, message): - if message.src in self._handlers: - if self._handlers[message.src](message): - return # Message was handeled by output - if message.type == gst.MESSAGE_EOS: logger.debug(u'GStreamer signalled end-of-stream. ' 'Telling backend ...') @@ -305,86 +301,4 @@ class GStreamer(ThreadingActor): gst.element_link_many(self._volume, output) logger.debug('Output set to %s', output.get_name()) - def list_outputs(self): - """ - Get list with the name of all active outputs. - - :rtype: list of strings - """ - return [output.get_name() for output in self._outputs] - - def remove_output(self, output): - """ - Remove output from our pipeline. - - :param output: output to remove from the pipeline - :type output: :class:`gst.Bin` - """ - peersrc = output.get_pad('sink').get_peer() - handler = peersrc.add_event_probe(self._handle_event_probe) - - struct = gst.Structure('mopidy-unlink') - struct.set_value('handler', handler) - - event = gst.event_new_custom(gst.EVENT_CUSTOM_DOWNSTREAM, struct) - self._volume.send_event(event) - - def _handle_event_probe(self, srcpad, event): - if event.type == gst.EVENT_CUSTOM_DOWNSTREAM and event.has_name('mopidy-unlink'): - data = self._get_structure_data(event.get_structure()) - - output = srcpad.get_peer().get_parent() - - srcpad.unlink(srcpad.get_peer()) - srcpad.remove_event_probe(data['handler']) - - output.set_state(gst.STATE_NULL) - self._pipeline.remove(output) - - logger.warning('Removed %s', output.get_name()) - return False - return True - - def _get_structure_data(self, struct): - # Ugly hack to get around missing get_value in pygst bindings :/ - data = {} - def get_data(key, value): - data[key] = value - struct.foreach(get_data) - return data - - def connect_message_handler(self, element, handler): - """ - Attach custom message handler for given element. - - Hook to allow outputs (or other code) to register custom message - handlers for all messages coming from the element in question. - - In the case of outputs, :meth:`mopidy.outputs.BaseOutput.on_connect` - should be used to attach such handlers and care should be taken to - remove them in :meth:`mopidy.outputs.BaseOutput.on_remove` using - :meth:`remove_message_handler`. - - The handler callback will only be given the message in question, and - is free to ignore the message. However, if the handler wants to prevent - the default handling of the message it should return :class:`True` - indicating that the message has been handled. - - Note that there can only be one handler per element. - - :param element: element to watch messages from - :type element: :class:`gst.Element` - :param handler: callable that takes :class:`gst.Message` and returns - :class:`True` if the message has been handeled - :type handler: callable - """ - self._handlers[element] = handler - - def remove_message_handler(self, element): - """ - Remove custom message handler. - - :param element: element to remove message handling from. - :type element: :class:`gst.Element` - """ - self._handlers.pop(element, None) + # FIXME re-add disconnect / swap output code? diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index d94c0727..21179f94 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -13,8 +13,7 @@ class BaseOutput(object): MESSAGE_ERROR = gst.MESSAGE_ERROR MESSAGE_WARNING = gst.MESSAGE_WARNING - def __init__(self, gstreamer): - self.gstreamer = gstreamer + def __init__(self): self.bin = self._build_bin() self.bin.set_name(self.get_name()) @@ -25,32 +24,6 @@ class BaseOutput(object): logger.debug('Creating new output: %s', description) return gst.parse_bin_from_description(description, True) - def connect(self): - """Attach output to GStreamer pipeline.""" - self.gstreamer.connect_output(self.bin) - self.on_connect() - - def on_connect(self): - """ - Called after output has been connected to GStreamer pipeline. - - *MAY be implemented by subclass.* - """ - pass - - def remove(self): - """Remove output from GStreamer pipeline.""" - self.gstreamer.remove_output(self.bin) - self.on_remove() - - def on_remove(self): - """ - Called after output has been removed from GStreamer pipeline. - - *MAY be implemented by subclass.* - """ - pass - def get_name(self): """ Get name of the output. Defaults to the output's class name. diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py index ffe09aae..0279ae2d 100644 --- a/mopidy/outputs/shoutcast.py +++ b/mopidy/outputs/shoutcast.py @@ -40,19 +40,3 @@ class ShoutcastOutput(BaseOutput): u'username': settings.SHOUTCAST_OUTPUT_USERNAME, u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, }) - - def on_connect(self): - self.gstreamer.connect_message_handler( - self.bin.get_by_name('shoutcast'), self.message_handler) - - def on_remove(self): - self.gstreamer.remove_message_handler( - self.bin.get_by_name('shoutcast')) - - def message_handler(self, message): - if message.type != self.MESSAGE_ERROR: - return False - error, debug = message.parse_error() - logger.warning('%s (%s)', error, debug) - self.remove() - return True diff --git a/mopidy/settings.py b/mopidy/settings.py index f3e012ed..392c9ad7 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -173,12 +173,8 @@ MPD_SERVER_PASSWORD = None #: #: Default:: #: -#: OUTPUTS = ( -#: u'mopidy.outputs.local.LocalOutput', -#: ) -OUTPUTS = ( - u'mopidy.outputs.local.LocalOutput', -) +#: OUTPUTS = (u'mopidy.outputs.local.LocalOutput',) +OUTPUTS = (u'mopidy.outputs.local.LocalOutput',) #: Hostname of the SHOUTcast server which Mopidy should stream audio to. #: From 11aa75796c7b08abd399aa403434f6dffb6b82cb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 4 Aug 2011 00:51:26 +0200 Subject: [PATCH 003/177] Get rid of current volume element (fixes #115) --- mopidy/gstreamer.py | 18 +++++++++++++----- mopidy/outputs/local.py | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index edcb3084..4ded2f95 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -38,7 +38,6 @@ class GStreamer(ThreadingActor): self._source = None self._tee = None self._uridecodebin = None - self._volume = None self._outputs = [] self._handlers = {} @@ -51,14 +50,12 @@ class GStreamer(ThreadingActor): description = ' ! '.join([ 'uridecodebin name=uri', 'audioconvert name=convert', - 'volume name=volume', 'tee name=tee']) logger.debug(u'Setting up base GStreamer pipeline: %s', description) self._pipeline = gst.parse_launch(description) self._tee = self._pipeline.get_by_name('tee') - self._volume = self._pipeline.get_by_name('volume') self._uridecodebin = self._pipeline.get_by_name('uri') self._uridecodebin.connect('notify::source', self._on_new_source) @@ -247,7 +244,14 @@ class GStreamer(ThreadingActor): :rtype: int in range [0..100] """ - return int(self._volume.get_property('volume') * 100) + mixers = self._pipeline.iterate_all_by_interface(gst.interfaces.Mixer) + try: + mixer = mixers.next() + except StopIteration: + return 0 + # FIXME this _will_ break for mixers that don't implement + # GstStreamVolume + return int(mixer.get_property('volume') * 100) def set_volume(self, volume): """ @@ -257,7 +261,11 @@ class GStreamer(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self._volume.set_property('volume', volume / 100.0) + mixers = self._pipeline.iterate_all_by_interface(gst.interfaces.Mixer) + for mixer in mixers: + # FIXME this _will_ break for mixers that don't implement + # GstStreamVolume + mixer.set_property('volume', volume / 100.0) return True def set_metadata(self, track): diff --git a/mopidy/outputs/local.py b/mopidy/outputs/local.py index 8101e026..62b26e3f 100644 --- a/mopidy/outputs/local.py +++ b/mopidy/outputs/local.py @@ -17,4 +17,4 @@ class LocalOutput(BaseOutput): """ def describe_bin(self): - return 'autoaudiosink' + return 'volume ! autoaudiosink' From 8f7961064a2632ecab261962db1dcda839a6b637 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 22 Aug 2012 15:46:23 +0200 Subject: [PATCH 004/177] Add debug proxy helper. Tool sits in front of MPD and Mopidy proxying commands to both. Only the reference backend's replies are passed to the client. All requests are logged, but only the response's unified diff is displayed. Intended use case is quick and simple protocol implementation comparisons. --- tools/debug-proxy.py | 177 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100755 tools/debug-proxy.py diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py new file mode 100755 index 00000000..cf84bd54 --- /dev/null +++ b/tools/debug-proxy.py @@ -0,0 +1,177 @@ +#!/usr/bin/python + +import argparse +import difflib +import sys + +from gevent import select +from gevent import server +from gevent import socket + + +def proxy(client, address, reference_address, actual_address): + """Main handler code that gets called for each connection.""" + client.setblocking(False) + + reference = connect(reference_address) + actual = connect(actual_address) + + if reference and actual: + loop(client, address, reference, actual) + else: + print 'Could not connect to one of the backends.' + + for sock in (client, reference, actual): + close(sock) + + +def connect(address): + """Connect to given address and set socket non blocking.""" + try: + sock = socket.socket() + sock.connect(address) + sock.setblocking(False) + except socket.error: + return None + return sock + + +def close(sock): + """Shutdown and close our sockets.""" + try: + sock.shutdown(socket.SHUT_WR) + sock.close() + except socket.error: + pass + + +def loop(client, address, reference, actual): + """Loop that handles one MPD reqeust/response pair per iteration.""" + + # Consume banners from backends + responses = dict() + disconnected = read([reference, actual], responses, find_response_end_token) + diff(address, '', responses[reference], responses[actual]) + + # We lost the a backend, might as well give up. + if disconnected: + return + + client.sendall(responses[reference]) + + while True: + responses = dict() + + # Get the command from the client. Not sure how an if this will handle + # client sending multiple commands currently :/ + disconnected = read([client], responses, find_request_end_token) + + # We lost the client, might as well give up. + if disconnected: + return + + # Send the entire command to both backends. + reference.sendall(responses[client]) + actual.sendall(responses[client]) + + # Get the entire resonse from both backends. + disconnected = read([reference, actual], responses, find_response_end_token) + + # Send the client the complete reference response + client.sendall(responses[reference]) + + # Compare our responses + diff(address, responses[client], responses[reference], responses[actual]) + + # Give up if we lost a backend. + if disconnected: + return + + +def read(sockets, responses, find_end_token): + """Keep reading from sockets until they disconnet or we find our token.""" + + # This function doesn't go to well with idle when backends are out of sync. + disconnected = False + + for sock in sockets: + responses.setdefault(sock, '') + + while sockets: + for sock in select.select(sockets, [], [])[0]: + data = sock.recv(4096) + responses[sock] += data + + if find_end_token(responses[sock]): + sockets.remove(sock) + + if not data: + sockets.remove(sock) + disconnected = True + + return disconnected + + +def find_response_end_token(data): + """Find token that indicates the response is over.""" + for line in data.splitlines(True): + if line.startswith(('OK', 'ACK')) and line.endswith('\n'): + return True + return False + + +def find_request_end_token(data): + """Find token that indicates that request is over.""" + lines = data.splitlines(True) + if not lines: + return False + elif 'command_list_ok_begin' == lines[0].strip(): + return 'command_list_end' == lines[-1].strip() + else: + return lines[0].endswith('\n') + + +def diff(address, command, reference_response, actual_response): + """Print command from client and a unified diff of the responses.""" + sys.stdout.write('[%s]:%s\n%s' % (address[0], address[1], command)) + for line in difflib.unified_diff(reference_response.splitlines(True), + actual_response.splitlines(True), + fromfile='Reference response', + tofile='Actual response'): + sys.stdout.write(line) + sys.stdout.flush() + + +def parse_args(): + """Handle flag parsing.""" + parser = argparse.ArgumentParser( + description='Proxy and compare MPD protocol interactions.') + parser.add_argument('--listen', default=':6600', type=parse_address, + help='address:port to listen on.') + parser.add_argument('--reference', default=':6601', type=parse_address, + help='address:port for the reference backend.') + parser.add_argument('--actual', default=':6602', type=parse_address, + help='address:port for the actual backend.') + + return parser.parse_args() + + +def parse_address(address): + """Convert host:port or port to address to pass to connect.""" + if ':' not in address: + return ('', int(address)) + host, port = address.rsplit(':', 1) + return (host, int(port)) + + +if __name__ == '__main__': + args = parse_args() + + def handle(client, address): + """Wrapper that adds reference and actual backends to proxy calls.""" + return proxy(client, address, args.reference, args.actual) + + try: + server.StreamServer(args.listen, handle).serve_forever() + except (KeyboardInterrupt, SystemExit): + pass From 4ff5c2e992aea195a80c2d9c83449e9dc5d88094 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 22 Aug 2012 23:16:03 +0200 Subject: [PATCH 005/177] Add color to console output and fix some things from review. --- tools/debug-proxy.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py index cf84bd54..3ff6f561 100755 --- a/tools/debug-proxy.py +++ b/tools/debug-proxy.py @@ -4,9 +4,12 @@ import argparse import difflib import sys -from gevent import select -from gevent import server -from gevent import socket +from gevent import select, server, socket + +COLORS = ['\033[1;%dm' % (30+i) for i in range(8)] +BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS +RESET = "\033[0m" +BOLD = "\033[1m" def proxy(client, address, reference_address, actual_address): @@ -53,7 +56,7 @@ def loop(client, address, reference, actual): disconnected = read([reference, actual], responses, find_response_end_token) diff(address, '', responses[reference], responses[actual]) - # We lost the a backend, might as well give up. + # We lost a backend, might as well give up. if disconnected: return @@ -138,7 +141,17 @@ def diff(address, command, reference_response, actual_response): actual_response.splitlines(True), fromfile='Reference response', tofile='Actual response'): + + if line.startswith('+') and not line.startswith('+++'): + sys.stdout.write(GREEN) + elif line.startswith('-') and not line.startswith('---'): + sys.stdout.write(RED) + elif line.startswith('@@'): + sys.stdout.write(CYAN) + sys.stdout.write(line) + sys.stdout.write(RESET) + sys.stdout.flush() From 1649abc410aa8c6b67cded667814ae664fc08666 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 22 Aug 2012 23:42:57 +0200 Subject: [PATCH 006/177] Add debug-proxy to 0.8 changelog. --- docs/changes.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index a4aae058..a2a45960 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,17 @@ Changes This change log is used to track all major changes to Mopidy. +v0.8 (in development) +===================== + +**Changes** + +- Added tools/debug-proxy.py to tee client requests to two backends and diff + responses. Intended as a developer tool for checking for MPD protocol changes + and various client support. Requires gevent, which currently is not a + dependency of Mopidy. + + v0.7.3 (2012-08-11) =================== From 8849c996754408f9e10e92c582d6f29d8b95bfba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 23 Aug 2012 00:24:13 +0200 Subject: [PATCH 007/177] Use recommended shebang for Python scripts --- tools/debug-proxy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py index 3ff6f561..2f54ea36 100755 --- a/tools/debug-proxy.py +++ b/tools/debug-proxy.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#! /usr/bin/env python import argparse import difflib From f995b2f1deb7914c214def8f8a137ac573b50200 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Aug 2012 01:07:22 +0200 Subject: [PATCH 008/177] Continue ripping out multi output support. --- mopidy/gstreamer.py | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 8781a4b2..4f36b94f 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -38,12 +38,11 @@ class GStreamer(ThreadingActor): self._source = None self._uridecodebin = None self._volume = None - self._outputs = [] - self._handlers = {} + self._output = None def on_start(self): self._setup_pipeline() - self._setup_outputs() + self._setup_output() self._setup_message_processor() def _setup_pipeline(self): @@ -62,10 +61,16 @@ class GStreamer(ThreadingActor): self._uridecodebin.connect('pad-added', self._on_new_pad, self._pipeline.get_by_name('convert').get_pad('sink')) - def _setup_outputs(self): - for klass in settings.OUTPUTS: - self._outputs.append(get_class(klass)()) - self.connect_output(self._outputs[0].bin) + def _setup_output(self): + self._output = get_class(settings.OUTPUTS[0])() + + if len(settings.OUTPUTS) > 1: + logger.warning('Only first output will be used.') + + self._pipeline.add(self._output.bin) + gst.element_link_many(self._volume, self._output.bin) + + logger.debug('Output set to %s', self._output.get_name()) def _setup_message_processor(self): bus = self._pipeline.get_bus() @@ -287,15 +292,3 @@ class GStreamer(ThreadingActor): event = gst.event_new_tag(taglist) self._pipeline.send_event(event) - - def connect_output(self, output): - """ - Connect output to pipeline. - - :param output: output to connect to the pipeline - :type output: :class:`gst.Bin` - """ - self._pipeline.add(output) - output.sync_state_with_parent() # Required to add to running pipe - gst.element_link_many(self._volume, output) - logger.debug('Output set to %s', output.get_name()) From 5790d0ba07d5429d09d93aa6dc8fe63796c44dd4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Aug 2012 01:13:08 +0200 Subject: [PATCH 009/177] Add removal of multiple outsputs support to changelog. --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index a2a45960..ad74ade9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,9 @@ v0.8 (in development) and various client support. Requires gevent, which currently is not a dependency of Mopidy. +- Removed most traces of multiple outputs support. Having this feature + currently seems to be more trouble than what it is worth. + v0.7.3 (2012-08-11) =================== From c565e274a52e87f7da37e787854e5862301838df Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Aug 2012 23:11:59 +0200 Subject: [PATCH 010/177] Replace OUTPUTS with OUTPUT and switch to simple outputs that return a gst.Bin --- mopidy/gstreamer.py | 16 ++----- mopidy/outputs.py | 96 +++++++++++++++++++++++++++++++++++++ mopidy/outputs/__init__.py | 78 ------------------------------ mopidy/outputs/custom.py | 34 ------------- mopidy/outputs/local.py | 20 -------- mopidy/outputs/shoutcast.py | 42 ---------------- mopidy/settings.py | 7 ++- mopidy/utils/__init__.py | 19 ++++++-- mopidy/utils/settings.py | 12 +++-- 9 files changed, 127 insertions(+), 197 deletions(-) create mode 100644 mopidy/outputs.py delete mode 100644 mopidy/outputs/__init__.py delete mode 100644 mopidy/outputs/custom.py delete mode 100644 mopidy/outputs/local.py delete mode 100644 mopidy/outputs/shoutcast.py diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 4f36b94f..8d8bedb4 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -7,8 +7,7 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.utils import get_class +from mopidy import settings, utils from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.gstreamer') @@ -62,15 +61,10 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) def _setup_output(self): - self._output = get_class(settings.OUTPUTS[0])() - - if len(settings.OUTPUTS) > 1: - logger.warning('Only first output will be used.') - - self._pipeline.add(self._output.bin) - gst.element_link_many(self._volume, self._output.bin) - - logger.debug('Output set to %s', self._output.get_name()) + self._output = utils.get_function(settings.OUTPUT)() + self._pipeline.add(self._output) + gst.element_link_many(self._volume, self._output) + logger.debug('Output set to %s', settings.OUTPUT) def _setup_message_processor(self): bus = self._pipeline.get_bus() diff --git a/mopidy/outputs.py b/mopidy/outputs.py new file mode 100644 index 00000000..d9619fb8 --- /dev/null +++ b/mopidy/outputs.py @@ -0,0 +1,96 @@ +import pygst +pygst.require('0.10') +import gst + +from mopidy import settings + + +def custom(): + """ + Custom output for using alternate setups. + + This output is intended to handle two main cases: + + 1. Simple things like switching which sink to use. Say :class:`LocalOutput` + doesn't work for you and you want to switch to ALSA, simple. Set + :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good + to go. Some possible sinks include: + + - alsasink + - osssink + - pulsesink + - ...and many more + + 2. Advanced setups that require complete control of the output bin. For + these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a + :command:`gst-launch` compatible string describing the target setup. + + **Dependencies:** + + - None + + **Settings:** + + - :attr:`mopidy.settings.CUSTOM_OUTPUT` + """ + return gst.parse_bin_from_description(settings.CUSTOM_OUTPUT, True) + + +def local(): + """ + Basic output to local audio sink. + + This output will normally tell GStreamer to choose whatever it thinks is + best for your system. In other words this is usually a sane choice. + + **Dependencies:** + + - None + + **Settings:** + + - None + """ + return gst.parse_bin_from_description('autoaudiosink', True) + + +def shoutcast(): + """ + Shoutcast streaming output. + + This output allows for streaming to an icecast server or anything else that + supports Shoutcast. The output supports setting for: server address, port, + mount point, user, password and encoder to use. Please see + :class:`mopidy.settings` for details about settings. + + **Dependencies:** + + - A SHOUTcast/Icecast server + + **Settings:** + + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` + """ + encoder = settings.SHOUTCAST_OUTPUT_ENCODER + output = gst.parse_bin_from_description( + '%s ! shout2send name=shoutcast' % encoder, True) + + shoutcast = output.get_by_name('shoutcast') + + properties = { + u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME, + u'port': settings.SHOUTCAST_OUTPUT_PORT, + u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, + u'username': settings.SHOUTCAST_OUTPUT_USERNAME, + u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, + } + + for name, value in properties.items(): + shoutcast.set_property(name, value) + + return output diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py deleted file mode 100644 index 21179f94..00000000 --- a/mopidy/outputs/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -import pygst -pygst.require('0.10') -import gst - -import logging - -logger = logging.getLogger('mopidy.outputs') - -class BaseOutput(object): - """Base class for pluggable audio outputs.""" - - MESSAGE_EOS = gst.MESSAGE_EOS - MESSAGE_ERROR = gst.MESSAGE_ERROR - MESSAGE_WARNING = gst.MESSAGE_WARNING - - def __init__(self): - self.bin = self._build_bin() - self.bin.set_name(self.get_name()) - - self.modify_bin() - - def _build_bin(self): - description = self.describe_bin() - logger.debug('Creating new output: %s', description) - return gst.parse_bin_from_description(description, True) - - def get_name(self): - """ - Get name of the output. Defaults to the output's class name. - - *MAY be implemented by subclass.* - - :rtype: string - """ - return self.__class__.__name__ - - def modify_bin(self): - """ - Modifies ``self.bin`` before it is installed if needed. - - Overriding this method allows for outputs to modify the constructed bin - before it is installed. This can for instance be a good place to call - `set_properties` on elements that need to be configured. - - *MAY be implemented by subclass.* - """ - pass - - def describe_bin(self): - """ - Return string describing the output bin in :command:`gst-launch` - format. - - For simple cases this can just be a sink such as ``autoaudiosink``, - or it can be a chain like ``element1 ! element2 ! sink``. See the - manpage of :command:`gst-launch` for details on the format. - - *MUST be implemented by subclass.* - - :rtype: string - """ - raise NotImplementedError - - def set_properties(self, element, properties): - """ - Helper method for setting of properties on elements. - - Will call :meth:`gst.Element.set_property` on ``element`` for each key - in ``properties`` that has a value that is not :class:`None`. - - :param element: element to set properties on - :type element: :class:`gst.Element` - :param properties: properties to set on element - :type properties: dict - """ - for key, value in properties.items(): - if value is not None: - element.set_property(key, value) diff --git a/mopidy/outputs/custom.py b/mopidy/outputs/custom.py deleted file mode 100644 index 09239a44..00000000 --- a/mopidy/outputs/custom.py +++ /dev/null @@ -1,34 +0,0 @@ -from mopidy import settings -from mopidy.outputs import BaseOutput - -class CustomOutput(BaseOutput): - """ - Custom output for using alternate setups. - - This output is intended to handle two main cases: - - 1. Simple things like switching which sink to use. Say :class:`LocalOutput` - doesn't work for you and you want to switch to ALSA, simple. Set - :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good - to go. Some possible sinks include: - - - alsasink - - osssink - - pulsesink - - ...and many more - - 2. Advanced setups that require complete control of the output bin. For - these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a - :command:`gst-launch` compatible string describing the target setup. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.CUSTOM_OUTPUT` - """ - - def describe_bin(self): - return settings.CUSTOM_OUTPUT diff --git a/mopidy/outputs/local.py b/mopidy/outputs/local.py deleted file mode 100644 index 8101e026..00000000 --- a/mopidy/outputs/local.py +++ /dev/null @@ -1,20 +0,0 @@ -from mopidy.outputs import BaseOutput - -class LocalOutput(BaseOutput): - """ - Basic output to local audio sink. - - This output will normally tell GStreamer to choose whatever it thinks is - best for your system. In other words this is usually a sane choice. - - **Dependencies:** - - - None - - **Settings:** - - - None - """ - - def describe_bin(self): - return 'autoaudiosink' diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py deleted file mode 100644 index 0279ae2d..00000000 --- a/mopidy/outputs/shoutcast.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -from mopidy import settings -from mopidy.outputs import BaseOutput - -logger = logging.getLogger('mopidy.outputs.shoutcast') - -class ShoutcastOutput(BaseOutput): - """ - Shoutcast streaming output. - - This output allows for streaming to an icecast server or anything else that - supports Shoutcast. The output supports setting for: server address, port, - mount point, user, password and encoder to use. Please see - :class:`mopidy.settings` for details about settings. - - **Dependencies:** - - - A SHOUTcast/Icecast server - - **Settings:** - - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` - """ - - def describe_bin(self): - return 'audioconvert ! %s ! shout2send name=shoutcast' \ - % settings.SHOUTCAST_OUTPUT_ENCODER - - def modify_bin(self): - self.set_properties(self.bin.get_by_name('shoutcast'), { - u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME, - u'port': settings.SHOUTCAST_OUTPUT_PORT, - u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, - u'username': settings.SHOUTCAST_OUTPUT_USERNAME, - u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, - }) diff --git a/mopidy/settings.py b/mopidy/settings.py index a47b389d..07bfda43 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -185,13 +185,12 @@ MPD_SERVER_PASSWORD = None #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 -#: List of outputs to use. See :mod:`mopidy.outputs` for all available -#: backends +#: Output to use. See :mod:`mopidy.outputs` for all available backends #: #: Default:: #: -#: OUTPUTS = (u'mopidy.outputs.local.LocalOutput',) -OUTPUTS = (u'mopidy.outputs.local.LocalOutput',) +#: OUTPUT = u'mopidy.outputs.local' +OUTPUT = u'mopidy.outputs.local' #: Hostname of the SHOUTcast server which Mopidy should stream audio to. #: diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 00129cdd..b1234aec 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -5,6 +5,8 @@ import sys logger = logging.getLogger('mopidy.utils') + +# TODO: user itertools.chain.from_iterable(the_list)? def flatten(the_list): result = [] for element in the_list: @@ -14,22 +16,31 @@ def flatten(the_list): result.append(element) return result + def import_module(name): __import__(name) return sys.modules[name] -def get_class(name): + +def _get_obj(name): logger.debug('Loading: %s', name) if '.' not in name: raise ImportError("Couldn't load: %s" % name) module_name = name[:name.rindex('.')] - class_name = name[name.rindex('.') + 1:] + obj_name = name[name.rindex('.') + 1:] try: module = import_module(module_name) - class_object = getattr(module, class_name) + obj = getattr(module, obj_name) except (ImportError, AttributeError): raise ImportError("Couldn't load: %s" % name) - return class_object + return obj + + +# We provide both get_class and get_function to make it more obvious what the +# intent of our code really is. +get_class = _get_obj +get_function = _get_obj + def locale_decode(bytestr): try: diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index ff449a61..65548f33 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -120,7 +120,6 @@ def validate_settings(defaults, settings): 'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', - 'OUTPUT': None, 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', @@ -140,11 +139,16 @@ def validate_settings(defaults, settings): if setting == 'BACKENDS': if 'mopidy.backends.despotify.DespotifyBackend' in value: - errors[setting] = (u'Deprecated setting value. ' + - '"mopidy.backends.despotify.DespotifyBackend" is no ' + - 'longer available.') + errors[setting] = (u'Deprecated setting value. ' + u'"mopidy.backends.despotify.DespotifyBackend" is no ' + u'longer available.') continue + if setting == 'OUTPUTS': + errors[setting] = (u'Deprecated setting, please change to OUTPUT. ' + u'Please note that output values have also changed.') + continue + if setting == 'SPOTIFY_BITRATE': if value not in (96, 160, 320): errors[setting] = (u'Unavailable Spotify bitrate. ' + From 6e9dd194df79bd3102f731ef45f0ce0e3cc0206e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 24 Aug 2012 00:16:33 +0200 Subject: [PATCH 011/177] Use current_playlist.length instead of len(current_playlist.tracks) --- mopidy/frontends/mpd/protocol/current_playlist.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 0d61c887..0c2c2d52 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -55,8 +55,7 @@ def addid(context, uri, songpos=None): track = context.backend.library.lookup(uri).get() if track is None: raise MpdNoExistError(u'No such song', command=u'addid') - if songpos and songpos > len( - context.backend.current_playlist.tracks.get()): + if songpos and songpos > context.backend.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') cp_track = context.backend.current_playlist.add(track, at_position=songpos).get() @@ -132,7 +131,7 @@ def move_range(context, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = len(context.backend.current_playlist.tracks.get()) + end = context.backend.current_playlist.length.get() start = int(start) end = int(end) to = int(to) From 2262bf91d5ce3e851d5bf7a3bb3f6a03f5243db5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 24 Aug 2012 00:21:03 +0200 Subject: [PATCH 012/177] Fix crash in 'playlistinfo' when called with a songpos not matching an CPID (fixes #162) --- docs/changes.rst | 4 ++++ mopidy/frontends/mpd/protocol/current_playlist.py | 2 +- tests/frontends/mpd/protocol/current_playlist_test.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index a2a45960..4dcc8c57 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,10 @@ v0.8 (in development) and various client support. Requires gevent, which currently is not a dependency of Mopidy. +- Fixed bug when the MPD command `playlistinfo` is used with a track position. + Track position and CPID was intermixed, so it would cause a crash if a CPID + matching the track position didn't exist. (Fixes: :issue:`162`) + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 0c2c2d52..c60cbc4a 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -243,7 +243,7 @@ def playlistinfo(context, songpos=None, """ if songpos is not None: songpos = int(songpos) - cp_track = context.backend.current_playlist.get(cpid=songpos).get() + cp_track = context.backend.current_playlist.cp_tracks.get()[songpos] return track_to_mpd_format(cp_track, position=songpos) else: if start is None: diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 321fc6ee..21889e82 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -285,6 +285,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistinfo_with_songpos(self): + # Make the track's CPID not match the playlist position + self.backend.current_playlist.cp_id = 17 self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), From 7948921510f59c5b1d4b40e336db698995f79bc5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 12:18:28 +0200 Subject: [PATCH 013/177] Make settings.OUTPUT a GStreamer bin description. --- mopidy/gstreamer.py | 2 +- mopidy/settings.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 8d8bedb4..52fe079e 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -61,7 +61,7 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) def _setup_output(self): - self._output = utils.get_function(settings.OUTPUT)() + self._output = gst.parse_bin_from_description(settings.OUTPUT, True) self._pipeline.add(self._output) gst.element_link_many(self._volume, self._output) logger.debug('Output set to %s', settings.OUTPUT) diff --git a/mopidy/settings.py b/mopidy/settings.py index 07bfda43..fce729d3 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -189,8 +189,8 @@ MPD_SERVER_MAX_CONNECTIONS = 20 #: #: Default:: #: -#: OUTPUT = u'mopidy.outputs.local' -OUTPUT = u'mopidy.outputs.local' +#: OUTPUT = u'autoaudiosink' +OUTPUT = u'autoaudiosink' #: Hostname of the SHOUTcast server which Mopidy should stream audio to. #: From 343207ebe27f1b172b20e66dc267cc67371bf925 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 12:37:23 +0200 Subject: [PATCH 014/177] Update docs with latest OUTPUT changes and fix issues raised in review of pull request. --- docs/changes.rst | 4 ++ docs/installation/gstreamer.rst | 9 ++-- docs/settings.rst | 19 ++++--- mopidy/outputs.py | 96 --------------------------------- mopidy/settings.py | 8 --- mopidy/utils/__init__.py | 14 ++--- mopidy/utils/settings.py | 14 +++-- 7 files changed, 29 insertions(+), 135 deletions(-) delete mode 100644 mopidy/outputs.py diff --git a/docs/changes.rst b/docs/changes.rst index ad74ade9..db6a1c60 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -16,6 +16,10 @@ v0.8 (in development) - Removed most traces of multiple outputs support. Having this feature currently seems to be more trouble than what it is worth. + :attr:`mopidy.settings.OUTPUTS` setting is no longer supported, and has been + replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer + bin descriped in the same format as gst-launch expects. Default value is + ``autoaudiosink``. v0.7.3 (2012-08-11) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index c6359f6f..546b53ba 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -112,12 +112,9 @@ Using a custom audio sink ========================= If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUTS` to -``mopidy.outputs.custom.CustomOutput``, and set the -:attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline -description describing the GStreamer sink you want to use. +``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUT` to a partial +GStreamer pipeline description describing the GStreamer sink you want to use. Example of ``settings.py`` for OSS4:: - OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',) - CUSTOM_OUTPUT = u'oss4sink' + OUTPUT = u'oss4sink' diff --git a/docs/settings.rst b/docs/settings.rst index 980fcd4c..f754bb5e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -157,18 +157,17 @@ server simultaneously. To use the SHOUTcast output, do the following: #. Install, configure and start the Icecast server. It can be found in the ``icecast2`` package in Debian/Ubuntu. -#. Set ``mopidy.outputs.shoutcast.ShoutcastOutput`` as the first output in the - :attr:`mopidy.settings.OUTPUTS` setting. +#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send`` (an ogg-vorbis + encoder could be used instead of lame). -#. Check the default values for the following settings, and alter them to match - your Icecast setup if needed: +#. You might also need to change the shout2send default settings, run + ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely + you want to change ``ip``, ``username``, ``password`` and ``mount``. For + example, to set the password use: ``lame ! shout2send password="s3cret"``. - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` +Other advanced setups are also possible for outputs. Basically anything you can +get a gst-lauch command to output to can be plugged into +:attr:`mopidy.settings.OUTPUT``. Available settings diff --git a/mopidy/outputs.py b/mopidy/outputs.py deleted file mode 100644 index d9619fb8..00000000 --- a/mopidy/outputs.py +++ /dev/null @@ -1,96 +0,0 @@ -import pygst -pygst.require('0.10') -import gst - -from mopidy import settings - - -def custom(): - """ - Custom output for using alternate setups. - - This output is intended to handle two main cases: - - 1. Simple things like switching which sink to use. Say :class:`LocalOutput` - doesn't work for you and you want to switch to ALSA, simple. Set - :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good - to go. Some possible sinks include: - - - alsasink - - osssink - - pulsesink - - ...and many more - - 2. Advanced setups that require complete control of the output bin. For - these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a - :command:`gst-launch` compatible string describing the target setup. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.CUSTOM_OUTPUT` - """ - return gst.parse_bin_from_description(settings.CUSTOM_OUTPUT, True) - - -def local(): - """ - Basic output to local audio sink. - - This output will normally tell GStreamer to choose whatever it thinks is - best for your system. In other words this is usually a sane choice. - - **Dependencies:** - - - None - - **Settings:** - - - None - """ - return gst.parse_bin_from_description('autoaudiosink', True) - - -def shoutcast(): - """ - Shoutcast streaming output. - - This output allows for streaming to an icecast server or anything else that - supports Shoutcast. The output supports setting for: server address, port, - mount point, user, password and encoder to use. Please see - :class:`mopidy.settings` for details about settings. - - **Dependencies:** - - - A SHOUTcast/Icecast server - - **Settings:** - - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` - """ - encoder = settings.SHOUTCAST_OUTPUT_ENCODER - output = gst.parse_bin_from_description( - '%s ! shout2send name=shoutcast' % encoder, True) - - shoutcast = output.get_by_name('shoutcast') - - properties = { - u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME, - u'port': settings.SHOUTCAST_OUTPUT_PORT, - u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, - u'username': settings.SHOUTCAST_OUTPUT_USERNAME, - u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, - } - - for name, value in properties.items(): - shoutcast.set_property(name, value) - - return output diff --git a/mopidy/settings.py b/mopidy/settings.py index fce729d3..0bb04823 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -26,14 +26,6 @@ BACKENDS = ( #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' -#: Which GStreamer bin description to use in -#: :class:`mopidy.outputs.custom.CustomOutput`. -#: -#: Default:: -#: -#: CUSTOM_OUTPUT = u'fakesink' -CUSTOM_OUTPUT = u'fakesink' - #: The log format used for debug logging. #: #: See http://docs.python.org/library/logging.html#formatter-objects for diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index b1234aec..567c7301 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -22,24 +22,18 @@ def import_module(name): return sys.modules[name] -def _get_obj(name): +def get_class(name): logger.debug('Loading: %s', name) if '.' not in name: raise ImportError("Couldn't load: %s" % name) module_name = name[:name.rindex('.')] - obj_name = name[name.rindex('.') + 1:] + cls_name = name[name.rindex('.') + 1:] try: module = import_module(module_name) - obj = getattr(module, obj_name) + cls = getattr(module, cls_name) except (ImportError, AttributeError): raise ImportError("Couldn't load: %s" % name) - return obj - - -# We provide both get_class and get_function to make it more obvious what the -# intent of our code really is. -get_class = _get_obj -get_function = _get_obj + return cls def locale_decode(bytestr): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 65548f33..8060c667 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -112,6 +112,7 @@ def validate_settings(defaults, settings): errors = {} changed = { + 'CUSTOM_OUTPUT': 'OUTPUT', 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', @@ -139,20 +140,23 @@ def validate_settings(defaults, settings): if setting == 'BACKENDS': if 'mopidy.backends.despotify.DespotifyBackend' in value: - errors[setting] = (u'Deprecated setting value. ' + errors[setting] = ( + u'Deprecated setting value. ' u'"mopidy.backends.despotify.DespotifyBackend" is no ' u'longer available.') continue if setting == 'OUTPUTS': - errors[setting] = (u'Deprecated setting, please change to OUTPUT. ' - u'Please note that output values have also changed.') + errors[setting] = ( + u'Deprecated setting, please change to OUTPUT. OUTPUT expectes ' + u'a GStreamer bin describing your desired output.') continue if setting == 'SPOTIFY_BITRATE': if value not in (96, 160, 320): - errors[setting] = (u'Unavailable Spotify bitrate. ' + - u'Available bitrates are 96, 160, and 320.') + errors[setting] = ( + u'Unavailable Spotify bitrate. Available bitrates are 96, ' + u'160, and 320.') if setting not in defaults: errors[setting] = u'Unknown setting. Is it misspelled?' From 6e3e1f997f9ece725910dc6aeafd5a91d2b045ac Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 14:30:13 +0200 Subject: [PATCH 015/177] Convert to only using GStreamer mixers. --- mopidy/core.py | 6 ++-- mopidy/gstreamer.py | 76 +++++++++++++++++++++++++++++++--------- mopidy/settings.py | 43 +++++++++-------------- mopidy/utils/__init__.py | 10 ++++++ mopidy/utils/settings.py | 4 +++ 5 files changed, 93 insertions(+), 46 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 596e0fe5..128b4723 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -106,10 +106,12 @@ def stop_gstreamer(): stop_actors_by_class(GStreamer) def setup_mixer(): - get_class(settings.MIXER).start() + # TODO: remove this hack which is just a stepping stone for our + # refactoring. + get_class('mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer').start() def stop_mixer(): - stop_actors_by_class(get_class(settings.MIXER)) + stop_actors_by_class(get_class('mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer')) def setup_backend(): get_class(settings.BACKENDS[0]).start() diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 03d79265..cf47308e 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -37,13 +37,17 @@ class GStreamer(ThreadingActor): self._source = None self._uridecodebin = None self._output = None + self._mixer = None def on_start(self): self._setup_pipeline() self._setup_output() + self._setup_mixer() self._setup_message_processor() def _setup_pipeline(self): + # TODO: replace with and input bin so we simply have an input bin we + # connect to an output bin with a mixer on the side. set_uri on bin? description = ' ! '.join([ 'uridecodebin name=uri', 'audioconvert name=convert']) @@ -64,6 +68,36 @@ class GStreamer(ThreadingActor): self._output) logger.debug('Output set to %s', settings.OUTPUT) + def _setup_mixer(self): + if not settings.MIXER: + logger.debug('Not adding mixer.') + return + + mixer = gst.element_factory_make(settings.MIXER) + if mixer.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + logger.warning('Adding mixer %r failed.', settings.MIXER) + return + + track = self._select_mixer_track(mixer) + if not track: + logger.warning('Could not find usable mixer track.') + return + + self._mixer = (mixer, track) + logger.info('Mixer set to %s using %s', + mixer.get_factory().get_name(), track.label) + + def _select_mixer_track(self, mixer): + # Look for track with label == MIXER_TRACK, otherwise fallback to + # master track which is also an output. + for track in mixer.list_tracks(): + if settings.MIXER_TRACK: + if track.label == settings.MIXER_TRACK: + return track + elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT): + return track + def _setup_message_processor(self): bus = self._pipeline.get_bus() bus.add_signal_watch() @@ -236,33 +270,41 @@ class GStreamer(ThreadingActor): def get_volume(self): """ - Get volume level of the GStreamer software mixer. + Get volume level of the installed mixer. - :rtype: int in range [0..100] + :rtype: int in range [-1..100] """ - mixers = self._pipeline.iterate_all_by_interface(gst.interfaces.Mixer) - try: - mixer = mixers.next() - except StopIteration: - return 0 - # FIXME this _will_ break for mixers that don't implement - # GstStreamVolume - return int(mixer.get_property('volume') * 100) + if self._mixer is None: + # TODO: add tests for this case and check we propagate change + return -1 + + mixer, track = self._mixer + + volumes = mixer.get_volume(track) + avg_volume = sum(volumes) / len(volumes) + return utils.rescale(avg_volume, + old=(track.min_volume, track.max_volume), + new=(0, 100)) def set_volume(self, volume): """ - Set volume level of the GStreamer software mixer. + Set volume level of the installed mixer. :param volume: the volume in the range [0..100] :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - mixers = self._pipeline.iterate_all_by_interface(gst.interfaces.Mixer) - for mixer in mixers: - # FIXME this _will_ break for mixers that don't implement - # GstStreamVolume - mixer.set_property('volume', volume / 100.0) - return True + if self._mixer is None: + return False + + mixer, track = self._mixer + + volume = utils.rescale(volume, old=(0, 100), + new=(track.min_volume, track.max_volume)) + volumes = (volume,) * track.num_channels + + mixer.set_volume(track, volumes) + return mixer.get_volume(track) == volumes def set_metadata(self, track): """ diff --git a/mopidy/settings.py b/mopidy/settings.py index 0bb04823..e8cedff6 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -103,40 +103,28 @@ LOCAL_PLAYLIST_PATH = None #: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache LOCAL_TAG_CACHE_FILE = None -#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. +#: Sound mixer to use. +#: +#: Expects a GStreamer mixer to use, typical values are: +#: alsamixer, pulsemixer, oss4mixer, ossmixer. +#: +#: Setting this to ``None`` means no volume controll. #: #: Default:: #: -#: MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer' -MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer' +#: MIXER = u'alsamixer' +# TODO: update to an automixer that tries to select correct mixer. +MIXER = u'alsamixer' -#: ALSA mixer only. What mixer control to use. If set to :class:`False`, first -#: ``Master`` and then ``PCM`` will be tried. +#: Sound mixer track to use. #: -#: Example: ``Master Front``. Default: :class:`False` -MIXER_ALSA_CONTROL = False - -#: External mixers only. Which port the mixer is connected to. +#: Name of the mixer track to use. If this is not set we will try to find the +#: output track with master set. #: -#: This must point to the device port like ``/dev/ttyUSB0``. +#: Default:: #: -#: Default: :class:`None` -MIXER_EXT_PORT = None - -#: External mixers only. What input source the external mixer should use. -#: -#: Example: ``Aux``. Default: :class:`None` -MIXER_EXT_SOURCE = None - -#: External mixers only. What state Speakers A should be in. -#: -#: Default: :class:`None`. -MIXER_EXT_SPEAKERS_A = None - -#: External mixers only. What state Speakers B should be in. -#: -#: Default: :class:`None`. -MIXER_EXT_SPEAKERS_B = None +#: MIXER_TRACK = None +MIXER_TRACK = None #: The maximum volume. Integer in the range 0 to 100. #: @@ -146,6 +134,7 @@ MIXER_EXT_SPEAKERS_B = None #: Default:: #: #: MIXER_MAX_VOLUME = 100 +# TODO: re-add support for this. MIXER_MAX_VOLUME = 100 #: Which address Mopidy's MPD server should bind to. diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 567c7301..e35c98a4 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1,3 +1,5 @@ +from __future__ import division + import locale import logging import os @@ -17,6 +19,14 @@ def flatten(the_list): return result +def rescale(v, old=None, new=None): + """Convert value between scales.""" + new_min, new_max = new + old_min, old_max = old + scaled = (new_max - new_min) / (old_max - old_min) * (v - old_min) + new_min + return int(scaled) + + def import_module(name): __import__(name) return sys.modules[name] diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 8060c667..52320099 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -121,6 +121,10 @@ def validate_settings(defaults, settings): 'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', + 'MIXER_ALSA_CONTROL': None, + 'MIXER_EXT_PORT': None, + 'MIXER_EXT_SPEAKERS_A': None, + 'MIXER_EXT_SPEAKERS_B': None, 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', From 5785087c95a1a6997a5b683958a68e5e9b70aa6a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 16:15:55 +0200 Subject: [PATCH 016/177] Add AutoAudioMixer that auto selects a suitable mixer. --- mopidy/gstreamer.py | 81 +++++++++++++++++++++++++++++++++++++++++++++ mopidy/settings.py | 5 ++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index cf47308e..1a80bffc 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -1,5 +1,6 @@ import pygst pygst.require('0.10') +import gobject import gst import logging @@ -13,6 +14,86 @@ from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.gstreamer') +# TODO: we might want to add some ranking to the mixers we know about? +# TODO: move to mixers module and do from mopidy.mixers import * to install +# elements. +class AutoAudioMixer(gst.Element): + __gstdetails__ = ('AutoAudioMixer', + 'Mixer', + 'Element automatically selects a mixer.', + 'Thomas Adamcik') + + def __init__(self): + gst.Element.__init__(self) + self._mixer = self._find_mixer() + self._mixer.set_state(gst.STATE_READY) + logger.debug('AutoAudioMixer choose: %s', self._mixer.get_name()) + + def _find_mixer(self): + registry = gst.registry_get_default() + + factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) + factories.sort(key: lambda f: (-f.get_rank(), f.get_name()) + + for factory in factories: + # Avoid sink/srcs that implment mixing. + if factory.get_klass() != 'Generic/Audio': + continue + # Avoid anything that doesn't implment mixing. + elif not factory.has_interface('GstMixer'): + continue + + element = factory.create() + if not element: + continue + + # Element has devices, try each one. + if hasattr(element, 'probe_get_values_name'): + devices = element.probe_get_values_name('device') + + for device in devices: + element.set_property('device', device) + if self._check_mixer(element): + return element + + # Otherwise just test it as is. + elif self._check_mixer(element): + return element + + def _check_mixer(self, element): + try: + # Only allow elements that succesfully become ready. + result = element.set_state(gst.STATE_READY) + if result != gst.STATE_CHANGE_SUCCESS: + return False + + # Only allow elements that have a least one output track. + output_flag = gst.interfaces.MIXER_TRACK_OUTPUT + return bool(self._find_track(element, output_flag)) + finally: + element.set_state(gst.STATE_NULL) + + def _find_track(self, element, flags): + # Return first track that matches flags. + for track in element.list_tracks(): + if track.flags & flags: + return track + return None + + def list_tracks(self): + return self._mixer.list_tracks() + + def get_volume(self, track): + return self._mixer.get_volume(track) + + def set_volume(self, track, volumes): + return self._mixer.set_volume(track, volumes) + + +gobject.type_register(AutoAudioMixer) +gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) + + class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/settings.py b/mopidy/settings.py index e8cedff6..bebcd24d 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -112,9 +112,8 @@ LOCAL_TAG_CACHE_FILE = None #: #: Default:: #: -#: MIXER = u'alsamixer' -# TODO: update to an automixer that tries to select correct mixer. -MIXER = u'alsamixer' +#: MIXER = u'autoaudiomixer' +MIXER = u'autoaudiomixer' #: Sound mixer track to use. #: From e840bce233899170322236389136d48ec25459a6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 18:00:53 +0200 Subject: [PATCH 017/177] Fix comments from review. --- docs/changes.rst | 2 +- docs/settings.rst | 9 +++++---- mopidy/gstreamer.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 76309461..f8f2d402 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -22,7 +22,7 @@ v0.8 (in development) currently seems to be more trouble than what it is worth. :attr:`mopidy.settings.OUTPUTS` setting is no longer supported, and has been replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer - bin descriped in the same format as gst-launch expects. Default value is + bin described in the same format as ``gst-launch`` expects. Default value is ``autoaudiosink``. diff --git a/docs/settings.rst b/docs/settings.rst index f754bb5e..2f0f0f12 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -157,16 +157,17 @@ server simultaneously. To use the SHOUTcast output, do the following: #. Install, configure and start the Icecast server. It can be found in the ``icecast2`` package in Debian/Ubuntu. -#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send`` (an ogg-vorbis +#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send`` (an Ogg Vorbis encoder could be used instead of lame). -#. You might also need to change the shout2send default settings, run +#. You might also need to change the ``shout2send`` default settings, run ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely you want to change ``ip``, ``username``, ``password`` and ``mount``. For - example, to set the password use: ``lame ! shout2send password="s3cret"``. + example, to set the password use: + ``lame ! shout2send username="foobar" password="s3cret"``. Other advanced setups are also possible for outputs. Basically anything you can -get a gst-lauch command to output to can be plugged into +get a ``gst-lauch`` command to output to can be plugged into :attr:`mopidy.settings.OUTPUT``. diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 52fe079e..0dd02937 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -7,7 +7,7 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, utils +from mopidy import settings from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.gstreamer') From b7e59c9cef9cc3cbce49b1fa608d88d9cb69f9eb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 18:10:49 +0200 Subject: [PATCH 018/177] Fix comments from review. --- mopidy/gstreamer.py | 16 ++++++++++------ mopidy/settings.py | 5 +++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index d157ee9d..15346939 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -36,7 +36,7 @@ class AutoAudioMixer(gst.Element): factories.sort(key: lambda f: (-f.get_rank(), f.get_name()) for factory in factories: - # Avoid sink/srcs that implment mixing. + # Avoid sink/srcs that implment mixing. if factory.get_klass() != 'Generic/Audio': continue # Avoid anything that doesn't implment mixing. @@ -353,6 +353,10 @@ class GStreamer(ThreadingActor): """ Get volume level of the installed mixer. + 0 == muted. + 100 == max volume for given system. + -1 == no mixer present, i.e. volume unknown. + :rtype: int in range [-1..100] """ if self._mixer is None: @@ -364,8 +368,7 @@ class GStreamer(ThreadingActor): volumes = mixer.get_volume(track) avg_volume = sum(volumes) / len(volumes) return utils.rescale(avg_volume, - old=(track.min_volume, track.max_volume), - new=(0, 100)) + old=(track.min_volume, track.max_volume), new=(0, 100)) def set_volume(self, volume): """ @@ -380,11 +383,12 @@ class GStreamer(ThreadingActor): mixer, track = self._mixer - volume = utils.rescale(volume, old=(0, 100), - new=(track.min_volume, track.max_volume)) - volumes = (volume,) * track.num_channels + volume = utils.rescale(volume, + old=(0, 100), new=(track.min_volume, track.max_volume)) + volumes = (volume,) * track.num_channels mixer.set_volume(track, volumes) + return mixer.get_volume(track) == volumes def set_metadata(self, track): diff --git a/mopidy/settings.py b/mopidy/settings.py index bebcd24d..1d5ec330 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -108,7 +108,7 @@ LOCAL_TAG_CACHE_FILE = None #: Expects a GStreamer mixer to use, typical values are: #: alsamixer, pulsemixer, oss4mixer, ossmixer. #: -#: Setting this to ``None`` means no volume controll. +#: Setting this to ``None`` means no volume control. #: #: Default:: #: @@ -118,7 +118,8 @@ MIXER = u'autoaudiomixer' #: Sound mixer track to use. #: #: Name of the mixer track to use. If this is not set we will try to find the -#: output track with master set. +#: output track with master set. As an example, using ``alsamixer`` you would +#: typically set this to ``Master`` or ``PCM``. #: #: Default:: #: From b2caad4d8c82d33f2c0e1a5e2a749d5998e1f0e4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 19:05:59 +0200 Subject: [PATCH 019/177] Implement Mixer interface properly. --- mopidy/gstreamer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 15346939..2a377443 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -17,7 +17,7 @@ logger = logging.getLogger('mopidy.gstreamer') # TODO: we might want to add some ranking to the mixers we know about? # TODO: move to mixers module and do from mopidy.mixers import * to install # elements. -class AutoAudioMixer(gst.Element): +class AutoAudioMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): __gstdetails__ = ('AutoAudioMixer', 'Mixer', 'Element automatically selects a mixer.', @@ -89,6 +89,9 @@ class AutoAudioMixer(gst.Element): def set_volume(self, track, volumes): return self._mixer.set_volume(track, volumes) + def set_record(self, track, record): + pass + gobject.type_register(AutoAudioMixer) gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) From b7734f6a766b88cdb5a506829d1330d30615ccc8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Aug 2012 19:47:45 +0200 Subject: [PATCH 020/177] Return None for unknown volume. --- mopidy/gstreamer.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 2a377443..e5d7337b 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -356,15 +356,14 @@ class GStreamer(ThreadingActor): """ Get volume level of the installed mixer. - 0 == muted. - 100 == max volume for given system. - -1 == no mixer present, i.e. volume unknown. + 0 == muted. + 100 == max volume for given system. + None == no mixer present, i.e. volume unknown. - :rtype: int in range [-1..100] + :rtype: int in range [0..100] """ if self._mixer is None: - # TODO: add tests for this case and check we propagate change - return -1 + return None mixer, track = self._mixer From 0f5bf655a0215f9f0d467096853511124a671884 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 31 Aug 2012 00:52:30 +0200 Subject: [PATCH 021/177] Fix import and factory sort code. --- mopidy/gstreamer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index e5d7337b..84b540a6 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -8,7 +8,7 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings +from mopidy import settings, utils from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.gstreamer') @@ -33,7 +33,7 @@ class AutoAudioMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer) registry = gst.registry_get_default() factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) - factories.sort(key: lambda f: (-f.get_rank(), f.get_name()) + factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) for factory in factories: # Avoid sink/srcs that implment mixing. From 5fcc4e67aa12eff00bfa3d56109bbde6f01ff59a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 21:55:01 +0200 Subject: [PATCH 022/177] Add --list-deps command with Pykka adapter --- mopidy/core.py | 4 ++++ mopidy/utils/deps.py | 43 ++++++++++++++++++++++++++++++++++++++++ tests/utils/deps_test.py | 27 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 mopidy/utils/deps.py create mode 100644 tests/utils/deps_test.py diff --git a/mopidy/core.py b/mopidy/core.py index 596e0fe5..9ae461d8 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -22,6 +22,7 @@ from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.gstreamer import GStreamer from mopidy.utils import get_class +from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.process import (exit_handler, stop_remaining_actors, @@ -77,6 +78,9 @@ def parse_options(): parser.add_option('--list-settings', action='callback', callback=list_settings_optparse_callback, help='list current settings') + parser.add_option('--list-deps', + action='callback', callback=list_deps_optparse_callback, + help='list dependencies and their versions') return parser.parse_args(args=mopidy_args)[0] def check_old_folders(): diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py new file mode 100644 index 00000000..7e6bdc11 --- /dev/null +++ b/mopidy/utils/deps.py @@ -0,0 +1,43 @@ +import sys + +import pykka + + +def list_deps_optparse_callback(*args): + """ + Prints a list of all dependencies. + + Called by optparse when Mopidy is run with the :option:`--list-deps` + option. + """ + print format_dependency_list() + sys.exit(0) + + +def format_dependency_list(adapters=None): + if adapters is None: + adapters = [ + pykka_info, + ] + + lines = [] + for adapter in adapters: + dep_info = adapter() + lines.append('%(name)s: %(version)s' % dep_info) + if 'path' in dep_info: + lines.append(' Imported from: %(path)s' % dep_info) + return '\n'.join(lines) + + +def pykka_info(): + if hasattr(pykka, '__version__'): + # Pykka >= 0.14 + version = pykka.__version__ + else: + # Pykka < 0.14 + version = pykka.get_version() + return { + 'name': 'Pykka', + 'version': version, + 'path': pykka.__file__, + } diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py new file mode 100644 index 00000000..f6d8c942 --- /dev/null +++ b/tests/utils/deps_test.py @@ -0,0 +1,27 @@ +import pykka + +from mopidy.utils import deps + +from tests import unittest + + +class DepsTest(unittest.TestCase): + def test_format_dependency_list(self): + adapters = [ + lambda: dict(name='Python', version='FooPython 2.7.3'), + lambda: dict(name='Platform', version='Loonix 4.0.1'), + lambda: dict(name='Pykka', version='0.1337', path='/foo/bar/baz') + ] + + result = deps.format_dependency_list(adapters) + + self.assertIn('Python: FooPython 2.7.3', result) + self.assertIn('Platform: Loonix 4.0.1', result) + self.assertIn('Imported from: /foo/bar/baz', result) + + def test_pykka_info(self): + result = deps.pykka_info() + + self.assertEquals('Pykka', result['name']) + self.assertEquals(pykka.__version__, result['version']) + self.assertIn('pykka', result['path']) From a25e7d95306a02ab52011cf383a9b28b9c3b39d7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 22:14:02 +0200 Subject: [PATCH 023/177] Add pyspotify adapter for --list-deps --- mopidy/utils/deps.py | 27 ++++++++++++++++++++++++++- tests/utils/deps_test.py | 14 +++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 7e6bdc11..febca8dc 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -2,6 +2,8 @@ import sys import pykka +from mopidy.utils.log import indent + def list_deps_optparse_callback(*args): """ @@ -18,14 +20,21 @@ def format_dependency_list(adapters=None): if adapters is None: adapters = [ pykka_info, + pyspotify_info, ] lines = [] for adapter in adapters: dep_info = adapter() - lines.append('%(name)s: %(version)s' % dep_info) + lines.append('%(name)s: %(version)s' % { + 'name': dep_info['name'], + 'version': dep_info.get('version', 'not found'), + }) if 'path' in dep_info: lines.append(' Imported from: %(path)s' % dep_info) + if 'other' in dep_info: + lines.append(' Other: %s' % ( + indent(dep_info['other'])),) return '\n'.join(lines) @@ -41,3 +50,19 @@ def pykka_info(): 'version': version, 'path': pykka.__file__, } + + +def pyspotify_info(): + dep_info = {'name': 'pyspotify'} + try: + import spotify + if hasattr(spotify, '__version__'): + dep_info['version'] = spotify.__version__ + else: + dep_info['version'] = '< 1.3' + dep_info['path'] = spotify.__file__ + dep_info['other'] = 'Built for libspotify API version %d' % ( + spotify.api_version,) + except ImportError: + pass + return dep_info diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index f6d8c942..a2d07b18 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -1,4 +1,5 @@ import pykka +import spotify from mopidy.utils import deps @@ -10,14 +11,16 @@ class DepsTest(unittest.TestCase): adapters = [ lambda: dict(name='Python', version='FooPython 2.7.3'), lambda: dict(name='Platform', version='Loonix 4.0.1'), - lambda: dict(name='Pykka', version='0.1337', path='/foo/bar/baz') + lambda: dict(name='Pykka', path='/foo/bar/baz', other='Quux') ] result = deps.format_dependency_list(adapters) self.assertIn('Python: FooPython 2.7.3', result) self.assertIn('Platform: Loonix 4.0.1', result) + self.assertIn('Pykka: not found', result) self.assertIn('Imported from: /foo/bar/baz', result) + self.assertIn('Quux', result) def test_pykka_info(self): result = deps.pykka_info() @@ -25,3 +28,12 @@ class DepsTest(unittest.TestCase): self.assertEquals('Pykka', result['name']) self.assertEquals(pykka.__version__, result['version']) self.assertIn('pykka', result['path']) + + def test_pyspotify_info(self): + result = deps.pyspotify_info() + + self.assertEquals('pyspotify', result['name']) + self.assertEquals(spotify.__version__, result['version']) + self.assertIn('spotify', result['path']) + self.assertIn('Built for libspotify API version', result['other']) + self.assertIn(str(spotify.api_version), result['other']) From 4c6a6af487c95051be281d2bfc41a758a22acac8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 22:40:08 +0200 Subject: [PATCH 024/177] Add Gstreamer adapter for --list-deps --- mopidy/utils/deps.py | 15 +++++++++++++++ tests/utils/deps_test.py | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index febca8dc..1b4d8be8 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -1,5 +1,9 @@ import sys +import pygst +pygst.require('0.10') +import gst + import pykka from mopidy.utils.log import indent @@ -19,6 +23,7 @@ def list_deps_optparse_callback(*args): def format_dependency_list(adapters=None): if adapters is None: adapters = [ + gstreamer_info, pykka_info, pyspotify_info, ] @@ -38,6 +43,16 @@ def format_dependency_list(adapters=None): return '\n'.join(lines) +def gstreamer_info(): + return { + 'name': 'Gstreamer', + 'version': '.'.join(map(str, gst.get_gst_version())), + 'path': gst.__file__, + 'other': 'Python wrapper: gst-python %s' % ( + '.'.join(map(str, gst.get_pygst_version()))), + } + + def pykka_info(): if hasattr(pykka, '__version__'): # Pykka >= 0.14 diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index a2d07b18..acf29b29 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -1,3 +1,7 @@ +import pygst +pygst.require('0.10') +import gst + import pykka import spotify @@ -22,6 +26,15 @@ class DepsTest(unittest.TestCase): self.assertIn('Imported from: /foo/bar/baz', result) self.assertIn('Quux', result) + def test_gstreamer_info(self): + result = deps.gstreamer_info() + + self.assertEquals('Gstreamer', result['name']) + self.assertEquals('.'.join(map(str, gst.get_gst_version())), result['version']) + self.assertIn('gst', result['path']) + self.assertIn('Python wrapper: gst-python', result['other']) + self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) + def test_pykka_info(self): result = deps.pykka_info() From 4284e08d699cdd528d389f56e77d1c37b6bd05cd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 22:46:18 +0200 Subject: [PATCH 025/177] Strip file name from printed import paths --- mopidy/utils/deps.py | 4 +++- tests/utils/deps_test.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 1b4d8be8..37f76c27 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -1,3 +1,4 @@ +import os import sys import pygst @@ -36,7 +37,8 @@ def format_dependency_list(adapters=None): 'version': dep_info.get('version', 'not found'), }) if 'path' in dep_info: - lines.append(' Imported from: %(path)s' % dep_info) + lines.append(' Imported from: %s' % ( + os.path.dirname(dep_info['path']))) if 'other' in dep_info: lines.append(' Other: %s' % ( indent(dep_info['other'])),) diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index acf29b29..67ca11ae 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -15,7 +15,7 @@ class DepsTest(unittest.TestCase): adapters = [ lambda: dict(name='Python', version='FooPython 2.7.3'), lambda: dict(name='Platform', version='Loonix 4.0.1'), - lambda: dict(name='Pykka', path='/foo/bar/baz', other='Quux') + lambda: dict(name='Pykka', path='/foo/bar/baz.py', other='Quux') ] result = deps.format_dependency_list(adapters) @@ -23,7 +23,8 @@ class DepsTest(unittest.TestCase): self.assertIn('Python: FooPython 2.7.3', result) self.assertIn('Platform: Loonix 4.0.1', result) self.assertIn('Pykka: not found', result) - self.assertIn('Imported from: /foo/bar/baz', result) + self.assertIn('Imported from: /foo/bar', result) + self.assertNotIn('/baz.py', result) self.assertIn('Quux', result) def test_gstreamer_info(self): From a661b6d8486d1a6b0ba9e9d7e5b6156bde9f86c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 22:49:59 +0200 Subject: [PATCH 026/177] Add pylast adapter for --list-deps --- mopidy/utils/deps.py | 12 ++++++++++++ tests/utils/deps_test.py | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 37f76c27..f72f9cc1 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -27,6 +27,7 @@ def format_dependency_list(adapters=None): gstreamer_info, pykka_info, pyspotify_info, + pylast_info, ] lines = [] @@ -83,3 +84,14 @@ def pyspotify_info(): except ImportError: pass return dep_info + + +def pylast_info(): + dep_info = {'name': 'pylast'} + try: + import pylast + dep_info['version'] = pylast.__version__ + dep_info['path'] = pylast.__file__ + except ImportError: + pass + return dep_info diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 67ca11ae..4df31eef 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -3,6 +3,7 @@ pygst.require('0.10') import gst import pykka +import pylast import spotify from mopidy.utils import deps @@ -51,3 +52,10 @@ class DepsTest(unittest.TestCase): self.assertIn('spotify', result['path']) self.assertIn('Built for libspotify API version', result['other']) self.assertIn(str(spotify.api_version), result['other']) + + def test_pylast_info(self): + result = deps.pylast_info() + + self.assertEquals('pylast', result['name']) + self.assertEquals(pylast.__version__, result['version']) + self.assertIn('pylast', result['path']) From 6e01b320d79c07649e0e501039bd4ee3232dd4d6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 22:57:29 +0200 Subject: [PATCH 027/177] Skip tests for unavailable optional deps --- tests/utils/deps_test.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 4df31eef..a0b4fe07 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -1,10 +1,17 @@ import pygst pygst.require('0.10') import gst - import pykka -import pylast -import spotify + +try: + import pylast +except ImportError: + pylast = False + +try: + import spotify +except ImportError: + spotify = False from mopidy.utils import deps @@ -44,6 +51,7 @@ class DepsTest(unittest.TestCase): self.assertEquals(pykka.__version__, result['version']) self.assertIn('pykka', result['path']) + @unittest.skipUnless(spotify, 'pyspotify not found') def test_pyspotify_info(self): result = deps.pyspotify_info() @@ -53,6 +61,7 @@ class DepsTest(unittest.TestCase): self.assertIn('Built for libspotify API version', result['other']) self.assertIn(str(spotify.api_version), result['other']) + @unittest.skipUnless(pylast, 'pylast not found') def test_pylast_info(self): result = deps.pylast_info() From 240ab0d2269b5aba51e4f2d3f6aff8a9f0e652a1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 22:58:26 +0200 Subject: [PATCH 028/177] Add dbus adapter for --list-deps --- mopidy/utils/deps.py | 12 ++++++++++++ tests/utils/deps_test.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index f72f9cc1..ffb69f5b 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -28,6 +28,7 @@ def format_dependency_list(adapters=None): pykka_info, pyspotify_info, pylast_info, + dbus_info, ] lines = [] @@ -95,3 +96,14 @@ def pylast_info(): except ImportError: pass return dep_info + + +def dbus_info(): + dep_info = {'name': 'dbus-python'} + try: + import dbus + dep_info['version'] = dbus.__version__ + dep_info['path'] = dbus.__file__ + except ImportError: + pass + return dep_info diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index a0b4fe07..988e2e84 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -3,6 +3,11 @@ pygst.require('0.10') import gst import pykka +try: + import dbus +except ImportError: + dbus = False + try: import pylast except ImportError: @@ -68,3 +73,11 @@ class DepsTest(unittest.TestCase): self.assertEquals('pylast', result['name']) self.assertEquals(pylast.__version__, result['version']) self.assertIn('pylast', result['path']) + + @unittest.skipUnless(dbus, 'dbus not found') + def test_dbus_info(self): + result = deps.dbus_info() + + self.assertEquals('dbus-python', result['name']) + self.assertEquals(dbus.__version__, result['version']) + self.assertIn('dbus', result['path']) From 44b63070468876486834f539a3b85b4bd1280e11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 23:13:15 +0200 Subject: [PATCH 029/177] Add pyserial adapter for --list-deps --- mopidy/utils/deps.py | 12 ++++++++++++ tests/utils/deps_test.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index ffb69f5b..8403c400 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -29,6 +29,7 @@ def format_dependency_list(adapters=None): pyspotify_info, pylast_info, dbus_info, + serial_info, ] lines = [] @@ -107,3 +108,14 @@ def dbus_info(): except ImportError: pass return dep_info + + +def serial_info(): + dep_info = {'name': 'pyserial'} + try: + import serial + dep_info['version'] = serial.VERSION + dep_info['path'] = serial.__file__ + except ImportError: + pass + return dep_info diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 988e2e84..968d23b0 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -13,6 +13,11 @@ try: except ImportError: pylast = False +try: + import serial +except ImportError: + serial = False + try: import spotify except ImportError: @@ -81,3 +86,11 @@ class DepsTest(unittest.TestCase): self.assertEquals('dbus-python', result['name']) self.assertEquals(dbus.__version__, result['version']) self.assertIn('dbus', result['path']) + + @unittest.skipUnless(serial, 'serial not found') + def test_serial_info(self): + result = deps.serial_info() + + self.assertEquals('pyserial', result['name']) + self.assertEquals(serial.VERSION, result['version']) + self.assertIn('serial', result['path']) From 05c935bc4c24fe8ebb8c6002a67dc33a5171312f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 31 Aug 2012 23:59:53 +0200 Subject: [PATCH 030/177] Add Python impl adapter for --list-deps --- mopidy/utils/deps.py | 11 +++++++++++ tests/utils/deps_test.py | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 8403c400..f0755202 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -1,4 +1,5 @@ import os +import platform import sys import pygst @@ -24,6 +25,7 @@ def list_deps_optparse_callback(*args): def format_dependency_list(adapters=None): if adapters is None: adapters = [ + python_info, gstreamer_info, pykka_info, pyspotify_info, @@ -48,6 +50,15 @@ def format_dependency_list(adapters=None): return '\n'.join(lines) +def python_info(): + return { + 'name': 'Python', + 'version': '%s %s' % (platform.python_implementation(), + platform.python_version()), + 'path': platform.__file__, + } + + def gstreamer_info(): return { 'name': 'Gstreamer', diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 968d23b0..18ec2fe9 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -1,3 +1,5 @@ +import platform + import pygst pygst.require('0.10') import gst @@ -45,6 +47,14 @@ class DepsTest(unittest.TestCase): self.assertNotIn('/baz.py', result) self.assertIn('Quux', result) + def test_python_info(self): + result = deps.python_info() + + self.assertEquals('Python', result['name']) + self.assertIn(platform.python_implementation(), result['version']) + self.assertIn(platform.python_version(), result['version']) + self.assertIn('python', result['path']) + def test_gstreamer_info(self): result = deps.gstreamer_info() From 41853dd3d878b46a617db03f26180f50a20ad1cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 00:00:14 +0200 Subject: [PATCH 031/177] Add platform adapter for --list-deps --- mopidy/utils/deps.py | 8 ++++++++ tests/utils/deps_test.py | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index f0755202..f2b89840 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -25,6 +25,7 @@ def list_deps_optparse_callback(*args): def format_dependency_list(adapters=None): if adapters is None: adapters = [ + platform_info, python_info, gstreamer_info, pykka_info, @@ -50,6 +51,13 @@ def format_dependency_list(adapters=None): return '\n'.join(lines) +def platform_info(): + return { + 'name': 'Platform', + 'version': platform.platform(), + } + + def python_info(): return { 'name': 'Python', diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 18ec2fe9..26ef7c5c 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -47,6 +47,12 @@ class DepsTest(unittest.TestCase): self.assertNotIn('/baz.py', result) self.assertIn('Quux', result) + def test_platform_info(self): + result = deps.platform_info() + + self.assertEquals('Platform', result['name']) + self.assertIn(platform.platform(), result['version']) + def test_python_info(self): result = deps.python_info() From d712551c3f346eb17b42e0000c6197d3e340a00c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 00:16:39 +0200 Subject: [PATCH 032/177] Add list of Gstreamer elements to checck in --list-deps --- mopidy/utils/deps.py | 57 ++++++++++++++++++++++++++++++++++++++-- tests/utils/deps_test.py | 1 + 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index f2b89840..13b48dc0 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -68,15 +68,68 @@ def python_info(): def gstreamer_info(): + other = [] + other.append('Python wrapper: gst-python %s' % ( + '.'.join(map(str, gst.get_pygst_version())))) + other.append('Elements:') + for name, status in _gstreamer_check_elements(): + other.append(' %s: %s' % (name, status)) return { 'name': 'Gstreamer', 'version': '.'.join(map(str, gst.get_gst_version())), 'path': gst.__file__, - 'other': 'Python wrapper: gst-python %s' % ( - '.'.join(map(str, gst.get_pygst_version()))), + 'other': '\n'.join(other), } +def _gstreamer_check_elements(): + elements_to_check = [ + # Core playback + 'uridecodebin', + + # External HTTP streams + 'souphttpsrc', + + # Spotify + 'appsrc', + + # Mixers and sinks + 'alsamixer', + 'alsasink', + 'ossmixer', + 'osssink', + 'oss4mixer', + 'oss4sink', + 'pulsemixer', + 'pulsesink', + + # MP3 encoding and decoding + 'mp3parse', + 'mad', + 'id3demux', + 'id3v2mux', + 'lame', + + # Ogg Vorbis encoding and decoding + 'vorbisdec', + 'vorbisenc', + 'vorbisparse', + 'oggdemux', + 'oggmux', + 'oggparse', + + # Flac decoding + 'flacdec', + 'flacparse', + + # Shoutcast output + 'shout2send', + ] + known_elements = [factory.get_name() for factory in + gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] + return [(element, element in known_elements) for element in elements_to_check] + + def pykka_info(): if hasattr(pykka, '__version__'): # Pykka >= 0.14 diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 26ef7c5c..9c623da0 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -69,6 +69,7 @@ class DepsTest(unittest.TestCase): self.assertIn('gst', result['path']) self.assertIn('Python wrapper: gst-python', result['other']) self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) + self.assertIn('Elements:', result['other']) def test_pykka_info(self): result = deps.pykka_info() From 45086fb11dbe37a1c493b2124147b514a3009d0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 00:20:53 +0200 Subject: [PATCH 033/177] Update changelog with --list-deps option (fixes #74) --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 4dcc8c57..2c697ca4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,10 @@ v0.8 (in development) Track position and CPID was intermixed, so it would cause a crash if a CPID matching the track position didn't exist. (Fixes: :issue:`162`) +- Added :option:`--list-deps` option to :cmd:`mopidy` command that lists + required and optional dependencies, their current versions, and some other + information useful for debugging. (Fixes: :issue:`74`) + v0.7.3 (2012-08-11) =================== From a452c49bd3ed4c55658440dc17d58e8f8b1402ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 00:49:12 +0200 Subject: [PATCH 034/177] We're only considering a small subset of the Gstreamer elements --- mopidy/utils/deps.py | 2 +- tests/utils/deps_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 13b48dc0..40b53174 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -71,7 +71,7 @@ def gstreamer_info(): other = [] other.append('Python wrapper: gst-python %s' % ( '.'.join(map(str, gst.get_pygst_version())))) - other.append('Elements:') + other.append('Relevant elements:') for name, status in _gstreamer_check_elements(): other.append(' %s: %s' % (name, status)) return { diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 9c623da0..9898b59f 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -69,7 +69,7 @@ class DepsTest(unittest.TestCase): self.assertIn('gst', result['path']) self.assertIn('Python wrapper: gst-python', result['other']) self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) - self.assertIn('Elements:', result['other']) + self.assertIn('Relevant elements:', result['other']) def test_pykka_info(self): result = deps.pykka_info() From f6cea72bf713796c274b9e475305c688adb2f86f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 00:49:47 +0200 Subject: [PATCH 035/177] Print 'OK' or 'not found' instead of True/False --- mopidy/utils/deps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 40b53174..7fce55db 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -73,7 +73,7 @@ def gstreamer_info(): '.'.join(map(str, gst.get_pygst_version())))) other.append('Relevant elements:') for name, status in _gstreamer_check_elements(): - other.append(' %s: %s' % (name, status)) + other.append(' %s: %s' % (name, 'OK' if status else 'not found')) return { 'name': 'Gstreamer', 'version': '.'.join(map(str, gst.get_gst_version())), From 703141c15b214edaff14a73a476db5def3472042 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 01:32:52 +0200 Subject: [PATCH 036/177] Make sure bad data to OUTPUT does not deadlock. - Moves GStreamer initialization out of on-start as it is not obvious to me how to stop rest of setup on other ways. - Note that gst.GError != gobject.GError as far as except is concerned. --- mopidy/core.py | 4 +++- mopidy/gstreamer.py | 13 +++++++++++-- tests/gstreamer_test.py | 6 +++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 596e0fe5..ddbf9b8b 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -20,7 +20,7 @@ sys.argv[1:] = gstreamer_args from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer +from mopidy.gstreamer import GStreamer, GStreamerError from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file @@ -45,6 +45,8 @@ def main(): loop.run() except SettingsError as e: logger.error(e.message) + except GStreamerError as e: + logger.error(e) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') except Exception as e: diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 0dd02937..8d349fcc 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -1,5 +1,6 @@ import pygst pygst.require('0.10') +import gobject import gst import logging @@ -13,6 +14,10 @@ from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.gstreamer') +class GStreamerError(Exception): + pass + + class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. @@ -39,7 +44,6 @@ class GStreamer(ThreadingActor): self._volume = None self._output = None - def on_start(self): self._setup_pipeline() self._setup_output() self._setup_message_processor() @@ -61,7 +65,12 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) def _setup_output(self): - self._output = gst.parse_bin_from_description(settings.OUTPUT, True) + try: + self._output = gst.parse_bin_from_description(settings.OUTPUT, True) + except gobject.GError as e: + raise GStreamerError('%r while creating %r' % (e.message, + settings.OUTPUT)) + self._pipeline.add(self._output) gst.element_link_many(self._volume, self._output) logger.debug('Output set to %s', settings.OUTPUT) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 012c9002..b370981a 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -14,7 +14,6 @@ class GStreamerTest(unittest.TestCase): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) self.gstreamer = GStreamer() - self.gstreamer.on_start() def prepare_uri(self, uri): self.gstreamer.prepare_change() @@ -71,3 +70,8 @@ class GStreamerTest(unittest.TestCase): @unittest.SkipTest def test_set_position(self): pass # TODO + + @unittest.SkipTest + def test_invalid_output_raises_error(self): + pass # TODO + From 0a86afbe31a42913947f55eef4f7eaca87a3830a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 11:19:46 +0200 Subject: [PATCH 037/177] Add audioresample and queue to pipeline, fixes #159 - Audioresample should perform as a noop in cases where no conversion is needed. In cases where the sink requires a fixed sample rate this will prevent output from breaking. - The queue is needed to ensure that our outputs play nicely and is simply a continuation of the queue that was in our old Output abstraction. --- mopidy/gstreamer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 8d349fcc..ab4fd59b 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -52,6 +52,8 @@ class GStreamer(ThreadingActor): description = ' ! '.join([ 'uridecodebin name=uri', 'audioconvert name=convert', + 'audioresample name=resample', + 'queue name=queue', 'volume name=volume']) logger.debug(u'Setting up base GStreamer pipeline: %s', description) From 387da5842582b950c329277bafb169d8682caaa3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 11:22:05 +0200 Subject: [PATCH 038/177] Deprecate and remove shoutcast settings. I've also verfied that the examples provided will actually work. --- mopidy/settings.py | 54 ---------------------------------------- mopidy/utils/settings.py | 16 ++++++------ 2 files changed, 9 insertions(+), 61 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 0bb04823..e7c5593a 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -184,60 +184,6 @@ MPD_SERVER_MAX_CONNECTIONS = 20 #: OUTPUT = u'autoaudiosink' OUTPUT = u'autoaudiosink' -#: Hostname of the SHOUTcast server which Mopidy should stream audio to. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' -SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' - -#: Port of the SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_PORT = 8000 -SHOUTCAST_OUTPUT_PORT = 8000 - -#: User to authenticate as against SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_USERNAME = u'source' -SHOUTCAST_OUTPUT_USERNAME = u'source' - -#: Password to authenticate with against SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme' -SHOUTCAST_OUTPUT_PASSWORD = u'hackme' - -#: Mountpoint to use for the stream on the SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_MOUNT = u'/stream' -SHOUTCAST_OUTPUT_MOUNT = u'/stream' - -#: Encoder to use to process audio data before streaming to SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' -SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' - #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 8060c667..a07075fb 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -136,29 +136,31 @@ def validate_settings(defaults, settings): else: errors[setting] = u'Deprecated setting. Use %s.' % ( changed[setting],) - continue - if setting == 'BACKENDS': + elif setting == 'BACKENDS': if 'mopidy.backends.despotify.DespotifyBackend' in value: errors[setting] = ( u'Deprecated setting value. ' u'"mopidy.backends.despotify.DespotifyBackend" is no ' u'longer available.') - continue - if setting == 'OUTPUTS': + elif setting == 'OUTPUTS': errors[setting] = ( u'Deprecated setting, please change to OUTPUT. OUTPUT expectes ' u'a GStreamer bin describing your desired output.') - continue - if setting == 'SPOTIFY_BITRATE': + elif setting == 'SPOTIFY_BITRATE': if value not in (96, 160, 320): errors[setting] = ( u'Unavailable Spotify bitrate. Available bitrates are 96, ' u'160, and 320.') - if setting not in defaults: + elif setting.startswith('SHOUTCAST_OUTPUT_'): + errors[setting] = ( + u'Deprecated setting, please set the value via the GStreamer ' + u'bin in OUTPUT.') + + elif setting not in defaults: errors[setting] = u'Unknown setting. Is it misspelled?' continue From 3c2576a6296c1f80dc97851db93d75554490a9c9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 11:46:58 +0200 Subject: [PATCH 039/177] Guess what setting you meant based on levenshtein. --- mopidy/utils/settings.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index ff449a61..ce245f3b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -12,6 +12,7 @@ from mopidy.utils.log import indent logger = logging.getLogger('mopidy.utils.settings') + class SettingsProxy(object): def __init__(self, default_settings_module): self.default = self._get_settings_dict_from_module( @@ -151,11 +152,17 @@ def validate_settings(defaults, settings): u'Available bitrates are 96, 160, and 320.') if setting not in defaults: - errors[setting] = u'Unknown setting. Is it misspelled?' + errors[setting] = u'Unknown setting.' + suggestion = did_you_mean(setting, defaults) + + if suggestion: + errors[setting] += u' Did you mean %s?' % suggestion + continue return errors + def list_settings_optparse_callback(*args): """ Prints a list of all settings. @@ -167,6 +174,7 @@ def list_settings_optparse_callback(*args): print format_settings_list(settings) sys.exit(0) + def format_settings_list(settings): errors = settings.get_errors() lines = [] @@ -181,8 +189,37 @@ def format_settings_list(settings): lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) + def mask_value_if_secret(key, value): if key.endswith('PASSWORD') and value: return u'********' else: return value + + +def did_you_mean(setting, defaults): + """Suggest most likely setting based on levenshtein.""" + candidates = [(levenshtein(setting, d), d) for d in defaults] + candidates.sort() + + if candidates[0][0] <= 3: + return candidates[0][1] + return None + + +def levenshtein(a, b, max=3): + "Calculates the Levenshtein distance between a and b." + n, m = len(a), len(b) + if n > m: + return levenshtein(b, a) + + current = xrange(n+1) + for i in xrange(1,m+1): + previous, current = current, [i]+[0]*n + for j in xrange(1,n+1): + add, delete = previous[j]+1, current[j-1]+1 + change = previous[j-1] + if a[j-1] != b[i-1]: + change = change + 1 + current[j] = min(add, delete, change) + return current[n] From 03a7f03bb8bb808646d6e59b46adbe55eb92fb0c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 12:11:09 +0200 Subject: [PATCH 040/177] Style fixes to levenshtein and did_you_mean. --- mopidy/utils/settings.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index ce245f3b..4de7d1cf 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -208,18 +208,18 @@ def did_you_mean(setting, defaults): def levenshtein(a, b, max=3): - "Calculates the Levenshtein distance between a and b." + """Calculates the Levenshtein distance between a and b.""" n, m = len(a), len(b) if n > m: return levenshtein(b, a) current = xrange(n+1) - for i in xrange(1,m+1): - previous, current = current, [i]+[0]*n - for j in xrange(1,n+1): - add, delete = previous[j]+1, current[j-1]+1 + for i in xrange(1, m+1): + previous, current = current, [i] + [0] * n + for j in xrange(1, n+1): + add, delete = previous[j] + 1, current[j-1] + 1 change = previous[j-1] if a[j-1] != b[i-1]: - change = change + 1 + change += 1 current[j] = min(add, delete, change) return current[n] From 4e4a209ec319d20f87d4b7cce6f1a6050b5c8a9e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 12:15:08 +0200 Subject: [PATCH 041/177] Fix existing settings tests that did_you_mean broke. --- mopidy/utils/settings.py | 3 +++ tests/utils/settings_test.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 4de7d1cf..34126907 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -199,6 +199,9 @@ def mask_value_if_secret(key, value): def did_you_mean(setting, defaults): """Suggest most likely setting based on levenshtein.""" + if not defaults: + return None + candidates = [(levenshtein(setting, d), d) for d in defaults] candidates.sort() diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 55e1156b..c129b9b5 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -23,7 +23,7 @@ class ValidateSettingsTest(unittest.TestCase): result = validate_settings(self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) self.assertEqual(result['MPD_SERVER_HOSTNMAE'], - u'Unknown setting. Is it misspelled?') + u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): result = validate_settings(self.defaults, From 1f8289a25616a2d15e21d54119297aba2ebb66d2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 12:22:41 +0200 Subject: [PATCH 042/177] Switch to only importing modules in settings_test. --- tests/utils/settings_test.py | 47 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index c129b9b5..1b0c4a2a 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,8 +1,7 @@ import os -from mopidy import settings as default_settings_module, SettingsError -from mopidy.utils.settings import (format_settings_list, mask_value_if_secret, - SettingsProxy, validate_settings) +import mopidy +from mopidy.utils import settings as setting_utils from tests import unittest @@ -16,29 +15,29 @@ class ValidateSettingsTest(unittest.TestCase): } def test_no_errors_yields_empty_dict(self): - result = validate_settings(self.defaults, {}) + result = setting_utils.validate_settings(self.defaults, {}) self.assertEqual(result, {}) def test_unknown_setting_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) self.assertEqual(result['MPD_SERVER_HOSTNMAE'], u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) self.assertEqual(result['SERVER_HOSTNAME'], u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') def test_unneeded_settings_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) self.assertEqual(result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') def test_deprecated_setting_value_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) self.assertEqual(result['BACKENDS'], u'Deprecated setting value. ' + @@ -46,33 +45,33 @@ class ValidateSettingsTest(unittest.TestCase): 'available.') def test_unavailable_bitrate_setting_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'SPOTIFY_BITRATE': 50}) self.assertEqual(result['SPOTIFY_BITRATE'], u'Unavailable Spotify bitrate. ' + u'Available bitrates are 96, 160, and 320.') def test_two_errors_are_both_reported(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'FOO': '', 'BAR': ''}) self.assertEqual(len(result), 2) def test_masks_value_if_secret(self): - secret = mask_value_if_secret('SPOTIFY_PASSWORD', 'bar') + secret = setting_utils.mask_value_if_secret('SPOTIFY_PASSWORD', 'bar') self.assertEqual(u'********', secret) def test_does_not_mask_value_if_not_secret(self): - not_secret = mask_value_if_secret('SPOTIFY_USERNAME', 'foo') + not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', 'foo') self.assertEqual('foo', not_secret) def test_does_not_mask_value_if_none(self): - not_secret = mask_value_if_secret('SPOTIFY_USERNAME', None) + not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) class SettingsProxyTest(unittest.TestCase): def setUp(self): - self.settings = SettingsProxy(default_settings_module) + self.settings = setting_utils.SettingsProxy(mopidy.settings) self.settings.local.clear() def test_set_and_get_attr(self): @@ -83,7 +82,7 @@ class SettingsProxyTest(unittest.TestCase): try: _ = self.settings.TEST self.fail(u'Should raise exception') - except SettingsError as e: + except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) def test_getattr_raises_error_on_empty_setting(self): @@ -91,7 +90,7 @@ class SettingsProxyTest(unittest.TestCase): try: _ = self.settings.TEST self.fail(u'Should raise exception') - except SettingsError as e: + except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) def test_getattr_does_not_raise_error_if_setting_is_false(self): @@ -177,44 +176,44 @@ class SettingsProxyTest(unittest.TestCase): class FormatSettingListTest(unittest.TestCase): def setUp(self): - self.settings = SettingsProxy(default_settings_module) + self.settings = setting_utils.SettingsProxy(mopidy.settings) def test_contains_the_setting_name(self): self.settings.TEST = u'test' - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_('TEST:' in result, result) def test_repr_of_a_string_value(self): self.settings.TEST = u'test' - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("TEST: u'test'" in result, result) def test_repr_of_an_int_value(self): self.settings.TEST = 123 - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("TEST: 123" in result, result) def test_repr_of_a_tuple_value(self): self.settings.TEST = (123, u'abc') - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("TEST: (123, u'abc')" in result, result) def test_passwords_are_masked(self): self.settings.TEST_PASSWORD = u'secret' - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("TEST_PASSWORD: u'secret'" not in result, result) self.assert_("TEST_PASSWORD: u'********'" in result, result) def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, result) def test_long_values_are_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend') - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("""FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) From e4d425d37aed3ab8a1a7c7b3c1428bb7bf4c03a8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 12:28:32 +0200 Subject: [PATCH 043/177] Add did you mean tests for settings. - Checks varying degrees of typos until the edit distance becomes to large. - Also updated did you mean to always uppercase it's input so we catch caps errors. --- mopidy/utils/settings.py | 1 + tests/utils/settings_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 34126907..4072f24d 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -202,6 +202,7 @@ def did_you_mean(setting, defaults): if not defaults: return None + setting = setting.upper() candidates = [(levenshtein(setting, d), d) for d in defaults] candidates.sort() diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 1b0c4a2a..7d104969 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -217,3 +217,27 @@ class FormatSettingListTest(unittest.TestCase): self.assert_("""FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) + + +class DidYouMeanTest(unittest.TestCase): + def testSuggestoins(self): + defaults = { + 'MPD_SERVER_HOSTNAME': '::', + 'MPD_SERVER_PORT': 6600, + 'SPOTIFY_BITRATE': 160, + } + + suggestion = setting_utils.did_you_mean('spotify_bitrate', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPOTIFY_BITROTE', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPITIFY_BITROT', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPTIFY_BITROT', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPTIFY_BITRO', defaults) + self.assertEqual(suggestion, None) From 5c6dc96f9dd4bcc5c3b72776c4b0b5b560748f94 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 13:07:14 +0200 Subject: [PATCH 044/177] Fix typo --- mopidy/utils/deps.py | 2 +- tests/utils/deps_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 7fce55db..2c68e429 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -75,7 +75,7 @@ def gstreamer_info(): for name, status in _gstreamer_check_elements(): other.append(' %s: %s' % (name, 'OK' if status else 'not found')) return { - 'name': 'Gstreamer', + 'name': 'GStreamer', 'version': '.'.join(map(str, gst.get_gst_version())), 'path': gst.__file__, 'other': '\n'.join(other), diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 9898b59f..f5aa0b1e 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -64,7 +64,7 @@ class DepsTest(unittest.TestCase): def test_gstreamer_info(self): result = deps.gstreamer_info() - self.assertEquals('Gstreamer', result['name']) + self.assertEquals('GStreamer', result['name']) self.assertEquals('.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertIn('Python wrapper: gst-python', result['other']) From 1c77f9178b56b7e25de24b4ceef9a64b416a9886 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Sep 2012 13:33:06 +0200 Subject: [PATCH 045/177] Update changelog with did-you-mean setting matching --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 2c697ca4..8c7e9d1d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -22,6 +22,10 @@ v0.8 (in development) required and optional dependencies, their current versions, and some other information useful for debugging. (Fixes: :issue:`74`) +- When unknown settings are encountered, we now check if it's similar to a + known setting, and suggests to the user what we think the setting should have + been. + v0.7.3 (2012-08-11) =================== From 2d5ba154ed93f80569906051d9abd562fdc949b5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 13:33:41 +0200 Subject: [PATCH 046/177] Switch to module imports and with assertRaises in init_test. --- tests/utils/init_test.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/utils/init_test.py b/tests/utils/init_test.py index 2097e3e6..f232e2ef 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/init_test.py @@ -1,24 +1,27 @@ -from mopidy.utils import get_class +from mopidy import utils from tests import unittest class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): - self.assertRaises(ImportError, get_class, 'foo.bar.Baz') + with self.assertRaises(ImportError): + utils.get_class('foo.bar.Baz') def test_loading_class_that_does_not_exist(self): - self.assertRaises(ImportError, get_class, 'unittest.FooBarBaz') + with self.assertRaises(ImportError): + utils.get_class('unittest.FooBarBaz') def test_loading_incorrect_class_path(self): - self.assertRaises(ImportError, get_class, 'foobarbaz') + with self.assertRaises(ImportError): + utils.get_class('foobarbaz') def test_import_error_message_contains_complete_class_path(self): try: - get_class('foo.bar.Baz') + utils.get_class('foo.bar.Baz') except ImportError as e: self.assert_('foo.bar.Baz' in str(e)) def test_loading_existing_class(self): - cls = get_class('unittest.TestCase') + cls = utils.get_class('unittest.TestCase') self.assertEqual(cls.__name__, 'TestCase') From b796d7c859b7a8f8208aa02d766ac66cb8b0c68f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 13:34:16 +0200 Subject: [PATCH 047/177] Add create fakemixer element for testing. - GStreamer tests now use this instead of a real mixer. - fakemixer and the autoaudiomixer still need to be moved. - We should probably use a fakesink as output as well. --- mopidy/gstreamer.py | 75 +++++++++++++++++++++++++++++++++++++++- mopidy/utils/__init__.py | 2 +- tests/gstreamer_test.py | 1 + 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 60e601f3..17657729 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -21,6 +21,8 @@ class GStreamerError(Exception): # TODO: we might want to add some ranking to the mixers we know about? # TODO: move to mixers module and do from mopidy.mixers import * to install # elements. +# TODO: use gst.Bin so we can add the real mixer and have state sync +# automatically. class AutoAudioMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): __gstdetails__ = ('AutoAudioMixer', 'Mixer', @@ -31,7 +33,7 @@ class AutoAudioMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer) gst.Element.__init__(self) self._mixer = self._find_mixer() self._mixer.set_state(gst.STATE_READY) - logger.debug('AutoAudioMixer choose: %s', self._mixer.get_name()) + logger.debug('AutoAudioMixer chose: %s', self._mixer.get_name()) def _find_mixer(self): registry = gst.registry_get_default() @@ -101,6 +103,77 @@ gobject.type_register(AutoAudioMixer) gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) +def create_fake_track(label, min_volume, max_volume, num_channels, flags): + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (100,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() + +class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): + __gstdetails__ = ('FakeMixer', + 'Mixer', + 'Fake mixer for use in tests.', + 'Thomas Adamcik') + + track_label = gobject.property(type=str, default='Master') + + track_min_volume = gobject.property(type=int, default=0) + + track_max_volume = gobject.property(type=int, default=100) + + track_num_channels = gobject.property(type=int, default=2) + + track_flags = gobject.property(type=int, + default=(gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) + + def __init__(self): + gst.Element.__init__(self) + + def list_tracks(self): + track = create_fake_track(self.track_label, + self.track_min_volume, + self.track_max_volume, + self.track_num_channels, + self.track_flags) + return [track] + + def get_volume(self, track): + return track.volumes + + def set_volume(self, track, volumes): + track.volumes = volumes + + def set_record(self, track, record): + pass + + +gobject.type_register(FakeMixer) +gst.element_register (FakeMixer, 'fakemixer', gst.RANK_MARGINAL) + + class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index e35c98a4..35ec916a 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -8,7 +8,7 @@ import sys logger = logging.getLogger('mopidy.utils') -# TODO: user itertools.chain.from_iterable(the_list)? +# TODO: use itertools.chain.from_iterable(the_list)? def flatten(the_list): result = [] for element in the_list: diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index b370981a..9d33901d 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -12,6 +12,7 @@ from tests import unittest, path_to_data_dir class GStreamerTest(unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + settings.MIXER = 'fakemixer' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) self.gstreamer = GStreamer() From 2b018606805e611e298573e730bc25d12a530244 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 14:01:11 +0200 Subject: [PATCH 048/177] Make it possible to override GStreamer settings in tests. - Specifically you can now pass in values instead of relying on global settings. --- mopidy/gstreamer.py | 34 +++++++++++++++++++--------------- tests/gstreamer_test.py | 5 +++-- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 17657729..74b66410 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -131,6 +131,7 @@ def create_fake_track(label, min_volume, max_volume, num_channels, flags): return Track() + class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): __gstdetails__ = ('FakeMixer', 'Mixer', @@ -181,10 +182,12 @@ class GStreamer(ThreadingActor): **Settings:** - :attr:`mopidy.settings.OUTPUT` + - :attr:`mopidy.settings.MIXER` + - :attr:`mopidy.settings.MIXER_TRACK` """ - def __init__(self): + def __init__(self, output=None, mixer=None, mixer_track=None): super(GStreamer, self).__init__() self._default_caps = gst.Caps(""" audio/x-raw-int, @@ -201,8 +204,9 @@ class GStreamer(ThreadingActor): self._mixer = None self._setup_pipeline() - self._setup_output() - self._setup_mixer() + self._setup_output(output or settings.OUTPUT) + self._setup_mixer(mixer or settings.MIXER, + mixer_track or settings.MIXER_TRACK) self._setup_message_processor() def _setup_pipeline(self): @@ -223,29 +227,29 @@ class GStreamer(ThreadingActor): self._uridecodebin.connect('pad-added', self._on_new_pad, self._pipeline.get_by_name('queue').get_pad('sink')) - def _setup_output(self): + def _setup_output(self, output_description): try: - self._output = gst.parse_bin_from_description(settings.OUTPUT, True) + self._output = gst.parse_bin_from_description(output_description, True) except gobject.GError as e: raise GStreamerError('%r while creating %r' % (e.message, - settings.OUTPUT)) + output_description)) self._pipeline.add(self._output) gst.element_link_many(self._pipeline.get_by_name('queue'), self._output) - logger.debug('Output set to %s', settings.OUTPUT) + logger.debug('Output set to %s', output_description) - def _setup_mixer(self): - if not settings.MIXER: + def _setup_mixer(self, mixer_element, track_label): + if not mixer_element: logger.debug('Not adding mixer.') return - mixer = gst.element_factory_make(settings.MIXER) + mixer = gst.element_factory_make(mixer_element) if mixer.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Adding mixer %r failed.', settings.MIXER) + logger.warning('Adding mixer %r failed.', mixer_element) return - track = self._select_mixer_track(mixer) + track = self._select_mixer_track(mixer, track_label) if not track: logger.warning('Could not find usable mixer track.') return @@ -254,12 +258,12 @@ class GStreamer(ThreadingActor): logger.info('Mixer set to %s using %s', mixer.get_factory().get_name(), track.label) - def _select_mixer_track(self, mixer): + def _select_mixer_track(self, mixer, track_label): # Look for track with label == MIXER_TRACK, otherwise fallback to # master track which is also an output. for track in mixer.list_tracks(): - if settings.MIXER_TRACK: - if track.label == settings.MIXER_TRACK: + if track_label: + if track.label == track_label: return track elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT): diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 9d33901d..a4f740b4 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -11,10 +11,11 @@ from tests import unittest, path_to_data_dir 'Our Windows build server does not support GStreamer yet') class GStreamerTest(unittest.TestCase): def setUp(self): + # TODO: does this modify global settings without reseting it? + # TODO: should use a fake backend stub for this test? settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - settings.MIXER = 'fakemixer' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer() + self.gstreamer = GStreamer(mixer='fakemixer') def prepare_uri(self, uri): self.gstreamer.prepare_change() From 9c30fab959b3a07d9f14d9309bf4a736923f28f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 14:33:32 +0200 Subject: [PATCH 049/177] Switch to using a mixerbin instead of element. - This allows us to set values like ``alsasink device=hw:1`` etc. - Adds an intial volume to our fakemixer. - Minor code cleanup for rescale() calls. --- mopidy/gstreamer.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 74b66410..bc0058dd 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -103,11 +103,12 @@ gobject.type_register(AutoAudioMixer) gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) -def create_fake_track(label, min_volume, max_volume, num_channels, flags): +def create_fake_track(label, intial_volume, min_volume, max_volume, + num_channels, flags): class Track(gst.interfaces.MixerTrack): def __init__(self): super(Track, self).__init__() - self.volumes = (100,) * self.num_channels + self.volumes = (intial_volume,) * self.num_channels @gobject.property def label(self): @@ -140,6 +141,8 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): track_label = gobject.property(type=str, default='Master') + track_initial_volume = gobject.property(type=int, default=0) + track_min_volume = gobject.property(type=int, default=0) track_max_volume = gobject.property(type=int, default=100) @@ -155,6 +158,7 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): def list_tracks(self): track = create_fake_track(self.track_label, + self.track_initial_volume, self.track_min_volume, self.track_max_volume, self.track_num_channels, @@ -244,11 +248,12 @@ class GStreamer(ThreadingActor): logger.debug('Not adding mixer.') return - mixer = gst.element_factory_make(mixer_element) - if mixer.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + mixerbin = gst.parse_bin_from_description(mixer_element, False) + if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: logger.warning('Adding mixer %r failed.', mixer_element) return + mixer = mixerbin.get_by_interface('GstMixer') track = self._select_mixer_track(mixer, track_label) if not track: logger.warning('Could not find usable mixer track.') @@ -454,10 +459,13 @@ class GStreamer(ThreadingActor): mixer, track = self._mixer + volumes = mixer.get_volume(track) - avg_volume = sum(volumes) / len(volumes) - return utils.rescale(avg_volume, - old=(track.min_volume, track.max_volume), new=(0, 100)) + avg_volume = float(sum(volumes)) / len(volumes) + + new_scale = (0, 100) + old_scale = (track.min_volume, track.max_volume) + return utils.rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): """ @@ -472,8 +480,10 @@ class GStreamer(ThreadingActor): mixer, track = self._mixer - volume = utils.rescale(volume, - old=(0, 100), new=(track.min_volume, track.max_volume)) + old_scale = (0, 100) + new_scale = (track.min_volume, track.max_volume) + + volume = utils.rescale(volume, old=old_scale, new=new_scale) volumes = (volume,) * track.num_channels mixer.set_volume(track, volumes) From 40502e41e5860bd3d787c0c738fbf16699611d72 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 14:35:20 +0200 Subject: [PATCH 050/177] Update tests to catch scaling problem in mixer. - Fixes problem where 60% became 59% due to bad rounding. - Tests assume scale of 0-65536 which matches ALSA. - Check all possible values of set_volume and ensure we the right value out. --- mopidy/utils/__init__.py | 4 ++-- tests/gstreamer_test.py | 18 ++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 35ec916a..aacc2e85 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -23,8 +23,8 @@ def rescale(v, old=None, new=None): """Convert value between scales.""" new_min, new_max = new old_min, old_max = old - scaled = (new_max - new_min) / (old_max - old_min) * (v - old_min) + new_min - return int(scaled) + scaling = float(new_max - new_min) / (old_max - old_min) + return round(scaling * (v - old_min) + new_min) def import_module(name): diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index a4f740b4..f30b672b 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -15,7 +15,7 @@ class GStreamerTest(unittest.TestCase): # TODO: should use a fake backend stub for this test? settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer(mixer='fakemixer') + self.gstreamer = GStreamer(mixer='fakemixer track_max_volume=65536') def prepare_uri(self, uri): self.gstreamer.prepare_change() @@ -50,20 +50,10 @@ class GStreamerTest(unittest.TestCase): def test_end_of_data_stream(self): pass # TODO - def test_default_get_volume_result(self): - self.assertEqual(100, self.gstreamer.get_volume()) - def test_set_volume(self): - self.assertTrue(self.gstreamer.set_volume(50)) - self.assertEqual(50, self.gstreamer.get_volume()) - - def test_set_volume_to_zero(self): - self.assertTrue(self.gstreamer.set_volume(0)) - self.assertEqual(0, self.gstreamer.get_volume()) - - def test_set_volume_to_one_hundred(self): - self.assertTrue(self.gstreamer.set_volume(100)) - self.assertEqual(100, self.gstreamer.get_volume()) + for value in range(0, 101): + self.assertTrue(self.gstreamer.set_volume(value)) + self.assertEqual(value, self.gstreamer.get_volume()) @unittest.SkipTest def test_set_state_encapsulation(self): From 03b836ed64d7e6d04f6e70084e04f5cbf3ea7eb9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 15:04:22 +0200 Subject: [PATCH 051/177] Removed autoaudiosink's device selection. The way this code was testing devices locked the element to using the wrong device. The incorrect device had a max volume of 39 on the Master track, really making accurate volume changes impossible. Instead of trying to make any guesses about this I'm leaving it to the element to have sensible defaults. Code will also ensure that it returns a newly created copy of the mixer, not one we have already used. --- mopidy/gstreamer.py | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index bc0058dd..3c5f4ed3 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -49,42 +49,34 @@ class AutoAudioMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer) elif not factory.has_interface('GstMixer'): continue - element = factory.create() - if not element: - continue + if self._test_mixer(factory): + return factory.create() - # Element has devices, try each one. - if hasattr(element, 'probe_get_values_name'): - devices = element.probe_get_values_name('device') + return None - for device in devices: - element.set_property('device', device) - if self._check_mixer(element): - return element + def _test_mixer(self, factory): + element = factory.create() + if not element: + return False - # Otherwise just test it as is. - elif self._check_mixer(element): - return element - - def _check_mixer(self, element): try: - # Only allow elements that succesfully become ready. result = element.set_state(gst.STATE_READY) if result != gst.STATE_CHANGE_SUCCESS: return False - # Only allow elements that have a least one output track. - output_flag = gst.interfaces.MIXER_TRACK_OUTPUT - return bool(self._find_track(element, output_flag)) + # Trust that the default device is sane and just check tracks. + return self._test_tracks(element) finally: element.set_state(gst.STATE_NULL) - def _find_track(self, element, flags): - # Return first track that matches flags. + def _test_tracks(self, element): + # Only allow elements that have a least one output track. + flags = gst.interfaces.MIXER_TRACK_OUTPUT + for track in element.list_tracks(): if track.flags & flags: - return track - return None + return True + return False def list_tracks(self): return self._mixer.list_tracks() @@ -452,7 +444,7 @@ class GStreamer(ThreadingActor): 100 == max volume for given system. None == no mixer present, i.e. volume unknown. - :rtype: int in range [0..100] + :rtype: int in range [0..100] or :class:`None` """ if self._mixer is None: return None From eee3edf7273bb660be70be1fe9b055111be80c33 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 15:21:13 +0200 Subject: [PATCH 052/177] Turn autoaudiomixer into a bin. This allows us to add our sub mixer that we are proxing (not sure if GstChildProxy can be used in Python) so that state changes to the parent propagates nicely. --- mopidy/gstreamer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 3c5f4ed3..de63b702 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -23,16 +23,16 @@ class GStreamerError(Exception): # elements. # TODO: use gst.Bin so we can add the real mixer and have state sync # automatically. -class AutoAudioMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): +class AutoAudioMixer(gst.Bin, gst.ImplementsInterface, gst.interfaces.Mixer): __gstdetails__ = ('AutoAudioMixer', 'Mixer', 'Element automatically selects a mixer.', 'Thomas Adamcik') def __init__(self): - gst.Element.__init__(self) + gst.Bin.__init__(self) self._mixer = self._find_mixer() - self._mixer.set_state(gst.STATE_READY) + self.add(self._mixer) logger.debug('AutoAudioMixer chose: %s', self._mixer.get_name()) def _find_mixer(self): From 036bf2ab24d80b974a290a955913e2b5e41f353e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 15:49:24 +0200 Subject: [PATCH 053/177] Update code to handle case where AutoAudioMixer fails to find a mixer. This change implies removing the GstMixer interface from the autoaudiomixer, which allows us to check if a mixer was found in a generic way by using get_by_interface. This also means we get direct access to the child mixer so the proxying code is no longer needed. --- mopidy/gstreamer.py | 50 +++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index de63b702..e14f4f78 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -21,9 +21,7 @@ class GStreamerError(Exception): # TODO: we might want to add some ranking to the mixers we know about? # TODO: move to mixers module and do from mopidy.mixers import * to install # elements. -# TODO: use gst.Bin so we can add the real mixer and have state sync -# automatically. -class AutoAudioMixer(gst.Bin, gst.ImplementsInterface, gst.interfaces.Mixer): +class AutoAudioMixer(gst.Bin): __gstdetails__ = ('AutoAudioMixer', 'Mixer', 'Element automatically selects a mixer.', @@ -31,9 +29,12 @@ class AutoAudioMixer(gst.Bin, gst.ImplementsInterface, gst.interfaces.Mixer): def __init__(self): gst.Bin.__init__(self) - self._mixer = self._find_mixer() - self.add(self._mixer) - logger.debug('AutoAudioMixer chose: %s', self._mixer.get_name()) + mixer = self._find_mixer() + if mixer: + self.add(mixer) + logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) + else: + logger.debug('AutoAudioMixer did not find any usable mixers') def _find_mixer(self): registry = gst.registry_get_default() @@ -78,18 +79,6 @@ class AutoAudioMixer(gst.Bin, gst.ImplementsInterface, gst.interfaces.Mixer): return True return False - def list_tracks(self): - return self._mixer.list_tracks() - - def get_volume(self, track): - return self._mixer.get_volume(track) - - def set_volume(self, track, volumes): - return self._mixer.set_volume(track, volumes) - - def set_record(self, track, record): - pass - gobject.type_register(AutoAudioMixer) gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) @@ -233,26 +222,34 @@ class GStreamer(ThreadingActor): self._pipeline.add(self._output) gst.element_link_many(self._pipeline.get_by_name('queue'), self._output) - logger.debug('Output set to %s', output_description) + logger.info('Output set to %s', output_description) - def _setup_mixer(self, mixer_element, track_label): - if not mixer_element: + def _setup_mixer(self, mixer_bin_description, track_label): + if not mixer_bin_description: logger.debug('Not adding mixer.') return - mixerbin = gst.parse_bin_from_description(mixer_element, False) - if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Adding mixer %r failed.', mixer_element) + mixerbin = gst.parse_bin_from_description(mixer_bin_description, False) + + # We assume that the bin will contain a single mixer. + mixer = mixerbin.get_by_interface('GstMixer') + if not mixer: + logger.warning('Did not find any mixers in %r', + mixer_bin_description) + return + + if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + logger.warning('Setting mixer %r to READY failed.', + mixer_bin_description) return - mixer = mixerbin.get_by_interface('GstMixer') track = self._select_mixer_track(mixer, track_label) if not track: logger.warning('Could not find usable mixer track.') return self._mixer = (mixer, track) - logger.info('Mixer set to %s using %s', + logger.info('Mixer set to %s using track called %s', mixer.get_factory().get_name(), track.label) def _select_mixer_track(self, mixer, track_label): @@ -451,7 +448,6 @@ class GStreamer(ThreadingActor): mixer, track = self._mixer - volumes = mixer.get_volume(track) avg_volume = float(sum(volumes)) / len(volumes) From 7574862491d4b8e04316c26ecda427907daf02f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 15:59:38 +0200 Subject: [PATCH 054/177] Cleanup error handling of bad GStreamer settings. No need to have a GStreamerError, we can just let the GErrors bubble now that we are initialising in the main thread. --- mopidy/core.py | 4 +--- mopidy/gstreamer.py | 24 +++++++++--------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 4012359f..128b4723 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -20,7 +20,7 @@ sys.argv[1:] = gstreamer_args from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer, GStreamerError +from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file @@ -45,8 +45,6 @@ def main(): loop.run() except SettingsError as e: logger.error(e.message) - except GStreamerError as e: - logger.error(e) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') except Exception as e: diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index e14f4f78..4eb94e91 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -14,10 +14,6 @@ from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.gstreamer') -class GStreamerError(Exception): - pass - - # TODO: we might want to add some ranking to the mixers we know about? # TODO: move to mixers module and do from mopidy.mixers import * to install # elements. @@ -213,34 +209,32 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('queue').get_pad('sink')) def _setup_output(self, output_description): - try: - self._output = gst.parse_bin_from_description(output_description, True) - except gobject.GError as e: - raise GStreamerError('%r while creating %r' % (e.message, - output_description)) + # This will raise a gobject.GError if the description is bad. + self._output = gst.parse_bin_from_description(output_description, True) self._pipeline.add(self._output) gst.element_link_many(self._pipeline.get_by_name('queue'), self._output) logger.info('Output set to %s', output_description) - def _setup_mixer(self, mixer_bin_description, track_label): - if not mixer_bin_description: - logger.debug('Not adding mixer.') + def _setup_mixer(self, mixer_description, track_label): + if not mixer_description: + logger.info('Not setting up mixer.') return - mixerbin = gst.parse_bin_from_description(mixer_bin_description, False) + # This will raise a gobject.GError if the description is bad. + mixerbin = gst.parse_bin_from_description(mixer_description, False) # We assume that the bin will contain a single mixer. mixer = mixerbin.get_by_interface('GstMixer') if not mixer: logger.warning('Did not find any mixers in %r', - mixer_bin_description) + mixer_description) return if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: logger.warning('Setting mixer %r to READY failed.', - mixer_bin_description) + mixer_description) return track = self._select_mixer_track(mixer, track_label) From 5a0199ac200ac1707cc0b4ab6f2e7454c1685f66 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 16:12:38 +0200 Subject: [PATCH 055/177] Remove MIXER_MAX_VOLUME setting. --- mopidy/mixers/base.py | 9 ++------- mopidy/settings.py | 11 ----------- mopidy/utils/settings.py | 1 + 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py index 82783be1..a387c143 100644 --- a/mopidy/mixers/base.py +++ b/mopidy/mixers/base.py @@ -5,13 +5,8 @@ from mopidy import listeners, settings logger = logging.getLogger('mopidy.mixers') class BaseMixer(object): - """ - **Settings:** - - - :attr:`mopidy.settings.MIXER_MAX_VOLUME` - """ - - amplification_factor = settings.MIXER_MAX_VOLUME / 100.0 + # TODO: remove completly + amplification_factor = 1.0 @property def volume(self): diff --git a/mopidy/settings.py b/mopidy/settings.py index 4e8370e6..72e805bf 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -126,17 +126,6 @@ MIXER = u'autoaudiomixer' #: MIXER_TRACK = None MIXER_TRACK = None -#: The maximum volume. Integer in the range 0 to 100. -#: -#: If this settings is set to 80, the mixer will set the actual volume to 80 -#: when asked to set it to 100. -#: -#: Default:: -#: -#: MIXER_MAX_VOLUME = 100 -# TODO: re-add support for this. -MIXER_MAX_VOLUME = 100 - #: Which address Mopidy's MPD server should bind to. #: #:Examples: diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 372dd8a0..70d45721 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -125,6 +125,7 @@ def validate_settings(defaults, settings): 'MIXER_EXT_PORT': None, 'MIXER_EXT_SPEAKERS_A': None, 'MIXER_EXT_SPEAKERS_B': None, + 'MIXER_MAX_VOLUME': None, 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', From f6b96680ae849d87510a6d52faa348084bffdb3a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 22:07:15 +0200 Subject: [PATCH 056/177] Fix MPD volume command. The command should return -1 when the volume is not known. --- mopidy/frontends/mpd/protocol/status.py | 4 ++-- tests/frontends/mpd/status_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index f32c46c8..81b68dd6 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -137,7 +137,7 @@ def status(context): Reports the current status of the player and the volume level. - - ``volume``: 0-100 + - ``volume``: 0-100 or -1 - ``repeat``: 0 or 1 - ``single``: 0 or 1 - ``consume``: 0 or 1 @@ -267,7 +267,7 @@ def _status_volume(futures): if volume is not None: return volume else: - return 0 + return -1 def _status_xfade(futures): return 0 # Not supported diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index bdd2dab8..9fa62321 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -42,10 +42,10 @@ class StatusHandlerTest(unittest.TestCase): self.assert_('playtime' in result) self.assert_(int(result['playtime']) >= 0) - def test_status_method_contains_volume_which_defaults_to_0(self): + def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) self.assert_('volume' in result) - self.assertEqual(int(result['volume']), 0) + self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): self.mixer.volume = 17 From 09b02f055859e003c121a1852a448934d8b99cc0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 16:09:14 +0200 Subject: [PATCH 057/177] Reraise exception without losing the traceback (fixes #173) --- mopidy/frontends/mpd/protocol/music_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index cde2754a..da8de91e 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -196,7 +196,7 @@ def _list_build_query(field, mpd_query): if error.message == 'No closing quotation': raise MpdArgError(u'Invalid unquoted character', command=u'list') else: - raise error + raise tokens = [t.decode('utf-8') for t in tokens] if len(tokens) == 1: if field == u'album': From 763d09b17263a58735a394ec6bd7e3a879c4cf60 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 22:15:43 +0200 Subject: [PATCH 058/177] Tweak OUTPUT docs --- docs/changes.rst | 10 +++++----- docs/settings.rst | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ce01c364..ce011d9b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -26,11 +26,11 @@ v0.8 (in development) known setting, and suggests to the user what we think the setting should have been. -- Removed most traces of multiple outputs support. Having this feature - currently seems to be more trouble than what it is worth. - :attr:`mopidy.settings.OUTPUTS` setting is no longer supported, and has been - replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer - bin described in the same format as ``gst-launch`` expects. Default value is +- Removed multiple outputs support. Having this feature currently seems to be + more trouble than what it is worth. The :attr:`mopidy.settings.OUTPUTS` + setting is no longer supported, and has been replaced with + :attr:`mopidy.settings.OUTPUT` which is a GStreamer bin description string in + the same format as ``gst-launch`` expects. Default value is ``autoaudiosink``. diff --git a/docs/settings.rst b/docs/settings.rst index 2f0f0f12..94f3c63b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -157,13 +157,13 @@ server simultaneously. To use the SHOUTcast output, do the following: #. Install, configure and start the Icecast server. It can be found in the ``icecast2`` package in Debian/Ubuntu. -#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send`` (an Ogg Vorbis - encoder could be used instead of lame). +#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send``. An Ogg Vorbis + encoder could be used instead of the lame MP3 encoder. #. You might also need to change the ``shout2send`` default settings, run ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely - you want to change ``ip``, ``username``, ``password`` and ``mount``. For - example, to set the password use: + you want to change ``ip``, ``username``, ``password``, and ``mount``. For + example, to set the username and password, use: ``lame ! shout2send username="foobar" password="s3cret"``. Other advanced setups are also possible for outputs. Basically anything you can From 7866d6230024d50c8dbf7d7ce286515dfb9b6f21 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 22:32:19 +0200 Subject: [PATCH 059/177] Improve docstring --- mopidy/utils/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 55bf43d0..d369fa44 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -102,7 +102,7 @@ def validate_settings(defaults, settings): Checks the settings for both errors like misspellings and against a set of rules for renamed settings, etc. - Returns of setting names with associated errors. + Returns mapping from setting names to associated errors. :param defaults: Mopidy's default settings :type defaults: dict From 252984138f1207a5713f3b8dbbce2e3f79415f3d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 22:32:44 +0200 Subject: [PATCH 060/177] Map old settings GSTREAMER_AUDIO_SINK and LOCAL_OUTPUT_OVERRIDE to the new OUTPUT setting --- mopidy/utils/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index d369fa44..aa6dd3ae 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -117,9 +117,9 @@ def validate_settings(defaults, settings): 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', - 'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT', + 'GSTREAMER_AUDIO_SINK': 'OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', - 'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT', + 'LOCAL_OUTPUT_OVERRIDE': 'OUTPUT', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', 'SERVER': None, From 5f8374a174b286c51446b94177fd3da65383cdd0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 22:32:57 +0200 Subject: [PATCH 061/177] Use same wording in setting validation and docs --- mopidy/utils/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index aa6dd3ae..ec58bab3 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -148,7 +148,7 @@ def validate_settings(defaults, settings): elif setting == 'OUTPUTS': errors[setting] = ( u'Deprecated setting, please change to OUTPUT. OUTPUT expectes ' - u'a GStreamer bin describing your desired output.') + u'a GStreamer bin description string for your desired output.') elif setting == 'SPOTIFY_BITRATE': if value not in (96, 160, 320): From ceda42151037c20571635e7bd3f8c1ee89d1ecd9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 1 Sep 2012 15:59:38 +0200 Subject: [PATCH 062/177] Cleanup error handling of bad GStreamer settings. No need to have a GStreamerError, we can just let the GErrors bubble now that we are initialising in the main thread. Conflicts: mopidy/gstreamer.py --- mopidy/core.py | 4 +--- mopidy/gstreamer.py | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 6e6972f5..9ae461d8 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -20,7 +20,7 @@ sys.argv[1:] = gstreamer_args from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer, GStreamerError +from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging @@ -46,8 +46,6 @@ def main(): loop.run() except SettingsError as e: logger.error(e.message) - except GStreamerError as e: - logger.error(e) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') except Exception as e: diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index ab4fd59b..866c8868 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -11,13 +11,10 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend + logger = logging.getLogger('mopidy.gstreamer') -class GStreamerError(Exception): - pass - - class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. From 4c94c45c2514651a8a17000863548433e8991e40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 22:52:40 +0200 Subject: [PATCH 063/177] Fix merge bummer --- mopidy/gstreamer.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 866c8868..a7adb17d 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -64,11 +64,8 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) def _setup_output(self): - try: - self._output = gst.parse_bin_from_description(settings.OUTPUT, True) - except gobject.GError as e: - raise GStreamerError('%r while creating %r' % (e.message, - settings.OUTPUT)) + # This will raise a gobject.GError if the description is bad. + self._output = gst.parse_bin_from_description(settings.OUTPUT, True) self._pipeline.add(self._output) gst.element_link_many(self._volume, self._output) From 5258dddb89d4bad461b2bb6299d13429190ba330 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 22:53:41 +0200 Subject: [PATCH 064/177] Change boolean arg to kwarg to make it selfdocumenting --- mopidy/gstreamer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index a7adb17d..6aa23152 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -65,7 +65,8 @@ class GStreamer(ThreadingActor): def _setup_output(self): # This will raise a gobject.GError if the description is bad. - self._output = gst.parse_bin_from_description(settings.OUTPUT, True) + self._output = gst.parse_bin_from_description(settings.OUTPUT, + ghost_unconnected_pads=True) self._pipeline.add(self._output) gst.element_link_many(self._volume, self._output) From f96b1e66daa3543040d9e981647f468dd82547e1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 23:22:20 +0200 Subject: [PATCH 065/177] Add issues that was fixed by output simplification to the changelog --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index ce011d9b..d766bb92 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -31,7 +31,7 @@ v0.8 (in development) setting is no longer supported, and has been replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer bin description string in the same format as ``gst-launch`` expects. Default value is - ``autoaudiosink``. + ``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`159`) v0.7.3 (2012-08-11) From a450493f679109ce256ac28bc15610495e5ff290 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 23:23:59 +0200 Subject: [PATCH 066/177] Another issue fixed by simplified outputs --- docs/changes.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index d766bb92..1e767d1c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -31,7 +31,8 @@ v0.8 (in development) setting is no longer supported, and has been replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer bin description string in the same format as ``gst-launch`` expects. Default value is - ``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`159`) + ``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`121`, + :issue:`159`) v0.7.3 (2012-08-11) From 4352eccdb05a51efa8789e9573e5c7799df2fc4c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 23:39:54 +0200 Subject: [PATCH 067/177] Merge mopidy.core into mopidy.__main__ --- bin/mopidy | 2 +- mopidy/__main__.py | 159 ++++++++++++++++++++++++++++++++++++++++++++- mopidy/core.py | 136 -------------------------------------- 3 files changed, 157 insertions(+), 140 deletions(-) delete mode 100644 mopidy/core.py diff --git a/bin/mopidy b/bin/mopidy index aabf21d3..0472518e 100755 --- a/bin/mopidy +++ b/bin/mopidy @@ -1,5 +1,5 @@ #! /usr/bin/env python if __name__ == '__main__': - from mopidy.core import main + from mopidy.__main__ import main main() diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 169c2754..c55a9940 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,10 +1,163 @@ +import logging +import optparse +import os +import signal +import sys + +import gobject +gobject.threads_init() + + +# Extract any non-GStreamer arguments, and leave the GStreamer arguments for +# processing by GStreamer. This needs to be done before GStreamer is imported, +# so that GStreamer doesn't hijack e.g. ``--help``. +# NOTE This naive fix does not support values like ``bar`` in +# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. + +def is_gst_arg(argument): + return argument.startswith('--gst') or argument == '--help-gst' + +gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] +mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] +sys.argv[1:] = gstreamer_args + + # Add ../ to the path so we can run Mopidy from a Git checkout without # installing it on the system. -import os -import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + +from mopidy import (get_version, settings, OptionalDependencyError, + SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) +from mopidy.gstreamer import GStreamer +from mopidy.utils import get_class +from mopidy.utils.deps import list_deps_optparse_callback +from mopidy.utils.log import setup_logging +from mopidy.utils.path import get_or_create_folder, get_or_create_file +from mopidy.utils.process import (exit_handler, stop_remaining_actors, + stop_actors_by_class) +from mopidy.utils.settings import list_settings_optparse_callback + + +logger = logging.getLogger('mopidy.main') + + +def main(): + signal.signal(signal.SIGTERM, exit_handler) + loop = gobject.MainLoop() + try: + options = parse_options() + setup_logging(options.verbosity_level, options.save_debug_log) + check_old_folders() + setup_settings(options.interactive) + setup_gstreamer() + setup_mixer() + setup_backend() + setup_frontends() + loop.run() + except SettingsError as e: + logger.error(e.message) + except KeyboardInterrupt: + logger.info(u'Interrupted. Exiting...') + except Exception as e: + logger.exception(e) + finally: + loop.quit() + stop_frontends() + stop_backend() + stop_mixer() + stop_gstreamer() + stop_remaining_actors() + + +def parse_options(): + parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) + parser.add_option('--help-gst', + action='store_true', dest='help_gst', + help='show GStreamer help options') + parser.add_option('-i', '--interactive', + action='store_true', dest='interactive', + help='ask interactively for required settings which are missing') + parser.add_option('-q', '--quiet', + action='store_const', const=0, dest='verbosity_level', + help='less output (warning level)') + parser.add_option('-v', '--verbose', + action='count', default=1, dest='verbosity_level', + help='more output (debug level)') + parser.add_option('--save-debug-log', + action='store_true', dest='save_debug_log', + help='save debug log to "./mopidy.log"') + parser.add_option('--list-settings', + action='callback', callback=list_settings_optparse_callback, + help='list current settings') + parser.add_option('--list-deps', + action='callback', callback=list_deps_optparse_callback, + help='list dependencies and their versions') + return parser.parse_args(args=mopidy_args)[0] + + +def check_old_folders(): + old_settings_folder = os.path.expanduser(u'~/.mopidy') + + if not os.path.isdir(old_settings_folder): + return + + logger.warning(u'Old settings folder found at %s, settings.py should be ' + 'moved to %s, any cache data should be deleted. See release notes ' + 'for further instructions.', old_settings_folder, SETTINGS_PATH) + + +def setup_settings(interactive): + get_or_create_folder(SETTINGS_PATH) + get_or_create_folder(DATA_PATH) + get_or_create_file(SETTINGS_FILE) + try: + settings.validate(interactive) + except SettingsError, e: + logger.error(e.message) + sys.exit(1) + + +def setup_gstreamer(): + GStreamer.start() + + +def stop_gstreamer(): + stop_actors_by_class(GStreamer) + + +def setup_mixer(): + get_class(settings.MIXER).start() + + +def stop_mixer(): + stop_actors_by_class(get_class(settings.MIXER)) + + +def setup_backend(): + get_class(settings.BACKENDS[0]).start() + + +def stop_backend(): + stop_actors_by_class(get_class(settings.BACKENDS[0])) + + +def setup_frontends(): + for frontend_class_name in settings.FRONTENDS: + try: + get_class(frontend_class_name).start() + except OptionalDependencyError as e: + logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + + +def stop_frontends(): + for frontend_class_name in settings.FRONTENDS: + try: + stop_actors_by_class(get_class(frontend_class_name)) + except OptionalDependencyError: + pass + + if __name__ == '__main__': - from mopidy.core import main main() diff --git a/mopidy/core.py b/mopidy/core.py deleted file mode 100644 index 9ae461d8..00000000 --- a/mopidy/core.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -import optparse -import os -import signal -import sys - -import gobject -gobject.threads_init() - -# Extract any non-GStreamer arguments, and leave the GStreamer arguments for -# processing by GStreamer. This needs to be done before GStreamer is imported, -# so that GStreamer doesn't hijack e.g. ``--help``. -# NOTE This naive fix does not support values like ``bar`` in -# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. -def is_gst_arg(argument): - return argument.startswith('--gst') or argument == '--help-gst' -gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] -mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] -sys.argv[1:] = gstreamer_args - -from mopidy import (get_version, settings, OptionalDependencyError, - SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer -from mopidy.utils import get_class -from mopidy.utils.deps import list_deps_optparse_callback -from mopidy.utils.log import setup_logging -from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import (exit_handler, stop_remaining_actors, - stop_actors_by_class) -from mopidy.utils.settings import list_settings_optparse_callback - -logger = logging.getLogger('mopidy.core') - -def main(): - signal.signal(signal.SIGTERM, exit_handler) - loop = gobject.MainLoop() - try: - options = parse_options() - setup_logging(options.verbosity_level, options.save_debug_log) - check_old_folders() - setup_settings(options.interactive) - setup_gstreamer() - setup_mixer() - setup_backend() - setup_frontends() - loop.run() - except SettingsError as e: - logger.error(e.message) - except KeyboardInterrupt: - logger.info(u'Interrupted. Exiting...') - except Exception as e: - logger.exception(e) - finally: - loop.quit() - stop_frontends() - stop_backend() - stop_mixer() - stop_gstreamer() - stop_remaining_actors() - -def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) - parser.add_option('--help-gst', - action='store_true', dest='help_gst', - help='show GStreamer help options') - parser.add_option('-i', '--interactive', - action='store_true', dest='interactive', - help='ask interactively for required settings which are missing') - parser.add_option('-q', '--quiet', - action='store_const', const=0, dest='verbosity_level', - help='less output (warning level)') - parser.add_option('-v', '--verbose', - action='count', default=1, dest='verbosity_level', - help='more output (debug level)') - parser.add_option('--save-debug-log', - action='store_true', dest='save_debug_log', - help='save debug log to "./mopidy.log"') - parser.add_option('--list-settings', - action='callback', callback=list_settings_optparse_callback, - help='list current settings') - parser.add_option('--list-deps', - action='callback', callback=list_deps_optparse_callback, - help='list dependencies and their versions') - return parser.parse_args(args=mopidy_args)[0] - -def check_old_folders(): - old_settings_folder = os.path.expanduser(u'~/.mopidy') - - if not os.path.isdir(old_settings_folder): - return - - logger.warning(u'Old settings folder found at %s, settings.py should be ' - 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, SETTINGS_PATH) - -def setup_settings(interactive): - get_or_create_folder(SETTINGS_PATH) - get_or_create_folder(DATA_PATH) - get_or_create_file(SETTINGS_FILE) - try: - settings.validate(interactive) - except SettingsError, e: - logger.error(e.message) - sys.exit(1) - -def setup_gstreamer(): - GStreamer.start() - -def stop_gstreamer(): - stop_actors_by_class(GStreamer) - -def setup_mixer(): - get_class(settings.MIXER).start() - -def stop_mixer(): - stop_actors_by_class(get_class(settings.MIXER)) - -def setup_backend(): - get_class(settings.BACKENDS[0]).start() - -def stop_backend(): - stop_actors_by_class(get_class(settings.BACKENDS[0])) - -def setup_frontends(): - for frontend_class_name in settings.FRONTENDS: - try: - get_class(frontend_class_name).start() - except OptionalDependencyError as e: - logger.info(u'Disabled: %s (%s)', frontend_class_name, e) - -def stop_frontends(): - for frontend_class_name in settings.FRONTENDS: - try: - stop_actors_by_class(get_class(frontend_class_name)) - except OptionalDependencyError: - pass From ea4fef5b7b0f1ae555655ff395cb43f45a3323a7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Sep 2012 23:40:22 +0200 Subject: [PATCH 068/177] Remove outdated docstring comment --- mopidy/backends/base/playback.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 16ac75d1..226efbe7 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -342,9 +342,6 @@ class PlaybackController(object): def on_end_of_track(self): """ Tell the playback controller that end of track is reached. - - Typically called by :class:`mopidy.process.CoreProcess` after a message - from a library thread is received. """ if self.state == self.STOPPED: return From 4c509c2e2ce23708928e9d54d64744dde5f740a2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 4 Sep 2012 00:21:00 +0200 Subject: [PATCH 069/177] Add volume to playback API of backends. - Add volume get/setter to the playback provider. - Add volume property to the playback controller. --- mopidy/backends/base/playback.py | 32 +++++++++++++++++++++++++++++ mopidy/backends/dummy/__init__.py | 10 +++++++++ mopidy/backends/local/__init__.py | 6 ++++++ mopidy/backends/spotify/playback.py | 6 ++++++ 4 files changed, 54 insertions(+) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 16ac75d1..778e7a54 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -319,6 +319,14 @@ class PlaybackController(object): def _current_wall_time(self): return int(time.time() * 1000) + @property + def volume(self): + return self.provider.get_volume() + + @volume.setter + def volume(self, volume): + self.provider.set_volume(volume) + def change_track(self, cp_track, on_error_step=1): """ Change to the given track, keeping the current playback state. @@ -604,3 +612,27 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ raise NotImplementedError + + # TODO: having these in the provider is stupid, but since we currently + # don't have gstreamer exposed in a sensible way for this... + # On the bright side it makes testing volume stuff less painful. + def get_volume(self): + """ + Get current volume + + *MUST be implemented by subclass.* + + :rtype: int [0..100] or :class:`None` + """ + raise NotImplementedError + + def set_volume(self, volume): + """ + Get current volume + + *MUST be implemented by subclass.* + + :param: volume + :type volume: int [0..100] + """ + raise NotImplementedError diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 70efb028..2234242c 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -56,6 +56,10 @@ class DummyLibraryProvider(BaseLibraryProvider): class DummyPlaybackProvider(BasePlaybackProvider): + def __init__(self, *args, **kwargs): + super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._volume = None + def pause(self): return True @@ -72,6 +76,12 @@ class DummyPlaybackProvider(BasePlaybackProvider): def stop(self): return True + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): def create(self, name): diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e8638a3a..1b1f9730 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -102,6 +102,12 @@ class LocalPlaybackProvider(BasePlaybackProvider): def stop(self): return self.backend.gstreamer.stop_playback().get() + def get_volume(self): + return self.backend.gstreamer.get_volume().get() + + def set_volume(self, volume): + self.backend.gstreamer.set_volume(volume).get() + class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index dc328fc9..116be285 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -41,3 +41,9 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): result = self.backend.gstreamer.stop_playback() self.backend.spotify.session.play(0) return result + + def get_volume(self): + return self.backend.gstreamer.get_volume() + + def set_volume(self, volume): + self.backend.gstreamer.set_volume(volume) From 14eeb20226b32bebfa657f7c5029a4310cf50257 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 4 Sep 2012 00:30:02 +0200 Subject: [PATCH 070/177] Update MPRIS to use playback.volume API. --- mopidy/frontends/mpris/objects.py | 18 ++++------------ .../frontends/mpris/player_interface_test.py | 21 ++++++++----------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 9ed1fe2c..fa5f9614 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -17,7 +17,6 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend from mopidy.backends.base.playback import PlaybackController -from mopidy.mixers.base import BaseMixer from mopidy.utils.process import exit_process # Must be done before dbus.SessionBus() is called @@ -37,7 +36,6 @@ class MprisObject(dbus.service.Object): def __init__(self): self._backend = None - self._mixer = None self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), @@ -95,14 +93,6 @@ class MprisObject(dbus.service.Object): self._backend = backend_refs[0].proxy() return self._backend - @property - def mixer(self): - if self._mixer is None: - mixer_refs = ActorRegistry.get_by_class(BaseMixer) - assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - self._mixer = mixer_refs[0].proxy() - return self._mixer - def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid @@ -380,7 +370,7 @@ class MprisObject(dbus.service.Object): return dbus.Dictionary(metadata, signature='sv') def get_Volume(self): - volume = self.mixer.volume.get() + volume = self.backend.playback.volume.get() if volume is not None: return volume / 100.0 @@ -391,11 +381,11 @@ class MprisObject(dbus.service.Object): if value is None: return elif value < 0: - self.mixer.volume = 0 + self.backend.playback.volume = 0 elif value > 1: - self.mixer.volume = 100 + self.backend.playback.volume = 100 elif 0 <= value <= 1: - self.mixer.volume = int(value * 100) + self.backend.playback.volume = int(value * 100) def get_Position(self): return self.backend.playback.time_position.get() * 1000 diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 24c426fb..d09d4f6b 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -5,7 +5,6 @@ import mock from mopidy import OptionalDependencyError from mopidy.backends.dummy import DummyBackend from mopidy.backends.base.playback import PlaybackController -from mopidy.mixers.dummy import DummyMixer from mopidy.models import Album, Artist, Track try: @@ -24,14 +23,12 @@ STOPPED = PlaybackController.STOPPED class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.mixer = DummyMixer.start().proxy() self.backend = DummyBackend.start().proxy() self.mpris = objects.MprisObject() self.mpris._backend = self.backend def tearDown(self): self.backend.stop() - self.mixer.stop() def test_get_playback_status_is_playing_when_playing(self): self.backend.playback.state = PLAYING @@ -208,36 +205,36 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): - self.mixer.volume = 0 + self.backend.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) - self.mixer.volume = 50 + self.backend.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0.5) - self.mixer.volume = 100 + self.backend.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.mixer.volume = 0 + self.backend.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.mixer.volume.get(), 0) + self.assertEquals(self.backend.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.mixer.volume.get(), 100) + self.assertEquals(self.backend.playback.volume.get(), 100) def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.mixer.volume.get(), 100) + self.assertEquals(self.backend.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.mixer.volume = 10 + self.backend.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.mixer.volume.get(), 10) + self.assertEquals(self.backend.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) From 4ffd06736e7afd9e8b1e6ee4b7ff0175876cbb4d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 4 Sep 2012 00:52:12 +0200 Subject: [PATCH 071/177] Update MPD frontend to use playback volume API. --- mopidy/frontends/mpd/dispatcher.py | 13 ------------- mopidy/frontends/mpd/protocol/playback.py | 2 +- mopidy/frontends/mpd/protocol/status.py | 4 ++-- tests/frontends/mpd/dispatcher_test.py | 3 --- tests/frontends/mpd/protocol/__init__.py | 3 --- tests/frontends/mpd/protocol/playback_test.py | 14 +++++++------- tests/frontends/mpd/status_test.py | 5 +---- 7 files changed, 11 insertions(+), 33 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 2b012c7c..94ac6bf9 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -15,7 +15,6 @@ from mopidy.frontends.mpd.protocol import (audio_output, command_list, connection, current_playlist, empty, music_db, playback, reflection, status, stickers, stored_playlists) # pylint: enable = W0611 -from mopidy.mixers.base import BaseMixer from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') @@ -235,7 +234,6 @@ class MpdContext(object): self.events = set() self.subscriptions = set() self._backend = None - self._mixer = None @property def backend(self): @@ -248,14 +246,3 @@ class MpdContext(object): 'Expected exactly one running backend.' self._backend = backend_refs[0].proxy() return self._backend - - @property - def mixer(self): - """ - The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. - """ - if self._mixer is None: - mixer_refs = ActorRegistry.get_by_class(BaseMixer) - assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - self._mixer = mixer_refs[0].proxy() - return self._mixer diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 948083a8..4cf33266 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -353,7 +353,7 @@ def setvol(context, volume): volume = 0 if volume > 100: volume = 100 - context.mixer.volume = volume + context.backend.playback.volume = volume @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 81b68dd6..4a9ad9a1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -168,7 +168,7 @@ def status(context): futures = { 'current_playlist.length': context.backend.current_playlist.length, 'current_playlist.version': context.backend.current_playlist.version, - 'mixer.volume': context.mixer.volume, + 'playback.volume': context.backend.playback.volume, 'playback.consume': context.backend.playback.consume, 'playback.random': context.backend.playback.random, 'playback.repeat': context.backend.playback.repeat, @@ -263,7 +263,7 @@ def _status_time_total(futures): return current_cp_track.track.length def _status_volume(futures): - volume = futures['mixer.volume'].get() + volume = futures['playback.volume'].get() if volume is not None: return volume else: diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index bfa7c548..63f6d299 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -2,7 +2,6 @@ from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request -from mopidy.mixers.dummy import DummyMixer from tests import unittest @@ -10,12 +9,10 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() self.dispatcher = MpdDispatcher() def tearDown(self): self.backend.stop().get() - self.mixer.stop().get() def test_register_same_pattern_twice_fails(self): func = lambda: None diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index b54906be..b39ded01 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -3,7 +3,6 @@ import mock from mopidy import settings from mopidy.backends import dummy as backend from mopidy.frontends import mpd -from mopidy.mixers import dummy as mixer from tests import unittest @@ -23,7 +22,6 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): self.backend = backend.DummyBackend.start().proxy() - self.mixer = mixer.DummyMixer.start().proxy() self.connection = MockConnection() self.session = mpd.MpdSession(self.connection) @@ -32,7 +30,6 @@ class BaseTestCase(unittest.TestCase): def tearDown(self): self.backend.stop().get() - self.mixer.stop().get() settings.runtime.clear() def sendRequest(self, request): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 01658f6d..87c9bbb8 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -76,37 +76,37 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.sendRequest(u'setvol "-10"') - self.assertEqual(0, self.mixer.volume.get()) + self.assertEqual(0, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_min(self): self.sendRequest(u'setvol "0"') - self.assertEqual(0, self.mixer.volume.get()) + self.assertEqual(0, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_middle(self): self.sendRequest(u'setvol "50"') - self.assertEqual(50, self.mixer.volume.get()) + self.assertEqual(50, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_max(self): self.sendRequest(u'setvol "100"') - self.assertEqual(100, self.mixer.volume.get()) + self.assertEqual(100, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_above_max(self): self.sendRequest(u'setvol "110"') - self.assertEqual(100, self.mixer.volume.get()) + self.assertEqual(100, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): self.sendRequest(u'setvol "+10"') - self.assertEqual(10, self.mixer.volume.get()) + self.assertEqual(10, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_without_quotes(self): self.sendRequest(u'setvol 50') - self.assertEqual(50, self.mixer.volume.get()) + self.assertEqual(50, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_single_off(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 9fa62321..3701faaf 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,7 +1,6 @@ from mopidy.backends import dummy as backend from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status -from mopidy.mixers import dummy as mixer from mopidy.models import Track from tests import unittest @@ -17,13 +16,11 @@ STOPPED = backend.PlaybackController.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): self.backend = backend.DummyBackend.start().proxy() - self.mixer = mixer.DummyMixer.start().proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): self.backend.stop().get() - self.mixer.stop().get() def test_stats_method(self): result = status.stats(self.context) @@ -48,7 +45,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.mixer.volume = 17 + self.backend.playback.volume = 17 result = dict(status.status(self.context)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 17) From c71202c2be48156264270a2af71d5ab100d865d4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 4 Sep 2012 00:54:28 +0200 Subject: [PATCH 072/177] Remove all of mopidy.mixers and tests.mixers modules. --- mopidy/core.py | 10 -- mopidy/mixers/__init__.py | 0 mopidy/mixers/alsa.py | 60 --------- mopidy/mixers/base.py | 63 --------- mopidy/mixers/denon.py | 58 -------- mopidy/mixers/dummy.py | 16 --- mopidy/mixers/gstreamer_software.py | 23 ---- mopidy/mixers/nad.py | 198 ---------------------------- mopidy/mixers/osa.py | 46 ------- tests/mixers/__init__.py | 0 tests/mixers/base_test.py | 38 ------ tests/mixers/denon_test.py | 42 ------ tests/mixers/dummy_test.py | 23 ---- 13 files changed, 577 deletions(-) delete mode 100644 mopidy/mixers/__init__.py delete mode 100644 mopidy/mixers/alsa.py delete mode 100644 mopidy/mixers/base.py delete mode 100644 mopidy/mixers/denon.py delete mode 100644 mopidy/mixers/dummy.py delete mode 100644 mopidy/mixers/gstreamer_software.py delete mode 100644 mopidy/mixers/nad.py delete mode 100644 mopidy/mixers/osa.py delete mode 100644 tests/mixers/__init__.py delete mode 100644 tests/mixers/base_test.py delete mode 100644 tests/mixers/denon_test.py delete mode 100644 tests/mixers/dummy_test.py diff --git a/mopidy/core.py b/mopidy/core.py index 7d2e01ff..ecded337 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -40,7 +40,6 @@ def main(): check_old_folders() setup_settings(options.interactive) setup_gstreamer() - setup_mixer() setup_backend() setup_frontends() loop.run() @@ -54,7 +53,6 @@ def main(): loop.quit() stop_frontends() stop_backend() - stop_mixer() stop_gstreamer() stop_remaining_actors() @@ -109,14 +107,6 @@ def setup_gstreamer(): def stop_gstreamer(): stop_actors_by_class(GStreamer) -def setup_mixer(): - # TODO: remove this hack which is just a stepping stone for our - # refactoring. - get_class('mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer').start() - -def stop_mixer(): - stop_actors_by_class(get_class('mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer')) - def setup_backend(): get_class(settings.BACKENDS[0]).start() diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py deleted file mode 100644 index acb12e66..00000000 --- a/mopidy/mixers/alsa.py +++ /dev/null @@ -1,60 +0,0 @@ -import alsaaudio -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.mixers.base import BaseMixer - -logger = logging.getLogger('mopidy.mixers.alsa') - -class AlsaMixer(ThreadingActor, BaseMixer): - """ - Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control - volume. - - **Dependencies:** - - - pyalsaaudio >= 0.2 (python-alsaaudio on Debian/Ubuntu) - - **Settings:** - - - :attr:`mopidy.settings.MIXER_ALSA_CONTROL` - """ - - def __init__(self): - super(AlsaMixer, self).__init__() - self._mixer = None - - def on_start(self): - self._mixer = alsaaudio.Mixer(self._get_mixer_control()) - assert self._mixer is not None - - def _get_mixer_control(self): - """Returns the first mixer control candidate that is known to ALSA""" - candidates = self._get_mixer_control_candidates() - for control in candidates: - if control in alsaaudio.mixers(): - logger.info(u'Mixer control in use: %s', control) - return control - else: - logger.debug(u'Mixer control not found, skipping: %s', control) - logger.warning(u'No working mixer controls found. Tried: %s', - candidates) - - def _get_mixer_control_candidates(self): - """ - A mixer named 'Master' does not always exist, so we fall back to using - 'PCM'. If this does not work for you, you may set - :attr:`mopidy.settings.MIXER_ALSA_CONTROL`. - """ - if settings.MIXER_ALSA_CONTROL: - return [settings.MIXER_ALSA_CONTROL] - return [u'Master', u'PCM'] - - def get_volume(self): - # FIXME does not seem to see external volume changes. - return self._mixer.getvolume()[0] - - def set_volume(self, volume): - self._mixer.setvolume(volume) diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py deleted file mode 100644 index a387c143..00000000 --- a/mopidy/mixers/base.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging - -from mopidy import listeners, settings - -logger = logging.getLogger('mopidy.mixers') - -class BaseMixer(object): - # TODO: remove completly - amplification_factor = 1.0 - - @property - def volume(self): - """ - The audio volume - - Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is - equal to 0. Values above 100 is equal to 100. - """ - if not hasattr(self, '_user_volume'): - self._user_volume = 0 - volume = self.get_volume() - if volume is None or not self.amplification_factor < 1: - return volume - else: - user_volume = int(volume / self.amplification_factor) - if (user_volume - 1) <= self._user_volume <= (user_volume + 1): - return self._user_volume - else: - return user_volume - - @volume.setter - def volume(self, volume): - if not hasattr(self, '_user_volume'): - self._user_volume = 0 - volume = int(volume) - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self._user_volume = volume - real_volume = int(volume * self.amplification_factor) - self.set_volume(real_volume) - self._trigger_volume_changed() - - def get_volume(self): - """ - Return volume as integer in range [0, 100]. :class:`None` if unknown. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def set_volume(self, volume): - """ - Set volume as integer in range [0, 100]. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def _trigger_volume_changed(self): - logger.debug(u'Triggering volume changed event') - listeners.BackendListener.send('volume_changed') diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py deleted file mode 100644 index b0abbdb9..00000000 --- a/mopidy/mixers/denon.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.mixers.base import BaseMixer - -logger = logging.getLogger(u'mopidy.mixers.denon') - -class DenonMixer(ThreadingActor, BaseMixer): - """ - Mixer for controlling Denon amplifiers and receivers using the RS-232 - protocol. - - The external mixer is the authoritative source for the current volume. - This allows the user to use his remote control the volume without Mopidy - cancelling the volume setting. - - **Dependencies** - - - pyserial (python-serial on Debian/Ubuntu) - - **Settings** - - - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` - """ - - def __init__(self, device=None): - super(DenonMixer, self).__init__() - self._device = device - self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] - self._volume = 0 - - def on_start(self): - if self._device is None: - from serial import Serial - self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2) - - def get_volume(self): - self._ensure_open_device() - self._device.write('MV?\r') - vol = str(self._device.readline()[2:4]) - logger.debug(u'_get_volume() = %s' % vol) - return self._levels.index(vol) - - def set_volume(self, volume): - # Clamp according to Denon-spec - if volume > 99: - volume = 99 - self._ensure_open_device() - self._device.write('MV%s\r'% self._levels[volume]) - vol = self._device.readline()[2:4] - self._volume = self._levels.index(vol) - - def _ensure_open_device(self): - if not self._device.isOpen(): - logger.debug(u'(re)connecting to Denon device') - self._device.open() diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py deleted file mode 100644 index 7262e83c..00000000 --- a/mopidy/mixers/dummy.py +++ /dev/null @@ -1,16 +0,0 @@ -from pykka.actor import ThreadingActor - -from mopidy.mixers.base import BaseMixer - -class DummyMixer(ThreadingActor, BaseMixer): - """Mixer which just stores and reports the chosen volume.""" - - def __init__(self): - super(DummyMixer, self).__init__() - self._volume = None - - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py deleted file mode 100644 index a38692db..00000000 --- a/mopidy/mixers/gstreamer_software.py +++ /dev/null @@ -1,23 +0,0 @@ -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry - -from mopidy.mixers.base import BaseMixer -from mopidy.gstreamer import GStreamer - -class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): - """Mixer which uses GStreamer to control volume in software.""" - - def __init__(self): - super(GStreamerSoftwareMixer, self).__init__() - self.output = None - - def on_start(self): - output_refs = ActorRegistry.get_by_class(GStreamer) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() - - def get_volume(self): - return self.output.get_volume().get() - - def set_volume(self, volume): - self.output.set_volume(volume).get() diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py deleted file mode 100644 index 78473308..00000000 --- a/mopidy/mixers/nad.py +++ /dev/null @@ -1,198 +0,0 @@ -import logging -import serial - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.mixers.base import BaseMixer - -logger = logging.getLogger('mopidy.mixers.nad') - -class NadMixer(ThreadingActor, BaseMixer): - """ - Mixer for controlling NAD amplifiers and receivers using the NAD RS-232 - protocol. - - The NAD mixer was created using a NAD C 355BEE amplifier, but should also - work with other NAD amplifiers supporting the same RS-232 protocol (v2.x). - The C 355BEE does not give you access to the current volume. It only - supports increasing or decreasing the volume one step at the time. Other - NAD amplifiers may support more advanced volume adjustment than what is - currently used by this mixer. - - Sadly, this means that if you use the remote control to change the volume - on the amplifier, Mopidy will no longer report the correct volume. - - **Dependencies** - - - pyserial (python-serial on Debian/Ubuntu) - - **Settings** - - - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` - - :attr:`mopidy.settings.MIXER_EXT_SOURCE` -- Example: ``Aux`` - - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_A` -- Example: ``On`` - - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_B` -- Example: ``Off`` - - """ - - def __init__(self): - super(NadMixer, self).__init__() - self._volume_cache = None - self._nad_talker = NadTalker.start().proxy() - - def get_volume(self): - return self._volume_cache - - def set_volume(self, volume): - self._volume_cache = volume - self._nad_talker.set_volume(volume) - - -class NadTalker(ThreadingActor): - """ - Independent process which does the communication with the NAD device. - - Since the communication is done in an independent process, Mopidy won't - block other requests while doing rather time consuming work like - calibrating the NAD device's volume. - """ - - # Timeout in seconds used for read/write operations. - # If you set the timeout too low, the reads will never get complete - # confirmations and calibration will decrease volume forever. If you set - # the timeout too high, stuff takes more time. 0.2s seems like a good value - # for NAD C 355BEE. - TIMEOUT = 0.2 - - # Number of volume levels the device supports. 40 for NAD C 355BEE. - VOLUME_LEVELS = 40 - - # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. - _nad_volume = None - - def __init__(self): - super(NadTalker, self).__init__() - self._device = None - - def on_start(self): - self._open_connection() - self._set_device_to_known_state() - - def _open_connection(self): - # Opens serial connection to the device. - # Communication settings: 115200 bps 8N1 - logger.info(u'Connecting to serial device "%s"', - settings.MIXER_EXT_PORT) - self._device = serial.Serial(port=settings.MIXER_EXT_PORT, - baudrate=115200, timeout=self.TIMEOUT) - self._get_device_model() - - def _set_device_to_known_state(self): - self._power_device_on() - self._select_speakers() - self._select_input_source() - self._unmute() - self._calibrate_volume() - - def _get_device_model(self): - model = self._ask_device('Main.Model') - logger.info(u'Connected to device of model "%s"', model) - return model - - def _power_device_on(self): - while self._ask_device('Main.Power') != 'On': - logger.info(u'Powering device on') - self._command_device('Main.Power', 'On') - - def _select_speakers(self): - if settings.MIXER_EXT_SPEAKERS_A is not None: - while (self._ask_device('Main.SpeakerA') - != settings.MIXER_EXT_SPEAKERS_A): - logger.info(u'Setting speakers A "%s"', - settings.MIXER_EXT_SPEAKERS_A) - self._command_device('Main.SpeakerA', - settings.MIXER_EXT_SPEAKERS_A) - if settings.MIXER_EXT_SPEAKERS_B is not None: - while (self._ask_device('Main.SpeakerB') != - settings.MIXER_EXT_SPEAKERS_B): - logger.info(u'Setting speakers B "%s"', - settings.MIXER_EXT_SPEAKERS_B) - self._command_device('Main.SpeakerB', - settings.MIXER_EXT_SPEAKERS_B) - - def _select_input_source(self): - if settings.MIXER_EXT_SOURCE is not None: - while self._ask_device('Main.Source') != settings.MIXER_EXT_SOURCE: - logger.info(u'Selecting input source "%s"', - settings.MIXER_EXT_SOURCE) - self._command_device('Main.Source', settings.MIXER_EXT_SOURCE) - - def _unmute(self): - while self._ask_device('Main.Mute') != 'Off': - logger.info(u'Unmuting device') - self._command_device('Main.Mute', 'Off') - - def _ask_device(self, key): - self._write('%s?' % key) - return self._readline().replace('%s=' % key, '') - - def _command_device(self, key, value): - if type(value) == unicode: - value = value.encode('utf-8') - self._write('%s=%s' % (key, value)) - self._readline() - - def _calibrate_volume(self): - # The NAD C 355BEE amplifier has 40 different volume levels. We have no - # way of asking on which level we are. Thus, we must calibrate the - # mixer by decreasing the volume 39 times. - logger.info(u'Calibrating NAD amplifier') - steps_left = self.VOLUME_LEVELS - 1 - while steps_left: - if self._decrease_volume(): - steps_left -= 1 - self._nad_volume = 0 - logger.info(u'Done calibrating NAD amplifier') - - def set_volume(self, volume): - # Increase or decrease the amplifier volume until it matches the given - # target volume. - logger.debug(u'Setting volume to %d' % volume) - target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) - if self._nad_volume is None: - return # Calibration needed - while target_nad_volume > self._nad_volume: - if self._increase_volume(): - self._nad_volume += 1 - while target_nad_volume < self._nad_volume: - if self._decrease_volume(): - self._nad_volume -= 1 - - def _increase_volume(self): - # Increase volume. Returns :class:`True` if confirmed by device. - self._write('Main.Volume+') - return self._readline() == 'Main.Volume+' - - def _decrease_volume(self): - # Decrease volume. Returns :class:`True` if confirmed by device. - self._write('Main.Volume-') - return self._readline() == 'Main.Volume-' - - def _write(self, data): - # Write data to device. Prepends and appends a newline to the data, as - # recommended by the NAD documentation. - if not self._device.isOpen(): - self._device.open() - self._device.write('\n%s\n' % data) - logger.debug('Write: %s', data) - - def _readline(self): - # Read line from device. The result is stripped for leading and - # trailing whitespace. - if not self._device.isOpen(): - self._device.open() - result = self._device.readline().strip() - if result: - logger.debug('Read: %s', result) - return result diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py deleted file mode 100644 index bd97d790..00000000 --- a/mopidy/mixers/osa.py +++ /dev/null @@ -1,46 +0,0 @@ -from subprocess import Popen, PIPE -import time - -from pykka.actor import ThreadingActor - -from mopidy.mixers.base import BaseMixer - -class OsaMixer(ThreadingActor, BaseMixer): - """ - Mixer which uses ``osascript`` on OS X to control volume. - - **Dependencies:** - - - None - - **Settings:** - - - None - """ - - CACHE_TTL = 30 - - _cache = None - _last_update = None - - def _valid_cache(self): - return (self._cache is not None - and self._last_update is not None - and (int(time.time() - self._last_update) < self.CACHE_TTL)) - - def get_volume(self): - if not self._valid_cache(): - try: - self._cache = int(Popen( - ['osascript', '-e', - 'output volume of (get volume settings)'], - stdout=PIPE).communicate()[0]) - except ValueError: - self._cache = None - self._last_update = int(time.time()) - return self._cache - - def set_volume(self, volume): - Popen(['osascript', '-e', 'set volume output volume %d' % volume]) - self._cache = volume - self._last_update = int(time.time()) diff --git a/tests/mixers/__init__.py b/tests/mixers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/mixers/base_test.py b/tests/mixers/base_test.py deleted file mode 100644 index 54cd8773..00000000 --- a/tests/mixers/base_test.py +++ /dev/null @@ -1,38 +0,0 @@ -class BaseMixerTest(object): - MIN = 0 - MAX = 100 - ACTUAL_MIN = MIN - ACTUAL_MAX = MAX - INITIAL = None - - mixer_class = None - - def setUp(self): - assert self.mixer_class is not None, \ - "mixer_class must be set in subclass" - # pylint: disable = E1102 - self.mixer = self.mixer_class() - # pylint: enable = E1102 - - def test_initial_volume(self): - self.assertEqual(self.mixer.volume, self.INITIAL) - - def test_volume_set_to_min(self): - self.mixer.volume = self.MIN - self.assertEqual(self.mixer.volume, self.ACTUAL_MIN) - - def test_volume_set_to_max(self): - self.mixer.volume = self.MAX - self.assertEqual(self.mixer.volume, self.ACTUAL_MAX) - - def test_volume_set_to_below_min_results_in_min(self): - self.mixer.volume = -10 - self.assertEqual(self.mixer.volume, self.ACTUAL_MIN) - - def test_volume_set_to_above_max_results_in_max(self): - self.mixer.volume = self.MAX + 10 - self.assertEqual(self.mixer.volume, self.ACTUAL_MAX) - - def test_volume_is_not_float(self): - self.mixer.volume = 1.0 / 3 * 100 - self.assertEqual(self.mixer.volume, 33) diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py deleted file mode 100644 index cdfe0772..00000000 --- a/tests/mixers/denon_test.py +++ /dev/null @@ -1,42 +0,0 @@ -from mopidy.mixers.denon import DenonMixer -from tests.mixers.base_test import BaseMixerTest - -from tests import unittest - - -class DenonMixerDeviceMock(object): - def __init__(self): - self._open = True - self.ret_val = bytes('MV00\r') - - def write(self, x): - if x[2] != '?': - self.ret_val = bytes(x) - - def read(self, x): - return self.ret_val - - def readline(self): - return self.ret_val - - def isOpen(self): - return self._open - - def open(self): - self._open = True - - -class DenonMixerTest(BaseMixerTest, unittest.TestCase): - ACTUAL_MAX = 99 - INITIAL = 1 - - mixer_class = DenonMixer - - def setUp(self): - self.device = DenonMixerDeviceMock() - self.mixer = DenonMixer(device=self.device) - - def test_reopen_device(self): - self.device._open = False - self.mixer.volume = 10 - self.assertTrue(self.device.isOpen()) diff --git a/tests/mixers/dummy_test.py b/tests/mixers/dummy_test.py deleted file mode 100644 index f9418d7a..00000000 --- a/tests/mixers/dummy_test.py +++ /dev/null @@ -1,23 +0,0 @@ -from mopidy.mixers.dummy import DummyMixer - -from tests import unittest -from tests.mixers.base_test import BaseMixerTest - - -class DummyMixerTest(BaseMixerTest, unittest.TestCase): - mixer_class = DummyMixer - - def test_set_volume_is_capped(self): - self.mixer.amplification_factor = 0.5 - self.mixer.volume = 100 - self.assertEquals(self.mixer._volume, 50) - - def test_get_volume_does_not_show_that_the_volume_is_capped(self): - self.mixer.amplification_factor = 0.5 - self.mixer._volume = 50 - self.assertEquals(self.mixer.volume, 100) - - def test_get_volume_get_the_same_number_as_was_set(self): - self.mixer.amplification_factor = 0.5 - self.mixer.volume = 13 - self.assertEquals(self.mixer.volume, 13) From 8281128a2eff054a14229e4b1471ff1278bbe3b1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 4 Sep 2012 01:04:59 +0200 Subject: [PATCH 073/177] Move mixers out of gstreamer module. Mixers now live in mopidy.mixers.. Futhermore mopidy.mixers should now import each of the mixers so that doing import mopidy.mixers is enough to install the gstreamer elements. --- mopidy/gstreamer.py | 143 +------------------------------------- mopidy/mixers/__init__.py | 2 + mopidy/mixers/auto.py | 72 +++++++++++++++++++ mopidy/mixers/fake.py | 80 +++++++++++++++++++++ 4 files changed, 155 insertions(+), 142 deletions(-) create mode 100644 mopidy/mixers/__init__.py create mode 100644 mopidy/mixers/auto.py create mode 100644 mopidy/mixers/fake.py diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index de5f988e..7bf5f370 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -1,6 +1,5 @@ import pygst pygst.require('0.10') -import gobject import gst import logging @@ -10,151 +9,11 @@ from pykka.registry import ActorRegistry from mopidy import settings, utils from mopidy.backends.base import Backend +from mopidy import mixers # Trigger install of gst mixer plugins. logger = logging.getLogger('mopidy.gstreamer') -# TODO: we might want to add some ranking to the mixers we know about? -# TODO: move to mixers module and do from mopidy.mixers import * to install -# elements. -class AutoAudioMixer(gst.Bin): - __gstdetails__ = ('AutoAudioMixer', - 'Mixer', - 'Element automatically selects a mixer.', - 'Thomas Adamcik') - - def __init__(self): - gst.Bin.__init__(self) - mixer = self._find_mixer() - if mixer: - self.add(mixer) - logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) - else: - logger.debug('AutoAudioMixer did not find any usable mixers') - - def _find_mixer(self): - registry = gst.registry_get_default() - - factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) - factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) - - for factory in factories: - # Avoid sink/srcs that implment mixing. - if factory.get_klass() != 'Generic/Audio': - continue - # Avoid anything that doesn't implment mixing. - elif not factory.has_interface('GstMixer'): - continue - - if self._test_mixer(factory): - return factory.create() - - return None - - def _test_mixer(self, factory): - element = factory.create() - if not element: - return False - - try: - result = element.set_state(gst.STATE_READY) - if result != gst.STATE_CHANGE_SUCCESS: - return False - - # Trust that the default device is sane and just check tracks. - return self._test_tracks(element) - finally: - element.set_state(gst.STATE_NULL) - - def _test_tracks(self, element): - # Only allow elements that have a least one output track. - flags = gst.interfaces.MIXER_TRACK_OUTPUT - - for track in element.list_tracks(): - if track.flags & flags: - return True - return False - - -gobject.type_register(AutoAudioMixer) -gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) - - -def create_fake_track(label, intial_volume, min_volume, max_volume, - num_channels, flags): - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (intial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() - - -class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('FakeMixer', - 'Mixer', - 'Fake mixer for use in tests.', - 'Thomas Adamcik') - - track_label = gobject.property(type=str, default='Master') - - track_initial_volume = gobject.property(type=int, default=0) - - track_min_volume = gobject.property(type=int, default=0) - - track_max_volume = gobject.property(type=int, default=100) - - track_num_channels = gobject.property(type=int, default=2) - - track_flags = gobject.property(type=int, - default=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) - - def __init__(self): - gst.Element.__init__(self) - - def list_tracks(self): - track = create_fake_track(self.track_label, - self.track_initial_volume, - self.track_min_volume, - self.track_max_volume, - self.track_num_channels, - self.track_flags) - return [track] - - def get_volume(self, track): - return track.volumes - - def set_volume(self, track, volumes): - track.volumes = volumes - - def set_record(self, track, record): - pass - -gobject.type_register(FakeMixer) -gst.element_register (FakeMixer, 'fakemixer', gst.RANK_MARGINAL) - - class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py new file mode 100644 index 00000000..cf282a03 --- /dev/null +++ b/mopidy/mixers/__init__.py @@ -0,0 +1,2 @@ +from mopidy.mixers.auto import AutoAudioMixer +from mopidy.mixers.fake import FakeMixer diff --git a/mopidy/mixers/auto.py b/mopidy/mixers/auto.py new file mode 100644 index 00000000..4b4ce543 --- /dev/null +++ b/mopidy/mixers/auto.py @@ -0,0 +1,72 @@ +import pygst +pygst.require('0.10') +import gobject +import gst + +import logging + +logger = logging.getLogger('mopidy.mixers.auto') + + +# TODO: we might want to add some ranking to the mixers we know about? +class AutoAudioMixer(gst.Bin): + __gstdetails__ = ('AutoAudioMixer', + 'Mixer', + 'Element automatically selects a mixer.', + 'Thomas Adamcik') + + def __init__(self): + gst.Bin.__init__(self) + mixer = self._find_mixer() + if mixer: + self.add(mixer) + logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) + else: + logger.debug('AutoAudioMixer did not find any usable mixers') + + def _find_mixer(self): + registry = gst.registry_get_default() + + factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) + factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) + + for factory in factories: + # Avoid sink/srcs that implment mixing. + if factory.get_klass() != 'Generic/Audio': + continue + # Avoid anything that doesn't implment mixing. + elif not factory.has_interface('GstMixer'): + continue + + if self._test_mixer(factory): + return factory.create() + + return None + + def _test_mixer(self, factory): + element = factory.create() + if not element: + return False + + try: + result = element.set_state(gst.STATE_READY) + if result != gst.STATE_CHANGE_SUCCESS: + return False + + # Trust that the default device is sane and just check tracks. + return self._test_tracks(element) + finally: + element.set_state(gst.STATE_NULL) + + def _test_tracks(self, element): + # Only allow elements that have a least one output track. + flags = gst.interfaces.MIXER_TRACK_OUTPUT + + for track in element.list_tracks(): + if track.flags & flags: + return True + return False + + +gobject.type_register(AutoAudioMixer) +gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) diff --git a/mopidy/mixers/fake.py b/mopidy/mixers/fake.py new file mode 100644 index 00000000..de0f6a50 --- /dev/null +++ b/mopidy/mixers/fake.py @@ -0,0 +1,80 @@ +import pygst +pygst.require('0.10') +import gobject +import gst + + +def create_fake_track(label, intial_volume, min_volume, max_volume, + num_channels, flags): + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (intial_volume,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() + + +class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): + __gstdetails__ = ('FakeMixer', + 'Mixer', + 'Fake mixer for use in tests.', + 'Thomas Adamcik') + + track_label = gobject.property(type=str, default='Master') + + track_initial_volume = gobject.property(type=int, default=0) + + track_min_volume = gobject.property(type=int, default=0) + + track_max_volume = gobject.property(type=int, default=100) + + track_num_channels = gobject.property(type=int, default=2) + + track_flags = gobject.property(type=int, + default=(gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) + + def __init__(self): + gst.Element.__init__(self) + + def list_tracks(self): + track = create_fake_track(self.track_label, + self.track_initial_volume, + self.track_min_volume, + self.track_max_volume, + self.track_num_channels, + self.track_flags) + return [track] + + def get_volume(self, track): + return track.volumes + + def set_volume(self, track, volumes): + track.volumes = volumes + + def set_record(self, track, record): + pass + + +gobject.type_register(FakeMixer) +gst.element_register (FakeMixer, 'fakemixer', gst.RANK_MARGINAL) From 4ce88faed457abbbc862ff62b122d204a3e7e3e7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Sep 2012 14:04:55 +0200 Subject: [PATCH 074/177] Fix deprecation warning by using str(ex) instead of ex.message (fixes #175) --- mopidy/frontends/mpd/protocol/music_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index da8de91e..3cf20c5d 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -193,7 +193,7 @@ def _list_build_query(field, mpd_query): # shlex does not seem to be friends with unicode objects tokens = shlex.split(mpd_query.encode('utf-8')) except ValueError as error: - if error.message == 'No closing quotation': + if str(error) == 'No closing quotation': raise MpdArgError(u'Invalid unquoted character', command=u'list') else: raise From 4b1595ce97aa63f032bd4c0a5faf1a22370eb80b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 4 Sep 2012 23:30:59 +0200 Subject: [PATCH 075/177] Attempt at running on travis-ci. - Installs our apt-repo to worker. - Installs all mopidy deps listed in apt. - Runs our tests with site-packages, thus we can only test the main python verision travis uses. --- .travis.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..a57f7474 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python + +install: + - "wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" + - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" + - "sudo apt-get update" + - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" + +before_script: + - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" + +script: nosetests From 1255b87877b685e23c900694b947cdc2011bb8c1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 5 Sep 2012 01:26:05 +0200 Subject: [PATCH 076/177] Try running with fakesink for gstreamer test. --- tests/gstreamer_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index b370981a..2f62424f 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -12,9 +12,13 @@ from tests import unittest, path_to_data_dir class GStreamerTest(unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) self.gstreamer = GStreamer() + def tearDown(self): + settings.runtime.clear() + def prepare_uri(self, uri): self.gstreamer.prepare_change() self.gstreamer.set_uri(uri) From 562c25043ab213c149e07a885458fab26c0cfe37 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 5 Sep 2012 20:34:53 +0200 Subject: [PATCH 077/177] Remove comment about get/set_volume. --- mopidy/backends/base/playback.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 778e7a54..a615f936 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -613,9 +613,6 @@ class BasePlaybackProvider(object): """ raise NotImplementedError - # TODO: having these in the provider is stupid, but since we currently - # don't have gstreamer exposed in a sensible way for this... - # On the bright side it makes testing volume stuff less painful. def get_volume(self): """ Get current volume From 8c038bbae616180095a1a831346f17136e0790bf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 5 Sep 2012 20:48:00 +0200 Subject: [PATCH 078/177] Review cleanup. --- mopidy/backends/spotify/playback.py | 2 +- mopidy/gstreamer.py | 4 ++-- mopidy/mixers/auto.py | 6 +++--- mopidy/mixers/fake.py | 2 +- mopidy/utils/settings.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 116be285..70cc4617 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -43,7 +43,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return result def get_volume(self): - return self.backend.gstreamer.get_volume() + return self.backend.gstreamer.get_volume().get() def set_volume(self, volume): self.backend.gstreamer.set_volume(volume) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 7bf5f370..3e538ccc 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -44,8 +44,8 @@ class GStreamer(ThreadingActor): self._setup_pipeline() self._setup_output(output or settings.OUTPUT) - self._setup_mixer(mixer or settings.MIXER, - mixer_track or settings.MIXER_TRACK) + self._setup_mixer( + mixer or settings.MIXER, mixer_track or settings.MIXER_TRACK) self._setup_message_processor() def _setup_pipeline(self): diff --git a/mopidy/mixers/auto.py b/mopidy/mixers/auto.py index 4b4ce543..f4bd0f92 100644 --- a/mopidy/mixers/auto.py +++ b/mopidy/mixers/auto.py @@ -31,10 +31,10 @@ class AutoAudioMixer(gst.Bin): factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) for factory in factories: - # Avoid sink/srcs that implment mixing. + # Avoid sink/srcs that implement mixing. if factory.get_klass() != 'Generic/Audio': continue - # Avoid anything that doesn't implment mixing. + # Avoid anything that doesn't implement mixing. elif not factory.has_interface('GstMixer'): continue @@ -69,4 +69,4 @@ class AutoAudioMixer(gst.Bin): gobject.type_register(AutoAudioMixer) -gst.element_register (AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) +gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) diff --git a/mopidy/mixers/fake.py b/mopidy/mixers/fake.py index de0f6a50..b697956a 100644 --- a/mopidy/mixers/fake.py +++ b/mopidy/mixers/fake.py @@ -77,4 +77,4 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): gobject.type_register(FakeMixer) -gst.element_register (FakeMixer, 'fakemixer', gst.RANK_MARGINAL) +gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index edda0222..726917c6 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -152,7 +152,7 @@ def validate_settings(defaults, settings): elif setting == 'OUTPUTS': errors[setting] = ( - u'Deprecated setting, please change to OUTPUT. OUTPUT expectes ' + u'Deprecated setting, please change to OUTPUT. OUTPUT expects ' u'a GStreamer bin description string for your desired output.') elif setting == 'SPOTIFY_BITRATE': From b0d6dc3e0cbb80f9fa9fec0006d88461e0b0f8a1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 5 Sep 2012 22:42:09 +0200 Subject: [PATCH 079/177] Remove injected gstreamer settings. --- mopidy/gstreamer.py | 27 ++++++++++++--------------- tests/gstreamer_test.py | 10 ++++++---- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 3e538ccc..5adfd754 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -26,7 +26,7 @@ class GStreamer(ThreadingActor): """ - def __init__(self, output=None, mixer=None, mixer_track=None): + def __init__(self): super(GStreamer, self).__init__() self._default_caps = gst.Caps(""" audio/x-raw-int, @@ -43,9 +43,8 @@ class GStreamer(ThreadingActor): self._mixer = None self._setup_pipeline() - self._setup_output(output or settings.OUTPUT) - self._setup_mixer( - mixer or settings.MIXER, mixer_track or settings.MIXER_TRACK) + self._setup_output() + self._setup_mixer() self._setup_message_processor() def _setup_pipeline(self): @@ -66,37 +65,35 @@ class GStreamer(ThreadingActor): self._uridecodebin.connect('pad-added', self._on_new_pad, self._pipeline.get_by_name('queue').get_pad('sink')) - def _setup_output(self, output_description): + def _setup_output(self): # This will raise a gobject.GError if the description is bad. self._output = gst.parse_bin_from_description( - output_description, ghost_unconnected_pads=True) + settings.OUTPUT, ghost_unconnected_pads=True) self._pipeline.add(self._output) gst.element_link_many(self._pipeline.get_by_name('queue'), self._output) - logger.info('Output set to %s', output_description) + logger.info('Output set to %s', settings.OUTPUT) - def _setup_mixer(self, mixer_description, track_label): - if not mixer_description: + def _setup_mixer(self): + if not settings.MIXER: logger.info('Not setting up mixer.') return # This will raise a gobject.GError if the description is bad. - mixerbin = gst.parse_bin_from_description(mixer_description, False) + mixerbin = gst.parse_bin_from_description(settings.MIXER, False) # We assume that the bin will contain a single mixer. mixer = mixerbin.get_by_interface('GstMixer') if not mixer: - logger.warning('Did not find any mixers in %r', - mixer_description) + logger.warning('Did not find any mixers in %r', settings.MIXER) return if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Setting mixer %r to READY failed.', - mixer_description) + logger.warning('Setting mixer %r to READY failed.', settings.MIXER) return - track = self._select_mixer_track(mixer, track_label) + track = self._select_mixer_track(mixer, settings.MIXER_TRACK) if not track: logger.warning('Could not find usable mixer track.') return diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index f30b672b..62633e4f 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -11,11 +11,13 @@ from tests import unittest, path_to_data_dir 'Our Windows build server does not support GStreamer yet') class GStreamerTest(unittest.TestCase): def setUp(self): - # TODO: does this modify global settings without reseting it? - # TODO: should use a fake backend stub for this test? - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + settings.MIXER = 'fakemixer track_max_volume=65536' + settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer(mixer='fakemixer track_max_volume=65536') + self.gstreamer = GStreamer() + + def tearDown(self): + settings.runtime.clear() def prepare_uri(self, uri): self.gstreamer.prepare_change() From 7e934401fb9ec0ec8a7b43dfffc8aa531b9024e2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 5 Sep 2012 22:55:57 +0200 Subject: [PATCH 080/177] Removed refrences to old mixers from docs. --- docs/api/backends/concepts.rst | 1 - docs/api/backends/controllers.rst | 10 +---- docs/api/mixers.rst | 43 ---------------------- docs/changes.rst | 8 ++++ docs/clients/mpd.rst | 5 --- docs/installation/index.rst | 3 -- docs/modules/mixers/alsa.rst | 7 ---- docs/modules/mixers/denon.rst | 7 ---- docs/modules/mixers/dummy.rst | 7 ---- docs/modules/mixers/gstreamer_software.rst | 7 ---- docs/modules/mixers/nad.rst | 7 ---- docs/modules/mixers/osa.rst | 7 ---- 12 files changed, 10 insertions(+), 102 deletions(-) delete mode 100644 docs/api/mixers.rst delete mode 100644 docs/modules/mixers/alsa.rst delete mode 100644 docs/modules/mixers/denon.rst delete mode 100644 docs/modules/mixers/dummy.rst delete mode 100644 docs/modules/mixers/gstreamer_software.rst delete mode 100644 docs/modules/mixers/nad.rst delete mode 100644 docs/modules/mixers/osa.rst diff --git a/docs/api/backends/concepts.rst b/docs/api/backends/concepts.rst index 0d476213..371e03bc 100644 --- a/docs/api/backends/concepts.rst +++ b/docs/api/backends/concepts.rst @@ -27,4 +27,3 @@ Providers: "Playback\ncontroller" -> "Playback\nproviders" Backend -> "Stored\nplaylists\ncontroller" "Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" - Backend -> Mixer diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst index 20dc2d61..bb1e20f5 100644 --- a/docs/api/backends/controllers.rst +++ b/docs/api/backends/controllers.rst @@ -20,19 +20,13 @@ The backend Playback controller =================== -Manages playback, with actions like play, pause, stop, next, previous, and -seek. +Manages playback, with actions like play, pause, stop, next, previous, +seek and volume control. .. autoclass:: mopidy.backends.base.PlaybackController :members: -Mixer controller -================ - -Manages volume. See :class:`mopidy.mixers.base.BaseMixer`. - - Current playlist controller =========================== diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst deleted file mode 100644 index 2459db8c..00000000 --- a/docs/api/mixers.rst +++ /dev/null @@ -1,43 +0,0 @@ -********* -Mixer API -********* - -Mixers are responsible for controlling volume. Clients of the mixers will -simply instantiate a mixer and read/write to the ``volume`` attribute:: - - >>> from mopidy.mixers.alsa import AlsaMixer - >>> mixer = AlsaMixer() - >>> mixer.volume - 100 - >>> mixer.volume = 80 - >>> mixer.volume - 80 - -Most users will use one of the internal mixers which controls the volume on the -computer running Mopidy. If you do not specify which mixer you want to use in -the settings, Mopidy will choose one for you based upon what OS you run. See -:attr:`mopidy.settings.MIXER` for the defaults. - -Mopidy also supports controlling volume on other hardware devices instead of on -the computer running Mopidy through the use of custom mixer implementations. To -enable one of the hardware device mixers, you must the set -:attr:`mopidy.settings.MIXER` setting to point to one of the classes found -below, and possibly add some extra settings required by the mixer you choose. - -All mixers should subclass :class:`mopidy.mixers.base.BaseMixer` and override -methods as described below. - -.. automodule:: mopidy.mixers.base - :synopsis: Mixer API - :members: - - -Mixer implementations -===================== - -* :mod:`mopidy.mixers.alsa` -* :mod:`mopidy.mixers.denon` -* :mod:`mopidy.mixers.dummy` -* :mod:`mopidy.mixers.gstreamer_software` -* :mod:`mopidy.mixers.osa` -* :mod:`mopidy.mixers.nad` diff --git a/docs/changes.rst b/docs/changes.rst index 1e767d1c..2664a083 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -34,6 +34,14 @@ v0.8 (in development) ``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`121`, :issue:`159`) +- Switch to pure GStreamer based mixing. This implies that users setup a + GStreamer bin with a mixer in it in :attr:`mopidy.setting.MIXER`. The default + value is ``autoaudiomixer``, a custom mixer that attempts to find a mixer that + will work on your system. If this picks the wrong mixer you can of course + override it. Setting the mixer to :class:`None` is also support. MPD protocol + support for volume has also been updated to return -1 when we have no mixer + set. + v0.7.3 (2012-08-11) =================== diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 4c789eba..844eaee7 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -181,11 +181,6 @@ they artist and album tabs do not hang. The folder tab still freezes when ``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've discovered a couple of bugs in Droid MPD Client. -The volume control is very slick, with a turn knob, just like on an amplifier. -It lends itself to showing off to friends when combined with Mopidy's external -amplifier mixers. Everybody loves turning a knob on a touch screen and see the -physical knob on the amplifier turn as well ;-) - Even though ``lsinfo`` returns the stored playlists for the folder tab, they are not displayed anywhere. Thus, we had to select an album in the album tab to complete the test procedure. diff --git a/docs/installation/index.rst b/docs/installation/index.rst index fae50a1b..766616ac 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -46,9 +46,6 @@ dependencies installed. sudo apt-get install python-dbus python-indicate - - Some custom mixers (but not the default one) require additional - dependencies. See the docs for each mixer. - Install latest stable release ============================= diff --git a/docs/modules/mixers/alsa.rst b/docs/modules/mixers/alsa.rst deleted file mode 100644 index e8b7ed6c..00000000 --- a/docs/modules/mixers/alsa.rst +++ /dev/null @@ -1,7 +0,0 @@ -************************************************* -:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux -************************************************* - -.. automodule:: mopidy.mixers.alsa - :synopsis: ALSA mixer for Linux - :members: diff --git a/docs/modules/mixers/denon.rst b/docs/modules/mixers/denon.rst deleted file mode 100644 index 7fb2d6cc..00000000 --- a/docs/modules/mixers/denon.rst +++ /dev/null @@ -1,7 +0,0 @@ -***************************************************************** -:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers -***************************************************************** - -.. automodule:: mopidy.mixers.denon - :synopsis: Hardware mixer for Denon amplifiers - :members: diff --git a/docs/modules/mixers/dummy.rst b/docs/modules/mixers/dummy.rst deleted file mode 100644 index 8ac18e10..00000000 --- a/docs/modules/mixers/dummy.rst +++ /dev/null @@ -1,7 +0,0 @@ -***************************************************** -:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing -***************************************************** - -.. automodule:: mopidy.mixers.dummy - :synopsis: Dummy mixer for testing - :members: diff --git a/docs/modules/mixers/gstreamer_software.rst b/docs/modules/mixers/gstreamer_software.rst deleted file mode 100644 index 98e09f44..00000000 --- a/docs/modules/mixers/gstreamer_software.rst +++ /dev/null @@ -1,7 +0,0 @@ -*************************************************************************** -:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms -*************************************************************************** - -.. automodule:: mopidy.mixers.gstreamer_software - :synopsis: Software mixer for all platforms - :members: diff --git a/docs/modules/mixers/nad.rst b/docs/modules/mixers/nad.rst deleted file mode 100644 index 56291cbb..00000000 --- a/docs/modules/mixers/nad.rst +++ /dev/null @@ -1,7 +0,0 @@ -************************************************************* -:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers -************************************************************* - -.. automodule:: mopidy.mixers.nad - :synopsis: Hardware mixer for NAD amplifiers - :members: diff --git a/docs/modules/mixers/osa.rst b/docs/modules/mixers/osa.rst deleted file mode 100644 index a4363cb4..00000000 --- a/docs/modules/mixers/osa.rst +++ /dev/null @@ -1,7 +0,0 @@ -********************************************** -:mod:`mopidy.mixers.osa` -- Osa mixer for OS X -********************************************** - -.. automodule:: mopidy.mixers.osa - :synopsis: Osa mixer for OS X - :members: From 6805c16b1a1801715363b9b755d6a9e3324380d7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 5 Sep 2012 23:54:37 +0200 Subject: [PATCH 081/177] Add .serialize() method to ImutableObjects. Should be useful for things like dumping to json. --- mopidy/models.py | 15 +++++++++++++++ tests/models_test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index 9a508ba7..3363a429 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -74,6 +74,19 @@ class ImmutableObject(object): % key) return self.__class__(**data) + def serialize(self): + data = {} + for key in self.__dict__.keys(): + public_key = key.lstrip('_') + value = self.__dict__[key] + if isinstance(value, (set, frozenset, list, tuple)): + value = [o.serialize() for o in value] + elif isinstance(value, ImmutableObject): + value = value.serialize() + if value: + data[public_key] = value + return data + class Artist(ImmutableObject): """ @@ -216,6 +229,8 @@ class Playlist(ImmutableObject): self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) super(Playlist, self).__init__(*args, **kwargs) + # TODO: def insert(self, pos, track): ... ? + @property def length(self): """The number of tracks in the playlist. Read-only.""" diff --git a/tests/models_test.py b/tests/models_test.py index 978f35b6..231587e4 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -79,6 +79,11 @@ class ArtistTest(unittest.TestCase): "Artist(name='name', uri='uri')", repr(Artist(uri='uri', name='name'))) + def test_serialize(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Artist(uri='uri', name='name').serialize()) + def test_eq_name(self): artist1 = Artist(name=u'name') artist2 = Artist(name=u'name') @@ -180,6 +185,17 @@ class AlbumTest(unittest.TestCase): "Album(artists=[Artist(name='foo')], name='name', uri='uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) + def test_serialize_without_artists(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Album(uri='uri', name='name').serialize()) + + def test_serialize_with_artists(self): + artist = Artist(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, + Album(uri='uri', name='name', artists=[artist]).serialize()) + def test_eq_name(self): album1 = Album(name=u'name') album2 = Album(name=u'name') @@ -360,6 +376,23 @@ class TrackTest(unittest.TestCase): "Track(artists=[Artist(name='foo')], name='name', uri='uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) + def test_serialize_without_artists(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Track(uri='uri', name='name').serialize()) + + def test_serialize_with_artists(self): + artist = Artist(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, + Track(uri='uri', name='name', artists=[artist]).serialize()) + + def test_serialize_with_album(self): + album = Album(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'album': album.serialize()}, + Track(uri='uri', name='name', album=album).serialize()) + def test_eq_uri(self): track1 = Track(uri=u'uri1') track2 = Track(uri=u'uri1') @@ -603,6 +636,17 @@ class PlaylistTest(unittest.TestCase): "uri='uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) + def test_serialize_without_tracks(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Playlist(uri='uri', name='name').serialize()) + + def test_serialize_with_tracks(self): + track = Track(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'tracks': [track.serialize()]}, + Playlist(uri='uri', name='name', tracks=[track]).serialize()) + def test_eq_name(self): playlist1 = Playlist(name=u'name') playlist2 = Playlist(name=u'name') From 8b4302e388cc0b744f2bb540d0ff417a372c4818 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 5 Sep 2012 23:58:30 +0200 Subject: [PATCH 082/177] Add Travis CI description to development docs --- docs/development/contributing.rst | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index 782d2f20..373da1a0 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -104,17 +104,26 @@ For more documentation on testing, check out the `nose documentation `_. -Continuous integration server -============================= +Continuous integration +====================== -We run a continuous integration (CI) server at http://ci.mopidy.com/ that runs -all test on multiple platforms (Ubuntu, OS X, etc.) for every commit we push to -GitHub. +Mopidy uses the free service `Travis CI `_ +for automatically running the test suite when code is pushed to GitHub. This +works both for the main Mopidy repo, but also for any forks. This way, any +contributions to Mopidy through GitHub will automatically be tested by Travis +CI, and the build status will be visible in the GitHub pull request interface, +making it easier to evaluate the quality of pull requests. -In addition to running tests, the CI server also gathers coverage statistics -and uses pylint to check for errors and possible improvements in our code. So, -if you're out of work, the code coverage and pylint data at the CI server -should give you a place to start. +In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all +test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to +the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't +tested by Jenkins before it is merged into the ``develop`` branch, which is a +bit late, but good enough to get broad testing before new code is released. + +In addition to running tests, the Jenkins CI server also gathers coverage +statistics and uses pylint to check for errors and possible improvements in our +code. So, if you're out of work, the code coverage and pylint data at the CI +server should give you a place to start. Writing documentation From d8f895fd38a5ccbe375bf749b8aa20d99e033409 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 5 Sep 2012 23:58:52 +0200 Subject: [PATCH 083/177] Add Travis CI build status badge to README --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 13ab0f92..0b0f6965 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,8 @@ Mopidy ****** +.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop + Mopidy is a music server which can play music from `Spotify `_ or from your local hard drive. To search for music in Spotify's vast archive, manage playlists, and play music, you can use most From 776a5040b1405f868b9bae77d42ece0468466fc3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 6 Sep 2012 00:10:24 +0200 Subject: [PATCH 084/177] Review nitpicks. --- docs/api/backends/controllers.rst | 2 +- docs/changes.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst index bb1e20f5..8d6687e2 100644 --- a/docs/api/backends/controllers.rst +++ b/docs/api/backends/controllers.rst @@ -21,7 +21,7 @@ Playback controller =================== Manages playback, with actions like play, pause, stop, next, previous, -seek and volume control. +seek, and volume control. .. autoclass:: mopidy.backends.base.PlaybackController :members: diff --git a/docs/changes.rst b/docs/changes.rst index 2664a083..963802d4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -38,9 +38,9 @@ v0.8 (in development) GStreamer bin with a mixer in it in :attr:`mopidy.setting.MIXER`. The default value is ``autoaudiomixer``, a custom mixer that attempts to find a mixer that will work on your system. If this picks the wrong mixer you can of course - override it. Setting the mixer to :class:`None` is also support. MPD protocol - support for volume has also been updated to return -1 when we have no mixer - set. + override it. Setting the mixer to :class:`None` is also supported. MPD + protocol support for volume has also been updated to return -1 when we have + no mixer set. v0.7.3 (2012-08-11) From 0aeb11b22ebd94b0263e209a517afae46eb1c9da Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 7 Sep 2012 00:51:08 +0200 Subject: [PATCH 085/177] Create a Track proxy for spotify (Fixes #72) --- mopidy/backends/spotify/library.py | 45 ++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a080c7bd..181dc19d 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -5,21 +5,54 @@ from spotify import Link, SpotifyError from mopidy.backends.base import BaseLibraryProvider from mopidy.backends.spotify.translator import SpotifyTranslator -from mopidy.models import Playlist +from mopidy.models import Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.library') + +class SpotifyTrack(Track): + def __init__(self, uri): + self._spotify_track = Link.from_string(uri).as_track() + self._unloaded_track = Track(uri=uri, name=u'[loading...]') + self._track = None + + @property + def _proxy(self): + if self._track: + return self._track + elif self._spotify_track.is_loaded(): + self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track) + return self._track + else: + return self._unloaded_track + + def __getattribute__(self, name): + if name.startswith('_'): + return super(SpotifyTrack, self).__getattribute__(name) + return self._proxy.__getattribute__(name) + + def __repr__(self): + return self._proxy.__repr__() + + def __hash__(self): # hash on just uri for consistency? + return hash(self._proxy.uri) + + def __eq__(self, other): # compare on just uri for consistency? + if not isinstance(other, Track): + return False + return self._proxy.uri == other.uri + + def copy(self, **values): # is it okay to return a plain track? + return self._proxy.copy(**values) + + class SpotifyLibraryProvider(BaseLibraryProvider): def find_exact(self, **query): return self.search(**query) def lookup(self, uri): try: - spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - return SpotifyTranslator.to_mopidy_track(spotify_track) + return SpotifyTrack(uri) except SpotifyError as e: logger.debug(u'Failed to lookup "%s": %s', uri, e) return None From ebead0d0d2c9c89807f0e687e40bddd16a519ec6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 01:00:56 +0200 Subject: [PATCH 086/177] Fix typo in variable name --- mopidy/mixers/fake.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/mixers/fake.py b/mopidy/mixers/fake.py index b697956a..83bde6fc 100644 --- a/mopidy/mixers/fake.py +++ b/mopidy/mixers/fake.py @@ -4,12 +4,12 @@ import gobject import gst -def create_fake_track(label, intial_volume, min_volume, max_volume, +def create_fake_track(label, initial_volume, min_volume, max_volume, num_channels, flags): class Track(gst.interfaces.MixerTrack): def __init__(self): super(Track, self).__init__() - self.volumes = (intial_volume,) * self.num_channels + self.volumes = (initial_volume,) * self.num_channels @gobject.property def label(self): From e3ba389996f986c0e87139594b5cd07143d5944b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 01:24:48 +0200 Subject: [PATCH 087/177] Make MPRIS frontend handle unknown volume --- mopidy/frontends/mpris/objects.py | 5 +++-- tests/frontends/mpris/player_interface_test.py | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index fa5f9614..6815c0d2 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -371,8 +371,9 @@ class MprisObject(dbus.service.Object): def get_Volume(self): volume = self.backend.playback.volume.get() - if volume is not None: - return volume / 100.0 + if volume is None: + return 0 + return volume / 100.0 def set_Volume(self, value): if not self.get_CanControl(): diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index d09d4f6b..b7ad1b60 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -205,6 +205,10 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): + self.backend.playback.volume = None + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 0) + self.backend.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) From 641f8d2e2db60149d9ece571053e2ef27808d5aa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 00:39:22 +0200 Subject: [PATCH 088/177] Port NAD hardware mixer to the GStreamer mixer API Fixes #179 --- docs/changes.rst | 20 ++++ mopidy/mixers/__init__.py | 1 + mopidy/mixers/nad.py | 243 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 mopidy/mixers/nad.py diff --git a/docs/changes.rst b/docs/changes.rst index 963802d4..6298c8e3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -42,6 +42,26 @@ v0.8 (in development) protocol support for volume has also been updated to return -1 when we have no mixer set. +- Removed the Denon hardware mixer, as it is not maintained. + +- Updated the NAD hardware mixer to work in the new GStreamer based mixing + regime. Settings are now passed as GStreamer element properties. In practice + that means that the following old-style config: + + MIXER = u'mopidy.mixers.nad.NadMixer' + MIXER_EXT_PORT = u'/dev/ttyUSB0' + MIXER_EXT_SOURCE = u'Aux' + MIXER_EXT_SPEAKERS_A = u'On' + MIXER_EXT_SPEAKERS_B = u'Off' + + Now is reduced to simply: + + MIXER = u'nadmixer port=/dev/ttyUSB0 source=Aux speakers-a=On speakers-b=Off' + + The ``port`` property defaults to ``/dev/ttyUSB0``, and the rest of the + properties may be left out if you don't want the mixer to adjust the settings + on your NAD amplifier when Mopidy is started. + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py index cf282a03..259557d1 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/mixers/__init__.py @@ -1,2 +1,3 @@ from mopidy.mixers.auto import AutoAudioMixer from mopidy.mixers.fake import FakeMixer +from mopidy.mixers.nad import NadMixer diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py new file mode 100644 index 00000000..e0bfa2d3 --- /dev/null +++ b/mopidy/mixers/nad.py @@ -0,0 +1,243 @@ +import logging + +import pygst +pygst.require('0.10') +import gobject +import gst + +try: + import serial +except ImportError: + serial = None + +from pykka.actor import ThreadingActor + +from mopidy.mixers.fake import create_fake_track + + +logger = logging.getLogger('mopidy.mixers.nad') + + +class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): + __gstdetails__ = ('NadMixer', + 'Mixer', + 'Mixer to control NAD amplifiers using a serial link', + 'Stein Magnus Jodal') + + port = gobject.property(type=str, default='/dev/ttyUSB0') + source = gobject.property(type=str) + speakers_a = gobject.property(type=str) + speakers_b = gobject.property(type=str) + + def __init__(self): + gst.Element.__init__(self) + self._volume_cache = 0 + self._nad_talker = None + + def list_tracks(self): + track = create_fake_track( + label='Master', + initial_volume=0, + min_volume=0, + max_volume=100, + num_channels=1, + flags=(gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) + return [track] + + def get_volume(self, track): + return [self._volume_cache] + + def set_volume(self, track, volumes): + if len(volumes): + volume = volumes[0] + self._volume_cache = volume + self._nad_talker.set_volume(volume) + + def set_mute(self, track, mute): + if mute: + self._nad_talker.mute() + else: + self._nad_talker.unmute() + + def do_change_state(self, transition): + if transition == gst.STATE_CHANGE_NULL_TO_READY: + if serial is None: + logger.warning(u'nadmixer dependency python-serial not found') + return gst.STATE_CHANGE_FAILURE + self._start_nad_talker() + return gst.STATE_CHANGE_SUCCESS + + def _start_nad_talker(self): + self._nad_talker = NadTalker.start( + port=self.port, + source=self.source or None, + speakers_a=self.speakers_a or None, + speakers_b=self.speakers_b or None + ).proxy() + + +gobject.type_register(NadMixer) +gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL) + + +class NadTalker(ThreadingActor): + """ + Independent thread which does the communication with the NAD device. + + Since the communication is done in an independent thread, Mopidy won't + block other requests while doing rather time consuming work like + calibrating the NAD device's volume. + """ + + # Serial link settings + BAUDRATE = 115200 + BYTESIZE = 8 + PARITY = 'N' + STOPBITS = 1 + + # Timeout in seconds used for read/write operations. + # If you set the timeout too low, the reads will never get complete + # confirmations and calibration will decrease volume forever. If you set + # the timeout too high, stuff takes more time. 0.2s seems like a good value + # for NAD C 355BEE. + TIMEOUT = 0.2 + + # Number of volume levels the device supports. 40 for NAD C 355BEE. + VOLUME_LEVELS = 40 + + def __init__(self, port, source, speakers_a, speakers_b): + super(NadTalker, self).__init__() + + self.port = port + self.source = source + if speakers_a in ('On', 'Off'): + self.speakers_a = speakers_a + else: + logger.warning('speakers-a must be "On" or "Off", or unset') + self.speakers_a = None + if speakers_b in ('On', 'Off'): + self.speakers_b = speakers_b + else: + logger.warning('speakers-b must be "On" or "Off", or unset') + self.speakers_b = None + + # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. + self._nad_volume = None + + self._device = None + + def on_start(self): + self._open_connection() + self._set_device_to_known_state() + + def _open_connection(self): + logger.info(u'Connecting to NAD amplifier using serial device "%s"', + self.port) + self._device = serial.Serial( + port=self.port, + baudrate=self.BAUDRATE, + bytesize=self.BYTESIZE, + parity=self.PARITY, + stopbits=self.STOPBITS, + timeout=self.TIMEOUT) + self._get_device_model() + + def _set_device_to_known_state(self): + self._power_device_on() + self._select_speakers() + self._select_input_source() + self._unmute() + self._calibrate_volume() + + def _get_device_model(self): + model = self._ask_device('Main.Model') + logger.info(u'Connected to NAD amplifier model "%s"', model) + return model + + def _power_device_on(self): + while self._ask_device('Main.Power') != 'On': + logger.info(u'Powering device on') + self._command_device('Main.Power', 'On') + + def _select_speakers(self): + if self.speakers_a is not None: + while (self._ask_device('Main.SpeakerA') != self.speakers_a): + logger.info(u'Setting speakers A to "%s"', self.speakers_a) + self._command_device('Main.SpeakerA', self.speakers_a) + if self.speakers_b is not None: + while (self._ask_device('Main.SpeakerB') != self.speakers_b): + logger.info(u'Setting speakers B to "%s"', self.speakers_b) + self._command_device('Main.SpeakerB', self.speakers_b) + + def _select_input_source(self): + if self.source is not None: + while self._ask_device('Main.Source') != self.source: + logger.info(u'Selecting input source "%s"', self.source) + self._command_device('Main.Source', self.source) + + def _unmute(self): + while self._ask_device('Main.Mute') != 'Off': + logger.info(u'Unmuting device') + self._command_device('Main.Mute', 'Off') + + def _ask_device(self, key): + self._write('%s?' % key) + return self._readline().replace('%s=' % key, '') + + def _command_device(self, key, value): + if type(value) == unicode: + value = value.encode('utf-8') + self._write('%s=%s' % (key, value)) + self._readline() + + def _calibrate_volume(self): + # The NAD C 355BEE amplifier has 40 different volume levels. We have no + # way of asking on which level we are. Thus, we must calibrate the + # mixer by decreasing the volume 39 times. + logger.info(u'Calibrating NAD amplifier by setting volume to 0') + self._nad_volume = self.VOLUME_LEVELS + self.set_volume(0) + logger.info(u'Done calibrating NAD amplifier') + + def set_volume(self, volume): + # Increase or decrease the amplifier volume until it matches the given + # target volume. + logger.debug(u'Setting volume to %d' % volume) + target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) + if self._nad_volume is None: + return # Calibration needed + while target_nad_volume > self._nad_volume: + if self._increase_volume(): + self._nad_volume += 1 + while target_nad_volume < self._nad_volume: + if self._decrease_volume(): + self._nad_volume -= 1 + + def _increase_volume(self): + # Increase volume. Returns :class:`True` if confirmed by device. + self._write('Main.Volume+') + return self._readline() == 'Main.Volume+' + + def _decrease_volume(self): + # Decrease volume. Returns :class:`True` if confirmed by device. + self._write('Main.Volume-') + return self._readline() == 'Main.Volume-' + + def _write(self, data): + # Write data to device. Prepends and appends a newline to the data, as + # recommended by the NAD documentation. + if not self._device.isOpen(): + self._device.open() + self._device.write('\n%s\n' % data) + logger.debug('Write: %s', data) + + def _readline(self): + # Read line from device. The result is stripped for leading and + # trailing whitespace. + if not self._device.isOpen(): + self._device.open() + result = self._device.readline().strip() + if result: + logger.debug('Read: %s', result) + return result From cd021cc8198932969c055d55b6e4a5c0daadcbf6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 22:03:30 +0200 Subject: [PATCH 089/177] Move create_track() out of the fakemixer as it is useful for other mixers --- mopidy/mixers/__init__.py | 37 ++++++++++++++++++++++++++++++ mopidy/mixers/fake.py | 48 +++++++-------------------------------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py index cf282a03..87fbd52f 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/mixers/__init__.py @@ -1,2 +1,39 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + + +def create_track(label, initial_volume, min_volume, max_volume, + num_channels, flags): + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (initial_volume,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() + + +# Import all mixers so that they are registered with GStreamer from mopidy.mixers.auto import AutoAudioMixer from mopidy.mixers.fake import FakeMixer diff --git a/mopidy/mixers/fake.py b/mopidy/mixers/fake.py index 83bde6fc..3c47ef33 100644 --- a/mopidy/mixers/fake.py +++ b/mopidy/mixers/fake.py @@ -3,35 +3,7 @@ pygst.require('0.10') import gobject import gst - -def create_fake_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (initial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() +from mopidy.mixers import create_track class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): @@ -41,15 +13,10 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): 'Thomas Adamcik') track_label = gobject.property(type=str, default='Master') - track_initial_volume = gobject.property(type=int, default=0) - track_min_volume = gobject.property(type=int, default=0) - track_max_volume = gobject.property(type=int, default=100) - track_num_channels = gobject.property(type=int, default=2) - track_flags = gobject.property(type=int, default=(gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) @@ -58,12 +25,13 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): gst.Element.__init__(self) def list_tracks(self): - track = create_fake_track(self.track_label, - self.track_initial_volume, - self.track_min_volume, - self.track_max_volume, - self.track_num_channels, - self.track_flags) + track = create_track( + self.track_label, + self.track_initial_volume, + self.track_min_volume, + self.track_max_volume, + self.track_num_channels, + self.track_flags) return [track] def get_volume(self, track): From f97ce0f06a60018ddc8f9cb850b25f5fa925197f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 22:17:09 +0200 Subject: [PATCH 090/177] Add note about how to avoid cyclic imports --- mopidy/mixers/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py index 87fbd52f..2067a4b5 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/mixers/__init__.py @@ -34,6 +34,9 @@ def create_track(label, initial_volume, min_volume, max_volume, return Track() -# Import all mixers so that they are registered with GStreamer +# Import all mixers so that they are registered with GStreamer. +# +# Keep these imports at the bottom of the file to avoid cyclic import problems +# when mixers use the above code. from mopidy.mixers.auto import AutoAudioMixer from mopidy.mixers.fake import FakeMixer From 1ccdb08420eb2af253f4efa319703605e7db15ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 22:18:23 +0200 Subject: [PATCH 091/177] Use create_track() from new location --- mopidy/mixers/nad.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index e0bfa2d3..49af8f06 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -12,7 +12,7 @@ except ImportError: from pykka.actor import ThreadingActor -from mopidy.mixers.fake import create_fake_track +from mopidy.mixers import create_track logger = logging.getLogger('mopidy.mixers.nad') @@ -35,7 +35,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): self._nad_talker = None def list_tracks(self): - track = create_fake_track( + track = create_track( label='Master', initial_volume=0, min_volume=0, From 5368f75f3a41cca0fe333f5f75dea272d88968d2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 22:22:05 +0200 Subject: [PATCH 092/177] Fix set_mute() implementation --- mopidy/mixers/nad.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 49af8f06..da205460 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -55,10 +55,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): self._nad_talker.set_volume(volume) def set_mute(self, track, mute): - if mute: - self._nad_talker.mute() - else: - self._nad_talker.unmute() + self._nad_talker.mute(mute) def do_change_state(self, transition): if transition == gst.STATE_CHANGE_NULL_TO_READY: @@ -147,7 +144,7 @@ class NadTalker(ThreadingActor): self._power_device_on() self._select_speakers() self._select_input_source() - self._unmute() + self.mute(False) self._calibrate_volume() def _get_device_model(self): @@ -176,10 +173,15 @@ class NadTalker(ThreadingActor): logger.info(u'Selecting input source "%s"', self.source) self._command_device('Main.Source', self.source) - def _unmute(self): - while self._ask_device('Main.Mute') != 'Off': - logger.info(u'Unmuting device') - self._command_device('Main.Mute', 'Off') + def mute(self, mute): + if mute: + while self._ask_device('Main.Mute') != 'On': + logger.info(u'Muting NAD amplifier') + self._command_device('Main.Mute', 'On') + else: + while self._ask_device('Main.Mute') != 'Off': + logger.info(u'Unmuting NAD amplifier') + self._command_device('Main.Mute', 'Off') def _ask_device(self, key): self._write('%s?' % key) From e741b4db7551795afb6c40ffb21bb31efa8a720d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 22:24:48 +0200 Subject: [PATCH 093/177] Prefer the word 'amplifier' over too generic 'device' in docs and log messages --- mopidy/mixers/nad.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index da205460..ce860d4a 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -80,11 +80,11 @@ gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL) class NadTalker(ThreadingActor): """ - Independent thread which does the communication with the NAD device. + Independent thread which does the communication with the NAD amplifier Since the communication is done in an independent thread, Mopidy won't block other requests while doing rather time consuming work like - calibrating the NAD device's volume. + calibrating the NAD amplifier's volume. """ # Serial link settings @@ -100,7 +100,7 @@ class NadTalker(ThreadingActor): # for NAD C 355BEE. TIMEOUT = 0.2 - # Number of volume levels the device supports. 40 for NAD C 355BEE. + # Number of volume levels the amplifier supports. 40 for NAD C 355BEE. VOLUME_LEVELS = 40 def __init__(self, port, source, speakers_a, speakers_b): @@ -129,7 +129,7 @@ class NadTalker(ThreadingActor): self._set_device_to_known_state() def _open_connection(self): - logger.info(u'Connecting to NAD amplifier using serial device "%s"', + logger.info(u'Connecting to NAD amplifier using "%s"', self.port) self._device = serial.Serial( port=self.port, @@ -154,7 +154,7 @@ class NadTalker(ThreadingActor): def _power_device_on(self): while self._ask_device('Main.Power') != 'On': - logger.info(u'Powering device on') + logger.info(u'Powering NAD amplifier on') self._command_device('Main.Power', 'On') def _select_speakers(self): From e57a71729a8bf937232e36ee70ac96ccb99cf124 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 23:01:08 +0200 Subject: [PATCH 094/177] Don't fail on GStreamer EOS if no backend is running This removes the printed AssertionError when running ScannerTest.test_data_is_set() --- mopidy/gstreamer.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 5adfd754..d9157a02 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -133,9 +133,7 @@ class GStreamer(ThreadingActor): def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: - logger.debug(u'GStreamer signalled end-of-stream. ' - 'Telling backend ...') - self._get_backend().playback.on_end_of_track() + self._notify_backend_of_eos() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() logger.error(u'%s %s', error, debug) @@ -144,10 +142,14 @@ class GStreamer(ThreadingActor): error, debug = message.parse_warning() logger.warning(u'%s %s', error, debug) - def _get_backend(self): + def _notify_backend_of_eos(self): backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - return backend_refs[0].proxy() + assert len(backend_refs) <= 1, 'Expected at most one running backend.' + if backend_refs: + logger.debug(u'Notifying backend of end-of-stream.') + backend_refs[0].proxy().playback.on_end_of_track() + else: + logger.debug(u'No backend to notify of end-of-stream found.') def set_uri(self, uri): """ From e948a51310e8f914118b748811972f83b9a43db3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 23:05:31 +0200 Subject: [PATCH 095/177] Remove duplicate tearDown() --- tests/gstreamer_test.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 62633e4f..790394f5 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -23,9 +23,6 @@ class GStreamerTest(unittest.TestCase): self.gstreamer.prepare_change() self.gstreamer.set_uri(uri) - def tearDown(self): - settings.runtime.clear() - def test_start_playback_existing_file(self): self.prepare_uri(self.song_uri) self.assertTrue(self.gstreamer.start_playback()) From 652f97054835366b932c02c00242dc9bdc6dd809 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 23:48:25 +0200 Subject: [PATCH 096/177] Remove old output docs --- docs/api/outputs.rst | 18 ------------------ docs/modules/outputs.rst | 11 ----------- 2 files changed, 29 deletions(-) delete mode 100644 docs/api/outputs.rst delete mode 100644 docs/modules/outputs.rst diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst deleted file mode 100644 index 7f487881..00000000 --- a/docs/api/outputs.rst +++ /dev/null @@ -1,18 +0,0 @@ -.. _output-api: - -********** -Output API -********** - -Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way. - -.. autoclass:: mopidy.outputs.BaseOutput - :members: - - -Output implementations -====================== - -* :class:`mopidy.outputs.custom.CustomOutput` -* :class:`mopidy.outputs.local.LocalOutput` -* :class:`mopidy.outputs.shoutcast.ShoutcastOutput` diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst deleted file mode 100644 index f80c16e3..00000000 --- a/docs/modules/outputs.rst +++ /dev/null @@ -1,11 +0,0 @@ -************************************************ -:mod:`mopidy.outputs` -- GStreamer audio outputs -************************************************ - -The following GStreamer audio outputs implements the :ref:`output-api`. - -.. autoclass:: mopidy.outputs.custom.CustomOutput - -.. autoclass:: mopidy.outputs.local.LocalOutput - -.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput From fd4d5b2f62c2d7ab99537e3fb2204a3f89afefe2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 23:48:55 +0200 Subject: [PATCH 097/177] Update autodoc dependency mock --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a33a8f2d..d8aa118e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,17 +22,17 @@ class Mock(object): def __call__(self, *args, **kwargs): return Mock() + def __or__(self, other): + return Mock() + @classmethod def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' - elif name[0] == name[0].upper(): - return type(name, (), {}) else: return Mock() MOCK_MODULES = [ - 'alsaaudio', 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', From 975c79c1e6868e67a99001fffe59bf5f2f49b77f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 8 Sep 2012 23:50:28 +0200 Subject: [PATCH 098/177] Remove invalid Sphinx text role --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 963802d4..204f1193 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,7 +18,7 @@ v0.8 (in development) Track position and CPID was intermixed, so it would cause a crash if a CPID matching the track position didn't exist. (Fixes: :issue:`162`) -- Added :option:`--list-deps` option to :cmd:`mopidy` command that lists +- Added :option:`--list-deps` option to the `mopidy` command that lists required and optional dependencies, their current versions, and some other information useful for debugging. (Fixes: :issue:`74`) From ccf2c12a189229d9f456cabeafcb713d081c0a2b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 9 Sep 2012 12:52:41 +0200 Subject: [PATCH 099/177] Reuse set-and-check logic --- mopidy/mixers/nad.py | 55 ++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index ce860d4a..eecf9aab 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -153,45 +153,23 @@ class NadTalker(ThreadingActor): return model def _power_device_on(self): - while self._ask_device('Main.Power') != 'On': - logger.info(u'Powering NAD amplifier on') - self._command_device('Main.Power', 'On') + self._check_and_set('Main.Power', 'On') def _select_speakers(self): if self.speakers_a is not None: - while (self._ask_device('Main.SpeakerA') != self.speakers_a): - logger.info(u'Setting speakers A to "%s"', self.speakers_a) - self._command_device('Main.SpeakerA', self.speakers_a) + self._check_and_set('Main.SpeakerA', self.speakers_a) if self.speakers_b is not None: - while (self._ask_device('Main.SpeakerB') != self.speakers_b): - logger.info(u'Setting speakers B to "%s"', self.speakers_b) - self._command_device('Main.SpeakerB', self.speakers_b) + self._check_and_set('Main.SpeakerB', self.speakers_b) def _select_input_source(self): if self.source is not None: - while self._ask_device('Main.Source') != self.source: - logger.info(u'Selecting input source "%s"', self.source) - self._command_device('Main.Source', self.source) + self._check_and_set('Main.Source', self.source) def mute(self, mute): if mute: - while self._ask_device('Main.Mute') != 'On': - logger.info(u'Muting NAD amplifier') - self._command_device('Main.Mute', 'On') + self._check_and_set('Main.Mute', 'On') else: - while self._ask_device('Main.Mute') != 'Off': - logger.info(u'Unmuting NAD amplifier') - self._command_device('Main.Mute', 'Off') - - def _ask_device(self, key): - self._write('%s?' % key) - return self._readline().replace('%s=' % key, '') - - def _command_device(self, key, value): - if type(value) == unicode: - value = value.encode('utf-8') - self._write('%s=%s' % (key, value)) - self._readline() + self._check_and_set('Main.Mute', 'Off') def _calibrate_volume(self): # The NAD C 355BEE amplifier has 40 different volume levels. We have no @@ -226,6 +204,27 @@ class NadTalker(ThreadingActor): self._write('Main.Volume-') return self._readline() == 'Main.Volume-' + def _check_and_set(self, key, value): + for attempt in range(1, 4): + if self._ask_device(key) == value: + return + logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', + key, value, attempt) + self._command_device(key, value) + if self._ask_device(key) != value: + logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"', + key, value) + + def _ask_device(self, key): + self._write('%s?' % key) + return self._readline().replace('%s=' % key, '') + + def _command_device(self, key, value): + if type(value) == unicode: + value = value.encode('utf-8') + self._write('%s=%s' % (key, value)) + self._readline() + def _write(self, data): # Write data to device. Prepends and appends a newline to the data, as # recommended by the NAD documentation. From affe7795694f5a96e190cee7c083af1032369e12 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 9 Sep 2012 12:53:36 +0200 Subject: [PATCH 100/177] Cleanup log messages --- mopidy/mixers/nad.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index eecf9aab..dad3e853 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -129,7 +129,7 @@ class NadTalker(ThreadingActor): self._set_device_to_known_state() def _open_connection(self): - logger.info(u'Connecting to NAD amplifier using "%s"', + logger.info(u'NAD amplifier: Connecting through "%s"', self.port) self._device = serial.Serial( port=self.port, @@ -149,7 +149,7 @@ class NadTalker(ThreadingActor): def _get_device_model(self): model = self._ask_device('Main.Model') - logger.info(u'Connected to NAD amplifier model "%s"', model) + logger.info(u'NAD amplifier: Connected to model "%s"', model) return model def _power_device_on(self): @@ -175,10 +175,10 @@ class NadTalker(ThreadingActor): # The NAD C 355BEE amplifier has 40 different volume levels. We have no # way of asking on which level we are. Thus, we must calibrate the # mixer by decreasing the volume 39 times. - logger.info(u'Calibrating NAD amplifier by setting volume to 0') + logger.info(u'NAD amplifier: Calibrating by setting volume to 0') self._nad_volume = self.VOLUME_LEVELS self.set_volume(0) - logger.info(u'Done calibrating NAD amplifier') + logger.info(u'NAD amplifier: Done calibrating') def set_volume(self, volume): # Increase or decrease the amplifier volume until it matches the given From 297b8db3cd858a3faa4219b0c0d9e466e6866f9c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 9 Sep 2012 12:54:17 +0200 Subject: [PATCH 101/177] Titlecase source and speaker settings --- docs/changes.rst | 2 +- mopidy/mixers/nad.py | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6298c8e3..922eba13 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,7 +56,7 @@ v0.8 (in development) Now is reduced to simply: - MIXER = u'nadmixer port=/dev/ttyUSB0 source=Aux speakers-a=On speakers-b=Off' + MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off' The ``port`` property defaults to ``/dev/ttyUSB0``, and the rest of the properties may be left out if you don't want the mixer to adjust the settings diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index dad3e853..de959d41 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -108,16 +108,8 @@ class NadTalker(ThreadingActor): self.port = port self.source = source - if speakers_a in ('On', 'Off'): - self.speakers_a = speakers_a - else: - logger.warning('speakers-a must be "On" or "Off", or unset') - self.speakers_a = None - if speakers_b in ('On', 'Off'): - self.speakers_b = speakers_b - else: - logger.warning('speakers-b must be "On" or "Off", or unset') - self.speakers_b = None + self.speakers_a = speakers_a + self.speakers_b = speakers_b # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. self._nad_volume = None @@ -157,13 +149,13 @@ class NadTalker(ThreadingActor): def _select_speakers(self): if self.speakers_a is not None: - self._check_and_set('Main.SpeakerA', self.speakers_a) + self._check_and_set('Main.SpeakerA', self.speakers_a.title()) if self.speakers_b is not None: - self._check_and_set('Main.SpeakerB', self.speakers_b) + self._check_and_set('Main.SpeakerB', self.speakers_b.title()) def _select_input_source(self): if self.source is not None: - self._check_and_set('Main.Source', self.source) + self._check_and_set('Main.Source', self.source.title()) def mute(self, mute): if mute: From ab7d0c4cc22289b44552722a062240865058827c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 9 Sep 2012 21:36:52 +0200 Subject: [PATCH 102/177] Don't block gobject event thread, fixes #150. --- mopidy/utils/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 4b8a9ac9..7d97daf8 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -150,7 +150,7 @@ class Connection(object): logger.log(level, reason) try: - self.actor_ref.stop() + self.actor_ref.stop(block=False) except ActorDeadError: pass From db3a201795f11a3df06b657408140babe3304793 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 9 Sep 2012 22:09:23 +0200 Subject: [PATCH 103/177] Fix tests and update docs with #150 fix. --- docs/changes.rst | 6 ++++++ tests/utils/network/connection_test.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index aae792fa..57224300 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -62,6 +62,12 @@ v0.8 (in development) properties may be left out if you don't want the mixer to adjust the settings on your NAD amplifier when Mopidy is started. +- Fixed :issue:`150` which caused some clients to block Mopidy completely. Bug + was caused by some clients sending ``close`` and then shutting down the + connection right away. This trigged a situation in which the connection + cleanup code would wait for an response that would never come inside the + event loop, blocking everything else. + v0.7.3 (2012-08-11) =================== diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index aa1be2b6..96ddb833 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -91,7 +91,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) - self.mock.actor_ref.stop.assert_called_once_with() + self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_handles_actor_already_being_stopped(self): self.mock.stopping = False @@ -100,7 +100,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) - self.mock.actor_ref.stop.assert_called_once_with() + self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_sets_stopping_to_true(self): self.mock.stopping = False From 911b45dce8f83b1abbb24df927de61bafef5eaf6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 9 Sep 2012 23:27:03 +0200 Subject: [PATCH 104/177] Document debug-proxy's existance. --- docs/development/contributing.rst | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index 373da1a0..74e2f0b5 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -125,6 +125,47 @@ statistics and uses pylint to check for errors and possible improvements in our code. So, if you're out of work, the code coverage and pylint data at the CI server should give you a place to start. +Protocol debugging +================== + +Since the main interface provided to Mopidy is through the MPD protocol, it is +crucial that we try and stay in sync with protocol developments. In an attempt +to make it easier to debug differences Mopidy and MPD protocol handling we have +created ``tools/debug-proxy.py``. + +This tool is proxy that sits in front of two MPD protocol aware servers and +sends all requests to both, returning the primary response to the client and +then printing any diff in the two responses. + +Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time +of writing. See ``--help`` for available options. Sample session:: + + [127.0.0.1]:59714 + listallinfo + --- Reference response + +++ Actual response + @@ -1,16 +1,1 @@ + -file: uri1 + -Time: 4 + -Artist: artist1 + -Title: track1 + -Album: album1 + -file: uri2 + -Time: 4 + -Artist: artist2 + -Title: track2 + -Album: album2 + -file: uri3 + -Time: 4 + -Artist: artist3 + -Title: track3 + -Album: album3 + -OK + +ACK [2@0] {listallinfo} incorrect arguments + +To ensure that Mopidy and MPD have comparable state it is suggested you setup +both to use ``tests/data/library_tag_cache`` for their tag cache and +``tests/data`` for music/playlist folders. Writing documentation ===================== From ee599b6235aff2572604580e5ea0c57438571e29 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 9 Sep 2012 23:57:06 +0200 Subject: [PATCH 105/177] Add changelog entry for #72 and remove old comments. --- docs/changes.rst | 3 +++ mopidy/backends/spotify/library.py | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 57224300..082ad136 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -68,6 +68,9 @@ v0.8 (in development) cleanup code would wait for an response that would never come inside the event loop, blocking everything else. +- Created a Spotify track proxy that will switch to using loaded data as soon + as it becomes available. Fixes :issue:`72`. + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 181dc19d..18276ecd 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -11,6 +11,7 @@ logger = logging.getLogger('mopidy.backends.spotify.library') class SpotifyTrack(Track): + """Proxy object for unloaded Spotify tracks.""" def __init__(self, uri): self._spotify_track = Link.from_string(uri).as_track() self._unloaded_track = Track(uri=uri, name=u'[loading...]') @@ -34,15 +35,15 @@ class SpotifyTrack(Track): def __repr__(self): return self._proxy.__repr__() - def __hash__(self): # hash on just uri for consistency? + def __hash__(self): return hash(self._proxy.uri) - def __eq__(self, other): # compare on just uri for consistency? + def __eq__(self, other): if not isinstance(other, Track): return False return self._proxy.uri == other.uri - def copy(self, **values): # is it okay to return a plain track? + def copy(self, **values): return self._proxy.copy(**values) From 94fdac04a12348ca1efa1427d6a18414008b8f24 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 10 Sep 2012 00:31:03 +0200 Subject: [PATCH 106/177] Cleanup after GStreamer actor Unregister callbacks and release pipeline resources when GStreamer actor shuts down. Fixes #185. --- mopidy/gstreamer.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index d9157a02..c25dde47 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -47,6 +47,10 @@ class GStreamer(ThreadingActor): self._setup_mixer() self._setup_message_processor() + def on_stop(self): + self._teardown_message_processor() + self._teardown_pipeline() + def _setup_pipeline(self): # TODO: replace with and input bin so we simply have an input bin we # connect to an output bin with a mixer on the side. set_uri on bin? @@ -65,6 +69,9 @@ class GStreamer(ThreadingActor): self._uridecodebin.connect('pad-added', self._on_new_pad, self._pipeline.get_by_name('queue').get_pad('sink')) + def _teardown_pipeline(self): + self._pipeline.set_state(gst.STATE_NULL) + def _setup_output(self): # This will raise a gobject.GError if the description is bad. self._output = gst.parse_bin_from_description( @@ -118,6 +125,10 @@ class GStreamer(ThreadingActor): bus.add_signal_watch() bus.connect('message', self._on_message) + def _teardown_message_processor(self): + bus = self._pipeline.get_bus() + bus.remove_signal_watch() + def _on_new_source(self, element, pad): self._source = element.get_property('source') try: From 1c4ee46c4cf382bcc111e09b4c511743d4d8dbab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 10 Sep 2012 00:31:41 +0200 Subject: [PATCH 107/177] Move GStreamer setup back into the actor thread Make sure to terminate the whole process on GError exceptions, so that we fail quickly on non-working output pipelines. --- mopidy/gstreamer.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index c25dde47..a1ddc93e 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -1,6 +1,7 @@ import pygst pygst.require('0.10') import gst +import gobject import logging @@ -9,7 +10,10 @@ from pykka.registry import ActorRegistry from mopidy import settings, utils from mopidy.backends.base import Backend -from mopidy import mixers # Trigger install of gst mixer plugins. +from mopidy.utils import process + +# Trigger install of gst mixer plugins +from mopidy import mixers logger = logging.getLogger('mopidy.gstreamer') @@ -42,10 +46,15 @@ class GStreamer(ThreadingActor): self._output = None self._mixer = None - self._setup_pipeline() - self._setup_output() - self._setup_mixer() - self._setup_message_processor() + def on_start(self): + try: + self._setup_pipeline() + self._setup_output() + self._setup_mixer() + self._setup_message_processor() + except gobject.GError as ex: + logger.exception(ex) + process.exit_process() def on_stop(self): self._teardown_message_processor() From 4bffea8b1f44c21e5e10ba9b2029d669791642bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 10 Sep 2012 00:32:36 +0200 Subject: [PATCH 108/177] Test the GStreamer class as an actor The test should use the same interface and code paths as production code. --- tests/gstreamer_test.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 790394f5..ce20d2b4 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -14,9 +14,10 @@ class GStreamerTest(unittest.TestCase): settings.MIXER = 'fakemixer track_max_volume=65536' settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer() + self.gstreamer = GStreamer.start().proxy() def tearDown(self): + self.gstreamer.stop() settings.runtime.clear() def prepare_uri(self, uri): @@ -25,21 +26,21 @@ class GStreamerTest(unittest.TestCase): def test_start_playback_existing_file(self): self.prepare_uri(self.song_uri) - self.assertTrue(self.gstreamer.start_playback()) + self.assertTrue(self.gstreamer.start_playback().get()) def test_start_playback_non_existing_file(self): self.prepare_uri(self.song_uri + 'bogus') - self.assertFalse(self.gstreamer.start_playback()) + self.assertFalse(self.gstreamer.start_playback().get()) def test_pause_playback_while_playing(self): self.prepare_uri(self.song_uri) self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.pause_playback()) + self.assertTrue(self.gstreamer.pause_playback().get()) def test_stop_playback_while_playing(self): self.prepare_uri(self.song_uri) self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.stop_playback()) + self.assertTrue(self.gstreamer.stop_playback().get()) @unittest.SkipTest def test_deliver_data(self): @@ -51,8 +52,8 @@ class GStreamerTest(unittest.TestCase): def test_set_volume(self): for value in range(0, 101): - self.assertTrue(self.gstreamer.set_volume(value)) - self.assertEqual(value, self.gstreamer.get_volume()) + self.assertTrue(self.gstreamer.set_volume(value).get()) + self.assertEqual(value, self.gstreamer.get_volume().get()) @unittest.SkipTest def test_set_state_encapsulation(self): From 2f33a6c4ffd780ac9c2fe4d881f516e265032d9a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 10 Sep 2012 23:19:40 +0200 Subject: [PATCH 109/177] Only remove signal watch if it has been added If removing unconditionally, we get the following error message: GStreamer-CRITICAL **: Bus bus2 has no signal watches attached --- mopidy/gstreamer.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index a1ddc93e..b8b30d14 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -32,6 +32,7 @@ class GStreamer(ThreadingActor): def __init__(self): super(GStreamer, self).__init__() + self._default_caps = gst.Caps(""" audio/x-raw-int, endianness=(int)1234, @@ -40,12 +41,15 @@ class GStreamer(ThreadingActor): depth=(int)16, signed=(boolean)true, rate=(int)44100""") + self._pipeline = None self._source = None self._uridecodebin = None self._output = None self._mixer = None + self._message_processor_set_up = False + def on_start(self): try: self._setup_pipeline() @@ -133,10 +137,12 @@ class GStreamer(ThreadingActor): bus = self._pipeline.get_bus() bus.add_signal_watch() bus.connect('message', self._on_message) + self._message_processor_set_up = True def _teardown_message_processor(self): - bus = self._pipeline.get_bus() - bus.remove_signal_watch() + if self._message_processor_set_up: + bus = self._pipeline.get_bus() + bus.remove_signal_watch() def _on_new_source(self, element, pad): self._source = element.get_property('source') From 0e56b15fccbfeadfca13e459197e7102861b6386 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Sep 2012 10:08:59 +0200 Subject: [PATCH 110/177] Teardown GStreamer mixer --- mopidy/gstreamer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index b8b30d14..5bdb7b39 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -62,6 +62,7 @@ class GStreamer(ThreadingActor): def on_stop(self): self._teardown_message_processor() + self._teardown_mixer() self._teardown_pipeline() def _setup_pipeline(self): @@ -133,6 +134,11 @@ class GStreamer(ThreadingActor): gst.interfaces.MIXER_TRACK_OUTPUT): return track + def _teardown_mixer(self): + if self._mixer is not None: + (mixer, track) = self._mixer + mixer.set_state(gst.STATE_NULL) + def _setup_message_processor(self): bus = self._pipeline.get_bus() bus.add_signal_watch() From 51256e18949bbb2acdc9fac97e934511bcfc831b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Sep 2012 11:18:08 +0200 Subject: [PATCH 111/177] Log warning message if mixer creation fails --- mopidy/gstreamer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 5bdb7b39..2ebe2d71 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -101,8 +101,12 @@ class GStreamer(ThreadingActor): logger.info('Not setting up mixer.') return - # This will raise a gobject.GError if the description is bad. - mixerbin = gst.parse_bin_from_description(settings.MIXER, False) + try: + mixerbin = gst.parse_bin_from_description(settings.MIXER, False) + except gobject.GError as ex: + logger.warning('Failed to create mixer "%s": %s', + settings.MIXER, ex) + return # We assume that the bin will contain a single mixer. mixer = mixerbin.get_by_interface('GstMixer') From a1146c2964162ef4cb71e297688dfe34c7ec9797 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Sep 2012 13:52:43 +0200 Subject: [PATCH 112/177] Log error and exit if output creation fails --- mopidy/gstreamer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 2ebe2d71..f131fdfc 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -87,9 +87,14 @@ class GStreamer(ThreadingActor): self._pipeline.set_state(gst.STATE_NULL) def _setup_output(self): - # This will raise a gobject.GError if the description is bad. - self._output = gst.parse_bin_from_description( - settings.OUTPUT, ghost_unconnected_pads=True) + try: + self._output = gst.parse_bin_from_description( + settings.OUTPUT, ghost_unconnected_pads=True) + except gobject.GError as ex: + logger.error('Failed to create output "%s": %s', + settings.OUTPUT, ex) + process.exit_process() + return self._pipeline.add(self._output) gst.element_link_many(self._pipeline.get_by_name('queue'), From a3ea1bc97a0781c3cb845244b1fd042c1767633f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Sep 2012 15:37:46 +0200 Subject: [PATCH 113/177] Use kwarg to make meaning obvious --- mopidy/gstreamer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index f131fdfc..6dc7b0aa 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -107,7 +107,8 @@ class GStreamer(ThreadingActor): return try: - mixerbin = gst.parse_bin_from_description(settings.MIXER, False) + mixerbin = gst.parse_bin_from_description(settings.MIXER, + ghost_unconnected_pads=False) except gobject.GError as ex: logger.warning('Failed to create mixer "%s": %s', settings.MIXER, ex) From dbf7030d5b5b4969272ee66d22bb5759efe0b4d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Sep 2012 23:23:36 +0200 Subject: [PATCH 114/177] Fix crash in local backend when looking up unknown path --- docs/changes.rst | 2 ++ mopidy/backends/local/__init__.py | 3 ++- tests/backends/base/library.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 082ad136..77d72383 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -71,6 +71,8 @@ v0.8 (in development) - Created a Spotify track proxy that will switch to using loaded data as soon as it becomes available. Fixes :issue:`72`. +- Fixed crash on lookup of unknown path when using local backend. + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 1b1f9730..263d2fc2 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -203,7 +203,8 @@ class LocalLibraryProvider(BaseLibraryProvider): try: return self._uri_mapping[uri] except KeyError: - raise LookupError('%s not found.' % uri) + logger.debug(u'Failed to lookup "%s"', uri) + return None def find_exact(self, **query): self._validate_query(query) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4b3ef5c0..f76d9d75 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -34,8 +34,8 @@ class LibraryControllerTest(object): self.assertEqual(track, self.tracks[0]) def test_lookup_unknown_track(self): - test = lambda: self.library.lookup('fake uri') - self.assertRaises(LookupError, test) + track = self.library.lookup('fake uri') + self.assertEquals(track, None) def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) From 85d90bbabb628f858889709874d52fb07788121e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 11:50:56 +0200 Subject: [PATCH 115/177] Cleanup settings docstrings --- mopidy/settings.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 72e805bf..0612fc24 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -1,5 +1,5 @@ """ -Available settings and their default values. +All available settings and their default values. .. warning:: @@ -14,6 +14,10 @@ Available settings and their default values. #: #: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) #: +#: Other typical values:: +#: +#: BACKENDS = (u'mopidy.backends.local.LocalBackend',) +#: #: .. note:: #: Currently only the first backend in the list is used. BACKENDS = ( @@ -106,9 +110,9 @@ LOCAL_TAG_CACHE_FILE = None #: Sound mixer to use. #: #: Expects a GStreamer mixer to use, typical values are: -#: alsamixer, pulsemixer, oss4mixer, ossmixer. +#: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. #: -#: Setting this to ``None`` means no volume control. +#: Setting this to :class:`None` turns off volume control. #: #: Default:: #: @@ -118,7 +122,7 @@ MIXER = u'autoaudiomixer' #: Sound mixer track to use. #: #: Name of the mixer track to use. If this is not set we will try to find the -#: output track with master set. As an example, using ``alsamixer`` you would +#: master output track. As an example, using ``alsamixer`` you would #: typically set this to ``Master`` or ``PCM``. #: #: Default:: @@ -128,7 +132,9 @@ MIXER_TRACK = None #: Which address Mopidy's MPD server should bind to. #: -#:Examples: +#: Used by :mod:`mopidy.frontends.mpd`. +#: +#: Examples: #: #: ``127.0.0.1`` #: Listens only on the IPv4 loopback interface. Default. @@ -142,16 +148,22 @@ MPD_SERVER_HOSTNAME = u'127.0.0.1' #: Which TCP port Mopidy's MPD server should listen to. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: 6600 MPD_SERVER_PORT = 6600 #: The password required for connecting to the MPD server. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: :class:`None`, which means no password required. MPD_SERVER_PASSWORD = None #: The maximum number of concurrent connections the MPD server will accept. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 From 414e7774a961d08bbb5df4900ef91df8cd615026 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 12:06:30 +0200 Subject: [PATCH 116/177] Add .mailmap for mapping of git Author tags belonging to the same person --- .mailmap | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..15d8f359 --- /dev/null +++ b/.mailmap @@ -0,0 +1,4 @@ +Kristian Klette +Johannes Knutsen +Johannes Knutsen +John Bäckstrand From 5ad75b18dea7f50aebaec6a119e3a5b471844004 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 12:08:19 +0200 Subject: [PATCH 117/177] Add link to contributors list at GitHub --- docs/authors.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/authors.rst b/docs/authors.rst index af84f842..04795ee6 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -9,6 +9,9 @@ Contributors to Mopidy in the order of appearance: - Thomas Adamcik - Kristian Klette +A complete list of persons with commits accepted into the Mopidy repo can be +found at `GitHub `_. + Showing your appreciation ========================= From c8b24303ca4d83b41c34f436e28e8b3212127dfa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 12:08:35 +0200 Subject: [PATCH 118/177] Remove sections about donations --- docs/authors.rst | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 04795ee6..822abc15 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -20,13 +20,3 @@ If you already enjoy Mopidy, or don't enjoy it and want to help us making Mopidy better, the best way to do so is to contribute back to the community. You can contribute code, documentation, tests, bug reports, or help other users, spreading the word, etc. - -If you want to show your appreciation in a less time consuming way, you can -`flattr us `_, or `donate money -`_ to Mopidy's development. - -We promise that any money donated -- to Pledgie, not Flattr, due to the size of -the amounts -- will be used to cover costs related to Mopidy development, like -service subscriptions (Spotify, Last.fm, etc.) and hardware devices like an -used iPod Touch for testing Mopidy with MPod. - From 91b2c0f43033655359ab2a6fd9c07a5012088a73 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 13:41:46 +0200 Subject: [PATCH 119/177] Update Homebrew installation guide --- docs/installation/gstreamer.rst | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 546b53ba..74b5e66b 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -54,15 +54,8 @@ Python bindings on OS X using Homebrew. #. Install `Homebrew `_. -#. Download our Homebrew formulas for ``pycairo``, ``pygobject``, ``pygtk``, - and ``gst-python``:: +#. Download our Homebrew formula for ``gst-python``:: - curl -o $(brew --prefix)/Library/Formula/pycairo.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pycairo.rb - curl -o $(brew --prefix)/Library/Formula/pygobject.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygobject.rb - curl -o $(brew --prefix)/Library/Formula/pygtk.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygtk.rb curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb @@ -77,13 +70,13 @@ Python bindings on OS X using Homebrew. You can either amend your ``PYTHONPATH`` permanently, by adding the following statement to your shell's init file, e.g. ``~/.bashrc``:: - export PYTHONPATH=$(brew --prefix)/lib/python2.6/site-packages:$PYTHONPATH + export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH Or, you can prefix the Mopidy command every time you run it:: - PYTHONPATH=$(brew --prefix)/lib/python2.6/site-packages mopidy + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - Note that you need to replace ``python2.6`` with ``python2.7`` if that's + Note that you need to replace ``python2.7`` with ``python2.6`` if that's the Python version you are using. To find your Python version, run:: python --version From 8d44f4697bd82e80adfcee0511e6a5d68c42966f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 10 Sep 2012 18:54:28 +0200 Subject: [PATCH 120/177] Fix typos, clarify docs --- docs/settings.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 94f3c63b..0c1a3c7e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -166,9 +166,9 @@ server simultaneously. To use the SHOUTcast output, do the following: example, to set the username and password, use: ``lame ! shout2send username="foobar" password="s3cret"``. -Other advanced setups are also possible for outputs. Basically anything you can -get a ``gst-lauch`` command to output to can be plugged into -:attr:`mopidy.settings.OUTPUT``. +Other advanced setups are also possible for outputs. Basically, anything you +can use with the ``gst-launch-0.10`` command can be plugged into +:attr:`mopidy.settings.OUTPUT`. Available settings From 3f923907cab105c3586592c08469e5987e5585bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 20:26:59 +0200 Subject: [PATCH 121/177] docs: Fix capitalization of GStreamer --- docs/changes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 77d72383..7f67973d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -951,7 +951,7 @@ Since the previous release Mopidy has seen about 300 commits, more than 200 new tests, a libspotify release, and major feature additions to Spotify. The new releases from Spotify have lead to updates to our dependencies, and also to new bugs in Mopidy. Thus, this is primarily a bugfix release, even though the not -yet finished work on a Gstreamer backend have been merged. +yet finished work on a GStreamer backend have been merged. All users are recommended to upgrade to 0.1.0a1, and should at the same time ensure that they have the latest versions of our dependencies: Despotify r508 @@ -976,7 +976,7 @@ As always, report problems at our IRC channel or our issue tracker. Thanks! - Several new generic features, like shuffle, consume, and playlist repeat. (Fixes: :issue:`3`) - **[Work in Progress]** A new backend for playing music from a local music - archive using the Gstreamer library. + archive using the GStreamer library. - Made :class:`mopidy.mixers.alsa.AlsaMixer` work on machines without a mixer named "Master". From 061adbddd0f9941d964997480102bca0030c2983 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 20:27:12 +0200 Subject: [PATCH 122/177] docs: Remove superflous word --- docs/installation/gstreamer.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 74b5e66b..42685ad0 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -2,7 +2,7 @@ GStreamer installation ********************** -To use the Mopidy, you first need to install GStreamer and the GStreamer Python +To use Mopidy, you first need to install GStreamer and the GStreamer Python bindings. From a08b1461ad884ef80da5e24693dcad91926cfc67 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 20:34:00 +0200 Subject: [PATCH 123/177] docs: Remove note about Ubuntu 10.04 and older --- docs/clients/mpd.rst | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 844eaee7..64bd304b 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -30,20 +30,13 @@ ncmpcpp A console client that generally works well with Mopidy, and is regularly used by Mopidy developers. -Search -^^^^^^ - -Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the -three search modes: +Search only works in two of the three search modes: - "Match if tag contains search phrase (regexes supported)" -- Does not work. The client tries to fetch all known metadata and do the search client side. - "Match if tag contains searched phrase (no regexes)" -- Works. - "Match only if both values are the same" -- Works. -If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp -from `Launchpad `_. - Communication mode ^^^^^^^^^^^^^^^^^^ From e304b85c87398387782a25491b14a81ff61d8ed6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 20:35:25 +0200 Subject: [PATCH 124/177] docs: Remove note about how to use ncmpcpp with Mopidy < 0.6, which is almost a year ago --- docs/clients/mpd.rst | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 64bd304b..d35b87bc 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -37,19 +37,6 @@ Search only works in two of the three search modes: - "Match if tag contains searched phrase (no regexes)" -- Works. - "Match only if both values are the same" -- Works. -Communication mode -^^^^^^^^^^^^^^^^^^ - -In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04, -ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy -did not support before Mopidy 0.6. To workaround this limitation in earlier -versions of Mopidy, edit the ncmpcpp configuration file at -``~/.ncmpcpp/config`` and add the following setting:: - - mpd_communication_mode = "polling" - -If you use Mopidy 0.6 or newer, you don't need to change anything. - Graphical clients ================= From 5b284af399858a9c545ccefb3194e67c333e6668 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 21:52:23 +0200 Subject: [PATCH 125/177] Update Android MPD client review (fixes #155) --- docs/clients/mpd.rst | 206 +++++++++++++++++++++++++------------------ 1 file changed, 119 insertions(+), 87 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index d35b87bc..e6137d7d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -82,8 +82,8 @@ It generally works well with Mopidy. Android clients =============== -We've tested all six MPD clients we could find for Android with Mopidy 0.3 on a -HTC Hero with Android 2.1, using the following test procedure: +We've tested all four MPD clients we could find for Android with Mopidy 0.7.3 on +a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure: #. Connect to Mopidy #. Search for ``foo``, with search type "any" if it can be selected @@ -107,133 +107,165 @@ HTC Hero with Android 2.1, using the following test procedure: #. Check if the app got support for single mode and consume mode #. Kill Mopidy and confirm that the app handles it without crashing -In summary: +We found that all four apps crashed on Android 4.1.1. -- BitMPC lacks finishing touches on its user interface but supports all - features tested. -- Droid MPD Client works well, but got a couple of bugs one can live with and - does not expose stored playlist anywhere. -- IcyBeats is not usable yet. -- MPDroid is working well and looking good, but does not have search - functionality. -- PMix is just a lesser MPDroid, so use MPDroid instead. -- ThreeMPD is too buggy to even get connected to Mopidy. +Combining what we managed to find before the apps crashed with our experience +from an older version of this review, using Android 2.1, we can say that: -Our recommendation: +- PMix can be ignored, because it is unmaintained and its fork MPDroid is + better on all fronts. -- If you do not care about looks, use BitMPC. -- If you do not care about stored playlists, use Droid MPD Client. -- If you do not care about searching, use MPDroid. +- Droid MPD Client was to buggy to get an impression from. Unclear if the bugs + are due to the app or that it hasn't been updated for Android 4.x. + +- BitMPC is in our experience feature complete, but ugly. + +- MPDroid, now that search is in place, is probably feature complete as well, + and looks nicer than BitMPC. + +In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try +anyway, try BitMPC and MPDroid. BitMPC ------ -We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings, -3.5 stars. +Test date: + 2012-09-12 +Tested version: + 1.0.0 (released 2010-04-12) +Downloads: + 5,000+ +Rating: + 3.7 stars from about 100 ratings -The user interface lacks some finishing touches. E.g. you can't enter a -hostname for the server. Only IPv4 addresses are allowed. -All features exercised in the test procedure works. BitMPC lacks support for -single mode and consume mode. BitMPC crashes if Mopidy is killed or crash. +- The user interface lacks some finishing touches. E.g. you can't enter a + hostname for the server. Only IPv4 addresses are allowed. + +- When we last tested the same version of BitMPC using Android 2.1: + + - All features exercised in the test procedure worked. + + - BitMPC lacked support for single mode and consume mode. + + - BitMPC crashed if Mopidy was killed or crashed. + +- When we tried to test using Android 4.1.1, BitMPC started and connected to + Mopidy without problems, but the app crashed as soon as fire off our search, + and continued to crash on startup after that. + +In conclusion, BitMPC is usable if you got an older Android phone and don't +care about looks. For newer Android versions, BitMPC will probably not work as +it hasn't been maintained for 2.5 years. Droid MPD Client ---------------- -We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings, -4 stars. +Test date: + 2012-09-12 +Tested version: + 1.4.0 (released 2011-12-20) +Downloads: + 10,000+ +Rating: + 4.2 stars from 400+ ratings -To find the search functionality, you have to select the menu, then "Playlist -manager", then the search tab. I do not understand why search is hidden inside -"Playlist manager". +- No intutive way to ask the app to connect to the server after adding the + server hostname to the settings. -The user interface have some French remnants, like "Rechercher" in the search -field. +- To find the search functionality, you have to select the menu, + then "Playlist manager", then the search tab. I do not understand why search + is hidden inside "Playlist manager". -When selecting the artist tab, it issues the ``list Artist`` command and -becomes stuck waiting for the results. Same thing happens for the album tab, -which issues ``list Album``, and the folder tab, which issues ``lsinfo``. -Mopidy returned zero hits immediately on all three commands. If Mopidy has -loaded your stored playlists and returns more than zero hits on these commands, -they artist and album tabs do not hang. The folder tab still freezes when -``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've -discovered a couple of bugs in Droid MPD Client. +- The tabs "Artists" and "Albums" did not contain anything, and did not cause + any requests. -Even though ``lsinfo`` returns the stored playlists for the folder tab, they -are not displayed anywhere. Thus, we had to select an album in the album tab to -complete the test procedure. +- The tab "Folders" showed a spinner and said "Updating data..." but did not + send any requests. -At one point, I had problems turning off repeat mode. After I adjusted the -volume and tried again, it worked. +- Searching for "foo" did nothing. No request was sent to the server. -Droid MPD client does not support single mode or consume mode. It does not -detect that the server is killed/crashed. You'll only notice it by no actions -having any effect, e.g. you can't turn the volume knob any more. +- Once, I managed to get a list of stored playlists in the "Search" tab, but I + never managed to reproduce this. Opening the stored playlists doesn't work, + because Mopidy haven't implemented ``lsinfo "Playlist name"`` (see + :issue:`193`). -In conclusion, some bugs and caveats, but most of the test procedure was -possible to perform. +- Droid MPD client does not support single mode or consume mode. +- Not able to complete the test procedure, due to the above problems. -IcyBeats --------- - -We tested version 0.2, which at the time had 50-100 downloads, no ratings. -The app was still in beta when we tried it. - -IcyBeats successfully connected to Mopidy and I was able to adjust volume. When -I was searching for some tracks, I could not figure out how to actually start -the search, as there was no search button and pressing enter in the input field -just added a new line. I was stuck. In other words, IcyBeats 0.2 is not usable -with Mopidy. - -IcyBeats does have something going for it: IcyBeats uses IPv6 to connect to -Mopidy. The future is just around the corner! +In conclusion, not a client we can recommend. MPDroid ------- -We tested version 0.6.9, which at the time had 5k-10k downloads, <200 ratings, -4.5 stars. MPDroid started out as a fork of PMix. +Test date: + 2012-09-12 +Tested version: + 0.7 (released 2011-06-19) +Downloads: + 10,000+ +Rating: + 4.5 stars from ~500 ratings -First of all, MPDroid's user interface looks nice. +- MPDroid started out as a fork of PMix. -I couldn't find any search functionality, so I added the initial track using -another client. Other than the missing search functionality, everything in the -test procedure worked out flawlessly. Like all other Android clients, MPDroid -does not support single mode or consume mode. When Mopidy is killed, MPDroid -handles it gracefully and asks if you want to try to reconnect. +- First of all, MPDroid's user interface looks nice. -All in all, MPDroid is a good MPD client without search support. +- Last time we tested MPDroid (v0.6.9), we couldn't find any search + functionality. Now we found it, and it worked. + +- Last time we tested MPDroid (v0.6.9) everything in the test procedure worked + out flawlessly. + +- Like all other Android clients, MPDroid does not support single mode or + consume mode. + +- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to + try to reconnect. + +- When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an + empty current playlist and pressing play. + +Disregarding Adnroid 4.x problems, MPDroid is a good MPD client. PMix ---- -We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings, -4 stars. +Test date: + 2012-09-12 +Tested version: + 0.4.0 (released 2010-03-06) +Downloads: + 10,000+ +Rating: + 3.8 stars from >200 ratings -Add MPDroid is a fork from PMix, it is no surprise that PMix does not support -search either. In addition, I could not find stored playlists. Other than that, -I was able to complete the test procedure. PMix crashed once during testing, -but handled the killing of Mopidy just as nicely as MPDroid. It does not -support single mode or consume mode. +- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes + as soon as it connects to Mopidy. + +- Last time we tested the same version of PMix using Android 2.1, we found + that: + + - PMix does not support search. + + - I could not find stored playlists. + + - Other than that, I was able to complete the test procedure. + + - PMix crashed once during testing. + + - PMix handled the killing of Mopidy just as nicely as MPDroid. + + - It does not support single mode or consume mode. All in all, PMix works but can do less than MPDroid. Use MPDroid instead. -ThreeMPD --------- - -We tested version 0.3.0, which at the time had 1k-5k downloads, <25 ratings, -2.5 average. The developer request users to use MPDroid instead, due to limited -time for maintenance. Does not support password authentication. - -ThreeMPD froze during startup, so we were not able to test it. - - .. _ios_mpd_clients: iPhone/iPod Touch clients From fd76b46d5ea072f8ec37599dc032cf0e4bdafc01 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 22:11:29 +0200 Subject: [PATCH 126/177] docs: Fix typo --- docs/clients/mpd.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index e6137d7d..6d0cc6a7 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -230,7 +230,7 @@ Rating: - When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an empty current playlist and pressing play. -Disregarding Adnroid 4.x problems, MPDroid is a good MPD client. +Disregarding Android 4.x problems, MPDroid is a good MPD client. PMix From a703b1986260fe12fd2ec3a1c9f19fff6077e9b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 22:20:00 +0200 Subject: [PATCH 127/177] docs: Update iOS clients section --- docs/clients/mpd.rst | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 6d0cc6a7..c7dc3799 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -268,23 +268,19 @@ All in all, PMix works but can do less than MPDroid. Use MPDroid instead. .. _ios_mpd_clients: -iPhone/iPod Touch clients -========================= - -impdclient ----------- - -There's an open source MPD client for iOS called `impdclient -`_ which has not seen any updates since -August 2008. So far, we've not heard of users trying it with Mopidy. Please -notify us of your successes and/or problems if you do try it out. - +iOS clients +=========== MPod ---- -The `MPoD `_ client can be -installed from the `iTunes Store +Test date: + 2011-01-19 +Tested version: + 1.5.1 + +The `MPoD `_ iPhone/iPod Touch +app can be installed from the `iTunes Store `_. Users have reported varying success in using MPoD together with Mopidy. Thus, @@ -328,3 +324,10 @@ we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d - **Wishlist:** MPoD supports autodetection/-configuration of MPD servers through the use of Bonjour. Mopidy does not currently support this, but there is a wishlist bug at :issue:`39`. + + +MPaD +---- + +The `MPaD `_ iPad app works +with Mopidy. A complete review may appear here in the future. From 0ed59a884564c1a6ba15a69e34782b9c43c801a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 22:54:34 +0200 Subject: [PATCH 128/177] gstreamer: Fix typo --- mopidy/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 6dc7b0aa..9d0cb97c 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -381,7 +381,7 @@ class GStreamer(ThreadingActor): deliver raw audio data to GStreamer. :param track: the current track - :type track: :class:`mopidy.modes.Track` + :type track: :class:`mopidy.models.Track` """ taglist = gst.TagList() artists = [a for a in (track.artists or []) if a.name] From 7525cad94c1084d9d2cdd4bb20c361bbe588d8d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Sep 2012 23:06:06 +0200 Subject: [PATCH 129/177] Let Track.date be an ISO-8601 string This lets us have less precision than full dates. E.g. Spotify tracks only got release year, not full release date. The original MPD server regularly expose data like this as "Date: 1977", so we don't need to fake more precision for MPD's sake. --- docs/changes.rst | 3 +++ mopidy/backends/spotify/translator.py | 6 ++---- mopidy/frontends/mpd/protocol/music_db.py | 2 +- mopidy/models.py | 4 ++-- tests/models_test.py | 16 ++++++++-------- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 7f67973d..de447dee 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -73,6 +73,9 @@ v0.8 (in development) - Fixed crash on lookup of unknown path when using local backend. +- Support tracks with only release year, and not a full release date, like e.g. + Spotify tracks. + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 2f47a42b..1a8f048d 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,4 +1,3 @@ -import datetime as dt import logging from spotify import Link, SpotifyError @@ -31,9 +30,8 @@ class SpotifyTranslator(object): if not spotify_track.is_loaded(): return Track(uri=uri, name=u'[loading...]') spotify_album = spotify_track.album() - if (spotify_album is not None and spotify_album.is_loaded() - and dt.MINYEAR <= int(spotify_album.year()) <= dt.MAXYEAR): - date = dt.date(spotify_album.year(), 1, 1) + if spotify_album is not None and spotify_album.is_loaded(): + date = spotify_album.year() else: date = None return Track( diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 3cf20c5d..d0128a1e 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -242,7 +242,7 @@ def _list_date(context, query): playlist = context.backend.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: - dates.add((u'Date', track.date.strftime('%Y-%m-%d'))) + dates.add((u'Date', track.date)) return dates @handle_request(r'^listall "(?P[^"]+)"') diff --git a/mopidy/models.py b/mopidy/models.py index 3363a429..6a2af914 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -157,8 +157,8 @@ class Track(ImmutableObject): :type album: :class:`Album` :param track_no: track number in album :type track_no: integer - :param date: track release date - :type date: :class:`datetime.date` + :param date: track release date (YYYY or YYYY-MM-DD) + :type date: string :param length: track length in milliseconds :type length: integer :param bitrate: bitrate in kbit/s diff --git a/tests/models_test.py b/tests/models_test.py index 231587e4..af90c5bd 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -338,7 +338,7 @@ class TrackTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, track, 'track_no', None) def test_date(self): - date = datetime.date(1977, 1, 1) + date = '1977-01-01' track = Track(date=date) self.assertEqual(track.date, date) self.assertRaises(AttributeError, setattr, track, 'date', None) @@ -434,7 +434,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_date(self): - date = datetime.date.today() + date = '1977-01-01' track1 = Track(date=date) track2 = Track(date=date) self.assertEqual(track1, track2) @@ -459,7 +459,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq(self): - date = datetime.date.today() + date = '1977-01-01' artists = [Artist()] album = Album() track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, @@ -508,8 +508,8 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne_date(self): - track1 = Track(date=datetime.date.today()) - track2 = Track(date=datetime.date.today()-datetime.timedelta(days=1)) + track1 = Track(date='1977-01-01') + track2 = Track(date='1977-01-02') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -534,12 +534,12 @@ class TrackTest(unittest.TestCase): def test_ne(self): track1 = Track(uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date=datetime.date.today(), length=100, bitrate=100, + track_no=1, date='1977-01-01', length=100, bitrate=100, musicbrainz_id='id1') track2 = Track(uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date=datetime.date.today()-datetime.timedelta(days=1), - length=200, bitrate=200, musicbrainz_id='id2') + track_no=2, date='1977-01-02', length=200, bitrate=200, + musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) From 0a0c7c59b7186df72eea3f2b099532ca05bc0bac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Sep 2012 15:17:10 +0200 Subject: [PATCH 130/177] docs: Merge development docs into a single document --- docs/changes.rst | 4 +- .../contributing.rst => development.rst} | 39 +++++++++++++++++-- docs/development/index.rst | 9 ----- docs/development/roadmap.rst | 34 ---------------- docs/index.rst | 2 +- docs/installation/index.rst | 2 +- 6 files changed, 40 insertions(+), 50 deletions(-) rename docs/{development/contributing.rst => development.rst} (81%) delete mode 100644 docs/development/index.rst delete mode 100644 docs/development/roadmap.rst diff --git a/docs/changes.rst b/docs/changes.rst index de447dee..c6b7e0ac 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -860,8 +860,8 @@ In the last two months, Mopidy's MPD frontend has gotten lots of stability fixes and error handling improvements, proper support for having the same track multiple times in a playlist, and support for IPv6. We have also fixed the choppy playback on the libspotify backend. For the road ahead of us, we got an -updated :doc:`release roadmap ` with our goals for the 0.1 -to 0.3 releases. +updated :doc:`release roadmap ` with our goals for the 0.1 to 0.3 +releases. Enjoy the best alpha relase of Mopidy ever :-) diff --git a/docs/development/contributing.rst b/docs/development.rst similarity index 81% rename from docs/development/contributing.rst rename to docs/development.rst index 74e2f0b5..c5020bd9 100644 --- a/docs/development/contributing.rst +++ b/docs/development.rst @@ -1,11 +1,42 @@ -***************** -How to contribute -***************** +*********** +Development +*********** Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at ``irc.freenode.net`` and through `GitHub `_. +Release schedule +================ + +We intend to have about one timeboxed feature release every month +in periods of active development. The feature releases are numbered 0.x.0. The +features added is a mix of what we feel is most important/requested of the +missing features, and features we develop just because we find them fun to +make, even though they may be useful for very few users or for a limited use +case. + +Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs +that are too serious to wait for the next feature release. We will only release +bugfix releases for the last feature release. E.g. when 0.3.0 is released, we +will no longer provide bugfix releases for the 0.2 series. In other words, +there will be just a single supported release at any point in time. + + +Feature wishlist +================ + +We maintain our collection of sane or less sane ideas for future Mopidy +features as `issues `_ at GitHub +labeled with `the "wishlist" label +`_. Feel free to vote +up any feature you would love to see in Mopidy, but please refrain from adding +a comment just to say "I want this too!". You are of course free to add +comments if you have suggestions for how the feature should work or be +implemented, and you may add new wishlist issues if your ideas are not already +represented. + + Code style ========== @@ -125,6 +156,7 @@ statistics and uses pylint to check for errors and possible improvements in our code. So, if you're out of work, the code coverage and pylint data at the CI server should give you a place to start. + Protocol debugging ================== @@ -167,6 +199,7 @@ To ensure that Mopidy and MPD have comparable state it is suggested you setup both to use ``tests/data/library_tag_cache`` for their tag cache and ``tests/data`` for music/playlist folders. + Writing documentation ===================== diff --git a/docs/development/index.rst b/docs/development/index.rst deleted file mode 100644 index 321b3242..00000000 --- a/docs/development/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -*********** -Development -*********** - -.. toctree:: - :maxdepth: 3 - - roadmap - contributing diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst deleted file mode 100644 index 6280762c..00000000 --- a/docs/development/roadmap.rst +++ /dev/null @@ -1,34 +0,0 @@ -******* -Roadmap -******* - - -Release schedule -================ - -We intend to have about one timeboxed feature release every month -in periods of active development. The feature releases are numbered 0.x.0. The -features added is a mix of what we feel is most important/requested of the -missing features, and features we develop just because we find them fun to -make, even though they may be useful for very few users or for a limited use -case. - -Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs -that are too serious to wait for the next feature release. We will only release -bugfix releases for the last feature release. E.g. when 0.3.0 is released, we -will no longer provide bugfix releases for the 0.2 series. In other words, -there will be just a single supported release at any point in time. - - -Feature wishlist -================ - -We maintain our collection of sane or less sane ideas for future Mopidy -features as `issues `_ at GitHub -labeled with `the "wishlist" label -`_. Feel free to vote -up any feature you would love to see in Mopidy, but please refrain from adding -a comment just to say "I want this too!". You are of course free to add -comments if you have suggestions for how the feature should work or be -implemented, and you may add new wishlist issues if your ideas are not already -represented. diff --git a/docs/index.rst b/docs/index.rst index 7e757de0..0af510d0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,7 +54,7 @@ Development documentation .. toctree:: :maxdepth: 3 - development/index + development Indices and tables ================== diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 766616ac..66b920f8 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -173,7 +173,7 @@ If you want to contribute to Mopidy, you should install Mopidy using Git. For an introduction to ``git``, please visit `git-scm.com `_. Also, please read our :doc:`developer documentation -`. +`. From AUR on ArchLinux From 0559213da326cbb4ccd5e2aa5bc5e547ef30f444 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Sep 2012 23:40:45 +0200 Subject: [PATCH 131/177] Move backend controllers to mopidy.core --- mopidy/backends/base/__init__.py | 11 +- mopidy/backends/base/library.py | 76 --- mopidy/backends/base/playback.py | 547 ----------------- mopidy/backends/base/stored_playlists.py | 116 ---- mopidy/backends/dummy/__init__.py | 22 +- mopidy/backends/local/__init__.py | 25 +- mopidy/backends/spotify/__init__.py | 15 +- mopidy/core/__init__.py | 4 + .../base => core}/current_playlist.py | 4 +- mopidy/core/library.py | 70 +++ mopidy/core/playback.py | 548 ++++++++++++++++++ mopidy/core/stored_playlists.py | 113 ++++ mopidy/frontends/mpd/protocol/playback.py | 12 +- mopidy/frontends/mpd/protocol/status.py | 12 +- mopidy/frontends/mpris/objects.py | 17 +- tests/core/__init__.py | 0 tests/frontends/mpd/protocol/playback_test.py | 8 +- tests/frontends/mpd/status_test.py | 11 +- .../frontends/mpris/player_interface_test.py | 9 +- 19 files changed, 803 insertions(+), 817 deletions(-) create mode 100644 mopidy/core/__init__.py rename mopidy/{backends/base => core}/current_playlist.py (99%) create mode 100644 mopidy/core/library.py create mode 100644 mopidy/core/playback.py create mode 100644 mopidy/core/stored_playlists.py create mode 100644 tests/core/__init__.py diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 76c7f078..e6c8b70a 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -1,12 +1,7 @@ -import logging +from .library import BaseLibraryProvider +from .playback import BasePlaybackProvider +from .stored_playlists import BaseStoredPlaylistsProvider -from .current_playlist import CurrentPlaylistController -from .library import LibraryController, BaseLibraryProvider -from .playback import PlaybackController, BasePlaybackProvider -from .stored_playlists import (StoredPlaylistsController, - BaseStoredPlaylistsProvider) - -logger = logging.getLogger('mopidy.backends.base') class Backend(object): #: The current playlist controller. An instance of diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py index 9e3afe9a..837eef49 100644 --- a/mopidy/backends/base/library.py +++ b/mopidy/backends/base/library.py @@ -1,79 +1,3 @@ -import logging - -logger = logging.getLogger('mopidy.backends.base') - -class LibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseLibraryProvider` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - def find_exact(self, **query): - """ - Search the library for tracks where ``field`` is ``values``. - - Examples:: - - # Returns results matching 'a' - find_exact(any=['a']) - # Returns results matching artist 'xyz' - find_exact(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - find_exact(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.find_exact(**query) - - def lookup(self, uri): - """ - Lookup track with given URI. Returns :class:`None` if not found. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` or :class:`None` - """ - return self.provider.lookup(uri) - - def refresh(self, uri=None): - """ - Refresh library. Limit to URI and below if an URI is given. - - :param uri: directory or track URI - :type uri: string - """ - return self.provider.refresh(uri) - - def search(self, **query): - """ - Search the library for tracks where ``field`` contains ``values``. - - Examples:: - - # Returns results matching 'a' - search(any=['a']) - # Returns results matching artist 'xyz' - search(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - search(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.search(**query) - - class BaseLibraryProvider(object): """ :param backend: backend the controller is a part of diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index dfcbe8bb..d2b9edd9 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,550 +1,3 @@ -import logging -import random -import time - -from mopidy.listeners import BackendListener - -logger = logging.getLogger('mopidy.backends.base') - - -def option_wrapper(name, default): - def get_option(self): - return getattr(self, name, default) - def set_option(self, value): - if getattr(self, name, default) != value: - self._trigger_options_changed() - return setattr(self, name, value) - return property(get_option, set_option) - - -class PlaybackController(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BasePlaybackProvider` - """ - - # pylint: disable = R0902 - # Too many instance attributes - - pykka_traversable = True - - #: Constant representing the paused state. - PAUSED = u'paused' - - #: Constant representing the playing state. - PLAYING = u'playing' - - #: Constant representing the stopped state. - STOPPED = u'stopped' - - #: :class:`True` - #: Tracks are removed from the playlist when they have been played. - #: :class:`False` - #: Tracks are not removed from the playlist. - consume = option_wrapper('_consume', False) - - #: The currently playing or selected track. - #: - #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or - #: :class:`None`. - current_cp_track = None - - #: :class:`True` - #: Tracks are selected at random from the playlist. - #: :class:`False` - #: Tracks are played in the order of the playlist. - random = option_wrapper('_random', False) - - #: :class:`True` - #: The current playlist is played repeatedly. To repeat a single track, - #: select both :attr:`repeat` and :attr:`single`. - #: :class:`False` - #: The current playlist is played once. - repeat = option_wrapper('_repeat', False) - - #: :class:`True` - #: Playback is stopped after current song, unless in :attr:`repeat` - #: mode. - #: :class:`False` - #: Playback continues after current song. - single = option_wrapper('_single', False) - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - self._state = self.STOPPED - self._shuffled = [] - self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = None - - def _get_cpid(self, cp_track): - if cp_track is None: - return None - return cp_track.cpid - - def _get_track(self, cp_track): - if cp_track is None: - return None - return cp_track.track - - @property - def current_cpid(self): - """ - The CPID (current playlist ID) of the currently playing or selected - track. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_cpid(self.current_cp_track) - - @property - def current_track(self): - """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_track(self.current_cp_track) - - @property - def current_playlist_position(self): - """ - The position of the current track in the current playlist. - - Read-only. - """ - if self.current_cp_track is None: - return None - try: - return self.backend.current_playlist.cp_tracks.index( - self.current_cp_track) - except ValueError: - return None - - @property - def track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_eot` for convenience. - """ - return self._get_track(self.cp_track_at_eot) - - @property - def cp_track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - Not necessarily the same track as :attr:`cp_track_at_next`. - """ - # pylint: disable = R0911 - # Too many return statements - - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat and self.single: - return cp_tracks[self.current_playlist_position] - - if self.repeat and not self.single: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_next` for convenience. - """ - return self._get_track(self.cp_track_at_next) - - @property - def cp_track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_previous` for convenience. - """ - return self._get_track(self.cp_track_at_previous) - - @property - def cp_track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ - if self.repeat or self.consume or self.random: - return self.current_cp_track - - if self.current_playlist_position in (None, 0): - return None - - return self.backend.current_playlist.cp_tracks[ - self.current_playlist_position - 1] - - @property - def state(self): - """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] - """ - return self._state - - @state.setter - def state(self, new_state): - (old_state, self._state) = (self.state, new_state) - logger.debug(u'Changing state: %s -> %s', old_state, new_state) - - self._trigger_playback_state_changed() - - # FIXME play_time stuff assumes backend does not have a better way of - # handeling this stuff :/ - if (old_state in (self.PLAYING, self.STOPPED) - and new_state == self.PLAYING): - self._play_time_start() - elif old_state == self.PLAYING and new_state == self.PAUSED: - self._play_time_pause() - elif old_state == self.PAUSED and new_state == self.PLAYING: - self._play_time_resume() - - @property - def time_position(self): - """Time position in milliseconds.""" - if self.state == self.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == self.PAUSED: - return self.play_time_accumulated - elif self.state == self.STOPPED: - return 0 - - def _play_time_start(self): - self.play_time_accumulated = 0 - self.play_time_started = self._current_wall_time - - def _play_time_pause(self): - time_since_started = self._current_wall_time - self.play_time_started - self.play_time_accumulated += time_since_started - - def _play_time_resume(self): - self.play_time_started = self._current_wall_time - - @property - def _current_wall_time(self): - return int(time.time() * 1000) - - @property - def volume(self): - return self.provider.get_volume() - - @volume.setter - def volume(self, volume): - self.provider.set_volume(volume) - - def change_track(self, cp_track, on_error_step=1): - """ - Change to the given track, keeping the current playback state. - - :param cp_track: track to change to - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - - """ - old_state = self.state - self.stop() - self.current_cp_track = cp_track - if old_state == self.PLAYING: - self.play(on_error_step=on_error_step) - elif old_state == self.PAUSED: - self.pause() - - def on_end_of_track(self): - """ - Tell the playback controller that end of track is reached. - """ - if self.state == self.STOPPED: - return - - original_cp_track = self.current_cp_track - - if self.cp_track_at_eot: - self._trigger_track_playback_ended() - self.play(self.cp_track_at_eot) - else: - self.stop(clear_current_track=True) - - if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track.cpid) - - def on_current_playlist_change(self): - """ - Tell the playback controller that the current playlist has changed. - - Used by :class:`mopidy.backends.base.CurrentPlaylistController`. - """ - self._first_shuffle = True - self._shuffled = [] - - if (not self.backend.current_playlist.cp_tracks or - self.current_cp_track not in - self.backend.current_playlist.cp_tracks): - self.stop(clear_current_track=True) - - def next(self): - """ - Change to the next track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - if self.cp_track_at_next: - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_next) - else: - self.stop(clear_current_track=True) - - def pause(self): - """Pause playback.""" - if self.provider.pause(): - self.state = self.PAUSED - self._trigger_track_playback_paused() - - def play(self, cp_track=None, on_error_step=1): - """ - Play the given track, or if the given track is :class:`None`, play the - currently active track. - - :param cp_track: track to play - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - """ - - if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks - elif cp_track is None: - if self.state == self.PAUSED: - return self.resume() - elif self.current_cp_track is not None: - cp_track = self.current_cp_track - elif self.current_cp_track is None and on_error_step == 1: - cp_track = self.cp_track_at_next - elif self.current_cp_track is None and on_error_step == -1: - cp_track = self.cp_track_at_previous - - if cp_track is not None: - self.current_cp_track = cp_track - self.state = self.PLAYING - if not self.provider.play(cp_track.track): - # Track is not playable - if self.random and self._shuffled: - self._shuffled.remove(cp_track) - if on_error_step == 1: - self.next() - elif on_error_step == -1: - self.previous() - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) - - self._trigger_track_playback_started() - - def previous(self): - """ - Change to the previous track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_previous, on_error_step=-1) - - def resume(self): - """If paused, resume playing the current track.""" - if self.state == self.PAUSED and self.provider.resume(): - self.state = self.PLAYING - self._trigger_track_playback_resumed() - - def seek(self, time_position): - """ - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if not self.backend.current_playlist.tracks: - return False - - if self.state == self.STOPPED: - self.play() - elif self.state == self.PAUSED: - self.resume() - - if time_position < 0: - time_position = 0 - elif time_position > self.current_track.length: - self.next() - return True - - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - - success = self.provider.seek(time_position) - if success: - self._trigger_seeked() - return success - - def stop(self, clear_current_track=False): - """ - Stop playing. - - :param clear_current_track: whether to clear the current track _after_ - stopping - :type clear_current_track: boolean - """ - if self.state != self.STOPPED: - if self.provider.stop(): - self._trigger_track_playback_ended() - self.state = self.STOPPED - if clear_current_track: - self.current_cp_track = None - - def _trigger_track_playback_paused(self): - logger.debug(u'Triggering track playback paused event') - if self.current_track is None: - return - BackendListener.send('track_playback_paused', - track=self.current_track, - time_position=self.time_position) - - def _trigger_track_playback_resumed(self): - logger.debug(u'Triggering track playback resumed event') - if self.current_track is None: - return - BackendListener.send('track_playback_resumed', - track=self.current_track, - time_position=self.time_position) - - def _trigger_track_playback_started(self): - logger.debug(u'Triggering track playback started event') - if self.current_track is None: - return - BackendListener.send('track_playback_started', - track=self.current_track) - - def _trigger_track_playback_ended(self): - logger.debug(u'Triggering track playback ended event') - if self.current_track is None: - return - BackendListener.send('track_playback_ended', - track=self.current_track, - time_position=self.time_position) - - def _trigger_playback_state_changed(self): - logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') - - def _trigger_options_changed(self): - logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') - - def _trigger_seeked(self): - logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') - - class BasePlaybackProvider(object): """ :param backend: the backend diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index 0ce2e196..d1d52c9a 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -1,120 +1,4 @@ from copy import copy -import logging - -logger = logging.getLogger('mopidy.backends.base') - -class StoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseStoredPlaylistsProvider` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return self.provider.playlists - - @playlists.setter - def playlists(self, playlists): - self.provider.playlists = playlists - - def create(self, name): - """ - Create a new playlist. - - :param name: name of the new playlist - :type name: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.create(name) - - def delete(self, playlist): - """ - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.delete(playlist) - - def get(self, **criteria): - """ - Get playlist by given criterias from the set of stored playlists. - - Raises :exc:`LookupError` if a unique match is not found. - - Examples:: - - get(name='a') # Returns track with name 'a' - get(uri='xyz') # Returns track with URI 'xyz' - get(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' - - :param criteria: one or more criteria to match by - :type criteria: dict - :rtype: :class:`mopidy.models.Playlist` - """ - matches = self.playlists - for (key, value) in criteria.iteritems(): - matches = filter(lambda p: getattr(p, key) == value, matches) - if len(matches) == 1: - return matches[0] - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - if len(matches) == 0: - raise LookupError('"%s" match no playlists' % criteria_string) - else: - raise LookupError('"%s" match multiple playlists' % criteria_string) - - def lookup(self, uri): - """ - Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. - - :param uri: playlist URI - :type uri: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.lookup(uri) - - def refresh(self): - """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. - """ - return self.provider.refresh() - - def rename(self, playlist, new_name): - """ - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - """ - return self.provider.rename(playlist, new_name) - - def save(self, playlist): - """ - Save the playlist to the set of stored playlists. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.save(playlist) class BaseStoredPlaylistsProvider(object): diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 2234242c..3ada0052 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,13 +1,11 @@ from pykka.actor import ThreadingActor -from mopidy.backends.base import (Backend, CurrentPlaylistController, - PlaybackController, BasePlaybackProvider, LibraryController, - BaseLibraryProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import core +from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, Backend): +class DummyBackend(ThreadingActor, base.Backend): """ A backend which implements the backend API in the simplest way possible. Used in tests of the frontends. @@ -18,24 +16,24 @@ class DummyBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(DummyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = DummyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = DummyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'dummy'] -class DummyLibraryProvider(BaseLibraryProvider): +class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] @@ -55,7 +53,7 @@ class DummyLibraryProvider(BaseLibraryProvider): return Playlist() -class DummyPlaybackProvider(BasePlaybackProvider): +class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._volume = None @@ -83,7 +81,7 @@ class DummyPlaybackProvider(BasePlaybackProvider): self._volume = volume -class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): playlist = Playlist(name=name) self._playlists.append(playlist) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 263d2fc2..e8d918b0 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,11 +7,8 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, DATA_PATH -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, BaseLibraryProvider, PlaybackController, - BasePlaybackProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import core, settings, DATA_PATH +from mopidy.backends import base from mopidy.models import Playlist, Track, Album from mopidy.gstreamer import GStreamer @@ -27,12 +24,10 @@ if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') -class LocalBackend(ThreadingActor, Backend): +class LocalBackend(ThreadingActor, base.Backend): """ A backend for playing music from a local music archive. - **Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local - **Dependencies:** - None @@ -47,10 +42,10 @@ class LocalBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(LocalBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = LocalLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = LocalPlaybackProvider(backend=self) @@ -58,7 +53,7 @@ class LocalBackend(ThreadingActor, Backend): provider=playback_provider) stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'file'] @@ -72,7 +67,7 @@ class LocalBackend(ThreadingActor, Backend): self.gstreamer = gstreamer_refs[0].proxy() -class LocalPlaybackController(PlaybackController): +class LocalPlaybackController(core.PlaybackController): def __init__(self, *args, **kwargs): super(LocalPlaybackController, self).__init__(*args, **kwargs) @@ -84,7 +79,7 @@ class LocalPlaybackController(PlaybackController): return self.backend.gstreamer.get_position().get() -class LocalPlaybackProvider(BasePlaybackProvider): +class LocalPlaybackProvider(base.BasePlaybackProvider): def pause(self): return self.backend.gstreamer.pause_playback().get() @@ -109,7 +104,7 @@ class LocalPlaybackProvider(BasePlaybackProvider): self.backend.gstreamer.set_volume(volume).get() -class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH @@ -182,7 +177,7 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): self._playlists.append(playlist) -class LocalLibraryProvider(BaseLibraryProvider): +class LocalLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(LocalLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 56775926..fef86280 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -3,16 +3,15 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, PlaybackController, StoredPlaylistsController) +from mopidy import core, settings +from mopidy.backends import base from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} -class SpotifyBackend(ThreadingActor, Backend): +class SpotifyBackend(ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ music streaming service. The backend uses the official `libspotify @@ -51,19 +50,19 @@ class SpotifyBackend(ThreadingActor, Backend): super(SpotifyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = SpotifyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = SpotifyStoredPlaylistsProvider( backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'spotify'] diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py new file mode 100644 index 00000000..16c09665 --- /dev/null +++ b/mopidy/core/__init__.py @@ -0,0 +1,4 @@ +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController +from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/core/current_playlist.py similarity index 99% rename from mopidy/backends/base/current_playlist.py rename to mopidy/core/current_playlist.py index d7e6c331..af06e05e 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -5,7 +5,9 @@ import random from mopidy.listeners import BackendListener from mopidy.models import CpTrack -logger = logging.getLogger('mopidy.backends.base') + +logger = logging.getLogger('mopidy.core') + class CurrentPlaylistController(object): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py new file mode 100644 index 00000000..fc55aaeb --- /dev/null +++ b/mopidy/core/library.py @@ -0,0 +1,70 @@ +class LibraryController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BaseLibraryProvider` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + def find_exact(self, **query): + """ + Search the library for tracks where ``field`` is ``values``. + + Examples:: + + # Returns results matching 'a' + find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.find_exact(**query) + + def lookup(self, uri): + """ + Lookup track with given URI. Returns :class:`None` if not found. + + :param uri: track URI + :type uri: string + :rtype: :class:`mopidy.models.Track` or :class:`None` + """ + return self.provider.lookup(uri) + + def refresh(self, uri=None): + """ + Refresh library. Limit to URI and below if an URI is given. + + :param uri: directory or track URI + :type uri: string + """ + return self.provider.refresh(uri) + + def search(self, **query): + """ + Search the library for tracks where ``field`` contains ``values``. + + Examples:: + + # Returns results matching 'a' + search(any=['a']) + # Returns results matching artist 'xyz' + search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.search(**query) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py new file mode 100644 index 00000000..a0c3ef30 --- /dev/null +++ b/mopidy/core/playback.py @@ -0,0 +1,548 @@ +import logging +import random +import time + +from mopidy.listeners import BackendListener + + +logger = logging.getLogger('mopidy.backends.base') + + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + + return property(get_option, set_option) + + +class PlaybackController(object): + """ + :param backend: the backend + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BasePlaybackProvider` + """ + + # pylint: disable = R0902 + # Too many instance attributes + + pykka_traversable = True + + #: Constant representing the paused state. + PAUSED = u'paused' + + #: Constant representing the playing state. + PLAYING = u'playing' + + #: Constant representing the stopped state. + STOPPED = u'stopped' + + #: :class:`True` + #: Tracks are removed from the playlist when they have been played. + #: :class:`False` + #: Tracks are not removed from the playlist. + consume = option_wrapper('_consume', False) + + #: The currently playing or selected track. + #: + #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or + #: :class:`None`. + current_cp_track = None + + #: :class:`True` + #: Tracks are selected at random from the playlist. + #: :class:`False` + #: Tracks are played in the order of the playlist. + random = option_wrapper('_random', False) + + #: :class:`True` + #: The current playlist is played repeatedly. To repeat a single track, + #: select both :attr:`repeat` and :attr:`single`. + #: :class:`False` + #: The current playlist is played once. + repeat = option_wrapper('_repeat', False) + + #: :class:`True` + #: Playback is stopped after current song, unless in :attr:`repeat` + #: mode. + #: :class:`False` + #: Playback continues after current song. + single = option_wrapper('_single', False) + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + self._state = self.STOPPED + self._shuffled = [] + self._first_shuffle = True + self.play_time_accumulated = 0 + self.play_time_started = None + + def _get_cpid(self, cp_track): + if cp_track is None: + return None + return cp_track.cpid + + def _get_track(self, cp_track): + if cp_track is None: + return None + return cp_track.track + + @property + def current_cpid(self): + """ + The CPID (current playlist ID) of the currently playing or selected + track. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_cpid(self.current_cp_track) + + @property + def current_track(self): + """ + The currently playing or selected :class:`mopidy.models.Track`. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_track(self.current_cp_track) + + @property + def current_playlist_position(self): + """ + The position of the current track in the current playlist. + + Read-only. + """ + if self.current_cp_track is None: + return None + try: + return self.backend.current_playlist.cp_tracks.index( + self.current_cp_track) + except ValueError: + return None + + @property + def track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_eot` for convenience. + """ + return self._get_track(self.cp_track_at_eot) + + @property + def cp_track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + Not necessarily the same track as :attr:`cp_track_at_next`. + """ + # pylint: disable = R0911 + # Too many return statements + + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat and self.single: + return cp_tracks[self.current_playlist_position] + + if self.repeat and not self.single: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_next` for convenience. + """ + return self._get_track(self.cp_track_at_next) + + @property + def cp_track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_previous` for convenience. + """ + return self._get_track(self.cp_track_at_previous) + + @property + def cp_track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ + if self.repeat or self.consume or self.random: + return self.current_cp_track + + if self.current_playlist_position in (None, 0): + return None + + return self.backend.current_playlist.cp_tracks[ + self.current_playlist_position - 1] + + @property + def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + return self._state + + @state.setter + def state(self, new_state): + (old_state, self._state) = (self.state, new_state) + logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed() + + # FIXME play_time stuff assumes backend does not have a better way of + # handeling this stuff :/ + if (old_state in (self.PLAYING, self.STOPPED) + and new_state == self.PLAYING): + self._play_time_start() + elif old_state == self.PLAYING and new_state == self.PAUSED: + self._play_time_pause() + elif old_state == self.PAUSED and new_state == self.PLAYING: + self._play_time_resume() + + @property + def time_position(self): + """Time position in milliseconds.""" + if self.state == self.PLAYING: + time_since_started = (self._current_wall_time - + self.play_time_started) + return self.play_time_accumulated + time_since_started + elif self.state == self.PAUSED: + return self.play_time_accumulated + elif self.state == self.STOPPED: + return 0 + + def _play_time_start(self): + self.play_time_accumulated = 0 + self.play_time_started = self._current_wall_time + + def _play_time_pause(self): + time_since_started = self._current_wall_time - self.play_time_started + self.play_time_accumulated += time_since_started + + def _play_time_resume(self): + self.play_time_started = self._current_wall_time + + @property + def _current_wall_time(self): + return int(time.time() * 1000) + + @property + def volume(self): + return self.provider.get_volume() + + @volume.setter + def volume(self, volume): + self.provider.set_volume(volume) + + def change_track(self, cp_track, on_error_step=1): + """ + Change to the given track, keeping the current playback state. + + :param cp_track: track to change to + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + + """ + old_state = self.state + self.stop() + self.current_cp_track = cp_track + if old_state == self.PLAYING: + self.play(on_error_step=on_error_step) + elif old_state == self.PAUSED: + self.pause() + + def on_end_of_track(self): + """ + Tell the playback controller that end of track is reached. + """ + if self.state == self.STOPPED: + return + + original_cp_track = self.current_cp_track + + if self.cp_track_at_eot: + self._trigger_track_playback_ended() + self.play(self.cp_track_at_eot) + else: + self.stop(clear_current_track=True) + + if self.consume: + self.backend.current_playlist.remove(cpid=original_cp_track.cpid) + + def on_current_playlist_change(self): + """ + Tell the playback controller that the current playlist has changed. + + Used by :class:`mopidy.backends.base.CurrentPlaylistController`. + """ + self._first_shuffle = True + self._shuffled = [] + + if (not self.backend.current_playlist.cp_tracks or + self.current_cp_track not in + self.backend.current_playlist.cp_tracks): + self.stop(clear_current_track=True) + + def next(self): + """ + Change to the next track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + if self.cp_track_at_next: + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_next) + else: + self.stop(clear_current_track=True) + + def pause(self): + """Pause playback.""" + if self.provider.pause(): + self.state = self.PAUSED + self._trigger_track_playback_paused() + + def play(self, cp_track=None, on_error_step=1): + """ + Play the given track, or if the given track is :class:`None`, play the + currently active track. + + :param cp_track: track to play + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + """ + + if cp_track is not None: + assert cp_track in self.backend.current_playlist.cp_tracks + elif cp_track is None: + if self.state == self.PAUSED: + return self.resume() + elif self.current_cp_track is not None: + cp_track = self.current_cp_track + elif self.current_cp_track is None and on_error_step == 1: + cp_track = self.cp_track_at_next + elif self.current_cp_track is None and on_error_step == -1: + cp_track = self.cp_track_at_previous + + if cp_track is not None: + self.current_cp_track = cp_track + self.state = self.PLAYING + if not self.provider.play(cp_track.track): + # Track is not playable + if self.random and self._shuffled: + self._shuffled.remove(cp_track) + if on_error_step == 1: + self.next() + elif on_error_step == -1: + self.previous() + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) + + self._trigger_track_playback_started() + + def previous(self): + """ + Change to the previous track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_previous, on_error_step=-1) + + def resume(self): + """If paused, resume playing the current track.""" + if self.state == self.PAUSED and self.provider.resume(): + self.state = self.PLAYING + self._trigger_track_playback_resumed() + + def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if not self.backend.current_playlist.tracks: + return False + + if self.state == self.STOPPED: + self.play() + elif self.state == self.PAUSED: + self.resume() + + if time_position < 0: + time_position = 0 + elif time_position > self.current_track.length: + self.next() + return True + + self.play_time_started = self._current_wall_time + self.play_time_accumulated = time_position + + success = self.provider.seek(time_position) + if success: + self._trigger_seeked() + return success + + def stop(self, clear_current_track=False): + """ + Stop playing. + + :param clear_current_track: whether to clear the current track _after_ + stopping + :type clear_current_track: boolean + """ + if self.state != self.STOPPED: + if self.provider.stop(): + self._trigger_track_playback_ended() + self.state = self.STOPPED + if clear_current_track: + self.current_cp_track = None + + def _trigger_track_playback_paused(self): + logger.debug(u'Triggering track playback paused event') + if self.current_track is None: + return + BackendListener.send('track_playback_paused', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_resumed(self): + logger.debug(u'Triggering track playback resumed event') + if self.current_track is None: + return + BackendListener.send('track_playback_resumed', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_started(self): + logger.debug(u'Triggering track playback started event') + if self.current_track is None: + return + BackendListener.send('track_playback_started', + track=self.current_track) + + def _trigger_track_playback_ended(self): + logger.debug(u'Triggering track playback ended event') + if self.current_track is None: + return + BackendListener.send('track_playback_ended', + track=self.current_track, + time_position=self.time_position) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') + + def _trigger_seeked(self): + logger.debug(u'Triggering seeked event') + BackendListener.send('seeked') diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py new file mode 100644 index 00000000..a29e34fc --- /dev/null +++ b/mopidy/core/stored_playlists.py @@ -0,0 +1,113 @@ +class StoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BaseStoredPlaylistsProvider` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return self.provider.playlists + + @playlists.setter + def playlists(self, playlists): + self.provider.playlists = playlists + + def create(self, name): + """ + Create a new playlist. + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.create(name) + + def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.delete(playlist) + + def get(self, **criteria): + """ + Get playlist by given criterias from the set of stored playlists. + + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + get(name='a') # Returns track with name 'a' + get(uri='xyz') # Returns track with URI 'xyz' + get(name='a', uri='xyz') # Returns track with name 'a' and URI + # 'xyz' + + :param criteria: one or more criteria to match by + :type criteria: dict + :rtype: :class:`mopidy.models.Playlist` + """ + matches = self.playlists + for (key, value) in criteria.iteritems(): + matches = filter(lambda p: getattr(p, key) == value, matches) + if len(matches) == 1: + return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError('"%s" match no playlists' % criteria_string) + else: + raise LookupError('"%s" match multiple playlists' + % criteria_string) + + def lookup(self, uri): + """ + Lookup playlist with given URI in both the set of stored playlists and + in any other playlist sources. + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.lookup(uri) + + def refresh(self): + """ + Refresh the stored playlists in + :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. + """ + return self.provider.refresh() + + def rename(self, playlist, new_name): + """ + Rename playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + :param new_name: the new name + :type new_name: string + """ + return self.provider.rename(playlist, new_name) + + def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.save(playlist) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 4cf33266..e6bb6478 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from mopidy.backends.base import PlaybackController +from mopidy import core from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -105,10 +105,10 @@ def pause(context, state=None): """ if state is None: if (context.backend.playback.state.get() == - PlaybackController.PLAYING): + core.PlaybackController.PLAYING): context.backend.playback.pause() elif (context.backend.playback.state.get() == - PlaybackController.PAUSED): + core.PlaybackController.PAUSED): context.backend.playback.resume() elif int(state): context.backend.playback.pause() @@ -185,9 +185,11 @@ def playpos(context, songpos): raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackController.PLAYING): + if (context.backend.playback.state.get() == + core.PlaybackController.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == + core.PlaybackController.PAUSED): return context.backend.playback.resume().get() elif context.backend.playback.current_cp_track.get() is not None: cp_track = context.backend.playback.current_cp_track.get() diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 4a9ad9a1..279978aa 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,6 +1,6 @@ import pykka.future -from mopidy.backends.base import PlaybackController +from mopidy import core from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format @@ -194,8 +194,8 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (PlaybackController.PLAYING, - PlaybackController.PAUSED): + if futures['playback.state'].get() in (core.PlaybackController.PLAYING, + core.PlaybackController.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) @@ -239,11 +239,11 @@ def _status_songpos(futures): def _status_state(futures): state = futures['playback.state'].get() - if state == PlaybackController.PLAYING: + if state == core.PlaybackController.PLAYING: return u'play' - elif state == PlaybackController.STOPPED: + elif state == core.PlaybackController.STOPPED: return u'stop' - elif state == PlaybackController.PAUSED: + elif state == core.PlaybackController.PAUSED: return u'pause' def _status_time(futures): diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 6815c0d2..bcd3de5c 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -14,9 +14,8 @@ except ImportError as import_error: from pykka.registry import ActorRegistry -from mopidy import settings +from mopidy import core, settings from mopidy.backends.base import Backend -from mopidy.backends.base.playback import PlaybackController from mopidy.utils.process import exit_process # Must be done before dbus.SessionBus() is called @@ -198,11 +197,11 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == core.PlaybackController.PLAYING: self.backend.playback.pause().get() - elif state == PlaybackController.PAUSED: + elif state == core.PlaybackController.PAUSED: self.backend.playback.resume().get() - elif state == PlaybackController.STOPPED: + elif state == core.PlaybackController.STOPPED: self.backend.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -220,7 +219,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PAUSED: + if state == core.PlaybackController.PAUSED: self.backend.playback.resume().get() else: self.backend.playback.play().get() @@ -287,11 +286,11 @@ class MprisObject(dbus.service.Object): def get_PlaybackStatus(self): state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == core.PlaybackController.PLAYING: return 'Playing' - elif state == PlaybackController.PAUSED: + elif state == core.PlaybackController.PAUSED: return 'Paused' - elif state == PlaybackController.STOPPED: + elif state == core.PlaybackController.STOPPED: return 'Stopped' def get_LoopStatus(self): diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 87c9bbb8..514c1599 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,12 +1,12 @@ -from mopidy.backends import base as backend +from mopidy import core from mopidy.models import Track from tests import unittest from tests.frontends.mpd import protocol -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED +PAUSED = core.PlaybackController.PAUSED +PLAYING = core.PlaybackController.PLAYING +STOPPED = core.PlaybackController.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 3701faaf..8fd8895d 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,13 +1,14 @@ -from mopidy.backends import dummy as backend +from mopidy import core +from mopidy.backends import dummy from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status from mopidy.models import Track from tests import unittest -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED +PAUSED = core.PlaybackController.PAUSED +PLAYING = core.PlaybackController.PLAYING +STOPPED = core.PlaybackController.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? @@ -15,7 +16,7 @@ STOPPED = backend.PlaybackController.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start().proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index b7ad1b60..48be504f 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -2,9 +2,8 @@ import sys import mock -from mopidy import OptionalDependencyError +from mopidy import core, OptionalDependencyError from mopidy.backends.dummy import DummyBackend -from mopidy.backends.base.playback import PlaybackController from mopidy.models import Album, Artist, Track try: @@ -14,9 +13,9 @@ except OptionalDependencyError: from tests import unittest -PLAYING = PlaybackController.PLAYING -PAUSED = PlaybackController.PAUSED -STOPPED = PlaybackController.STOPPED +PLAYING = core.PlaybackController.PLAYING +PAUSED = core.PlaybackController.PAUSED +STOPPED = core.PlaybackController.STOPPED @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') From e0b26fcb814702657d2a03586e9486b49f01387c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Sep 2012 23:50:09 +0200 Subject: [PATCH 132/177] docs: Move controllers out of the Backend API --- .../{backends/providers.rst => backends.rst} | 0 docs/api/backends/controllers.rst | 54 ------------------- docs/api/{backends => }/concepts.rst | 0 docs/api/core.rst | 49 +++++++++++++++++ docs/api/index.rst | 10 ++-- 5 files changed, 55 insertions(+), 58 deletions(-) rename docs/api/{backends/providers.rst => backends.rst} (100%) delete mode 100644 docs/api/backends/controllers.rst rename docs/api/{backends => }/concepts.rst (100%) create mode 100644 docs/api/core.rst diff --git a/docs/api/backends/providers.rst b/docs/api/backends.rst similarity index 100% rename from docs/api/backends/providers.rst rename to docs/api/backends.rst diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst deleted file mode 100644 index 8d6687e2..00000000 --- a/docs/api/backends/controllers.rst +++ /dev/null @@ -1,54 +0,0 @@ -.. _backend-controller-api: - -********************** -Backend controller API -********************** - - -The backend controller API is the interface that is used by frontends like -:mod:`mopidy.frontends.mpd`. If you want to implement your own backend, see the -:ref:`backend-provider-api`. - - -The backend -=========== - -.. autoclass:: mopidy.backends.base.Backend - :members: - - -Playback controller -=================== - -Manages playback, with actions like play, pause, stop, next, previous, -seek, and volume control. - -.. autoclass:: mopidy.backends.base.PlaybackController - :members: - - -Current playlist controller -=========================== - -Manages everything related to the currently loaded playlist. - -.. autoclass:: mopidy.backends.base.CurrentPlaylistController - :members: - - -Stored playlists controller -=========================== - -Manages stored playlist. - -.. autoclass:: mopidy.backends.base.StoredPlaylistsController - :members: - - -Library controller -================== - -Manages the music library, e.g. searching for tracks to be added to a playlist. - -.. autoclass:: mopidy.backends.base.LibraryController - :members: diff --git a/docs/api/backends/concepts.rst b/docs/api/concepts.rst similarity index 100% rename from docs/api/backends/concepts.rst rename to docs/api/concepts.rst diff --git a/docs/api/core.rst b/docs/api/core.rst new file mode 100644 index 00000000..1852733f --- /dev/null +++ b/docs/api/core.rst @@ -0,0 +1,49 @@ +.. _backend-controller-api: + +******** +Core API +******** + + +The core API is the interface that is used by frontends like +:mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the +backends. + +If you want to implement your own backend, see the :ref:`backend-provider-api`. + + +Playback controller +=================== + +Manages playback, with actions like play, pause, stop, next, previous, +seek, and volume control. + +.. autoclass:: mopidy.core.PlaybackController + :members: + + +Current playlist controller +=========================== + +Manages everything related to the currently loaded playlist. + +.. autoclass:: mopidy.core.CurrentPlaylistController + :members: + + +Stored playlists controller +=========================== + +Manages stored playlist. + +.. autoclass:: mopidy.core.StoredPlaylistsController + :members: + + +Library controller +================== + +Manages the music library, e.g. searching for tracks to be added to a playlist. + +.. autoclass:: mopidy.core.LibraryController + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 1f37e9ff..b5be8ed4 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -5,7 +5,9 @@ API reference .. toctree:: :glob: - backends/concepts - backends/controllers - backends/providers - * + concepts + models + backends + core + frontends + listeners From f3cb3036d4627255e6ac85d9527387d3bc17f83c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Sep 2012 23:57:07 +0200 Subject: [PATCH 133/177] docs: Update a couple of references and titles --- docs/api/backends.rst | 18 +++++++++--------- docs/api/concepts.rst | 6 +++--- docs/api/core.rst | 4 +--- docs/changes.rst | 6 +++--- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 61e5f68a..781723d6 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -1,12 +1,12 @@ -.. _backend-provider-api: +.. _backend-api: -******************** -Backend provider API -******************** +*********** +Backend API +*********** -The backend provider API is the interface that must be implemented when you -create a backend. If you are working on a frontend and need to access the -backend, see the :ref:`backend-controller-api`. +The backend API is the interface that must be implemented when you create a +backend. If you are working on a frontend and need to access the backend, see +the :ref:`core-api`. Playback provider @@ -30,8 +30,8 @@ Library provider :members: -Backend provider implementations -================================ +Backend implementations +======================= * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.spotify` diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 371e03bc..ae959237 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -1,4 +1,4 @@ -.. _backend-concepts: +.. _concepts: ********************************************** The backend, controller, and provider concepts @@ -12,11 +12,11 @@ Controllers: functionality. Most, but not all, controllers delegates some work to one or more providers. The controllers are responsible for choosing the right provider for any given task based upon i.e. the track's URI. See - :ref:`backend-controller-api` for more details. + :ref:`core-api` for more details. Providers: Anything specific to i.e. Spotify integration or local storage is contained in the providers. To integrate with new music sources, you just add new - providers. See :ref:`backend-provider-api` for more details. + providers. See :ref:`backend-api` for more details. .. digraph:: backend_relations diff --git a/docs/api/core.rst b/docs/api/core.rst index 1852733f..b37c8730 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -1,4 +1,4 @@ -.. _backend-controller-api: +.. _core-api: ******** Core API @@ -9,8 +9,6 @@ The core API is the interface that is used by frontends like :mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the backends. -If you want to implement your own backend, see the :ref:`backend-provider-api`. - Playback controller =================== diff --git a/docs/changes.rst b/docs/changes.rst index c6b7e0ac..3b77f61a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -616,9 +616,9 @@ to this problem. :class:`mopidy.models.Album`, and :class:`mopidy.models.Track`. - Prepare for multi-backend support (see :issue:`40`) by introducing the - :ref:`provider concept `. Split the backend API into a - :ref:`backend controller API ` (for frontend use) - and a :ref:`backend provider API ` (for backend + :ref:`provider concept `. Split the backend API into a + :ref:`backend controller API ` (for frontend use) + and a :ref:`backend provider API ` (for backend implementation use), which includes the following changes: - Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`. From aab37302a1096a19e0ed02fa1e77cf369772bf21 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 00:53:23 +0200 Subject: [PATCH 134/177] Rename mopidy.gstreamer to mopidy.audio --- docs/modules/audio.rst | 7 +++++ docs/modules/gstreamer.rst | 7 ----- mopidy/__main__.py | 14 ++++----- mopidy/{gstreamer.py => audio/__init__.py} | 6 ++-- mopidy/backends/local/__init__.py | 33 +++++++++++----------- mopidy/backends/spotify/__init__.py | 13 ++++----- mopidy/backends/spotify/playback.py | 20 ++++++------- mopidy/backends/spotify/session_manager.py | 17 ++++++----- tests/{gstreamer_test.py => audio_test.py} | 30 +++++++++----------- tests/backends/base/current_playlist.py | 4 +-- tests/backends/base/playback.py | 8 +++--- 11 files changed, 77 insertions(+), 82 deletions(-) create mode 100644 docs/modules/audio.rst delete mode 100644 docs/modules/gstreamer.rst rename mopidy/{gstreamer.py => audio/__init__.py} (99%) rename tests/{gstreamer_test.py => audio_test.py} (64%) diff --git a/docs/modules/audio.rst b/docs/modules/audio.rst new file mode 100644 index 00000000..0f1c3bfb --- /dev/null +++ b/docs/modules/audio.rst @@ -0,0 +1,7 @@ +************************************* +:mod:`mopidy.audio` -- Audio playback +************************************* + +.. automodule:: mopidy.audio + :synopsis: Audio playback through GStreamer + :members: diff --git a/docs/modules/gstreamer.rst b/docs/modules/gstreamer.rst deleted file mode 100644 index 205b0a3e..00000000 --- a/docs/modules/gstreamer.rst +++ /dev/null @@ -1,7 +0,0 @@ -******************************************** -:mod:`mopidy.gstreamer` -- GStreamer adapter -******************************************** - -.. automodule:: mopidy.gstreamer - :synopsis: GStreamer adapter - :members: diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9bee390e..416429bc 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -30,7 +30,7 @@ sys.path.insert(0, from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer +from mopidy.audio import Audio from mopidy.utils import get_class from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging @@ -51,7 +51,7 @@ def main(): setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - setup_gstreamer() + setup_audio() setup_backend() setup_frontends() loop.run() @@ -65,7 +65,7 @@ def main(): loop.quit() stop_frontends() stop_backend() - stop_gstreamer() + stop_audio() stop_remaining_actors() @@ -117,12 +117,12 @@ def setup_settings(interactive): sys.exit(1) -def setup_gstreamer(): - GStreamer.start() +def setup_audio(): + Audio.start() -def stop_gstreamer(): - stop_actors_by_class(GStreamer) +def stop_audio(): + stop_actors_by_class(Audio) def setup_backend(): get_class(settings.BACKENDS[0]).start() diff --git a/mopidy/gstreamer.py b/mopidy/audio/__init__.py similarity index 99% rename from mopidy/gstreamer.py rename to mopidy/audio/__init__.py index 9d0cb97c..28abd75d 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/audio/__init__.py @@ -15,10 +15,10 @@ from mopidy.utils import process # Trigger install of gst mixer plugins from mopidy import mixers -logger = logging.getLogger('mopidy.gstreamer') +logger = logging.getLogger('mopidy.audio') -class GStreamer(ThreadingActor): +class Audio(ThreadingActor): """ Audio output through `GStreamer `_. @@ -31,7 +31,7 @@ class GStreamer(ThreadingActor): """ def __init__(self): - super(GStreamer, self).__init__() + super(Audio, self).__init__() self._default_caps = gst.Caps(""" audio/x-raw-int, diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e8d918b0..022b253b 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,10 +7,9 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import core, settings, DATA_PATH +from mopidy import audio, core, settings, DATA_PATH from mopidy.backends import base from mopidy.models import Playlist, Track, Album -from mopidy.gstreamer import GStreamer from .translator import parse_m3u, parse_mpd_tag_cache @@ -58,13 +57,13 @@ class LocalBackend(ThreadingActor, base.Backend): self.uri_schemes = [u'file'] - self.gstreamer = None + self.audio = None def on_start(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running GStreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + audio_refs = ActorRegistry.get_by_class(audio.Audio) + assert len(audio_refs) == 1, \ + 'Expected exactly one running Audio instance.' + self.audio = audio_refs[0].proxy() class LocalPlaybackController(core.PlaybackController): @@ -76,32 +75,32 @@ class LocalPlaybackController(core.PlaybackController): @property def time_position(self): - return self.backend.gstreamer.get_position().get() + return self.backend.audio.get_position().get() class LocalPlaybackProvider(base.BasePlaybackProvider): def pause(self): - return self.backend.gstreamer.pause_playback().get() + return self.backend.audio.pause_playback().get() def play(self, track): - self.backend.gstreamer.prepare_change() - self.backend.gstreamer.set_uri(track.uri).get() - return self.backend.gstreamer.start_playback().get() + self.backend.audio.prepare_change() + self.backend.audio.set_uri(track.uri).get() + return self.backend.audio.start_playback().get() def resume(self): - return self.backend.gstreamer.start_playback().get() + return self.backend.audio.start_playback().get() def seek(self, time_position): - return self.backend.gstreamer.set_position(time_position).get() + return self.backend.audio.set_position(time_position).get() def stop(self): - return self.backend.gstreamer.stop_playback().get() + return self.backend.audio.stop_playback().get() def get_volume(self): - return self.backend.gstreamer.get_volume().get() + return self.backend.audio.get_volume().get() def set_volume(self, volume): - self.backend.gstreamer.set_volume(volume).get() + self.backend.audio.set_volume(volume).get() class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index fef86280..1feb1c65 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -3,9 +3,8 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import core, settings +from mopidy import audio, core, settings from mopidy.backends import base -from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') @@ -67,7 +66,7 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.uri_schemes = [u'spotify'] - self.gstreamer = None + self.audio = None self.spotify = None # Fail early if settings are not present @@ -75,10 +74,10 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.password = settings.SPOTIFY_PASSWORD def on_start(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running GStreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + audio_refs = ActorRegistry.get_by_class(audio.Audio) + assert len(audio_refs) == 1, \ + 'Expected exactly one running Audio instance.' + self.audio = audio_refs[0].proxy() logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 70cc4617..307cf4bf 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -8,7 +8,7 @@ logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.gstreamer.pause_playback() + return self.backend.audio.pause_playback() def play(self, track): if self.backend.playback.state == self.backend.playback.PLAYING: @@ -19,10 +19,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.gstreamer.prepare_change() - self.backend.gstreamer.set_uri('appsrc://') - self.backend.gstreamer.start_playback() - self.backend.gstreamer.set_metadata(track) + self.backend.audio.prepare_change() + self.backend.audio.set_uri('appsrc://') + self.backend.audio.start_playback() + self.backend.audio.set_metadata(track) return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) @@ -32,18 +32,18 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.gstreamer.prepare_change() + self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.gstreamer.start_playback() + self.backend.audio.start_playback() return True def stop(self): - result = self.backend.gstreamer.stop_playback() + result = self.backend.audio.stop_playback() self.backend.spotify.session.play(0) return result def get_volume(self): - return self.backend.gstreamer.get_volume().get() + return self.backend.audio.get_volume().get() def set_volume(self, volume): - self.backend.gstreamer.set_volume(volume) + self.backend.audio.set_volume(volume) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 481f7a94..aa3734ae 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,14 +6,13 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import get_version, settings, CACHE_PATH +from mopidy import audio, get_version, settings, CACHE_PATH from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist -from mopidy.gstreamer import GStreamer from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -34,7 +33,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): BaseThread.__init__(self) self.name = 'SpotifyThread' - self.gstreamer = None + self.audio = None self.backend = None self.connected = threading.Event() @@ -50,10 +49,10 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connect() def setup(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running gstreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + audio_refs = ActorRegistry.get_by_class(audio.Audio) + assert len(audio_refs) == 1, \ + 'Expected exactly one running Audio instance.' + self.audio = audio_refs[0].proxy() backend_refs = ActorRegistry.get_by_class(Backend) assert len(backend_refs) == 1, 'Expected exactly one running backend.' @@ -117,7 +116,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.gstreamer.emit_data(capabilites, bytes(frames)) + self.audio.emit_data(capabilites, bytes(frames)) return num_frames def play_token_lost(self, session): @@ -143,7 +142,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug(u'End of data stream reached') - self.gstreamer.emit_end_of_stream() + self.audio.emit_end_of_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data diff --git a/tests/gstreamer_test.py b/tests/audio_test.py similarity index 64% rename from tests/gstreamer_test.py rename to tests/audio_test.py index ce20d2b4..fcafa75f 100644 --- a/tests/gstreamer_test.py +++ b/tests/audio_test.py @@ -1,7 +1,6 @@ import sys -from mopidy import settings -from mopidy.gstreamer import GStreamer +from mopidy import audio, settings from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir @@ -9,38 +8,38 @@ from tests import unittest, path_to_data_dir @unittest.skipIf(sys.platform == 'win32', 'Our Windows build server does not support GStreamer yet') -class GStreamerTest(unittest.TestCase): +class AudioTest(unittest.TestCase): def setUp(self): settings.MIXER = 'fakemixer track_max_volume=65536' settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer.start().proxy() + self.audio = audio.Audio.start().proxy() def tearDown(self): - self.gstreamer.stop() + self.audio.stop() settings.runtime.clear() def prepare_uri(self, uri): - self.gstreamer.prepare_change() - self.gstreamer.set_uri(uri) + self.audio.prepare_change() + self.audio.set_uri(uri) def test_start_playback_existing_file(self): self.prepare_uri(self.song_uri) - self.assertTrue(self.gstreamer.start_playback().get()) + self.assertTrue(self.audio.start_playback().get()) def test_start_playback_non_existing_file(self): self.prepare_uri(self.song_uri + 'bogus') - self.assertFalse(self.gstreamer.start_playback().get()) + self.assertFalse(self.audio.start_playback().get()) def test_pause_playback_while_playing(self): self.prepare_uri(self.song_uri) - self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.pause_playback().get()) + self.audio.start_playback() + self.assertTrue(self.audio.pause_playback().get()) def test_stop_playback_while_playing(self): self.prepare_uri(self.song_uri) - self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.stop_playback().get()) + self.audio.start_playback() + self.assertTrue(self.audio.stop_playback().get()) @unittest.SkipTest def test_deliver_data(self): @@ -52,8 +51,8 @@ class GStreamerTest(unittest.TestCase): def test_set_volume(self): for value in range(0, 101): - self.assertTrue(self.gstreamer.set_volume(value).get()) - self.assertEqual(value, self.gstreamer.get_volume().get()) + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) @unittest.SkipTest def test_set_state_encapsulation(self): @@ -66,4 +65,3 @@ class GStreamerTest(unittest.TestCase): @unittest.SkipTest def test_invalid_output_raises_error(self): pass # TODO - diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index e99cd56c..44e9390e 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,8 +1,8 @@ import mock import random +from mopidy import audio from mopidy.models import CpTrack, Playlist, Track -from mopidy.gstreamer import GStreamer from tests.backends.base import populate_playlist @@ -12,7 +12,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.gstreamer = mock.Mock(spec=GStreamer) + self.backend.audio = mock.Mock(spec=audio.Audio) self.controller = self.backend.current_playlist self.playback = self.backend.playback diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 40c49709..dcd43983 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -2,8 +2,8 @@ import mock import random import time +from mopidy import audio from mopidy.models import Track -from mopidy.gstreamer import GStreamer from tests import unittest from tests.backends.base import populate_playlist @@ -16,7 +16,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.gstreamer = mock.Mock(spec=GStreamer) + self.backend.audio = mock.Mock(spec=audio.Audio) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -729,7 +729,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.gstreamer.get_position = mock.Mock(return_value=future) + self.backend.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -737,7 +737,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.gstreamer.get_position = mock.Mock(return_value=future) + self.backend.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) From 4dd95804f25dd9dee2c260d8a23b2879cb50e2fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 00:56:49 +0200 Subject: [PATCH 135/177] Rename mopidy.mixers to mopidy.audio.mixers --- mopidy/audio/__init__.py | 2 +- mopidy/{ => audio}/mixers/__init__.py | 6 +++--- mopidy/{ => audio}/mixers/auto.py | 2 +- mopidy/{ => audio}/mixers/fake.py | 2 +- mopidy/{ => audio}/mixers/nad.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) rename mopidy/{ => audio}/mixers/__init__.py (88%) rename mopidy/{ => audio}/mixers/auto.py (97%) rename mopidy/{ => audio}/mixers/fake.py (96%) rename mopidy/{ => audio}/mixers/nad.py (98%) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 28abd75d..78a53277 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -13,7 +13,7 @@ from mopidy.backends.base import Backend from mopidy.utils import process # Trigger install of gst mixer plugins -from mopidy import mixers +from mopidy.audio import mixers logger = logging.getLogger('mopidy.audio') diff --git a/mopidy/mixers/__init__.py b/mopidy/audio/mixers/__init__.py similarity index 88% rename from mopidy/mixers/__init__.py rename to mopidy/audio/mixers/__init__.py index 317188fc..a0247519 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -38,6 +38,6 @@ def create_track(label, initial_volume, min_volume, max_volume, # # Keep these imports at the bottom of the file to avoid cyclic import problems # when mixers use the above code. -from mopidy.mixers.auto import AutoAudioMixer -from mopidy.mixers.fake import FakeMixer -from mopidy.mixers.nad import NadMixer +from .auto import AutoAudioMixer +from .fake import FakeMixer +from .nad import NadMixer diff --git a/mopidy/mixers/auto.py b/mopidy/audio/mixers/auto.py similarity index 97% rename from mopidy/mixers/auto.py rename to mopidy/audio/mixers/auto.py index f4bd0f92..1233afa3 100644 --- a/mopidy/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -5,7 +5,7 @@ import gst import logging -logger = logging.getLogger('mopidy.mixers.auto') +logger = logging.getLogger('mopidy.audio.mixers.auto') # TODO: we might want to add some ranking to the mixers we know about? diff --git a/mopidy/mixers/fake.py b/mopidy/audio/mixers/fake.py similarity index 96% rename from mopidy/mixers/fake.py rename to mopidy/audio/mixers/fake.py index 3c47ef33..c5faa03f 100644 --- a/mopidy/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -3,7 +3,7 @@ pygst.require('0.10') import gobject import gst -from mopidy.mixers import create_track +from mopidy.audio.mixers import create_track class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): diff --git a/mopidy/mixers/nad.py b/mopidy/audio/mixers/nad.py similarity index 98% rename from mopidy/mixers/nad.py rename to mopidy/audio/mixers/nad.py index de959d41..667dee53 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -12,10 +12,10 @@ except ImportError: from pykka.actor import ThreadingActor -from mopidy.mixers import create_track +from mopidy.audio.mixers import create_track -logger = logging.getLogger('mopidy.mixers.nad') +logger = logging.getLogger('mopidy.audio.mixers.nad') class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): From 2ba05f940599914f9ef49c4813b280a9206c4b80 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 00:28:14 +0200 Subject: [PATCH 136/177] Add PlaybackState enum --- docs/api/core.rst | 3 + mopidy/backends/spotify/playback.py | 3 +- mopidy/core/__init__.py | 2 +- mopidy/core/playback.py | 67 +++++++------ mopidy/frontends/mpd/protocol/playback.py | 14 +-- mopidy/frontends/mpd/protocol/status.py | 12 +-- mopidy/frontends/mpris/objects.py | 17 ++-- mopidy/models.py | 1 + tests/backends/base/current_playlist.py | 9 +- tests/backends/base/playback.py | 93 ++++++++++--------- tests/backends/local/playback_test.py | 7 +- tests/frontends/mpd/protocol/playback_test.py | 9 +- tests/frontends/mpd/status_test.py | 9 +- .../frontends/mpris/player_interface_test.py | 9 +- 14 files changed, 136 insertions(+), 119 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index b37c8730..e74d9f45 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -16,6 +16,9 @@ Playback controller Manages playback, with actions like play, pause, stop, next, previous, seek, and volume control. +.. autoclass:: mopidy.core.PlaybackState + :members: + .. autoclass:: mopidy.core.PlaybackController :members: diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 307cf4bf..cf16c35e 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -3,6 +3,7 @@ import logging from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackProvider +from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify.playback') @@ -11,7 +12,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.backend.audio.pause_playback() def play(self, track): - if self.backend.playback.state == self.backend.playback.PLAYING: + if self.backend.playback.state == PlaybackState.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: return False diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 16c09665..87df96c9 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,4 +1,4 @@ from .current_playlist import CurrentPlaylistController from .library import LibraryController -from .playback import PlaybackController +from .playback import PlaybackController, PlaybackState from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index a0c3ef30..dfd1676e 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -20,6 +20,22 @@ def option_wrapper(name, default): return property(get_option, set_option) + +class PlaybackState(object): + """ + Enum of playback states. + """ + + #: Constant representing the paused state. + PAUSED = u'paused' + + #: Constant representing the playing state. + PLAYING = u'playing' + + #: Constant representing the stopped state. + STOPPED = u'stopped' + + class PlaybackController(object): """ :param backend: the backend @@ -33,15 +49,6 @@ class PlaybackController(object): pykka_traversable = True - #: Constant representing the paused state. - PAUSED = u'paused' - - #: Constant representing the playing state. - PLAYING = u'playing' - - #: Constant representing the stopped state. - STOPPED = u'stopped' - #: :class:`True` #: Tracks are removed from the playlist when they have been played. #: :class:`False` @@ -77,7 +84,7 @@ class PlaybackController(object): def __init__(self, backend, provider): self.backend = backend self.provider = provider - self._state = self.STOPPED + self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True self.play_time_accumulated = 0 @@ -287,24 +294,26 @@ class PlaybackController(object): # FIXME play_time stuff assumes backend does not have a better way of # handeling this stuff :/ - if (old_state in (self.PLAYING, self.STOPPED) - and new_state == self.PLAYING): + if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED) + and new_state == PlaybackState.PLAYING): self._play_time_start() - elif old_state == self.PLAYING and new_state == self.PAUSED: + elif (old_state == PlaybackState.PLAYING + and new_state == PlaybackState.PAUSED): self._play_time_pause() - elif old_state == self.PAUSED and new_state == self.PLAYING: + elif (old_state == PlaybackState.PAUSED + and new_state == PlaybackState.PLAYING): self._play_time_resume() @property def time_position(self): """Time position in milliseconds.""" - if self.state == self.PLAYING: + if self.state == PlaybackState.PLAYING: time_since_started = (self._current_wall_time - self.play_time_started) return self.play_time_accumulated + time_since_started - elif self.state == self.PAUSED: + elif self.state == PlaybackState.PAUSED: return self.play_time_accumulated - elif self.state == self.STOPPED: + elif self.state == PlaybackState.STOPPED: return 0 def _play_time_start(self): @@ -345,16 +354,16 @@ class PlaybackController(object): old_state = self.state self.stop() self.current_cp_track = cp_track - if old_state == self.PLAYING: + if old_state == PlaybackState.PLAYING: self.play(on_error_step=on_error_step) - elif old_state == self.PAUSED: + elif old_state == PlaybackState.PAUSED: self.pause() def on_end_of_track(self): """ Tell the playback controller that end of track is reached. """ - if self.state == self.STOPPED: + if self.state == PlaybackState.STOPPED: return original_cp_track = self.current_cp_track @@ -398,7 +407,7 @@ class PlaybackController(object): def pause(self): """Pause playback.""" if self.provider.pause(): - self.state = self.PAUSED + self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): @@ -417,7 +426,7 @@ class PlaybackController(object): if cp_track is not None: assert cp_track in self.backend.current_playlist.cp_tracks elif cp_track is None: - if self.state == self.PAUSED: + if self.state == PlaybackState.PAUSED: return self.resume() elif self.current_cp_track is not None: cp_track = self.current_cp_track @@ -428,7 +437,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track - self.state = self.PLAYING + self.state = PlaybackState.PLAYING if not self.provider.play(cp_track.track): # Track is not playable if self.random and self._shuffled: @@ -455,8 +464,8 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if self.state == self.PAUSED and self.provider.resume(): - self.state = self.PLAYING + if self.state == PlaybackState.PAUSED and self.provider.resume(): + self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() def seek(self, time_position): @@ -470,9 +479,9 @@ class PlaybackController(object): if not self.backend.current_playlist.tracks: return False - if self.state == self.STOPPED: + if self.state == PlaybackState.STOPPED: self.play() - elif self.state == self.PAUSED: + elif self.state == PlaybackState.PAUSED: self.resume() if time_position < 0: @@ -497,10 +506,10 @@ class PlaybackController(object): stopping :type clear_current_track: boolean """ - if self.state != self.STOPPED: + if self.state != PlaybackState.STOPPED: if self.provider.stop(): self._trigger_track_playback_ended() - self.state = self.STOPPED + self.state = PlaybackState.STOPPED if clear_current_track: self.current_cp_track = None diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index e6bb6478..b0c299c8 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from mopidy import core +from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -104,11 +104,9 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (context.backend.playback.state.get() == - core.PlaybackController.PLAYING): + if (context.backend.playback.state.get() == PlaybackState.PLAYING): context.backend.playback.pause() - elif (context.backend.playback.state.get() == - core.PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == PlaybackState.PAUSED): context.backend.playback.resume() elif int(state): context.backend.playback.pause() @@ -185,11 +183,9 @@ def playpos(context, songpos): raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == - core.PlaybackController.PLAYING): + if (context.backend.playback.state.get() == PlaybackState.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == - core.PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == PlaybackState.PAUSED): return context.backend.playback.resume().get() elif context.backend.playback.current_cp_track.get() is not None: cp_track = context.backend.playback.current_cp_track.get() diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 279978aa..fc24e1e1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,6 +1,6 @@ import pykka.future -from mopidy import core +from mopidy.core import PlaybackState from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format @@ -194,8 +194,8 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (core.PlaybackController.PLAYING, - core.PlaybackController.PAUSED): + if futures['playback.state'].get() in (PlaybackState.PLAYING, + PlaybackState.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) @@ -239,11 +239,11 @@ def _status_songpos(futures): def _status_state(futures): state = futures['playback.state'].get() - if state == core.PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: return u'play' - elif state == core.PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: return u'stop' - elif state == core.PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: return u'pause' def _status_time(futures): diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index bcd3de5c..93669977 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -14,8 +14,9 @@ except ImportError as import_error: from pykka.registry import ActorRegistry -from mopidy import core, settings +from mopidy import settings from mopidy.backends.base import Backend +from mopidy.core import PlaybackState from mopidy.utils.process import exit_process # Must be done before dbus.SessionBus() is called @@ -197,11 +198,11 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == core.PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: self.backend.playback.pause().get() - elif state == core.PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: self.backend.playback.resume().get() - elif state == core.PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: self.backend.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -219,7 +220,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == core.PlaybackController.PAUSED: + if state == PlaybackState.PAUSED: self.backend.playback.resume().get() else: self.backend.playback.play().get() @@ -286,11 +287,11 @@ class MprisObject(dbus.service.Object): def get_PlaybackStatus(self): state = self.backend.playback.state.get() - if state == core.PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: return 'Playing' - elif state == core.PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: return 'Paused' - elif state == core.PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: return 'Stopped' def get_LoopStatus(self): diff --git a/mopidy/models.py b/mopidy/models.py index 6a2af914..507ca088 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,5 +1,6 @@ from collections import namedtuple + class ImmutableObject(object): """ Superclass for immutable objects whose fields can only be modified via the diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 44e9390e..bfc0a254 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -2,6 +2,7 @@ import mock import random from mopidy import audio +from mopidy.core import PlaybackState from mopidy.models import CpTrack, Playlist, Track from tests.backends.base import populate_playlist @@ -71,9 +72,9 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_clear_when_playing(self): self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.controller.clear() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_get_by_uri_returns_unique_match(self): track = Track(uri='a') @@ -134,13 +135,13 @@ class CurrentPlaylistControllerTest(object): self.playback.play() track = self.playback.current_track self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) @populate_playlist def test_append_preserves_stopped_state(self): self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) def test_index_returns_index_of_track(self): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index dcd43983..b9661df9 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -3,6 +3,7 @@ import random import time from mopidy import audio +from mopidy.core import PlaybackState from mopidy.models import Track from tests import unittest @@ -26,21 +27,21 @@ class PlaybackControllerTest(object): 'First song needs to be at least 2000 miliseconds' def test_initial_state_is_stopped(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_play_with_empty_playlist(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_play_with_empty_playlist_return_value(self): self.assertEqual(self.playback.play(), None) @populate_playlist def test_play_state(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_return_value(self): @@ -48,9 +49,9 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_track_state(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play(self.current_playlist.cp_tracks[-1]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_track_return_value(self): @@ -70,7 +71,7 @@ class PlaybackControllerTest(object): track = self.playback.current_track self.playback.pause() self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) @populate_playlist @@ -81,7 +82,7 @@ class PlaybackControllerTest(object): track = self.playback.current_track self.playback.pause() self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) @populate_playlist @@ -106,12 +107,12 @@ class PlaybackControllerTest(object): def test_current_track_after_completed_playlist(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -141,17 +142,17 @@ class PlaybackControllerTest(object): self.playback.next() self.playback.stop() self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_previous_at_start_of_playlist(self): self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) def test_previous_for_empty_playlist(self): self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -185,20 +186,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_does_not_trigger_playback(self): self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) self.assertEqual(self.playback.current_playlist_position, i) self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_until_end_of_playlist_and_play_from_start(self): @@ -208,15 +209,15 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) def test_next_for_empty_playlist(self): self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_skips_to_next_track_on_failure(self): @@ -321,20 +322,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_does_not_trigger_playback(self): self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) self.assertEqual(self.playback.current_playlist_position, i) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): @@ -344,15 +345,15 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) def test_end_of_track_for_empty_playlist(self): self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): @@ -534,13 +535,13 @@ class PlaybackControllerTest(object): self.playback.play() current_track = self.playback.current_track self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_on_current_playlist_change_when_stopped(self): self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -549,26 +550,26 @@ class PlaybackControllerTest(object): self.playback.pause() current_track = self.playback.current_track self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.backend.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_pause_when_stopped(self): self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_when_playing(self): self.playback.play() self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_when_paused(self): self.playback.play() self.playback.pause() self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_return_value(self): @@ -578,20 +579,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_resume_when_stopped(self): self.playback.resume() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_resume_when_playing(self): self.playback.play() self.playback.resume() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_resume_when_paused(self): self.playback.play() self.playback.pause() self.playback.resume() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_resume_return_value(self): @@ -624,12 +625,12 @@ class PlaybackControllerTest(object): def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_seek_when_stopped_triggers_play(self): self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_seek_when_playing(self): @@ -666,7 +667,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @unittest.SkipTest @populate_playlist @@ -686,7 +687,7 @@ class PlaybackControllerTest(object): def test_seek_beyond_end_of_song_for_last_track(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.seek(self.current_playlist.tracks[-1].length * 100) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @unittest.SkipTest @populate_playlist @@ -702,25 +703,25 @@ class PlaybackControllerTest(object): self.playback.seek(-1000) position = self.playback.time_position self.assert_(position >= 0, position) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_stop_when_stopped(self): self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_stop_when_playing(self): self.playback.play() self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_stop_when_paused(self): self.playback.play() self.playback.pause() self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_stop_return_value(self): self.playback.play() @@ -810,7 +811,7 @@ class PlaybackControllerTest(object): def test_end_of_playlist_stops(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): self.assertEqual(self.playback.repeat, False) @@ -835,9 +836,9 @@ class PlaybackControllerTest(object): for _ in self.tracks: self.playback.next() self.assertNotEqual(self.playback.track_at_next, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_random_until_end_of_playlist_with_repeat(self): diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 788fe33c..4dede6ad 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -2,6 +2,7 @@ import sys from mopidy import settings from mopidy.backends.local import LocalBackend +from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -39,14 +40,14 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def test_play_mp3(self): self.add_track('blank.mp3') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_ogg(self): self.add_track('blank.ogg') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_flac(self): self.add_track('blank.flac') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 514c1599..88452d3d 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,12 +1,13 @@ -from mopidy import core +from mopidy.core import PlaybackState from mopidy.models import Track from tests import unittest from tests.frontends.mpd import protocol -PAUSED = core.PlaybackController.PAUSED -PLAYING = core.PlaybackController.PLAYING -STOPPED = core.PlaybackController.STOPPED + +PAUSED = PlaybackState.PAUSED +PLAYING = PlaybackState.PLAYING +STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 8fd8895d..455dba45 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,14 +1,15 @@ -from mopidy import core from mopidy.backends import dummy +from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status from mopidy.models import Track from tests import unittest -PAUSED = core.PlaybackController.PAUSED -PLAYING = core.PlaybackController.PLAYING -STOPPED = core.PlaybackController.STOPPED + +PAUSED = PlaybackState.PAUSED +PLAYING = PlaybackState.PLAYING +STOPPED = PlaybackState.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 48be504f..a6415b2f 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -2,8 +2,9 @@ import sys import mock -from mopidy import core, OptionalDependencyError +from mopidy import OptionalDependencyError from mopidy.backends.dummy import DummyBackend +from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track try: @@ -13,9 +14,9 @@ except OptionalDependencyError: from tests import unittest -PLAYING = core.PlaybackController.PLAYING -PAUSED = core.PlaybackController.PAUSED -STOPPED = core.PlaybackController.STOPPED +PLAYING = PlaybackState.PLAYING +PAUSED = PlaybackState.PAUSED +STOPPED = PlaybackState.STOPPED @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') From 25b14cbfb315d901b2524377b35f3020bf4dd4dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 01:43:45 +0200 Subject: [PATCH 137/177] docs: Fix broken autodocs --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index d8aa118e..cd59d14d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,6 +29,8 @@ class Mock(object): def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' + elif name[0] == name[0].upper() and not name.startswith('MIXER_TRACK'): + return type(name, (), {}) else: return Mock() From 2321a77e37c7eea9028f82ce041b050b712b6db5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 01:45:27 +0200 Subject: [PATCH 138/177] docs: Fix Sphinx warning --- mopidy/audio/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 78a53277..25c53c5a 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -331,9 +331,14 @@ class Audio(ThreadingActor): """ Get volume level of the installed mixer. - 0 == muted. - 100 == max volume for given system. - None == no mixer present, i.e. volume unknown. + Example values: + + 0: + Muted. + 100: + Max volume for given system. + :class:`None`: + No mixer present, so the volume is unknown. :rtype: int in range [0..100] or :class:`None` """ From fd60d42be6eacfa5a7e1811c78580ed8f8f909ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 02:08:23 +0200 Subject: [PATCH 139/177] Make LocalPlaybackProvider the default implementation of BasePlaybackProvider --- mopidy/backends/base/playback.py | 30 +++++++++++++++-------------- mopidy/backends/local/__init__.py | 27 +------------------------- mopidy/backends/spotify/playback.py | 12 +----------- 3 files changed, 18 insertions(+), 51 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index d2b9edd9..ae5a4383 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -13,73 +13,75 @@ class BasePlaybackProvider(object): """ Pause playback. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.pause_playback().get() def play(self, track): """ Play given track. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + self.backend.audio.prepare_change() + self.backend.audio.set_uri(track.uri).get() + return self.backend.audio.start_playback().get() def resume(self): """ Resume playback at the same time position playback was paused. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.start_playback().get() def seek(self, time_position): """ Seek to a given time position. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param time_position: time position in milliseconds :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.set_position(time_position).get() def stop(self): """ Stop playback. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.stop_playback().get() def get_volume(self): """ Get current volume - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: int [0..100] or :class:`None` """ - raise NotImplementedError + return self.backend.audio.get_volume().get() def set_volume(self, volume): """ Get current volume - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param: volume :type volume: int [0..100] """ - raise NotImplementedError + self.backend.audio.set_volume(volume) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 022b253b..b49406c6 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -47,7 +47,7 @@ class LocalBackend(ThreadingActor, base.Backend): self.library = core.LibraryController(backend=self, provider=library_provider) - playback_provider = LocalPlaybackProvider(backend=self) + playback_provider = base.BasePlaybackProvider(backend=self) self.playback = LocalPlaybackController(backend=self, provider=playback_provider) @@ -78,31 +78,6 @@ class LocalPlaybackController(core.PlaybackController): return self.backend.audio.get_position().get() -class LocalPlaybackProvider(base.BasePlaybackProvider): - def pause(self): - return self.backend.audio.pause_playback().get() - - def play(self, track): - self.backend.audio.prepare_change() - self.backend.audio.set_uri(track.uri).get() - return self.backend.audio.start_playback().get() - - def resume(self): - return self.backend.audio.start_playback().get() - - def seek(self, time_position): - return self.backend.audio.set_position(time_position).get() - - def stop(self): - return self.backend.audio.stop_playback().get() - - def get_volume(self): - return self.backend.audio.get_volume().get() - - def set_volume(self, volume): - self.backend.audio.set_volume(volume).get() - - class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index cf16c35e..1c20da87 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -8,9 +8,6 @@ from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): - def pause(self): - return self.backend.audio.pause_playback() - def play(self, track): if self.backend.playback.state == PlaybackState.PLAYING: self.backend.spotify.session.play(0) @@ -39,12 +36,5 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return True def stop(self): - result = self.backend.audio.stop_playback() self.backend.spotify.session.play(0) - return result - - def get_volume(self): - return self.backend.audio.get_volume().get() - - def set_volume(self, volume): - self.backend.audio.set_volume(volume) + return super(SpotifyPlaybackProvider, self).stop() From e6485e4abea48c14adf1d8a3088f1861b5c1d06b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 15 Sep 2012 14:01:05 +0200 Subject: [PATCH 140/177] Add basic docs for Audio API, fixes #177. --- docs/api/audio.rst | 19 +++++++++++++++++++ docs/api/index.rst | 1 + mopidy/audio/__init__.py | 2 ++ 3 files changed, 22 insertions(+) create mode 100644 docs/api/audio.rst diff --git a/docs/api/audio.rst b/docs/api/audio.rst new file mode 100644 index 00000000..d5fb5dd9 --- /dev/null +++ b/docs/api/audio.rst @@ -0,0 +1,19 @@ +.. _audio-api: + +********* +Audio API +********* + +The audio API is the interface we have built around GStreamer to support our +specific use cases. Most backends should be able to get by with simply setting +the URI of the resource they want to play, for these cases the default playback +provider should be used. + +For more advanced cases such as when the raw audio data is delivered outside of +GStreamer or the backend needs to add metadata to the currently playing resource, +developers should sub-class the base playback provider and implement the extra +behaviour that is needed through the following API: + + +.. autoclass:: mopidy.audio.Audio + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index b5be8ed4..618096ee 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -9,5 +9,6 @@ API reference models backends core + audio frontends listeners diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 25c53c5a..dd98dfa8 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -208,6 +208,8 @@ class Audio(ThreadingActor): """ Call this to deliver raw audio data to be played. + Note that the uri must be set to ``appsrc://`` for this to work. + :param capabilities: a GStreamer capabilities string :type capabilities: string :param data: raw audio data to be played From e3bc0e79b976ae4903735d2bbca8cee1c6915d0f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 15 Sep 2012 15:18:50 +0200 Subject: [PATCH 141/177] Fix bug in local playlist handling. --- mopidy/backends/local/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index b49406c6..c7126824 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -93,7 +93,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.info('Loading playlists from %s', self._folder) for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:len('.m3u')] + name = os.path.basename(m3u)[:-len('.m3u')] tracks = [] for uri in parse_m3u(m3u): try: From 144311420486c8087c9e0c0203431f968947f9f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Sep 2012 17:45:28 +0200 Subject: [PATCH 142/177] docs: Remove duplicate API doc --- docs/modules/audio.rst | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 docs/modules/audio.rst diff --git a/docs/modules/audio.rst b/docs/modules/audio.rst deleted file mode 100644 index 0f1c3bfb..00000000 --- a/docs/modules/audio.rst +++ /dev/null @@ -1,7 +0,0 @@ -************************************* -:mod:`mopidy.audio` -- Audio playback -************************************* - -.. automodule:: mopidy.audio - :synopsis: Audio playback through GStreamer - :members: From a8d1d41ab3c7aef11754f387458038297ebbc549 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 23:59:57 +0200 Subject: [PATCH 143/177] Use assertIn and assertNotIn in tests --- tests/backends/base/current_playlist.py | 2 +- tests/backends/base/playback.py | 6 +- tests/backends/base/stored_playlists.py | 4 +- tests/backends/local/playback_test.py | 2 +- tests/frontends/mpd/dispatcher_test.py | 6 +- tests/frontends/mpd/protocol/__init__.py | 4 +- .../mpd/protocol/command_list_test.py | 4 +- tests/frontends/mpd/serializer_test.py | 50 +++++++-------- tests/frontends/mpd/status_test.py | 62 +++++++++---------- .../frontends/mpris/player_interface_test.py | 2 +- tests/help_test.py | 18 +++--- tests/models_test.py | 4 +- tests/utils/init_test.py | 2 +- tests/utils/settings_test.py | 17 +++-- tests/version_test.py | 6 +- 15 files changed, 94 insertions(+), 95 deletions(-) diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index bfc0a254..430e4c40 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -206,7 +206,7 @@ class CurrentPlaylistControllerTest(object): version = self.controller.version self.controller.remove(uri=track1.uri) self.assert_(version < self.controller.version) - self.assert_(track1 not in self.controller.tracks) + self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @populate_playlist diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index b9661df9..1e434e35 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -274,7 +274,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assert_(self.tracks[0] in self.backend.current_playlist.tracks) + self.assertIn(self.tracks[0], self.backend.current_playlist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -411,7 +411,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assert_(self.tracks[0] not in self.backend.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -855,7 +855,7 @@ class PlaybackControllerTest(object): self.playback.play() played = [] for _ in self.tracks: - self.assert_(self.playback.current_track not in played) + self.assertNotIn(self.playback.current_track, played) played.append(self.playback.current_track) self.playback.next() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 54315e62..1e575b9e 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -30,7 +30,7 @@ class StoredPlaylistsControllerTest(object): def test_create_in_playlists(self): playlist = self.stored.create('test') self.assert_(self.stored.playlists) - self.assert_(playlist in self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) def test_playlists_empty_to_start_with(self): self.assert_(not self.stored.playlists) @@ -101,7 +101,7 @@ class StoredPlaylistsControllerTest(object): # FIXME should we handle playlists without names? playlist = Playlist(name='test') self.stored.save(playlist) - self.assert_(playlist in self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4dede6ad..c167fbcc 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -35,7 +35,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): self.backend.current_playlist.add(track) def test_uri_scheme(self): - self.assert_('file' in self.backend.uri_schemes) + self.assertIn('file', self.backend.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 63f6d299..9f05d7dd 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -37,7 +37,7 @@ class MpdDispatcherTest(unittest.TestCase): expected_handler (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) - self.assert_('arg1' in kwargs) + self.assertIn('arg1', kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') def test_handling_unknown_request_yields_error(self): @@ -48,5 +48,5 @@ class MpdDispatcherTest(unittest.TestCase): expected = 'magic' request_handlers['known request'] = lambda x: expected result = self.dispatcher.handle_request('known request') - self.assert_(u'OK' in result) - self.assert_(expected in result) + self.assertIn(u'OK', result) + self.assertIn(expected, result) diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index b39ded01..3b8fbe33 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -42,7 +42,7 @@ class BaseTestCase(unittest.TestCase): self.assertEqual([], self.connection.response) def assertInResponse(self, value): - self.assert_(value in self.connection.response, u'Did not find %s ' + self.assertIn(value, self.connection.response, u'Did not find %s ' 'in %s' % (repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): @@ -51,7 +51,7 @@ class BaseTestCase(unittest.TestCase): (repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): - self.assert_(value not in self.connection.response, u'Found %s in %s' % + self.assertNotIn(value, self.connection.response, u'Found %s in %s' % (repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index a81725ad..65b051d3 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -21,7 +21,7 @@ class CommandListsTest(protocol.BaseTestCase): self.assertEqual([], self.dispatcher.command_list) self.assertEqual(False, self.dispatcher.command_list_ok) self.sendRequest(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) + self.assertIn(u'ping', self.dispatcher.command_list) self.sendRequest(u'command_list_end') self.assertInResponse(u'OK') self.assertEqual(False, self.dispatcher.command_list) @@ -42,7 +42,7 @@ class CommandListsTest(protocol.BaseTestCase): self.assertEqual([], self.dispatcher.command_list) self.assertEqual(True, self.dispatcher.command_list_ok) self.sendRequest(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) + self.assertIn(u'ping', self.dispatcher.command_list) self.sendRequest(u'command_list_end') self.assertInResponse(u'list_OK') self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index a20abaed..e6cd80e2 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -31,66 +31,66 @@ class TrackMpdFormatTest(unittest.TestCase): def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format(Track()) - self.assert_(('file', '') in result) - self.assert_(('Time', 0) in result) - self.assert_(('Artist', '') in result) - self.assert_(('Title', '') in result) - self.assert_(('Album', '') in result) - self.assert_(('Track', 0) in result) - self.assert_(('Date', '') in result) + self.assertIn(('file', ''), result) + self.assertIn(('Time', 0), result) + self.assertIn(('Artist', ''), result) + self.assertIn(('Title', ''), result) + self.assertIn(('Album', ''), result) + self.assertIn(('Track', 0), result) + self.assertIn(('Date', ''), result) self.assertEqual(len(result), 7) def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format(Track(), position=1) - self.assert_(('Pos', 1) not in result) + self.assertNotIn(('Pos', 1), result) def test_track_to_mpd_format_with_cpid(self): result = translator.track_to_mpd_format(CpTrack(1, Track())) - self.assert_(('Id', 1) not in result) + self.assertNotIn(('Id', 1), result) def test_track_to_mpd_format_with_position_and_cpid(self): result = translator.track_to_mpd_format(CpTrack(2, Track()), position=1) - self.assert_(('Pos', 1) in result) - self.assert_(('Id', 2) in result) + self.assertIn(('Pos', 1), result) + self.assertIn(('Id', 2), result) def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( CpTrack(122, self.track), position=9) - self.assert_(('file', 'a uri') in result) - self.assert_(('Time', 137) in result) - self.assert_(('Artist', 'an artist') in result) - self.assert_(('Title', 'a name') in result) - self.assert_(('Album', 'an album') in result) - self.assert_(('AlbumArtist', 'an other artist') in result) - self.assert_(('Track', '7/13') in result) - self.assert_(('Date', datetime.date(1977, 1, 1)) in result) - self.assert_(('Pos', 9) in result) - self.assert_(('Id', 122) in result) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertIn(('Artist', 'an artist'), result) + self.assertIn(('Title', 'a name'), result) + self.assertIn(('Album', 'an album'), result) + self.assertIn(('AlbumArtist', 'an other artist'), result) + self.assertIn(('Track', '7/13'), result) + self.assertIn(('Date', datetime.date(1977, 1, 1)), result) + self.assertIn(('Pos', 9), result) + self.assertIn(('Id', 122), result) self.assertEqual(len(result), 10) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.copy(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_TRACKID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_TRACKID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): album = self.track.album.copy(musicbrainz_id='foo') track = self.track.copy(album=album) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ALBUMID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') album = self.track.album.copy(artists=[artist]) track = self.track.copy(album=album) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ALBUMARTISTID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ALBUMARTISTID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_artistid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') track = self.track.copy(artists=[artist]) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ARTISTID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) def test_artists_to_mpd_format(self): artists = [Artist(name=u'ABBA'), Artist(name=u'Beatles')] diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 455dba45..2bc3488b 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -26,123 +26,123 @@ class StatusHandlerTest(unittest.TestCase): def test_stats_method(self): result = status.stats(self.context) - self.assert_('artists' in result) + self.assertIn('artists', result) self.assert_(int(result['artists']) >= 0) - self.assert_('albums' in result) + self.assertIn('albums', result) self.assert_(int(result['albums']) >= 0) - self.assert_('songs' in result) + self.assertIn('songs', result) self.assert_(int(result['songs']) >= 0) - self.assert_('uptime' in result) + self.assertIn('uptime', result) self.assert_(int(result['uptime']) >= 0) - self.assert_('db_playtime' in result) + self.assertIn('db_playtime', result) self.assert_(int(result['db_playtime']) >= 0) - self.assert_('db_update' in result) + self.assertIn('db_update', result) self.assert_(int(result['db_update']) >= 0) - self.assert_('playtime' in result) + self.assertIn('playtime', result) self.assert_(int(result['playtime']) >= 0) def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) - self.assert_('volume' in result) + self.assertIn('volume', result) self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): self.backend.playback.volume = 17 result = dict(status.status(self.context)) - self.assert_('volume' in result) + self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): result = dict(status.status(self.context)) - self.assert_('repeat' in result) + self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.backend.playback.repeat = 1 result = dict(status.status(self.context)) - self.assert_('repeat' in result) + self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): result = dict(status.status(self.context)) - self.assert_('random' in result) + self.assertIn('random', result) self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.backend.playback.random = 1 result = dict(status.status(self.context)) - self.assert_('random' in result) + self.assertIn('random', result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): result = dict(status.status(self.context)) - self.assert_('single' in result) - self.assert_(int(result['single']) in (0, 1)) + self.assertIn('single', result) + self.assertIn(int(result['single']), (0, 1)) def test_status_method_contains_consume_is_0(self): result = dict(status.status(self.context)) - self.assert_('consume' in result) + self.assertIn('consume', result) self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.backend.playback.consume = 1 result = dict(status.status(self.context)) - self.assert_('consume' in result) + self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) - self.assert_('playlist' in result) - self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) + self.assertIn('playlist', result) + self.assertIn(int(result['playlist']), xrange(0, 2**31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) - self.assert_('playlistlength' in result) + self.assertIn('playlistlength', result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) - self.assert_('xfade' in result) + self.assertIn('xfade', result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.backend.playback.state = PLAYING result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.backend.playback.state = STOPPED result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.backend.playback.state = PLAYING self.backend.playback.state = PAUSED result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): self.backend.current_playlist.append([Track()]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('song' in result) + self.assertIn('song', result) self.assert_(int(result['song']) >= 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): self.backend.current_playlist.append([Track()]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('songid' in result) + self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): self.backend.current_playlist.append([Track(length=None)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('time' in result) + self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) @@ -152,7 +152,7 @@ class StatusHandlerTest(unittest.TestCase): self.backend.current_playlist.append([Track(length=10000)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('time' in result) + self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) @@ -162,19 +162,19 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.state = PAUSED self.backend.playback.play_time_accumulated = 59123 result = dict(status.status(self.context)) - self.assert_('elapsed' in result) + self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): self.backend.playback.state = PAUSED self.backend.playback.play_time_accumulated = 123 # Less than 1000ms result = dict(status.status(self.context)) - self.assert_('elapsed' in result) + self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '0.123') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('bitrate' in result) + self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index a6415b2f..db7f9265 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -141,7 +141,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assert_('mpris:trackid' in result.keys()) + self.assertIn('mpris:trackid', result.keys()) self.assertEquals(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): diff --git a/tests/help_test.py b/tests/help_test.py index 1fa22c2f..a2803b72 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -13,18 +13,18 @@ class HelpTest(unittest.TestCase): args = [sys.executable, mopidy_dir, '--help'] process = subprocess.Popen(args, stdout=subprocess.PIPE) output = process.communicate()[0] - self.assert_('--version' in output) - self.assert_('--help' in output) - self.assert_('--help-gst' in output) - self.assert_('--interactive' in output) - self.assert_('--quiet' in output) - self.assert_('--verbose' in output) - self.assert_('--save-debug-log' in output) - self.assert_('--list-settings' in output) + self.assertIn('--version', output) + self.assertIn('--help', output) + self.assertIn('--help-gst', output) + self.assertIn('--interactive', output) + self.assertIn('--quiet', output) + self.assertIn('--verbose', output) + self.assertIn('--save-debug-log', output) + self.assertIn('--list-settings', output) def test_help_gst_has_gstreamer_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help-gst'] process = subprocess.Popen(args, stdout=subprocess.PIPE) output = process.communicate()[0] - self.assert_('--gst-version' in output) + self.assertIn('--gst-version', output) diff --git a/tests/models_test.py b/tests/models_test.py index af90c5bd..779d1a4b 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -43,7 +43,7 @@ class GenericCopyTets(unittest.TestCase): artist2 = Artist(name='bar') track = Track(artists=[artist1]) copy = track.copy(artists=[artist2]) - self.assert_(artist2 in copy.artists) + self.assertIn(artist2, copy.artists) def test_copying_track_with_invalid_key(self): test = lambda: Track().copy(invalid_key=True) @@ -155,7 +155,7 @@ class AlbumTest(unittest.TestCase): def test_artists(self): artist = Artist() album = Album(artists=[artist]) - self.assert_(artist in album.artists) + self.assertIn(artist, album.artists) self.assertRaises(AttributeError, setattr, album, 'artists', None) def test_num_tracks(self): diff --git a/tests/utils/init_test.py b/tests/utils/init_test.py index f232e2ef..bdd0adc5 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/init_test.py @@ -20,7 +20,7 @@ class GetClassTest(unittest.TestCase): try: utils.get_class('foo.bar.Baz') except ImportError as e: - self.assert_('foo.bar.Baz' in str(e)) + self.assertIn('foo.bar.Baz', str(e)) def test_loading_existing_class(self): cls = utils.get_class('unittest.TestCase') diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 7d104969..cf476c24 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -107,7 +107,7 @@ class SettingsProxyTest(unittest.TestCase): def test_setattr_updates_runtime_settings(self): self.settings.TEST = 'test' - self.assert_('TEST' in self.settings.runtime) + self.assertIn('TEST', self.settings.runtime) def test_setattr_updates_runtime_with_value(self): self.settings.TEST = 'test' @@ -181,34 +181,33 @@ class FormatSettingListTest(unittest.TestCase): def test_contains_the_setting_name(self): self.settings.TEST = u'test' result = setting_utils.format_settings_list(self.settings) - self.assert_('TEST:' in result, result) + self.assertIn('TEST:', result, result) def test_repr_of_a_string_value(self): self.settings.TEST = u'test' result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST: u'test'" in result, result) + self.assertIn("TEST: u'test'", result, result) def test_repr_of_an_int_value(self): self.settings.TEST = 123 result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST: 123" in result, result) + self.assertIn("TEST: 123", result, result) def test_repr_of_a_tuple_value(self): self.settings.TEST = (123, u'abc') result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST: (123, u'abc')" in result, result) + self.assertIn("TEST: (123, u'abc')", result, result) def test_passwords_are_masked(self): self.settings.TEST_PASSWORD = u'secret' result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST_PASSWORD: u'secret'" not in result, result) - self.assert_("TEST_PASSWORD: u'********'" in result, result) + self.assertNotIn("TEST_PASSWORD: u'secret'", result, result) + self.assertIn("TEST_PASSWORD: u'********'", result, result) def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) result = setting_utils.format_settings_list(self.settings) - self.assert_("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, - result) + self.assertIn("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) def test_long_values_are_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', diff --git a/tests/version_test.py b/tests/version_test.py index 26045ac1..85b182f0 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -31,10 +31,10 @@ class VersionTest(unittest.TestCase): self.assert_(SV(__version__) < SV('0.8.0')) def test_get_platform_contains_platform(self): - self.assert_(platform.platform() in get_platform()) + self.assertIn(platform.platform(), get_platform()) def test_get_python_contains_python_implementation(self): - self.assert_(platform.python_implementation() in get_python()) + self.assertIn(platform.python_implementation(), get_python()) def test_get_python_contains_python_version(self): - self.assert_(platform.python_version() in get_python()) + self.assertIn(platform.python_version(), get_python()) From 2cd729aa2f990154467f097565851a038f1c5da5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Sep 2012 18:50:43 +0200 Subject: [PATCH 144/177] docs: Include git revision in version number if we're in a git repo --- docs/conf.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd59d14d..8129adec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -53,11 +53,6 @@ MOCK_MODULES = [ for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() -def get_version(): - init_py = open('../mopidy/__init__.py').read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) - return metadata['version'] - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -96,6 +91,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors' # built documents. # # The full version, including alpha/beta/rc tags. +from mopidy import get_version release = get_version() # The short X.Y version. version = '.'.join(release.split('.')[:2]) From e905fd8d8a8723efd8ea01dc138530daca47eb3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Sep 2012 19:19:45 +0200 Subject: [PATCH 145/177] Reorganize v0.8 changelog --- docs/changes.rst | 66 +++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3b77f61a..3eb5947c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,24 +7,7 @@ This change log is used to track all major changes to Mopidy. v0.8 (in development) ===================== -**Changes** - -- Added tools/debug-proxy.py to tee client requests to two backends and diff - responses. Intended as a developer tool for checking for MPD protocol changes - and various client support. Requires gevent, which currently is not a - dependency of Mopidy. - -- Fixed bug when the MPD command `playlistinfo` is used with a track position. - Track position and CPID was intermixed, so it would cause a crash if a CPID - matching the track position didn't exist. (Fixes: :issue:`162`) - -- Added :option:`--list-deps` option to the `mopidy` command that lists - required and optional dependencies, their current versions, and some other - information useful for debugging. (Fixes: :issue:`74`) - -- When unknown settings are encountered, we now check if it's similar to a - known setting, and suggests to the user what we think the setting should have - been. +**Audio output and mixer changes** - Removed multiple outputs support. Having this feature currently seems to be more trouble than what it is worth. The :attr:`mopidy.settings.OUTPUTS` @@ -36,9 +19,9 @@ v0.8 (in development) - Switch to pure GStreamer based mixing. This implies that users setup a GStreamer bin with a mixer in it in :attr:`mopidy.setting.MIXER`. The default - value is ``autoaudiomixer``, a custom mixer that attempts to find a mixer that - will work on your system. If this picks the wrong mixer you can of course - override it. Setting the mixer to :class:`None` is also supported. MPD + value is ``autoaudiomixer``, a custom mixer that attempts to find a mixer + that will work on your system. If this picks the wrong mixer you can of + course override it. Setting the mixer to :class:`None` is also supported. MPD protocol support for volume has also been updated to return -1 when we have no mixer set. @@ -46,7 +29,7 @@ v0.8 (in development) - Updated the NAD hardware mixer to work in the new GStreamer based mixing regime. Settings are now passed as GStreamer element properties. In practice - that means that the following old-style config: + that means that the following old-style config:: MIXER = u'mopidy.mixers.nad.NadMixer' MIXER_EXT_PORT = u'/dev/ttyUSB0' @@ -54,7 +37,7 @@ v0.8 (in development) MIXER_EXT_SPEAKERS_A = u'On' MIXER_EXT_SPEAKERS_B = u'Off' - Now is reduced to simply: + Now is reduced to simply:: MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off' @@ -62,20 +45,41 @@ v0.8 (in development) properties may be left out if you don't want the mixer to adjust the settings on your NAD amplifier when Mopidy is started. -- Fixed :issue:`150` which caused some clients to block Mopidy completely. Bug - was caused by some clients sending ``close`` and then shutting down the - connection right away. This trigged a situation in which the connection - cleanup code would wait for an response that would never come inside the - event loop, blocking everything else. +**Changes** -- Created a Spotify track proxy that will switch to using loaded data as soon - as it becomes available. Fixes :issue:`72`. +- When unknown settings are encountered, we now check if it's similar to a + known setting, and suggests to the user what we think the setting should have + been. -- Fixed crash on lookup of unknown path when using local backend. +- Added :option:`--list-deps` option to the ``mopidy`` command that lists + required and optional dependencies, their current versions, and some other + information useful for debugging. (Fixes: :issue:`74`) + +- Added ``tools/debug-proxy.py`` to tee client requests to two backends and + diff responses. Intended as a developer tool for checking for MPD protocol + changes and various client support. Requires gevent, which currently is not a + dependency of Mopidy. - Support tracks with only release year, and not a full release date, like e.g. Spotify tracks. +**Bug fixes** + +- :issue:`72`: Created a Spotify track proxy that will switch to using loaded + data as soon as it becomes available. + +- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a + track position. Track position and CPID was intermixed, so it would cause a + crash if a CPID matching the track position didn't exist. + +- :issue:`150`: Fix bug which caused some clients to block Mopidy completely. + The bug was caused by some clients sending ``close`` and then shutting down + the connection right away. This trigged a situation in which the connection + cleanup code would wait for an response that would never come inside the + event loop, blocking everything else. + +- Fixed crash on lookup of unknown path when using local backend. + v0.7.3 (2012-08-11) =================== From 31d015f9fd9af093b9e31a7493d140828da4472f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Sep 2012 19:21:23 +0200 Subject: [PATCH 146/177] docs: Fix typo --- docs/changes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3eb5947c..43b930b8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,9 +18,9 @@ v0.8 (in development) :issue:`159`) - Switch to pure GStreamer based mixing. This implies that users setup a - GStreamer bin with a mixer in it in :attr:`mopidy.setting.MIXER`. The default - value is ``autoaudiomixer``, a custom mixer that attempts to find a mixer - that will work on your system. If this picks the wrong mixer you can of + GStreamer bin with a mixer in it in :attr:`mopidy.settings.MIXER`. The + default value is ``autoaudiomixer``, a custom mixer that attempts to find a + mixer that will work on your system. If this picks the wrong mixer you can of course override it. Setting the mixer to :class:`None` is also supported. MPD protocol support for volume has also been updated to return -1 when we have no mixer set. From 3cf1b13d4945c6200eca893e4b1f900101530d80 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 17:47:51 +0200 Subject: [PATCH 147/177] Cleanup mopidy.utils.settings. - Move to module import for stdlib - Extract path manipulation code to a method - Avoid uneeded copying of settings dict by binding current localy. --- mopidy/utils/settings.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 726917c6..4c2da4bc 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,10 +1,12 @@ # Absolute import needed to import ~/.mopidy/settings.py and not ourselves from __future__ import absolute_import -from copy import copy + +import copy import getpass import logging import os -from pprint import pformat +import pprint +import string import sys from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE @@ -39,7 +41,7 @@ class SettingsProxy(object): @property def current(self): - current = copy(self.default) + current = copy.copy(self.default) current.update(self.local) current.update(self.runtime) return current @@ -47,16 +49,18 @@ class SettingsProxy(object): def __getattr__(self, attr): if not self._is_setting(attr): return - if attr not in self.current: + + current = self.current # bind locally to avoid copying+updates + if attr not in current: raise SettingsError(u'Setting "%s" is not set.' % attr) - value = self.current[attr] + + value = current[attr] if isinstance(value, basestring) and len(value) == 0: raise SettingsError(u'Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = os.path.expanduser(value) - value = os.path.abspath(value) + value = self.expandpath(value) return value def __setattr__(self, attr, value): @@ -65,6 +69,11 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) + def expandpath(self, value): + value = os.path.expanduser(value) + value = os.path.abspath(value) + return value + def validate(self, interactive): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) @@ -194,7 +203,8 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent(pformat(masked_value), places=2))) + lines.append(u'%s: %s' % (key, indent( + pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % indent(pformat(default_value), places=4)) From 355ff811af3e5f8c4ae38765a9d75ceab61d7ba4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 18:03:07 +0200 Subject: [PATCH 148/177] Add $XDG_name_DIR substitution to _FILE and _PATH settings. This change removes the practice of hardcoding fallbacks to these paths outside of the base settings file. We can probably get rid of some of the location CONSTANTS that are currently in use in mopidy/__init__.py --- mopidy/backends/local/__init__.py | 18 +++++------------- mopidy/backends/spotify/session_manager.py | 5 ++--- mopidy/settings.py | 21 ++++++++++++--------- mopidy/utils/settings.py | 9 ++++++++- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index c7126824..975ec458 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -15,13 +15,6 @@ from .translator import parse_m3u, parse_mpd_tag_cache logger = logging.getLogger(u'mopidy.backends.local') -DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists') -DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache') -DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)) - -if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): - DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') - class LocalBackend(ThreadingActor, base.Backend): """ @@ -81,7 +74,7 @@ class LocalPlaybackController(core.PlaybackController): class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH + self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() def lookup(self, uri): @@ -158,12 +151,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE - music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH + tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE, + settings.LOCAL_MUSIC_PATH) - tracks = parse_mpd_tag_cache(tag_cache, music_folder) - - logger.info('Loading tracks in %s from %s', music_folder, tag_cache) + logger.info('Loading tracks in %s from %s', settings.LOCAL_MUSIC_PATH, + settings.LOCAL_TAG_CACHE_FILE) for track in tracks: self._uri_mapping[track.uri] = track diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index aa3734ae..856257f1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,7 +6,7 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import audio, get_version, settings, CACHE_PATH +from mopidy import audio, get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager @@ -22,8 +22,7 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') class SpotifySessionManager(BaseThread, PyspotifySessionManager): - cache_location = (settings.SPOTIFY_CACHE_PATH - or os.path.join(CACHE_PATH, 'spotify')) + cache_location = settings.SPOTIFY_CACHE_PATH settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() diff --git a/mopidy/settings.py b/mopidy/settings.py index 0612fc24..a2270707 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -85,9 +85,8 @@ LASTFM_PASSWORD = u'' #: #: Default:: #: -#: # Defaults to asking glib where music is stored, fallback is ~/music -#: LOCAL_MUSIC_PATH = None -LOCAL_MUSIC_PATH = None +#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' +LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' #: Path to playlist folder with m3u files for local music. #: @@ -95,8 +94,8 @@ LOCAL_MUSIC_PATH = None #: #: Default:: #: -#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists -LOCAL_PLAYLIST_PATH = None +#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' +LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: Path to tag cache for local music. #: @@ -104,8 +103,8 @@ LOCAL_PLAYLIST_PATH = None #: #: Default:: #: -#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache -LOCAL_TAG_CACHE_FILE = None +#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' +LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: Sound mixer to use. #: @@ -177,7 +176,11 @@ OUTPUT = u'autoaudiosink' #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_CACHE_PATH = None +#: +#: Default:: +#: +#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' +SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' #: Your Spotify Premium username. #: @@ -194,7 +197,7 @@ SPOTIFY_PASSWORD = u'' #: Available values are 96, 160, and 320. #: #: Used by :mod:`mopidy.backends.spotify`. -# +#: #: Default:: #: #: SPOTIFY_BITRATE = 160 diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 4c2da4bc..fae4278f 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -3,6 +3,7 @@ from __future__ import absolute_import import copy import getpass +import glib import logging import os import pprint @@ -14,6 +15,12 @@ from mopidy.utils.log import indent logger = logging.getLogger('mopidy.utils.settings') +XDG_DIRS = { + 'XDG_CACHE_DIR': glib.get_user_cache_dir(), + 'XDG_DATA_DIR': glib.get_user_data_dir(), + 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), +} + class SettingsProxy(object): def __init__(self, default_settings_module): @@ -72,7 +79,7 @@ class SettingsProxy(object): def expandpath(self, value): value = os.path.expanduser(value) value = os.path.abspath(value) - return value + return string.Template(value).safe_substitute(XDG_DIRS) def validate(self, interactive): if interactive: From 7ceb53006408f568da9d9ba600c46f03580fcd99 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 18:14:11 +0200 Subject: [PATCH 149/177] Updated find files to ignore hidden files and folders. --- mopidy/utils/path.py | 20 +++++++++++++++----- tests/data/.blank.mp3 | Bin 0 -> 9360 bytes tests/data/.hidden/.gitignore | 0 tests/utils/path_test.py | 6 ++++++ 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 tests/data/.blank.mp3 create mode 100644 tests/data/.hidden/.gitignore diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 5d99ac12..b276a027 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -47,21 +47,31 @@ def split_path(path): break return parts -# pylint: disable = W0612 -# Unused variable 'dirnames' def find_files(path): if os.path.isfile(path): if not isinstance(path, unicode): path = path.decode('utf-8') - yield path + if not os.path.basename(path).startswith('.'): + yield path else: for dirpath, dirnames, filenames in os.walk(path): + # Filter out hidden folders by modifying dirnames in place. + for dirname in dirnames: + if dirname.startswith('.'): + dirnames.remove(dirname) + for filename in filenames: + # Skip hidden files. + if filename.startswith('.'): + continue + filename = os.path.join(dirpath, filename) if not isinstance(filename, unicode): - filename = filename.decode('utf-8') + try: + filename = filename.decode('utf-8') + except UnicodeDecodeError: + filename = filename.decode('latin1') yield filename -# pylint: enable = W0612 # FIXME replace with mock usage in tests. class Mtime(object): diff --git a/tests/data/.blank.mp3 b/tests/data/.blank.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ef159a700449f6a2bf4c03fc206be8f2ff1c7469 GIT binary patch literal 9360 zcmeHtXH*ki+ivJZ5Ru+Ok=~nhm4HBifRu!;0@ADWrcwn0(tGG7l+Zf_5J99X9qEc9 z3JQt{B0P{Y=(()ZEx8_x67N`Ez!DP5|CTjer?w$w?|J`H~8QSR{au!br(a zZo~^A!hkq*7Z)Ca8~+a$;Unu1grtkSjZGbRyAR?2t`i;sw{*{L&h)N%$F4P<_2x9> zpWk9tZ(6_K4_-;ERUdNe@AG->ra70nifVq<&9zo9fW5m z{j7b|XIZbQm*nksQ}atkRnw6&IR;uw#Y9G&48*3U*#%bgGMej5T(wUe%T7xjh^%0U z5*Zl0&t>3DL$gbjnof~=jf4MMd|FVH)~sv`TJk(fo?oMc=Y8yeFA;$f&=Y{0)EKoA1BfC*GJTS8cv+ z7__{E%OV@XU3W(3b%$d!AtbKzosYf3mO(u9?0>vQpu)4a?{&w)D)Mw?{tR@==!F!G z4TL2y1-N!ck5Mntsy(Vz4PukeAa|N~e?0#2iQx;F;JjqS;ULw`+dI6XBlAY+*00`g zXJRvJL+d_tZ%m!bt8!|T4k~#RjyD_|n+1iSt5e!DAw;##!F7Y&#BLBe7P4I5FsAm? z@y@OoX-u`VFsnE%h)0;SCUgXYyAv}}84-BZsg7>yZAIckHDINjSmRpn^kblT1oqm)zF{ET4G-)a9S zg9Gshaf}^LKuxrfwuW@^58$M;%(hUqQ&nPS!4lIzDefchKIfe_5gSdAVJHF}rFPH-?cY`dGT;THyrmQah**zTU zOuR0ZJjals$9PjYu6RdCBx>NmUUPo(N`=6&1AI`P^lO*eHIQc&R-fkp&#_7j`2=s~ zUmRNV4qplLJK^GaV8`VrA&p#Evv>?{A|;{=LUKC!wYJ~f zI3hiY45{>tcjK29_4nSrXc2|?MjDAch7YjtUvqp$KYS~Gd2#$6=P%a z`I;(WBRlANgE$6Vpdv1AaTy8~)~a>)s793~vIS6`24JmJPqTB@CXg9{vsi37=b=CK zB+e-$50s09E$zt^RB2MY7GJ!d3n5aytKwpLELv9=ruJ$`w!Y(~0X9;EW9un>^P=z$ zW+z>KOSMXA*o3(5oBOYBsnhZMMx1>@sRv_>n$yw@43yCMgj@I4-|3M$v$hA!3ZI2v z^y8~YIh=%dERSMwW+CxF2@nQHA-C~-P zyp=0f_oy85Jvz5PG;bN%@Tsp<#uQ7>Ma{|;_Ucwrn?JTu zlJj8aCf{UZn|qp{vdb8Ud@-0x9c(8h1Zm~OY^h$s)Q1xYgZ0eL2A+#%pj<>8J5d17 z2+H_8faznvyx!d(eY2fZ$}IH$XMUjM)<%Tq(5#>OQ?df-_b0YIp@IR*wrAMQ*9T$@ zmtlK9-p+pB{wj0&#j$K_^Af4nmGrAVB(!~#)@z+KIEjZt2~Cz?`X)2Rc6{u;EOm7wN8dEPb`~7a6 ztLk|WPWaPnbWpG-%^;pG=^T%)#T6g_10m|vnHU)_W&6dvzvb;|#)RE@s9~v9${dk; zX%D-}b#+^UA12YR4dRjJjtm6tkhXxG(w9A~x%CtIX}&)5{GPz%i;QR1w&`>au|{DC zy5;-l`FAhY{t)`>!==7+-tP@O`l-7fWAfR#@qqpF^1EoEKcSpTvr=d6PpGd>F*?Pg z^YJN1OMqd2t~~x$Wrlw%SEolsmckL2VWD0;-@UF%CO-J_GEo{dsC-8F=TiC9C_Sk6 z>+EP>5RWwX6L&c#B{U+KOggfL0>Sn0kN=P~llW;RC06R}+uN$J#rqpkv_d!dNFKf) zr0mzMN3q*uC z2759G$aUp9Pe^UK^VFi?>{*ia>afZfi=Ng>Eq^3qGMQCPR+6E_HD`%s`(p2NAZ%H* z4-Je+qhCv?zlvle`IkMjQAL4rsRaku;8QBV7>4_encpk2J)ay|seg8O#MB%hOxh9n z-S@lzy(Gh>-NNCYVFF*;w>r579ax#@bIB=EH08@4u38bSpX;Lt7sZY@ zin1T1lqD8h6?4u+85{eHnga%fPdQ$KkR0#TX4EJ*c|sA{^K$BX{lcrd+LIv9+?)fT z{mhEv`laVQv%Sb!GT8^|+kNH}4TG3;^R^$HpRBLlJeHp+EzzHnF-7zt*n@rFOMjtR z>0CK%=q>ekTaycJ=xE-Bdl5gbwVnyPmV}V-^Aen`a1cp03(CcGr?de=c5c>;nc8z1 zPIpK=Z$;Wue6&`zjAOWOGC{7M*R~m(9p7~FX#m6{#o+_+1Nggxqe&il-UU1DJ7tU4 zWW^-%n;$fjGx}WUqywt6Y$YLlmo9ZJPJhr1y1Tmm`&aIf{dm9~-vXgIwfH|*ON5Ke z{#JeMz^nUR{LxV9k>pg*>_-W2Cl7Gy^V1zVK2-2IG&$$h0Bo5ytST4HudW`~9wd^` z`0jpq^fLw}?qPK!(4KFz?}YxvBVA$x;iVx;XDoKd#HBWhc9uFV)J}io>Tfb#K6)NQ z!zuad#r|`GqYFB(cA^;IoQVH*!$_N4@vbo_7X$0eJ-~iu8L4I^V;gMji1JwvC`MrI zo-v-(G3jb|sj9oiT@I8d0@Gn;30d>yNrh_irOb(@SJ>7pwdtDNXM!nC8}4ffKR=HT zjIV0T@mL&R?=NrlPsnmcC*ZnMj(Z_Qf>rS6$73kN#u8NS-bPEBT1h|4GuLY-y~J4^ zb7OY0@D1g0C=Ff%#3Rd@b`Q+&oJ*Yua*FlPN~)oVljQ=$Z*nBkDAnu6OzG3^gxvO1 z))9%Cr)(#=)EWWOJfjZ5iq}Mk=(MmO(`Rcpgsx4=0Q z75(H=aUX26hqcQa&$L^u+FrH(_a5wP z>dqT8+xRr{Tt{MC4ih1j0NCd`c*phmPg#Ha#gfL9*}~`9SleI>5KDfoL5Ng9xh`=w zgK@)z#`TXnGNQ1FR5YSI6_>z(UQ`iHSobHxE0B-UU@P_f-5VO3GMuj&r>9!pdfq83 zhq;H?QODnZQ69g+CC|YKFG}NW?>4IOv2Iu4C3G&ACKZ(%i=KSa+ zwpIiyKhf}_;>8|Ao7UG9j7O1n)NSpyxi_821#QDL^qFg@hW8WmyC=N*vd()hKKFP| zJ!&!Ovq^0}x)eyKo@CBuH_Z7rD}g3XB-Qq_^DTckPu5~hp6CL1%)?Hr)YdtEB>l@7 z8f}A?!+WgVswW^GDJTrYGfCSz%2dmrcj45x?dTdQz!un;;ym!qyF33$9fz05?{kz# z@^YfrlySe*H|C&KLsU{;t%x#Lz;g$kU*0D@ZYjUdPFFUnTAXj?@teoz;UVP1+8WgI5N*>rQ1<3FspXa1Q`!h<PZ@wNQaz)WPksB3O{2>*{ zL5{wb*Am>#HXAiC#c@&Rkh}tXaXl+g_6p@QZ8A12#J{1>Z~d0pJHqS1OG63b;bCI~ z>%&>b6(uyuCDDhQoaReBHODy%=YtE*D}FF#H`?rpM+!9pn_CR_W~@w>X>JoQkjaX* z=AFO9tY!k_!wF{_n4Rp-sQ!$7tf>muvjV}b``6dTF5XY1zY33z{5~+*B>PrTjM<0ll z{a`>UQ?H-*{C1OZ?5XDn%kbH(6?{KWxg`4 z`##1kCYh(>_8T{+bU1R5GeF8|DW!uxF>`|IJ$a*Txl!S38Rt^H_}tHziPZOkv#VH` zNve%eR3^^B3Apj%eV`^%J>63XC3ICMkB3EPgLqgtctb(GzxVLVlwncPHjJO;lND)D zp|Rt=QKEfNs`p(L{(+j$@l({I>lbTz<5u137yrzV2rbczi)Zp2SE%90O`U(gl6>IZ zd?PlTaPpIAd{NCJB2S9*CoQok*x>*+R-X$Yx_%~g$rVFw3@LGH5RU{f2lyN3#rwX{pLh2?#h5H-V*imSd{g7=&D?gHf|`iei`!sL zoK#M>@>*EvIQ^Y7?deHqwmO#-lNZ3>h>IHDN9M&WXaR*n9$1Kx{JNOV*N(5;7H#(L zxSKKn(VL! za85!AMO8sVqBI?$R;QNzFZ+}RDKVOo4tBJSZW(A{-)SfG4OfSK z&@|9}^b47BxkJrtW5pk*jn2j6i@yOqPQcz|k0S9_rDyl>-N{m7(ycNc;yDr!4-YHo zU&52(1(VyD_<;%|VOU(6N^EYr)%QQgTTdp3g3NB}&-+b8CqGVVN{vw?6Gi+Qo5rl} zeOQ+woc9wh#$5sv_x0m=naqIjg%2+QeCt*x?tph7=(5f)2U_7jtvU9(n|iZ)ah0Vk zRhOmJzXWiLhsYT#MemMr!e3NBvL9%U4I&-Xwfqe7tifuj0{Ry}u@P^cvn8uWdjEMi z;o_~=z11geeUEAK!ipB#zi5!SnFYn;YEuH|Aw+GnsOC)NimZ4e8I!p(vk+=fE)FhB zd*C?;8a^pxz+n5@GxbmGchv5|81G%B=S_f-4chuhsOr5Xp<|+=j)z^3d?T)oqc2|) zkFtx4OQ!Jq5Cz+wZt`6UN`ySM@yCzmKe9PQYVb=(+Xn9YRv$|jy*!Hwa0XLm@kK9$ z1XoDgqcCRE8Cv<%q*n8%Au7L;d(@L*g4dU?mR1qOBMkKc<_RVUBmRd%>F^Jl+IO@& zE;1-T`j`7-U7^ls;oX*$NK8(XiHp42dz)p`S8N<~H^gn>tg;jqWtfoRjT#*hY>;E- z`9-CN_jsgkfiL%xw6UCvxVWy_-!1?wg*y&ISKji!^h)fxx@Ce=Y-dU5vDBJ~L?dHH z-^U}hyk)fb}3GawiQdQ0w=zSQ;tU1DG&GU3A$Wv2ABAup(dNah} z^~^6n;X;J?`AFnL-<^5+NL}kkj_ALI9M}71H=JzlCR_m`8xG(2unR(b?W|DJ=7$+K zW3=SuM{5q7&|M+!>v96ivcC%%Z}7+{aR=6FN?1|0>HYC=OE{UcUq( zDhjPiaDA(b&PD6%I)QSrKnI*gfH+oBfv>23HG0u9{;Wws&#-%rbYI9S{>OZuC_C$T z22ItSUy~B5YhJE;Ob;KWmRMyGX;VSzZR;F-*SK1u%csx8zJ!D=z_`u$RNlr!J5iSt z6DjZ?8HL)~uf;S;a$I3%eTW#CGz$&}%2JT|p3h#0h~_b%ze@5?kwWg62nWyRf_S(& zm+=74Ml{N5p`*8@b!5H`T#{U{)fVUY%)6cJ^ASgGZtX0xGU3VPdWSL2cRJy2^Au$q z+)HgbA_OKOxMJs}ys^yx+NiJAP7j}Kl){65h1vX;40BB2X(grB2?0xnW5C11oi!)% zqBTV4#B9nY?OL&_|OA6KgE!2H*{OnKL)9|+%ECIc> zmp;PvSmyNFEwzD)p*p+{sE6As4qY6bSBqu2B~}vu>YaU;{3SR~5cF{pZ%$nW*R-{NjP@lEn?v_FTb^ zN7lcycC9SzClDUH+O{H6zCTt-wFg`bhc7Nf8B zTfU5n5WFJnOA+%tB_D`KlGAPwtiw}3kZ0L0evY9Zl(o~tz&s6as*^Lr53CRU#0B{5 zU~eOT%hXU)XfresnEfQwv?SAK8(R859ET~oNHH=cX?lc-C*U!_K>|Q`6D#m~x4mw) zdrN$?f$G*InOT?c=@h1^;e&K>y3^@V!#uZJXIm(q-7>xdwI{D@`4gF{6ii7A^i8Zo zw^-N=fjVkZTpBg0v7nTQ!M^S{D9TFzu15gZa;uD~r^d?#RB#B;(SwyR{z)N&kiGWd z$}cF3pG*r2U$Ph|7Z)e&Z(c@ph!)ha$r)j`oyvuysuMqSAGBUg>WovAt)fDvC%)Wb zGD2!mMa~aVcfeP*cA7crmQo?3D4CIHPiCk3pd!I7T)WC}qf&`6Z)Sat-s)wOya#Zd zV4z4!0nC|*pxR!$@1W9}$2!cRY!!rBzV2nOP@ax7pj<_`7OJ<<$GFY@&C9?X63Abo z+SO!Yypd_5&;Jn;sDkU}MAN!)K4g@qE2Wh;NVFW)62qi>!KMc~##S)iIu-=N1xy5e z5?d)!^HRXX8I+5N**FlK{}?3z?S119fPkKzH?v*RIZsh|1d>g*8OEPI$isjkMZA3K zSuZ_YgqcRGLt=erZc*vm@6ch-B`plbeRB(4pUNRDkKh@r=jFi6rXF=nC@{H zX#vKI3-<&{g7ch15r?Z+7U%_iMmSib%MG0rCo(rK8|0?4k*Lo=|Mr!l9LouR&t(`q z`3=<~EvN^qQq~T+p^^+I%VL+}XE#ktZEfY>WLrAwdR-r3$1E12^+ro;YlA&&t%K$# zWa_U|Xo&mN`bxq}{@NAx%foFGoMQa)? z_1S8o6;?g1??3)n&`cnlFa7+=qIr9_P*FTs90?pop>f^CKqW*B=nLp8IR?@vxZ!XJ zLGftHMl$szhp5K6Qhq?hD{6^lSLkfVDzN0ezol91RnD$^BnGjf|bLL z!|x5bT{rJWTI*_aSakMHO3(GIliOf}d6j=;%j5NdfbUn#6K9HqUq#o@jb9TMfAliA@YULa~E>?KDHn zHZ2C27EJ_k`{~=qS&UgUe=bnDLr*kErDWH@gQ>bcb4}BJh?apBj=NN?b_GU8oh3-e zF6P{gtEALnZ^zwvar+4q=(A|I6e4e_CYQ zZxJT#zl7c*5Voa+6HEIEgoUJsjPNgxq6xtN{&{Am3H0x*Luw2R?~;n6q#z0N)e#Tm zfGxs_rX*LZTh{+R(3X@%{f7lUM-=#i22S(>0{IX3{&(Bpa~#3vfM?|Y-HrcY4++!x i`}Ggczgz$M7U3;$Kn@+<3 literal 0 HcmV?d00001 diff --git a/tests/data/.hidden/.gitignore b/tests/data/.hidden/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 19bae375..184970ae 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -156,6 +156,12 @@ class FindFilesTest(unittest.TestCase): self.assert_(is_unicode(name), '%s is not unicode object' % repr(name)) + def test_ignores_hidden_folders(self): + self.assertEqual(self.find('.hidden'), []) + + def test_ignores_hidden_files(self): + self.assertEqual(self.find('.blank.mp3'), []) + class MtimeTest(unittest.TestCase): def tearDown(self): From c2e1b0d6727ff0a0c0f2efdc23e88ce685e7a731 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 18:17:28 +0200 Subject: [PATCH 150/177] Use find_files() as an iterator in scanner. --- mopidy/scanner.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 3bcf03d9..29511c80 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -52,7 +52,7 @@ def translator(data): class Scanner(object): def __init__(self, folder, data_callback, error_callback=None): - self.uris = [path_to_uri(f) for f in find_files(folder)] + self.files = find_files(folder) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() @@ -114,18 +114,19 @@ class Scanner(object): return None def next_uri(self): - if not self.uris: - return self.stop() - + try: + uri = path_to_uri(self.files.next()) + except StopIteration: + self.stop() + return False self.pipe.set_state(gst.STATE_NULL) - self.uribin.set_property('uri', self.uris.pop()) + self.uribin.set_property('uri', uri) self.pipe.set_state(gst.STATE_PAUSED) + return True def start(self): - if not self.uris: - return - self.next_uri() - self.loop.run() + if self.next_uri(): + self.loop.run() def stop(self): self.pipe.set_state(gst.STATE_NULL) From 6cc57701f96e582317ee1e219ac53e2d838a3f47 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 19:28:15 +0200 Subject: [PATCH 151/177] Update parse_m3u to allow caller to decide what location playlist is relative to. --- mopidy/backends/local/__init__.py | 4 ++-- mopidy/backends/local/translator.py | 6 ++---- tests/backends/local/translator_test.py | 28 +++++++++++++++++-------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 975ec458..db86e56f 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,7 +7,7 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import audio, core, settings, DATA_PATH +from mopidy import audio, core, settings from mopidy.backends import base from mopidy.models import Playlist, Track, Album @@ -88,7 +88,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): name = os.path.basename(m3u)[:-len('.m3u')] tracks = [] - for uri in parse_m3u(m3u): + for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: tracks.append(self.backend.library.lookup(uri)) except LookupError, e: diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 3b610a94..1fea555c 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -7,7 +7,7 @@ from mopidy.models import Track, Artist, Album from mopidy.utils import locale_decode from mopidy.utils.path import path_to_uri -def parse_m3u(file_path): +def parse_m3u(file_path, music_folder): """ Convert M3U file list of uris @@ -29,8 +29,6 @@ def parse_m3u(file_path): """ uris = [] - folder = os.path.dirname(file_path) - try: with open(file_path) as m3u: contents = m3u.readlines() @@ -48,7 +46,7 @@ def parse_m3u(file_path): if line.startswith('file://'): uris.append(line) else: - path = path_to_uri(folder, line) + path = path_to_uri(music_folder, line) uris.append(path) return uris diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 1dceb737..08f29c1b 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -9,6 +9,7 @@ from mopidy.models import Track, Artist, Album from tests import unittest, path_to_data_dir +data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir(u'æøå.mp3') @@ -21,22 +22,32 @@ encoded_uri = path_to_uri(encoded_path) class M3UToUriTest(unittest.TestCase): def test_empty_file(self): - uris = parse_m3u(path_to_data_dir('empty.m3u')) + uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) self.assertEqual([], uris) def test_basic_file(self): - uris = parse_m3u(path_to_data_dir('one.m3u')) + uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir) self.assertEqual([song1_uri], uris) def test_file_with_comment(self): - uris = parse_m3u(path_to_data_dir('comment.m3u')) + uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) self.assertEqual([song1_uri], uris) + def test_file_is_relative_to_correct_folder(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write('song1.mp3') + try: + uris = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_uri], uris) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + def test_file_with_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): @@ -48,29 +59,28 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri, song2_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) - def test_file_with_uri(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - uris = parse_m3u(path_to_data_dir('encoding.m3u')) + uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) self.assertEqual([encoded_uri], uris) def test_open_missing_file(self): - uris = parse_m3u(path_to_data_dir('non-existant.m3u')) + uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) self.assertEqual([], uris) From f9a9d264dccfd0db339589c5789bd58613b0cdcc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Sep 2012 20:28:01 +0200 Subject: [PATCH 152/177] Log and exit if output setup causes LinkError --- mopidy/audio/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index dd98dfa8..7d5b626c 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -90,16 +90,18 @@ class Audio(ThreadingActor): try: self._output = gst.parse_bin_from_description( settings.OUTPUT, ghost_unconnected_pads=True) + self._pipeline.add(self._output) + gst.element_link_many(self._pipeline.get_by_name('queue'), + self._output) + logger.info('Output set to %s', settings.OUTPUT) except gobject.GError as ex: logger.error('Failed to create output "%s": %s', settings.OUTPUT, ex) process.exit_process() - return - - self._pipeline.add(self._output) - gst.element_link_many(self._pipeline.get_by_name('queue'), - self._output) - logger.info('Output set to %s', settings.OUTPUT) + except gst.LinkError as ex: + logger.error('Failed to link output "%s": %s', + settings.OUTPUT, ex) + process.exit_process() def _setup_mixer(self): if not settings.MIXER: From fdde2eb2bf2920ebdb2c560d76d9c080a78a6d4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Sep 2012 20:29:24 +0200 Subject: [PATCH 153/177] docs: Don't guesstime release to include multi-backend --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 0c1a3c7e..a79dfd78 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -56,7 +56,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See Currently, Mopidy supports using Spotify *or* local storage as a music source. We're working on using both sources simultaneously, and will - hopefully have support for this in the 0.6 release. + have support for this in a future release. .. _generating_a_tag_cache: From 0c674c5341ff53daeced9844b88767924f66d36a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Sep 2012 21:30:02 +0200 Subject: [PATCH 154/177] Remove dead code --- mopidy/audio/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 7d5b626c..448412b4 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -98,10 +98,6 @@ class Audio(ThreadingActor): logger.error('Failed to create output "%s": %s', settings.OUTPUT, ex) process.exit_process() - except gst.LinkError as ex: - logger.error('Failed to link output "%s": %s', - settings.OUTPUT, ex) - process.exit_process() def _setup_mixer(self): if not settings.MIXER: From dda5e5261a448c57892e9ef3d7efa9447deea6fd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:07:59 +0200 Subject: [PATCH 155/177] Move and rename expand_path to mopidy.utils.path Also switches a bit move of mopidy.utils.settings over to module imports and double spaces between functions. --- mopidy/utils/path.py | 23 ++++++++++++++++++++++- mopidy/utils/settings.py | 24 ++++++------------------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index b276a027..ee8f3c65 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,11 +1,20 @@ +import glib import logging import os -import sys import re +import string +import sys import urllib logger = logging.getLogger('mopidy.utils.path') +XDG_DIRS = { + 'XDG_CACHE_DIR': glib.get_user_cache_dir(), + 'XDG_DATA_DIR': glib.get_user_data_dir(), + 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), +} + + def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): @@ -16,6 +25,7 @@ def get_or_create_folder(folder): os.makedirs(folder, 0755) return folder + def get_or_create_file(filename): filename = os.path.expanduser(filename) if not os.path.isfile(filename): @@ -23,6 +33,7 @@ def get_or_create_file(filename): open(filename, 'w') return filename + def path_to_uri(*paths): path = os.path.join(*paths) path = path.encode('utf-8') @@ -30,6 +41,7 @@ def path_to_uri(*paths): return 'file:' + urllib.pathname2url(path) return 'file://' + urllib.pathname2url(path) + def uri_to_path(uri): if sys.platform == 'win32': path = urllib.url2pathname(re.sub('^file:', '', uri)) @@ -37,6 +49,7 @@ def uri_to_path(uri): path = urllib.url2pathname(re.sub('^file://', '', uri)) return path.encode('latin1').decode('utf-8') # Undo double encoding + def split_path(path): parts = [] while True: @@ -47,6 +60,13 @@ def split_path(path): break return parts + +def expand_path(path): + path = os.path.expanduser(path) + path = os.path.abspath(path) + return string.Template(path).safe_substitute(XDG_DIRS) + + def find_files(path): if os.path.isfile(path): if not isinstance(path, unicode): @@ -73,6 +93,7 @@ def find_files(path): filename = filename.decode('latin1') yield filename + # FIXME replace with mock usage in tests. class Mtime(object): def __init__(self): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index fae4278f..e6c35ce1 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -3,24 +3,17 @@ from __future__ import absolute_import import copy import getpass -import glib import logging import os import pprint -import string import sys from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE -from mopidy.utils.log import indent +from mopidy.utils import log +from mopidy.utils import path logger = logging.getLogger('mopidy.utils.settings') -XDG_DIRS = { - 'XDG_CACHE_DIR': glib.get_user_cache_dir(), - 'XDG_DATA_DIR': glib.get_user_data_dir(), - 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), -} - class SettingsProxy(object): def __init__(self, default_settings_module): @@ -67,7 +60,7 @@ class SettingsProxy(object): if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = self.expandpath(value) + value = path.expand_path(value) return value def __setattr__(self, attr, value): @@ -76,17 +69,12 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) - def expandpath(self, value): - value = os.path.expanduser(value) - value = os.path.abspath(value) - return string.Template(value).safe_substitute(XDG_DIRS) - def validate(self, interactive): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): logger.error(u'Settings validation errors: %s', - indent(self.get_errors_as_string())) + log.indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): @@ -210,11 +198,11 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent( + lines.append(u'%s: %s' % (key, log.indent( pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % - indent(pformat(default_value), places=4)) + log.indent(pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) From a707daf45814c36b24d959e7415f834d64861d4e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:26:44 +0200 Subject: [PATCH 156/177] Add tests for expand_path and fix ordering. Expansions need to happen before abspath is called or else result is wrong. --- mopidy/utils/path.py | 3 ++- tests/utils/path_test.py | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index ee8f3c65..7f1b9233 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -62,9 +62,10 @@ def split_path(path): def expand_path(path): + path = string.Template(path).safe_substitute(XDG_DIRS) path = os.path.expanduser(path) path = os.path.abspath(path) - return string.Template(path).safe_substitute(XDG_DIRS) + return path def find_files(path): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 184970ae..d6b2b5a7 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -1,12 +1,13 @@ # encoding: utf-8 +import glib import os import shutil import sys import tempfile from mopidy.utils.path import (get_or_create_folder, mtime, - path_to_uri, uri_to_path, split_path, find_files) + path_to_uri, uri_to_path, expand_path, split_path, find_files) from tests import unittest, path_to_data_dir @@ -135,6 +136,30 @@ class SplitPathTest(unittest.TestCase): self.assertEqual([], split_path('/')) +class ExpandPathTest(unittest.TestCase): + # TODO: test via mocks? + + def test_empty_path(self): + self.assertEqual(os.path.abspath('.'), expand_path('')) + + def test_absolute_path(self): + self.assertEqual('/tmp/foo', expand_path('/tmp/foo')) + + def test_home_dir_expansion(self): + self.assertEqual(os.path.expanduser('~/foo'), expand_path('~/foo')) + + def test_abspath(self): + self.assertEqual(os.path.abspath('./foo'), expand_path('./foo')) + + def test_xdg_subsititution(self): + self.assertEqual(glib.get_user_data_dir() + '/foo', + expand_path('$XDG_DATA_DIR/foo')) + + def test_xdg_subsititution_unknown(self): + self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', + expand_path('/tmp/$XDG_INVALID_DIR/foo')) + + class FindFilesTest(unittest.TestCase): def find(self, path): return list(find_files(path_to_data_dir(path))) From 5a47dfe159db09061aa6b16d8fd8c9673bcd4487 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:44:15 +0200 Subject: [PATCH 157/177] Update import style in tests.utils.path --- tests/utils/path_test.py | 75 ++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index d6b2b5a7..d782aa15 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -6,8 +6,7 @@ import shutil import sys import tempfile -from mopidy.utils.path import (get_or_create_folder, mtime, - path_to_uri, uri_to_path, expand_path, split_path, find_files) +from mopidy.utils import path from tests import unittest, path_to_data_dir @@ -24,7 +23,7 @@ class GetOrCreateFolderTest(unittest.TestCase): folder = os.path.join(self.parent, 'test') self.assert_(not os.path.exists(folder)) self.assert_(not os.path.isdir(folder)) - created = get_or_create_folder(folder) + created = path.get_or_create_folder(folder) self.assert_(os.path.exists(folder)) self.assert_(os.path.isdir(folder)) self.assertEqual(created, folder) @@ -36,7 +35,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assert_(not os.path.isdir(level2_folder)) self.assert_(not os.path.exists(level3_folder)) self.assert_(not os.path.isdir(level3_folder)) - created = get_or_create_folder(level3_folder) + created = path.get_or_create_folder(level3_folder) self.assert_(os.path.exists(level2_folder)) self.assert_(os.path.isdir(level2_folder)) self.assert_(os.path.exists(level3_folder)) @@ -44,7 +43,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assertEqual(created, level3_folder) def test_creating_existing_folder(self): - created = get_or_create_folder(self.parent) + created = path.get_or_create_folder(self.parent) self.assert_(os.path.exists(self.parent)) self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) @@ -53,116 +52,116 @@ class GetOrCreateFolderTest(unittest.TestCase): conflicting_file = os.path.join(self.parent, 'test') open(conflicting_file, 'w').close() folder = os.path.join(self.parent, 'test') - self.assertRaises(OSError, get_or_create_folder, folder) + self.assertRaises(OSError, path.get_or_create_folder, folder) class PathToFileURITest(unittest.TestCase): def test_simple_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc/fstab') + result = path.path_to_uri(u'/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') def test_folder_and_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/', u'clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/', u'clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc', u'fstab') + result = path.path_to_uri(u'/etc', u'fstab') self.assertEqual(result, u'file:///etc/fstab') def test_space_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/test this') + result = path.path_to_uri(u'C:/test this') self.assertEqual(result, 'file:///C://test%20this') else: - result = path_to_uri(u'/tmp/test this') + result = path.path_to_uri(u'/tmp/test this') self.assertEqual(result, u'file:///tmp/test%20this') def test_unicode_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/æøå') + result = path.path_to_uri(u'C:/æøå') self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') else: - result = path_to_uri(u'/tmp/æøå') + result = path.path_to_uri(u'/tmp/æøå') self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://WINDOWS/clock.avi') + result = path.uri_to_path('file:///C://WINDOWS/clock.avi') self.assertEqual(result, u'C:/WINDOWS/clock.avi') else: - result = uri_to_path('file:///etc/fstab') + result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, u'/etc/fstab') def test_space_in_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://test%20this') + result = path.uri_to_path('file:///C://test%20this') self.assertEqual(result, u'C:/test this') else: - result = uri_to_path(u'file:///tmp/test%20this') + result = path.uri_to_path(u'file:///tmp/test%20this') self.assertEqual(result, u'/tmp/test this') def test_unicode_in_uri(self): if sys.platform == 'win32': - result = uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'C:/æøå') else: - result = uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'/tmp/æøå') class SplitPathTest(unittest.TestCase): def test_empty_path(self): - self.assertEqual([], split_path('')) + self.assertEqual([], path.split_path('')) def test_single_folder(self): - self.assertEqual(['foo'], split_path('foo')) + self.assertEqual(['foo'], path.split_path('foo')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_initial_slash_is_ignored(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('/foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): - self.assertEqual([], split_path('/')) + self.assertEqual([], path.split_path('/')) class ExpandPathTest(unittest.TestCase): # TODO: test via mocks? def test_empty_path(self): - self.assertEqual(os.path.abspath('.'), expand_path('')) + self.assertEqual(os.path.abspath('.'), path.expand_path('')) def test_absolute_path(self): - self.assertEqual('/tmp/foo', expand_path('/tmp/foo')) + self.assertEqual('/tmp/foo', path.expand_path('/tmp/foo')) def test_home_dir_expansion(self): - self.assertEqual(os.path.expanduser('~/foo'), expand_path('~/foo')) + self.assertEqual(os.path.expanduser('~/foo'), path.expand_path('~/foo')) def test_abspath(self): - self.assertEqual(os.path.abspath('./foo'), expand_path('./foo')) + self.assertEqual(os.path.abspath('./foo'), path.expand_path('./foo')) def test_xdg_subsititution(self): self.assertEqual(glib.get_user_data_dir() + '/foo', - expand_path('$XDG_DATA_DIR/foo')) + path.expand_path('$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', - expand_path('/tmp/$XDG_INVALID_DIR/foo')) + path.expand_path('/tmp/$XDG_INVALID_DIR/foo')) class FindFilesTest(unittest.TestCase): - def find(self, path): - return list(find_files(path_to_data_dir(path))) + def find(self, value): + return list(path.find_files(path_to_data_dir(value))) def test_basic_folder(self): self.assert_(self.find('')) @@ -190,12 +189,12 @@ class FindFilesTest(unittest.TestCase): class MtimeTest(unittest.TestCase): def tearDown(self): - mtime.undo_fake() + path.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat('.').st_mtime) - self.assertEqual(mtime_dir, mtime('.')) + self.assertEqual(mtime_dir, path.mtime('.')) def test_fake_time_is_returned(self): - mtime.set_fake_time(123456) - self.assertEqual(mtime('.'), 123456) + path.mtime.set_fake_time(123456) + self.assertEqual(path.mtime('.'), 123456) From 049840daaf2312602870390881019fdcc87dc686 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:53:58 +0200 Subject: [PATCH 158/177] Update changelog. --- docs/changes.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 43b930b8..27b8731b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -63,6 +63,16 @@ v0.8 (in development) - Support tracks with only release year, and not a full release date, like e.g. Spotify tracks. +- Default value of ``LOCAL_MUSIC_PATH`` has been updated to be + ``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of + local backend that relied on the old default ``~/music`` need to update their + settings. Note that the code responsible for finding this music now also + ignores UNIX hidden files and folders. + +- File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and + ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated + to use this instead of hidden away defaults. + **Bug fixes** - :issue:`72`: Created a Spotify track proxy that will switch to using loaded @@ -80,6 +90,12 @@ v0.8 (in development) - Fixed crash on lookup of unknown path when using local backend. +- :issue:`189` ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has + been updated so all of the code now uses the correct value. + +- Fixed incorrect track URIs generated by ``parse_m3u`` code, generated tracks + are now relative to ``LOCAL_MUSIC_PATH``. + v0.7.3 (2012-08-11) =================== From 9b6c17db96879839d9ebae7b419007a2dcf304a1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Sep 2012 00:03:48 +0200 Subject: [PATCH 159/177] docs: Cleanup changelog --- docs/changes.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 27b8731b..1c516a0d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -78,23 +78,23 @@ v0.8 (in development) - :issue:`72`: Created a Spotify track proxy that will switch to using loaded data as soon as it becomes available. -- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a - track position. Track position and CPID was intermixed, so it would cause a - crash if a CPID matching the track position didn't exist. - - :issue:`150`: Fix bug which caused some clients to block Mopidy completely. The bug was caused by some clients sending ``close`` and then shutting down the connection right away. This trigged a situation in which the connection cleanup code would wait for an response that would never come inside the event loop, blocking everything else. +- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a + track position. Track position and CPID was intermixed, so it would cause a + crash if a CPID matching the track position didn't exist. + - Fixed crash on lookup of unknown path when using local backend. -- :issue:`189` ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has +- :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has been updated so all of the code now uses the correct value. -- Fixed incorrect track URIs generated by ``parse_m3u`` code, generated tracks - are now relative to ``LOCAL_MUSIC_PATH``. +- Fixed incorrect track URIs generated by M3U playlist parsing code. Generated + tracks are now relative to ``LOCAL_MUSIC_PATH``. v0.7.3 (2012-08-11) From 71682d3d9fff78ede42dc5bff1107a2d0d4d4955 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 22:50:57 +0200 Subject: [PATCH 160/177] Switched over to playbin2 for audio playback. Covers first half of #171 which is simply an port of the functionality we used to have. Second half is actually taking advantage of playbin2 with respect to EOT handling etc. --- mopidy/audio/__init__.py | 86 ++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 57 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 448412b4..498fbdc9 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -42,17 +42,14 @@ class Audio(ThreadingActor): signed=(boolean)true, rate=(int)44100""") - self._pipeline = None - self._source = None - self._uridecodebin = None - self._output = None + self._playbin = None self._mixer = None self._message_processor_set_up = False def on_start(self): try: - self._setup_pipeline() + self._setup_playbin() self._setup_output() self._setup_mixer() self._setup_message_processor() @@ -63,36 +60,22 @@ class Audio(ThreadingActor): def on_stop(self): self._teardown_message_processor() self._teardown_mixer() - self._teardown_pipeline() + self._teardown_playbin() - def _setup_pipeline(self): - # TODO: replace with and input bin so we simply have an input bin we - # connect to an output bin with a mixer on the side. set_uri on bin? - description = ' ! '.join([ - 'uridecodebin name=uri', - 'audioconvert name=convert', - 'audioresample name=resample', - 'queue name=queue']) + def _setup_playbin(self): + self._playbin = gst.element_factory_make('playbin2') - logger.debug(u'Setting up base GStreamer pipeline: %s', description) + fakesink = gst.element_factory_make('fakesink') + self._playbin.set_property('video-sink', fakesink) - self._pipeline = gst.parse_launch(description) - self._uridecodebin = self._pipeline.get_by_name('uri') - - self._uridecodebin.connect('notify::source', self._on_new_source) - self._uridecodebin.connect('pad-added', self._on_new_pad, - self._pipeline.get_by_name('queue').get_pad('sink')) - - def _teardown_pipeline(self): - self._pipeline.set_state(gst.STATE_NULL) + def _teardown_playbin(self): + self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): try: - self._output = gst.parse_bin_from_description( + output = gst.parse_bin_from_description( settings.OUTPUT, ghost_unconnected_pads=True) - self._pipeline.add(self._output) - gst.element_link_many(self._pipeline.get_by_name('queue'), - self._output) + self._playbin.set_property('audio-sink', output) logger.info('Output set to %s', settings.OUTPUT) except gobject.GError as ex: logger.error('Failed to create output "%s": %s', @@ -148,29 +131,16 @@ class Audio(ThreadingActor): mixer.set_state(gst.STATE_NULL) def _setup_message_processor(self): - bus = self._pipeline.get_bus() + bus = self._playbin.get_bus() bus.add_signal_watch() bus.connect('message', self._on_message) self._message_processor_set_up = True def _teardown_message_processor(self): if self._message_processor_set_up: - bus = self._pipeline.get_bus() + bus = self._playbin.get_bus() bus.remove_signal_watch() - def _on_new_source(self, element, pad): - self._source = element.get_property('source') - try: - self._source.set_property('caps', self._default_caps) - except TypeError: - pass - - def _on_new_pad(self, source, pad, target_pad): - if not pad.is_linked(): - if target_pad.is_linked(): - target_pad.get_peer().unlink(target_pad) - pad.link(target_pad) - def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: self._notify_backend_of_eos() @@ -200,7 +170,7 @@ class Audio(ThreadingActor): :param uri: the URI to play :type uri: string """ - self._uridecodebin.set_property('uri', uri) + self._playbin.set_property('uri', uri) def emit_data(self, capabilities, data): """ @@ -215,18 +185,20 @@ class Audio(ThreadingActor): caps = gst.caps_from_string(capabilities) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) - self._source.set_property('caps', caps) - self._source.emit('push-buffer', buffer_) + + source = self._playbin.get_property('source') + source.set_property('caps', caps) + source.emit('push-buffer', buffer_) def emit_end_of_stream(self): """ - Put an end-of-stream token on the pipeline. This is typically used in + Put an end-of-stream token on the playbin. This is typically used in combination with :meth:`emit_data`. We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - self._source.emit('end-of-stream') + self._playbin.get_property('source').emit('end-of-stream') def get_position(self): """ @@ -234,10 +206,10 @@ class Audio(ThreadingActor): :rtype: int """ - if self._pipeline.get_state()[1] == gst.STATE_NULL: + if self._playbin.get_state()[1] == gst.STATE_NULL: return 0 try: - position = self._pipeline.query_position(gst.FORMAT_TIME)[0] + position = self._playbin.query_position(gst.FORMAT_TIME)[0] return position // gst.MSECOND except gst.QueryError, e: logger.error('time_position failed: %s', e) @@ -251,10 +223,10 @@ class Audio(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self._pipeline.get_state() # block until state changes are done - handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._pipeline.get_state() # block until seek is done + self._playbin.get_state() # block until seek is done return handeled def start_playback(self): @@ -308,12 +280,12 @@ class Audio(ThreadingActor): "READY" -> "NULL" "READY" -> "PAUSED" - :param state: State to set pipeline to. One of: `gst.STATE_NULL`, + :param state: State to set playbin to. One of: `gst.STATE_NULL`, `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. :type state: :class:`gst.State` :rtype: :class:`True` if successfull, else :class:`False` """ - result = self._pipeline.set_state(state) + result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: logger.warning('Setting GStreamer state to %s: failed', state.value_name) @@ -382,7 +354,7 @@ class Audio(ThreadingActor): Set track metadata for currently playing song. Only needs to be called by sources such as `appsrc` which do not - already inject tags in pipeline, e.g. when using :meth:`emit_data` to + already inject tags in playbin, e.g. when using :meth:`emit_data` to deliver raw audio data to GStreamer. :param track: the current track @@ -407,4 +379,4 @@ class Audio(ThreadingActor): taglist[gst.TAG_ALBUM] = track.album.name event = gst.event_new_tag(taglist) - self._pipeline.send_event(event) + self._playbin.send_event(event) From 413c22e117d588ed649cf5488515e312310d1bc2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 23:41:39 +0200 Subject: [PATCH 161/177] Move mixer track out to it's own attribute. --- mopidy/audio/__init__.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 498fbdc9..8a40692b 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -44,6 +44,7 @@ class Audio(ThreadingActor): self._playbin = None self._mixer = None + self._mixer_track = None self._message_processor_set_up = False @@ -110,7 +111,8 @@ class Audio(ThreadingActor): logger.warning('Could not find usable mixer track.') return - self._mixer = (mixer, track) + self._mixer = mixer + self._mixer_track = track logger.info('Mixer set to %s using track called %s', mixer.get_factory().get_name(), track.label) @@ -127,8 +129,7 @@ class Audio(ThreadingActor): def _teardown_mixer(self): if self._mixer is not None: - (mixer, track) = self._mixer - mixer.set_state(gst.STATE_NULL) + self._mixer.set_state(gst.STATE_NULL) def _setup_message_processor(self): bus = self._playbin.get_bus() @@ -317,13 +318,11 @@ class Audio(ThreadingActor): if self._mixer is None: return None - mixer, track = self._mixer - - volumes = mixer.get_volume(track) + volumes = self._mixer.get_volume(self._mixer_track) avg_volume = float(sum(volumes)) / len(volumes) new_scale = (0, 100) - old_scale = (track.min_volume, track.max_volume) + old_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) return utils.rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): @@ -337,17 +336,15 @@ class Audio(ThreadingActor): if self._mixer is None: return False - mixer, track = self._mixer - old_scale = (0, 100) - new_scale = (track.min_volume, track.max_volume) + new_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) volume = utils.rescale(volume, old=old_scale, new=new_scale) - volumes = (volume,) * track.num_channels - mixer.set_volume(track, volumes) + volumes = (volume,) * self._mixer_track.num_channels + self._mixer.set_volume(self._mixer_track, volumes) - return mixer.get_volume(track) == volumes + return self._mixer.get_volume(self._mixer_track) == volumes def set_metadata(self, track): """ From 9866d78c6598561cc7e71957ab781fde435940c6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 23:49:29 +0200 Subject: [PATCH 162/177] Re-add software mixing, fixes #203. --- mopidy/audio/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 8a40692b..df5efb92 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -45,6 +45,7 @@ class Audio(ThreadingActor): self._playbin = None self._mixer = None self._mixer_track = None + self._software_mixing = False self._message_processor_set_up = False @@ -88,6 +89,11 @@ class Audio(ThreadingActor): logger.info('Not setting up mixer.') return + if settings.MIXER == 'software': + self._software_mixing = True + logger.info('Mixer set to software mixing.') + return + try: mixerbin = gst.parse_bin_from_description(settings.MIXER, ghost_unconnected_pads=False) @@ -315,6 +321,9 @@ class Audio(ThreadingActor): :rtype: int in range [0..100] or :class:`None` """ + if self._software_mixing: + return round(self._playbin.get_property('volume') * 100) + if self._mixer is None: return None @@ -333,6 +342,10 @@ class Audio(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ + if self._software_mixing: + self._playbin.set_property('volume', volume / 100.0) + return True + if self._mixer is None: return False From 8f045b6d6bfa8b53ffa60e4090718cbee3ee6ee5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 23:56:58 +0200 Subject: [PATCH 163/177] Update documentaion and changelog with respect to software mixing and playbin2 switch. --- docs/changes.rst | 7 ++++++- mopidy/settings.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 1c516a0d..5df46066 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,7 +23,7 @@ v0.8 (in development) mixer that will work on your system. If this picks the wrong mixer you can of course override it. Setting the mixer to :class:`None` is also supported. MPD protocol support for volume has also been updated to return -1 when we have - no mixer set. + no mixer set. ``software`` can be used to force software mixing. - Removed the Denon hardware mixer, as it is not maintained. @@ -73,6 +73,9 @@ v0.8 (in development) ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated to use this instead of hidden away defaults. +- Playback is no done using ``playbin2`` from GStreamer instead of rolling our + own. This is the first step towards resolving :issue:`171`. + **Bug fixes** - :issue:`72`: Created a Spotify track proxy that will switch to using loaded @@ -96,6 +99,8 @@ v0.8 (in development) - Fixed incorrect track URIs generated by M3U playlist parsing code. Generated tracks are now relative to ``LOCAL_MUSIC_PATH``. +- :issue:`203`: Re-add support for software mixing. + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/settings.py b/mopidy/settings.py index a2270707..98f7e05e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -111,7 +111,8 @@ LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: Expects a GStreamer mixer to use, typical values are: #: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. #: -#: Setting this to :class:`None` turns off volume control. +#: Setting this to :class:`None` turns off volume control. ``software`` +#: can be used to force software mixing in the application. #: #: Default:: #: From 8ff98195c768acb61c2c725f33f7c405f524991f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 19 Sep 2012 00:09:23 +0200 Subject: [PATCH 164/177] Document settings profiles hack. --- docs/development.rst | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index c5020bd9..c60580e1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -196,8 +196,9 @@ of writing. See ``--help`` for available options. Sample session:: +ACK [2@0] {listallinfo} incorrect arguments To ensure that Mopidy and MPD have comparable state it is suggested you setup -both to use ``tests/data/library_tag_cache`` for their tag cache and -``tests/data`` for music/playlist folders. +both to use ``tests/data/advanced_tag_cache`` for their tag cache and +``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for +playlists. Writing documentation @@ -246,3 +247,32 @@ Creating releases python setup.py sdist upload #. Spread the word. + + +Setting profiles during development +=================================== + +While developing Mopidy switching settings back and forth can become an all too +frequent occurrence. As a quick hack to get around this you can structure your +settings file in the following way:: + + import os + profile = os.environ.get('PROFILE', '').split(',') + + if 'spotify' in profile: + BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) + elif 'local' in profile: + BACKENDS = (u'mopidy.backends.local.LocalBackend',) + LOCAL_MUSIC_PATH = u'~/music' + + if 'shoutcast' in profile: + OUTPUT = u'lame ! shout2send mount="/stream"' + elif 'silent' in profile: + OUTPUT = 'fakesink' + MIXER = None + + SPOTIFY_USERNAME = 'xxxxx' + SPOTIFY_PASSWORD = 'xxxxx' + +Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +if you for instance want to test Spotify without any actual audio output. From 9339833266268f3eafb8082242b85a602ea5e5b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 00:47:32 +0200 Subject: [PATCH 165/177] docs: Fix typo --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5df46066..5fde020c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -73,7 +73,7 @@ v0.8 (in development) ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated to use this instead of hidden away defaults. -- Playback is no done using ``playbin2`` from GStreamer instead of rolling our +- Playback is now done using ``playbin2`` from GStreamer instead of rolling our own. This is the first step towards resolving :issue:`171`. **Bug fixes** From d1d5a084a273686153f56cfe6917b46913002c0d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 01:05:02 +0200 Subject: [PATCH 166/177] Log Spotify playlist loading completion on INFO level --- mopidy/backends/spotify/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 856257f1..ce1226d8 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -153,7 +153,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists - logger.debug(u'Refreshed %d stored playlist(s)', len(playlists)) + logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): """Search method used by Mopidy backend""" From 52c7726de2d1b47091f8a47337f26c9785bcfe03 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 01:50:48 +0200 Subject: [PATCH 167/177] MPD command 'close' does not return 'OK' Test broke when Pykka actors started processing the actor inbox before stopping themselves. --- tests/frontends/mpd/protocol/authentication_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py index 20422f5b..0f0d9c86 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -38,7 +38,6 @@ class AuthenticationTest(protocol.BaseTestCase): self.sendRequest(u'close') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' From 36698d1ae6e38482af65bd2a0ac9bdc97f2bd04a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 02:03:47 +0200 Subject: [PATCH 168/177] Hack to fix random test failure With Pykka 0.16, test_status_method_when_playing_contains_time_with_length fails now and then because play_time_started is not initialized before it is used as an int. I'm allowing myself to fix this in the simplest way possible instead of tracking the issue down, since I'm already working on a refactor of the time position code. --- mopidy/core/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index dfd1676e..31a1acc5 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -88,7 +88,7 @@ class PlaybackController(object): self._shuffled = [] self._first_shuffle = True self.play_time_accumulated = 0 - self.play_time_started = None + self.play_time_started = 0 def _get_cpid(self, cp_track): if cp_track is None: From 772185ddc9c0bae6b94cf123719193f5e895eef7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 02:40:51 +0200 Subject: [PATCH 169/177] Make first log line prettier --- mopidy/utils/log.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 0e5dfc29..0e353117 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -9,8 +9,9 @@ def setup_logging(verbosity_level, save_debug_log): if save_debug_log: setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s on %s %s', - get_version(), get_platform(), get_python()) + logger.info(u'Starting Mopidy %s', get_version()) + logger.info(u'OS: %s', get_platform()) + logger.info(u'Python: %s', get_python()) def setup_root_logger(): root = logging.getLogger('') From 0c9966197b12d76a65740c7c77715832ceaed38d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 13:34:18 +0200 Subject: [PATCH 170/177] Make log output consistent with --list-deps output --- mopidy/utils/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 0e353117..191efa2f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -10,7 +10,7 @@ def setup_logging(verbosity_level, save_debug_log): setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') logger.info(u'Starting Mopidy %s', get_version()) - logger.info(u'OS: %s', get_platform()) + logger.info(u'Platform: %s', get_platform()) logger.info(u'Python: %s', get_python()) def setup_root_logger(): From 402e3043f6857ac3a9cfa71865dbd6be719b0d9b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 23:51:31 +0200 Subject: [PATCH 171/177] Steps before log setup should be outside try-except If the steps before the log setup are inside the try-except and they fail, the error will not be visible since the log system hasn't been set up yet. It is better to not catch the exception so that the error will be visible. --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 416429bc..35518874 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -46,8 +46,8 @@ logger = logging.getLogger('mopidy.main') def main(): signal.signal(signal.SIGTERM, exit_handler) loop = gobject.MainLoop() + options = parse_options() try: - options = parse_options() setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) From 49201def742c828480818a71e103fb9dc8fb31c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 23:54:41 +0200 Subject: [PATCH 172/177] Fix NameError caused by change to module imports --- mopidy/utils/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index e6c35ce1..5468b9bf 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -198,11 +198,11 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, log.indent( - pprint.pformat(masked_value), places=2))) + lines.append(u'%s: %s' % ( + key, log.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % - log.indent(pformat(default_value), places=4)) + log.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) From 0800e86a0587d3a32f68d1d0154cf10c3e9d7ec6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 23:58:38 +0200 Subject: [PATCH 173/177] docs: Use unicode literals in settings examples --- docs/development.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index c60580e1..49d8add5 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -264,15 +264,15 @@ settings file in the following way:: elif 'local' in profile: BACKENDS = (u'mopidy.backends.local.LocalBackend',) LOCAL_MUSIC_PATH = u'~/music' - + if 'shoutcast' in profile: OUTPUT = u'lame ! shout2send mount="/stream"' elif 'silent' in profile: - OUTPUT = 'fakesink' + OUTPUT = u'fakesink' MIXER = None - SPOTIFY_USERNAME = 'xxxxx' - SPOTIFY_PASSWORD = 'xxxxx' + SPOTIFY_USERNAME = u'xxxxx' + SPOTIFY_PASSWORD = u'xxxxx' Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` if you for instance want to test Spotify without any actual audio output. From 6451519d2ae332c8e06a5929fb189d74ec9e7d19 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 00:28:30 +0200 Subject: [PATCH 174/177] MPD: Support 'playid 0' without quotes around id --- mopidy/frontends/mpd/protocol/playback.py | 4 ++-- tests/frontends/mpd/protocol/playback_test.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b0c299c8..356196e6 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -121,8 +121,8 @@ def play(context): """ return context.backend.playback.play().get() -@handle_request(r'^playid "(?P\d+)"$') -@handle_request(r'^playid "(?P-1)"$') +@handle_request(r'^playid (?P-?\d+)$') +@handle_request(r'^playid "(?P-?\d+)"$') def playid(context, cpid): """ *musicpd.org, playback section:* diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 88452d3d..4f8f7430 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -287,6 +287,13 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assertInResponse(u'OK') + def test_playid_without_quotes(self): + self.backend.current_playlist.append([Track()]) + + self.sendRequest(u'playid 0') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') + def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) From 7fda9dc1981c4843ec755851c48bd0a023007316 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 00:28:46 +0200 Subject: [PATCH 175/177] MPD: Fix copy-paste error in docs --- mopidy/frontends/mpd/protocol/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 356196e6..4152f11e 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -161,11 +161,11 @@ def playpos(context, songpos): *Clarifications:* - - ``playid "-1"`` when playing is ignored. - - ``playid "-1"`` when paused resumes playback. - - ``playid "-1"`` when stopped with a current track starts playback at the + - ``play "-1"`` when playing is ignored. + - ``play "-1"`` when paused resumes playback. + - ``play "-1"`` when stopped with a current track starts playback at the current track. - - ``playid "-1"`` when stopped without a current track, e.g. after playlist + - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* From 7d4b605ee5780fca4be789a2922d64a2fd1bccfe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:01:38 +0200 Subject: [PATCH 176/177] Update version number to 0.8.0 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 11293446..26e5b904 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -8,7 +8,7 @@ from subprocess import PIPE, Popen import glib -__version__ = '0.7.3' +__version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') diff --git a/tests/version_test.py b/tests/version_test.py index 85b182f0..c3eb00c1 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -27,8 +27,9 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.6.1') < SV('0.7.0')) self.assert_(SV('0.7.0') < SV('0.7.1')) self.assert_(SV('0.7.1') < SV('0.7.2')) - self.assert_(SV('0.7.2') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.0')) + self.assert_(SV('0.7.2') < SV('0.7.3')) + self.assert_(SV('0.7.3') < SV(__version__)) + self.assert_(SV(__version__) < SV('0.8.1')) def test_get_platform_contains_platform(self): self.assertIn(platform.platform(), get_platform()) From 5d3a2fcba5dc79046d5fa2efd5c19b3c3f35173f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:04:41 +0200 Subject: [PATCH 177/177] Update changelog for v0.8.0 --- docs/changes.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5fde020c..bd90111e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,8 +4,13 @@ Changes This change log is used to track all major changes to Mopidy. -v0.8 (in development) -===================== + +v0.8.0 (2012-09-20) +=================== + +This release does not include any major new features. We've done a major +cleanup of how audio outputs and audio mixers work, and on the way we've +resolved a bunch of related issues. **Audio output and mixer changes**