From c376ac4183e290449f032fb0f7ea289386bdacbe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 22:58:13 +0200 Subject: [PATCH 01/60] audio: Start adding playlist typefinder code. This allows gstreamer pipelines to determine when they are getting m3u, pls or xspf files and distinguish them from text/plain content. --- mopidy/audio/actor.py | 3 ++- mopidy/audio/playlists.py | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 mopidy/audio/playlists.py diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index af0a0c68..a943b567 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -11,13 +11,14 @@ import pykka from mopidy.utils import process -from . import mixers, utils +from . import mixers, playlists, utils from .constants import PlaybackState from .listener import AudioListener logger = logging.getLogger('mopidy.audio') mixers.register_mixers() +playlists.register_typefinders() MB = 1 << 20 diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py new file mode 100644 index 00000000..5235d4dd --- /dev/null +++ b/mopidy/audio/playlists.py @@ -0,0 +1,52 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst + +import xml.dom.pulldom + + +# TODO: make detect_FOO_header reusable in general mopidy code. +# i.e. give it just a "peek" like function. +def detect_m3u_header(typefind): + return typefind.peek(0, 8) == b'#EXTM3U\n' + + +def detect_pls_header(typefind): + print repr(typefind.peek(0, 11) == b'[playlist]\n') + return typefind.peek(0, 11) == b'[playlist]\n' + + +def detect_xspf_header(typefind): + # Get more data than the 90 needed for header in case spacing is funny. + data = typefind.peek(0, 150) + + # Bail early if the words xml and playlist are not present. + if not data or b'xml' not in data or b'playlist' not in data: + return False + + # TODO: handle parser errors. + # Try parsing what we have, bailing on first element. + for event, node in xml.dom.pulldom.parseString(data): + if event == xml.dom.pulldom.START_ELEMENT: + return (node.tagName == 'playlist' and + node.node.namespaceURI == 'http://xspf.org/ns/0/') + return False + + +def playlist_typefinder(typefind, func, caps): + if func(typefind): + typefind.suggest(gst.TYPE_FIND_MAXIMUM, caps) + + +def register_typefind(mimetype, func, extensions): + caps = gst.caps_from_string(mimetype) + gst.type_find_register(mimetype, gst.RANK_PRIMARY, playlist_typefinder, + extensions, caps, func, caps) + + +def register_typefinders(): + register_typefind('audio/x-mpegurl', detect_m3u_header, [b'm3u', b'm3u8']) + register_typefind('audio/x-scpls', detect_pls_header, [b'pls']) + register_typefind('application/xspf+xml', detect_xspf_header, [b'xspf']) From 0f749702c48f74297370a7b87df3daac0754121f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 23:10:16 +0200 Subject: [PATCH 02/60] audio: Add simple parsers for m3u, pls, xspf and uri lists. These parsers, and the detectors should probably be moved out at some point, but for now the simple version of these will do. --- mopidy/audio/playlists.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 5235d4dd..b7821ed7 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -4,6 +4,8 @@ import pygst pygst.require('0.10') import gst +import ConfigParser as configparser +import xml.etree.ElementTree import xml.dom.pulldom @@ -35,6 +37,36 @@ def detect_xspf_header(typefind): return False +def parse_m3u(data): + # TODO: convert non uris to file uris. + for line in data.readlines(): + if not line.startswith('#') and line.strip(): + yield line + + +def parse_pls(data): + # TODO: error handling of bad playlists. + # TODO: convert non uris to file uris. + cp = configparser.RawConfigParser() + cp.readfp(data) + for i in xrange(1, cp.getint('playlist', 'numberofentries')): + yield cp.get('playlist', 'file%d' % i) + + +def parse_xspf(data): + # TODO: handle parser errors + root = xml.etree.ElementTree.fromstring(data.read()) + tracklist = tree.find('{http://xspf.org/ns/0/}trackList') + for track in tracklist.findall('{http://xspf.org/ns/0/}track'): + yield track.findtext('{http://xspf.org/ns/0/}location') + + +def parse_urilist(data): + for line in data.readlines(): + if not line.startswith('#') and line.strip(): + yield line + + def playlist_typefinder(typefind, func, caps): if func(typefind): typefind.suggest(gst.TYPE_FIND_MAXIMUM, caps) From 1138ff879358d1ffc83ce97dd15ef90dc92dc023 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 23:15:32 +0200 Subject: [PATCH 03/60] audui: Add BasePlaylistElement This element is the building block for the "decoders" that will convert the m3u, pls and xspf files to urilists and also the urilist player. --- mopidy/audio/playlists.py | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index b7821ed7..59877939 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -5,6 +5,7 @@ pygst.require('0.10') import gst import ConfigParser as configparser +import io import xml.etree.ElementTree import xml.dom.pulldom @@ -82,3 +83,49 @@ def register_typefinders(): register_typefind('audio/x-mpegurl', detect_m3u_header, [b'm3u', b'm3u8']) register_typefind('audio/x-scpls', detect_pls_header, [b'pls']) register_typefind('application/xspf+xml', detect_xspf_header, [b'xspf']) + + +class BasePlaylistElement(gst.Bin): + sinktemplate = None + srctemplate = None + ghostsrc = False + + def __init__(self): + super(BasePlaylistElement, self).__init__() + self._data = io.BytesIO() + self._done = False + + self.sink = gst.Pad(self.sinktemplate) + self.sink.set_chain_function(self._chain) + self.sink.set_event_function(self._event) + self.add_pad(self.sink) + + if self.ghostsrc: + self.src = gst.ghost_pad_new_notarget('src', gst.PAD_SRC) + else: + self.src = gst.Pad(self.srctemplate) + self.add_pad(self.src) + + def convert(self, data): + raise NotImplementedError + + def handle(self, uris): + self.src.push(gst.Buffer('\n'.join(uris))) + return False + + def _chain(self, pad, buf): + if not self._done: + self._data.write(buf.data) + return gst.FLOW_OK + return gst.FLOW_EOS + + def _event(self, pad, event): + if event.type == gst.EVENT_NEWSEGMENT: + return True + + if event.type == gst.EVENT_EOS: + self._done = True + self._data.seek(0) + if self.handle(list(self.convert(self._data))): + return True + return pad.event_default(event) From e2f9a3bad6a92f5d01e9de259cb01825b85973af Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 23:22:24 +0200 Subject: [PATCH 04/60] audio: Add playlist decoders. These elements convert their respective formats to an urilist that we can handle in a genric way. --- mopidy/audio/actor.py | 2 + mopidy/audio/playlists.py | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a943b567..c68a5417 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -18,7 +18,9 @@ from .listener import AudioListener logger = logging.getLogger('mopidy.audio') mixers.register_mixers() + playlists.register_typefinders() +playlists.register_elements() MB = 1 << 20 diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 59877939..4432887d 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import pygst pygst.require('0.10') import gst +import gobject import ConfigParser as configparser import io @@ -129,3 +130,82 @@ class BasePlaylistElement(gst.Bin): if self.handle(list(self.convert(self._data))): return True return pad.event_default(event) + + +class M3UDecoder(BasePlaylistElement): + __gstdetails__ = ('M3U Decoder', + 'Decoder', + 'Convert .m3u to text/uri-list', + 'Mopidy') + + sinktemplate = gst.PadTemplate ('sink', + gst.PAD_SINK, + gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-mpegurl')) + + srctemplate = gst.PadTemplate ('src', + gst.PAD_SRC, + gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinktemplate, srctemplate) + + def convert(self, data): + return parse_m3u(data) + + +class PLSDecoder(BasePlaylistElement): + __gstdetails__ = ('PLS Decoder', + 'Decoder', + 'Convert .pls to text/uri-list', + 'Mopidy') + + sinktemplate = gst.PadTemplate ('sink', + gst.PAD_SINK, + gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-scpls')) + + srctemplate = gst.PadTemplate ('src', + gst.PAD_SRC, + gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinktemplate, srctemplate) + + def convert(self, data): + return parse_pls(data) + + +class XSPFDecoder(BasePlaylistElement): + __gstdetails__ = ('XSPF Decoder', + 'Decoder', + 'Convert .pls to text/uri-list', + 'Mopidy') + + sinktemplate = gst.PadTemplate ('sink', + gst.PAD_SINK, + gst.PAD_ALWAYS, + gst.caps_from_string('application/xspf+xml')) + + srctemplate = gst.PadTemplate ('src', + gst.PAD_SRC, + gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinktemplate, srctemplate) + + def convert(self, data): + return parse_xspf(data) + + + +def register_element(element_class): + gobject.type_register(element_class) + gst.element_register( + element_class, element_class.__name__.lower(), gst.RANK_MARGINAL) + + +def register_elements(): + register_element(M3UDecoder) + register_element(PLSDecoder) + register_element(XSPFDecoder) From acbaab59e5c341eaa2e61bc18d317eedba2e66f8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 23:31:39 +0200 Subject: [PATCH 05/60] audio: A uri list element. This element "simply" takes the list of uris that our other elements have already converted to simpler format, picks the first uri and play it. This is done by ensuring that we block the right EOS messages, and all new segment messages from the original sources. With these events blocked we can inject our own nested uridecodebin to play the uri and push our own data. The nested uridecodebin is setup with caps('any') to ensure that we don't suddenly demux and end up with multiple streams by accident. --- mopidy/audio/playlists.py | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 4432887d..764ec785 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -198,6 +198,49 @@ class XSPFDecoder(BasePlaylistElement): return parse_xspf(data) +class UriListElement(BasePlaylistElement): + __gstdetails__ = ('URIListDemuxer', + 'Demuxer', + 'Convert a text/uri-list to a stream', + 'Mopidy') + + sinktemplate = gst.PadTemplate ('sink', + gst.PAD_SINK, + gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + srctemplate = gst.PadTemplate ('src', + gst.PAD_SRC, + gst.PAD_ALWAYS, + gst.caps_new_any()) + + ghostsrc = True # We need to hook this up to our internal decodebin + + __gsttemplates__ = (sinktemplate, srctemplate) + + def __init__(self): + super(UriListElement, self).__init__() + self.uridecodebin = gst.element_factory_make('uridecodebin') + self.uridecodebin.connect('pad-added', self.pad_added) + # Limit to anycaps so we get a single stream out, letting other + # elmenets downstream figure out actual muxing + self.uridecodebin.set_property('caps', gst.caps_new_any()) + + def pad_added(self, src, pad): + self.src.set_target(pad) + + def handle(self, uris): + # TODO: hookup about to finish and errors to rest of uris so we + # round robin, only giving up once all have been tried. + self.add(self.uridecodebin) + self.uridecodebin.set_state(gst.STATE_READY) + self.uridecodebin.set_property('uri', uris[0]) + self.uridecodebin.sync_state_with_parent() + return True # Make sure we consume the EOS that triggered us. + + def convert(self, data): + return parse_urilist(data) + def register_element(element_class): gobject.type_register(element_class) @@ -209,3 +252,4 @@ def register_elements(): register_element(M3UDecoder) register_element(PLSDecoder) register_element(XSPFDecoder) + register_element(UriListElement) From a2b95c3a3a92d398933baee932808a09400be272 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 23:54:23 +0200 Subject: [PATCH 06/60] audio: Cleanup playlists elements docs and interace. --- mopidy/audio/playlists.py | 93 +++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 764ec785..7c1a2d4c 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -40,7 +40,7 @@ def detect_xspf_header(typefind): def parse_m3u(data): - # TODO: convert non uris to file uris. + # TODO: convert non URIs to file URIs. for line in data.readlines(): if not line.startswith('#') and line.strip(): yield line @@ -48,7 +48,7 @@ def parse_m3u(data): def parse_pls(data): # TODO: error handling of bad playlists. - # TODO: convert non uris to file uris. + # TODO: convert non URIs to file URIs. cp = configparser.RawConfigParser() cp.readfp(data) for i in xrange(1, cp.getint('playlist', 'numberofentries')): @@ -87,31 +87,64 @@ def register_typefinders(): class BasePlaylistElement(gst.Bin): - sinktemplate = None - srctemplate = None - ghostsrc = False + """Base class for creating GStreamer elements for playlist support. + + This element performs the following steps: + + 1. Initializes src and sink pads for the element. + 2. Collects data from the sink until EOS is reached. + 3. Passes the collected data to :meth:`convert` to get a list of URIs. + 4. Passes the list of URIs to :meth:`handle`, default handling is to pass + the URIs to the src element as a uri-list. + 5. If handle returned true, the EOS consumed and nothing more happens, if + it is not consumed it flows on to the next element downstream, which is + likely our uri-list consumer which needs the EOS to know we are done + sending URIs. + """ + + sinkpad_template = None + """GStreamer pad template to use for sink, must be overriden.""" + + srcpad_template = None + """GStreamer pad template to use for src, must be overriden.""" + + ghost_srcpad = False + """Indicates if src pad should be ghosted or not.""" def __init__(self): + """Sets up src and sink pads plus behaviour.""" super(BasePlaylistElement, self).__init__() self._data = io.BytesIO() self._done = False - self.sink = gst.Pad(self.sinktemplate) - self.sink.set_chain_function(self._chain) - self.sink.set_event_function(self._event) - self.add_pad(self.sink) + self.sinkpad = gst.Pad(self.sinkpad_template) + self.sinkpad.set_chain_function(self._chain) + self.sinkpad.set_event_function(self._event) + self.add_pad(self.sinkpad) - if self.ghostsrc: - self.src = gst.ghost_pad_new_notarget('src', gst.PAD_SRC) + if self.ghost_srcpad: + self.srcpad = gst.ghost_pad_new_notarget('src', gst.PAD_SRC) else: - self.src = gst.Pad(self.srctemplate) - self.add_pad(self.src) + self.srcpad = gst.Pad(self.srcpad_template) + self.add_pad(self.srcpad) def convert(self, data): + """Convert the data we have colleted to URIs. + + :param data: Collected data buffer. + :type data: :class:`io.BytesIO` + :returns: iterable or generator of URIs. + """ raise NotImplementedError def handle(self, uris): - self.src.push(gst.Buffer('\n'.join(uris))) + """Do something usefull with the URIs. + + :param uris: List of URIs. + :type uris: :type:`list` + :returns: Boolean indicating if EOS should be consumed. + """ + self.srcpad.push(gst.Buffer('\n'.join(uris))) return False def _chain(self, pad, buf): @@ -129,6 +162,8 @@ class BasePlaylistElement(gst.Bin): self._data.seek(0) if self.handle(list(self.convert(self._data))): return True + + # Ensure we handle remaining events in a sane way. return pad.event_default(event) @@ -138,17 +173,17 @@ class M3UDecoder(BasePlaylistElement): 'Convert .m3u to text/uri-list', 'Mopidy') - sinktemplate = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate ('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-mpegurl')) - srctemplate = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate ('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) - __gsttemplates__ = (sinktemplate, srctemplate) + __gsttemplates__ = (sinkpad_template, srcpad_template) def convert(self, data): return parse_m3u(data) @@ -160,17 +195,17 @@ class PLSDecoder(BasePlaylistElement): 'Convert .pls to text/uri-list', 'Mopidy') - sinktemplate = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate ('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-scpls')) - srctemplate = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate ('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) - __gsttemplates__ = (sinktemplate, srctemplate) + __gsttemplates__ = (sinkpad_template, srcpad_template) def convert(self, data): return parse_pls(data) @@ -182,17 +217,17 @@ class XSPFDecoder(BasePlaylistElement): 'Convert .pls to text/uri-list', 'Mopidy') - sinktemplate = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate ('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('application/xspf+xml')) - srctemplate = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate ('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) - __gsttemplates__ = (sinktemplate, srctemplate) + __gsttemplates__ = (sinkpad_template, srcpad_template) def convert(self, data): return parse_xspf(data) @@ -204,19 +239,19 @@ class UriListElement(BasePlaylistElement): 'Convert a text/uri-list to a stream', 'Mopidy') - sinktemplate = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate ('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) - srctemplate = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate ('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_new_any()) - ghostsrc = True # We need to hook this up to our internal decodebin + ghost_srcpad = True # We need to hook this up to our internal decodebin - __gsttemplates__ = (sinktemplate, srctemplate) + __gsttemplates__ = (sinkpad_template, srcpad_template) def __init__(self): super(UriListElement, self).__init__() @@ -227,10 +262,10 @@ class UriListElement(BasePlaylistElement): self.uridecodebin.set_property('caps', gst.caps_new_any()) def pad_added(self, src, pad): - self.src.set_target(pad) + self.srcpad.set_target(pad) def handle(self, uris): - # TODO: hookup about to finish and errors to rest of uris so we + # TODO: hookup about to finish and errors to rest of URIs so we # round robin, only giving up once all have been tried. self.add(self.uridecodebin) self.uridecodebin.set_state(gst.STATE_READY) From a112275c25734dfe3f260b00f92628edbae9471e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 29 May 2013 00:18:23 +0200 Subject: [PATCH 07/60] audio: Add IcySrc. Quick hack that wraps a regular HTTP src in a custom bin that converts icy:// to http:// - this is needed to get for instance http://somafm.com/m3u/groovesalad.m3u to work. --- mopidy/audio/playlists.py | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 7c1a2d4c..ea7ff042 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -277,6 +277,55 @@ class UriListElement(BasePlaylistElement): return parse_urilist(data) +class IcySrc(gst.Bin, gst.URIHandler): + __gstdetails__ = ('IcySrc', + 'Src', + 'Http src wrapper for icy:// support.', + 'Mopidy') + + srcpad_template = gst.PadTemplate ('src', + gst.PAD_SRC, + gst.PAD_ALWAYS, + gst.caps_new_any()) + + __gsttemplates__ = (srcpad_template,) + + def __init__(self): + super(IcySrc, self).__init__() + self._httpsrc = gst.element_make_from_uri(gst.URI_SRC, 'http://') + try: + self._httpsrc.set_property('iradio-mode', True) + except TypeError: + pass + self.add(self._httpsrc) + + self._srcpad = gst.GhostPad('src', self._httpsrc.get_pad('src')) + self.add_pad(self._srcpad) + + @classmethod + def do_get_type_full(cls): + return gst.URI_SRC + + @classmethod + def do_get_protocols_full(cls): + return [b'icy', b'icyx'] + + def do_set_uri(self, uri): + if uri.startswith('icy://'): + return self._httpsrc.set_uri(b'http://' + uri[len('icy://'):]) + elif uri.startswith('icyx://'): + return self._httpsrc.set_uri(b'https://' + uri[len('icyx://'):]) + else: + return False + + def do_get_uri(self): + uri = self._httpsrc.get_uri() + if uri.startswith('http://'): + return b'icy://' + uri[len('http://'):] + else: + return b'icyx://' + uri[len('https://'):] + + def register_element(element_class): gobject.type_register(element_class) gst.element_register( @@ -288,3 +337,4 @@ def register_elements(): register_element(PLSDecoder) register_element(XSPFDecoder) register_element(UriListElement) + register_element(IcySrc) From d3f97c128c0fbf6d83bc99c4f3513542d47dcb91 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 2 Jun 2013 16:03:29 +0200 Subject: [PATCH 08/60] audio: Review cleanups --- mopidy/audio/playlists.py | 47 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index ea7ff042..71c76cef 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -18,7 +18,6 @@ def detect_m3u_header(typefind): def detect_pls_header(typefind): - print repr(typefind.peek(0, 11) == b'[playlist]\n') return typefind.peek(0, 11) == b'[playlist]\n' @@ -65,7 +64,7 @@ def parse_xspf(data): def parse_urilist(data): for line in data.readlines(): - if not line.startswith('#') and line.strip(): + if not line.startswith('#') and gst.uri_is_valid(line.strip()): yield line @@ -131,18 +130,18 @@ class BasePlaylistElement(gst.Bin): def convert(self, data): """Convert the data we have colleted to URIs. - :param data: Collected data buffer. + :param data: collected data buffer :type data: :class:`io.BytesIO` - :returns: iterable or generator of URIs. + :returns: iterable or generator of URIs """ raise NotImplementedError def handle(self, uris): - """Do something usefull with the URIs. + """Do something useful with the URIs. - :param uris: List of URIs. + :param uris: list of URIs :type uris: :type:`list` - :returns: Boolean indicating if EOS should be consumed. + :returns: boolean indicating if EOS should be consumed """ self.srcpad.push(gst.Buffer('\n'.join(uris))) return False @@ -167,18 +166,18 @@ class BasePlaylistElement(gst.Bin): return pad.event_default(event) -class M3UDecoder(BasePlaylistElement): +class M3uDecoder(BasePlaylistElement): __gstdetails__ = ('M3U Decoder', 'Decoder', 'Convert .m3u to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-mpegurl')) - srcpad_template = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) @@ -189,18 +188,18 @@ class M3UDecoder(BasePlaylistElement): return parse_m3u(data) -class PLSDecoder(BasePlaylistElement): +class PlsDecoder(BasePlaylistElement): __gstdetails__ = ('PLS Decoder', 'Decoder', 'Convert .pls to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-scpls')) - srcpad_template = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) @@ -211,18 +210,18 @@ class PLSDecoder(BasePlaylistElement): return parse_pls(data) -class XSPFDecoder(BasePlaylistElement): +class XspfDecoder(BasePlaylistElement): __gstdetails__ = ('XSPF Decoder', 'Decoder', 'Convert .pls to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('application/xspf+xml')) - srcpad_template = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) @@ -239,12 +238,12 @@ class UriListElement(BasePlaylistElement): 'Convert a text/uri-list to a stream', 'Mopidy') - sinkpad_template = gst.PadTemplate ('sink', + sinkpad_template = gst.PadTemplate('sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) - srcpad_template = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_new_any()) @@ -258,7 +257,7 @@ class UriListElement(BasePlaylistElement): self.uridecodebin = gst.element_factory_make('uridecodebin') self.uridecodebin.connect('pad-added', self.pad_added) # Limit to anycaps so we get a single stream out, letting other - # elmenets downstream figure out actual muxing + # elements downstream figure out actual muxing self.uridecodebin.set_property('caps', gst.caps_new_any()) def pad_added(self, src, pad): @@ -280,10 +279,10 @@ class UriListElement(BasePlaylistElement): class IcySrc(gst.Bin, gst.URIHandler): __gstdetails__ = ('IcySrc', 'Src', - 'Http src wrapper for icy:// support.', + 'HTTP src wrapper for icy:// support.', 'Mopidy') - srcpad_template = gst.PadTemplate ('src', + srcpad_template = gst.PadTemplate('src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_new_any()) @@ -333,8 +332,8 @@ def register_element(element_class): def register_elements(): - register_element(M3UDecoder) - register_element(PLSDecoder) - register_element(XSPFDecoder) + register_element(M3uDecoder) + register_element(PlsDecoder) + register_element(XspfDecoder) register_element(UriListElement) register_element(IcySrc) From f67aa95c2ec7ccce3bd9398d8a7a0cd2324f922c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 26 Jun 2013 22:29:13 +0200 Subject: [PATCH 09/60] audio: add basic .asx support --- mopidy/audio/playlists.py | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 71c76cef..ca807ed9 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -34,7 +34,22 @@ def detect_xspf_header(typefind): for event, node in xml.dom.pulldom.parseString(data): if event == xml.dom.pulldom.START_ELEMENT: return (node.tagName == 'playlist' and - node.node.namespaceURI == 'http://xspf.org/ns/0/') + node.namespaceURI == 'http://xspf.org/ns/0/') + return False + + +def detect_asx_header(typefind): + data = typefind.peek(0, 50) + + # Bail early if the words xml and playlist are not present. + if not data or b'asx' not in data: + return False + + # TODO: handle parser errors. + # Try parsing what we have, bailing on first element. + for event, node in xml.dom.pulldom.parseString(data): + if event == xml.dom.pulldom.START_ELEMENT: + return node.tagName == 'asx' return False @@ -56,12 +71,21 @@ def parse_pls(data): def parse_xspf(data): # TODO: handle parser errors + # TODO: make sure tracklist == trackList etc. root = xml.etree.ElementTree.fromstring(data.read()) tracklist = tree.find('{http://xspf.org/ns/0/}trackList') for track in tracklist.findall('{http://xspf.org/ns/0/}track'): yield track.findtext('{http://xspf.org/ns/0/}location') +def parse_asx(data): + # TODO: handle parser errors + # TODO: make sure entry == Entry etc. + root = xml.etree.ElementTree.fromstring(data.read()) + for entry in root.findall('entry'): + yield entry.find('ref').attrib['href'] + + def parse_urilist(data): for line in data.readlines(): if not line.startswith('#') and gst.uri_is_valid(line.strip()): @@ -83,6 +107,9 @@ def register_typefinders(): register_typefind('audio/x-mpegurl', detect_m3u_header, [b'm3u', b'm3u8']) register_typefind('audio/x-scpls', detect_pls_header, [b'pls']) register_typefind('application/xspf+xml', detect_xspf_header, [b'xspf']) + # NOTE: seems we can't use video/x-ms-asf which is the correct mime for asx + # as it is shared with asf for streaming videos :/ + register_typefind('audio/x-ms-asx', detect_asx_header, [b'asx']) class BasePlaylistElement(gst.Bin): @@ -232,6 +259,28 @@ class XspfDecoder(BasePlaylistElement): return parse_xspf(data) +class AsxDecoder(BasePlaylistElement): + __gstdetails__ = ('ASX Decoder', + 'Decoder', + 'Convert .asx to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate('sink', + gst.PAD_SINK, + gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-ms-asx')) + + srcpad_template = gst.PadTemplate('src', + gst.PAD_SRC, + gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_asx(data) + + class UriListElement(BasePlaylistElement): __gstdetails__ = ('URIListDemuxer', 'Demuxer', @@ -266,6 +315,7 @@ class UriListElement(BasePlaylistElement): def handle(self, uris): # TODO: hookup about to finish and errors to rest of URIs so we # round robin, only giving up once all have been tried. + # TODO: uris could be empty. self.add(self.uridecodebin) self.uridecodebin.set_state(gst.STATE_READY) self.uridecodebin.set_property('uri', uris[0]) @@ -335,5 +385,7 @@ def register_elements(): register_element(M3uDecoder) register_element(PlsDecoder) register_element(XspfDecoder) + register_element(AsxDecoder) register_element(UriListElement) + # TODO: only register if nothing can handle icy scheme register_element(IcySrc) From 0bcb805cf52f324e195ff62c7935ff5e57793cf7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 26 Jun 2013 23:31:53 +0200 Subject: [PATCH 10/60] audio: Improve xml playlist handling --- mopidy/audio/playlists.py | 63 ++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index ca807ed9..aa02d112 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -7,8 +7,11 @@ import gobject import ConfigParser as configparser import io -import xml.etree.ElementTree -import xml.dom.pulldom + +try: + import xml.etree.cElementTree as elementtree +except ImportError: + import xml.etree.ElementTree as elementtree # TODO: make detect_FOO_header reusable in general mopidy code. @@ -23,33 +26,22 @@ def detect_pls_header(typefind): def detect_xspf_header(typefind): # Get more data than the 90 needed for header in case spacing is funny. - data = typefind.peek(0, 150) - - # Bail early if the words xml and playlist are not present. - if not data or b'xml' not in data or b'playlist' not in data: - return False - - # TODO: handle parser errors. - # Try parsing what we have, bailing on first element. - for event, node in xml.dom.pulldom.parseString(data): - if event == xml.dom.pulldom.START_ELEMENT: - return (node.tagName == 'playlist' and - node.namespaceURI == 'http://xspf.org/ns/0/') + data = io.BytesIO(typefind.peek(0, 150)) + try: + for event, element in elementtree.iterparse(data, events=('start',)): + return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' + except elementtree.ParseError: + pass return False def detect_asx_header(typefind): - data = typefind.peek(0, 50) - - # Bail early if the words xml and playlist are not present. - if not data or b'asx' not in data: - return False - - # TODO: handle parser errors. - # Try parsing what we have, bailing on first element. - for event, node in xml.dom.pulldom.parseString(data): - if event == xml.dom.pulldom.START_ELEMENT: - return node.tagName == 'asx' + data = io.BytesIO(typefind.peek(0, 50)) + try: + for event, element in elementtree.iterparse(data, events=('start',)): + return element.tag.lower() == 'asx' + except elementtree.ParseError: + pass return False @@ -71,19 +63,21 @@ def parse_pls(data): def parse_xspf(data): # TODO: handle parser errors - # TODO: make sure tracklist == trackList etc. - root = xml.etree.ElementTree.fromstring(data.read()) - tracklist = tree.find('{http://xspf.org/ns/0/}trackList') - for track in tracklist.findall('{http://xspf.org/ns/0/}track'): - yield track.findtext('{http://xspf.org/ns/0/}location') + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + + ns = 'http://xspf.org/ns/0/' + for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): + yield track.findtext('{%s}location' % ns) def parse_asx(data): # TODO: handle parser errors - # TODO: make sure entry == Entry etc. - root = xml.etree.ElementTree.fromstring(data.read()) - for entry in root.findall('entry'): - yield entry.find('ref').attrib['href'] + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + + for ref in element.findall('entry/ref'): + yield ref.get('href', '').strip() def parse_urilist(data): @@ -170,6 +164,7 @@ class BasePlaylistElement(gst.Bin): :type uris: :type:`list` :returns: boolean indicating if EOS should be consumed """ + # TODO: handle unicode uris which we can get out of elementtree self.srcpad.push(gst.Buffer('\n'.join(uris))) return False From f3051c9dd32c8600a5ceee5386e378d70cc1073e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 26 Jun 2013 23:34:55 +0200 Subject: [PATCH 11/60] audio: Only install icysrc when nothing is already supporting the scheme --- mopidy/audio/playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index aa02d112..17b94b97 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -382,5 +382,5 @@ def register_elements(): register_element(XspfDecoder) register_element(AsxDecoder) register_element(UriListElement) - # TODO: only register if nothing can handle icy scheme - register_element(IcySrc) + if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'): + register_element(IcySrc) From 6e942a92b3a74d845f55b30e3e6a1b452d6a5b00 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 4 Jul 2013 17:49:10 +0200 Subject: [PATCH 12/60] audio: Post an error if an urilist expands to another urilist --- mopidy/audio/playlists.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 17b94b97..011326ee 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -306,8 +306,21 @@ class UriListElement(BasePlaylistElement): def pad_added(self, src, pad): self.srcpad.set_target(pad) + pad.add_event_probe(self.pad_event) + + def pad_event(self, pad, event): + if event.has_name('urilist-played'): + error = gst.GError(gst.RESOURCE_ERROR, gst.RESOURCE_ERROR_FAILED, + b'Nested playlists not supported.') + message = gst.message_new_error(self, error, b'Playlists pointing to other playlists is not supported') + self.post_message(message) + return True def handle(self, uris): + struct = gst.Structure('urilist-played') + event = gst.event_new_custom(gst.EVENT_CUSTOM_UPSTREAM, struct) + self.sinkpad.push_event(event) + # TODO: hookup about to finish and errors to rest of URIs so we # round robin, only giving up once all have been tried. # TODO: uris could be empty. From 7256f706ebd84273248eb5c43f728a850dc058c1 Mon Sep 17 00:00:00 2001 From: Terje Larsen Date: Fri, 2 Aug 2013 10:00:48 +0200 Subject: [PATCH 13/60] Update index.rst python2-pylast has been moved into the community packages: https://www.archlinux.org/packages/community/any/python2-pylast/ --- docs/installation/index.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index ae8f6b01..72035f56 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -87,8 +87,9 @@ Mopidy Git repo, which always corresponds to the latest release. To upgrade Mopidy to future releases, just rerun ``makepkg``. #. Optional: If you want to scrobble your played tracks to Last.fm, you need to - install `python2-pylast - `_ from AUR. + install `python2-pylast`:: + + sudo pacman -S python2-pylast #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. From 390194afc338af1da107e7f03d1d9a5c9476214b Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 17 Sep 2013 12:33:27 +0200 Subject: [PATCH 14/60] Adding the possibility to mute at app level mopidy --- mopidy/audio/actor.py | 6 ++++++ mopidy/core/playback.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6a1d7f6b..4fc4b91b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -537,6 +537,12 @@ class Audio(pykka.ThreadingActor): return self._mixer.get_volume(self._mixer_track) == volumes + def get_mute(self): + return self._playbin.get_property('mute') + + def set_mute(self, status): + self._playbin.set_property('mute', bool(status)) + def _rescale(self, value, old=None, new=None): """Convert value between scales.""" new_min, new_max = new diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ea849dbf..69195bad 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -24,6 +24,7 @@ class PlaybackController(object): self._shuffled = [] self._first_shuffle = True self._volume = None + self._mute = None def _get_backend(self): if self.current_tl_track is None: @@ -288,6 +289,22 @@ class PlaybackController(object): volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" + def get_mute(self): + if self.audio: + return self.audio.get_mute().get() + else: + return self._mute + + def set_mute(self, value): + value = bool(value) + if self.audio: + self.audio.set_mute(value) + else: + self._mute = value + + mute = property(get_mute, set_mute) + """Let the audio get muted, maintaining previous volume""" + ### Methods def change_track(self, tl_track, on_error_step=1): From 47635d5cfecb16f5df0fef8e02706bd2f0a8d6dd Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 17 Sep 2013 12:34:24 +0200 Subject: [PATCH 15/60] Adding mpd frontend capabilities for audio-mute --- mopidy/frontends/mpd/protocol/audio_output.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 01982a71..03289cd3 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -13,7 +13,8 @@ def disableoutput(context, outputid): Turns an output off. """ - raise MpdNotImplemented # TODO + if int(outputid) == 0: + context.core.playback.set_mute(True) @handle_request(r'^enableoutput "(?P\d+)"$') @@ -25,7 +26,8 @@ def enableoutput(context, outputid): Turns an output on. """ - raise MpdNotImplemented # TODO + if int(outputid) == 0: + context.core.playback.set_mute(False) @handle_request(r'^outputs$') @@ -37,8 +39,9 @@ def outputs(context): Shows information about all outputs. """ + ena = 0 if context.core.playback.get_mute().get() else 1 return [ ('outputid', 0), ('outputname', 'Default'), - ('outputenabled', 1), + ('outputenabled', ena), ] From de86274cea97523126a84b92d1879bb3593a8993 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 13:58:05 +0200 Subject: [PATCH 16/60] readme: Add crate.io sheilds to readme. --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index c9db495e..a34b1bb6 100644 --- a/README.rst +++ b/README.rst @@ -27,3 +27,11 @@ To get started with Mopidy, check out `the docs `_. .. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop :target: https://travis-ci.org/mopidy/mopidy + +.. image:: https://pypip.in/v/Mopidy/badge.png + :target: https://crate.io/packages/Mopidy/ + :alt: Latest PyPI version + +.. image:: https://pypip.in/d/Mopidy/badge.png + :target: https://crate.io/packages/Mopidy/ + :alt: Number of PyPI downloads From 5bb2c4e590eadfe32cad009f5bb1ea995e69fa95 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 6 Oct 2013 14:18:01 +0200 Subject: [PATCH 17/60] readme: Add Coveralls test coverage shield --- README.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index a34b1bb6..515fa3ba 100644 --- a/README.rst +++ b/README.rst @@ -25,9 +25,6 @@ To get started with Mopidy, check out `the docs `_. - Mailing list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ -.. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop - :target: https://travis-ci.org/mopidy/mopidy - .. image:: https://pypip.in/v/Mopidy/badge.png :target: https://crate.io/packages/Mopidy/ :alt: Latest PyPI version @@ -35,3 +32,11 @@ To get started with Mopidy, check out `the docs `_. .. image:: https://pypip.in/d/Mopidy/badge.png :target: https://crate.io/packages/Mopidy/ :alt: Number of PyPI downloads + +.. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop + :target: https://travis-ci.org/mopidy/mopidy + :alt: Travis CI build status + +.. image:: https://coveralls.io/repos/mopidy/mopidy/badge.png?branch=develop + :target: https://coveralls.io/r/mopidy/mopidy?branch=develop + :alt: Test coverage From 9e682d9248a532121491c67f7691c94cb97371d5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 14:18:53 +0200 Subject: [PATCH 18/60] audio: Check for asx/xspf in data before parsing during detection. --- mopidy/audio/playlists.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 011326ee..9f56ea27 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -25,8 +25,10 @@ def detect_pls_header(typefind): def detect_xspf_header(typefind): - # Get more data than the 90 needed for header in case spacing is funny. - data = io.BytesIO(typefind.peek(0, 150)) + data = typefind.peek(0, 150) + if b'xspf' not in data: + return False + try: for event, element in elementtree.iterparse(data, events=('start',)): return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' @@ -36,7 +38,10 @@ def detect_xspf_header(typefind): def detect_asx_header(typefind): - data = io.BytesIO(typefind.peek(0, 50)) + data = typefind.peek(0, 50) + if b'asx' not in data: + return False + try: for event, element in elementtree.iterparse(data, events=('start',)): return element.tag.lower() == 'asx' From 4a944332370d167c797866b3272c6fc0d2a7d6d4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 14:19:28 +0200 Subject: [PATCH 19/60] audio: Wrap long line, and explain conidtional instalation of icy element. --- mopidy/audio/playlists.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 9f56ea27..6f4fdd46 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -317,8 +317,8 @@ class UriListElement(BasePlaylistElement): if event.has_name('urilist-played'): error = gst.GError(gst.RESOURCE_ERROR, gst.RESOURCE_ERROR_FAILED, b'Nested playlists not supported.') - message = gst.message_new_error(self, error, b'Playlists pointing to other playlists is not supported') - self.post_message(message) + message = b'Playlists pointing to other playlists is not supported' + self.post_message(gst.message_new_error(self, error, message)) return True def handle(self, uris): @@ -400,5 +400,7 @@ def register_elements(): register_element(XspfDecoder) register_element(AsxDecoder) register_element(UriListElement) + + # Only register icy if gst install can't handle it on it's own. if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'): register_element(IcySrc) From b1b522694e1c36da23feee5fb1e322a10e880201 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 15:22:17 +0200 Subject: [PATCH 20/60] audio: Fix lots of playlist issues and add tests. --- mopidy/audio/playlists.py | 45 ++++++++---- tests/audio/playlists_test.py | 127 ++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 tests/audio/playlists_test.py diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 6f4fdd46..096348a0 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -21,7 +21,7 @@ def detect_m3u_header(typefind): def detect_pls_header(typefind): - return typefind.peek(0, 11) == b'[playlist]\n' + return typefind.peek(0, 11).lower() == b'[playlist]\n' def detect_xspf_header(typefind): @@ -30,7 +30,8 @@ def detect_xspf_header(typefind): return False try: - for event, element in elementtree.iterparse(data, events=('start',)): + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' except elementtree.ParseError: pass @@ -43,7 +44,8 @@ def detect_asx_header(typefind): return False try: - for event, element in elementtree.iterparse(data, events=('start',)): + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): return element.tag.lower() == 'asx' except elementtree.ParseError: pass @@ -52,24 +54,38 @@ def detect_asx_header(typefind): def parse_m3u(data): # TODO: convert non URIs to file URIs. + found_header = False for line in data.readlines(): + if found_header or line.startswith('#EXTM3U'): + found_header = True + else: + continue if not line.startswith('#') and line.strip(): - yield line + yield line.strip() def parse_pls(data): - # TODO: error handling of bad playlists. # TODO: convert non URIs to file URIs. - cp = configparser.RawConfigParser() - cp.readfp(data) - for i in xrange(1, cp.getint('playlist', 'numberofentries')): - yield cp.get('playlist', 'file%d' % i) + try: + cp = configparser.RawConfigParser() + cp.readfp(data) + except configparser.Error: + return + + for section in cp.sections(): + if section.lower() != 'playlist': + continue + for i in xrange(cp.getint(section, 'numberofentries')): + yield cp.get(section, 'file%d' % (i+1)) def parse_xspf(data): # TODO: handle parser errors - for event, element in elementtree.iterparse(data): - element.tag = element.tag.lower() # normalize + try: + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return ns = 'http://xspf.org/ns/0/' for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): @@ -78,8 +94,11 @@ def parse_xspf(data): def parse_asx(data): # TODO: handle parser errors - for event, element in elementtree.iterparse(data): - element.tag = element.tag.lower() # normalize + try: + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return for ref in element.findall('entry/ref'): yield ref.get('href', '').strip() diff --git a/tests/audio/playlists_test.py b/tests/audio/playlists_test.py new file mode 100644 index 00000000..9f28527e --- /dev/null +++ b/tests/audio/playlists_test.py @@ -0,0 +1,127 @@ +#encoding: utf-8 + +from __future__ import unicode_literals + +import io +import unittest + +from mopidy.audio import playlists + +class TypeFind(object): + def __init__(self, data): + self.data = data + + def peek(self, start, end): + return self.data[start:end] + + +BAD = b'foobarbaz' + +M3U = b"""#EXTM3U +#EXTINF:123, Sample artist - Sample title +file:///tmp/foo +#EXTINF:321,Example Artist - Example title +file:///tmp/bar +#EXTINF:213,Some Artist - Other title +file:///tmp/baz +""" + +PLS = b"""[Playlist] +NumberOfEntries=3 +File1=file:///tmp/foo +Title1=Sample Title +Length1=123 +File2=file:///tmp/bar +Title2=Example title +Length2=321 +File3=file:///tmp/baz +Title3=Other title +Length3=213 +Version=2 +""" + +ASX = b""" + Example + + Sample Title + + + + Example title + + + + Other title + + + +""" + +XSPF = b""" + + + + Sample Title + file:///tmp/foo + + + Example title + file:///tmp/bar + + + Other title + file:///tmp/baz + + + +""" + + +class BasePlaylistTest(object): + valid = None + invalid = None + detect = None + parse = None + + def test_detect_valid_header(self): + self.assertTrue(self.detect(TypeFind(self.valid))) + + def test_detect_invalid_header(self): + self.assertFalse(self.detect(TypeFind(self.invalid))) + + def test_parse_valid_playlist(self): + uris = list(self.parse(io.BytesIO(self.valid))) + expected = [b'file:///tmp/foo', b'file:///tmp/bar', b'file:///tmp/baz'] + self.assertEqual(uris, expected) + + def test_parse_invalid_playlist(self): + uris = list(self.parse(io.BytesIO(self.invalid))) + self.assertEqual(uris, []) + + +class M3uPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = M3U + invalid = BAD + detect = staticmethod(playlists.detect_m3u_header) + parse = staticmethod(playlists.parse_m3u) + + +class PlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = PLS + invalid = BAD + detect = staticmethod(playlists.detect_pls_header) + parse = staticmethod(playlists.parse_pls) + + +class AsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = ASX + invalid = BAD + detect = staticmethod(playlists.detect_asx_header) + parse = staticmethod(playlists.parse_asx) + + +class XspfPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = XSPF + invalid = BAD + detect = staticmethod(playlists.detect_xspf_header) + parse = staticmethod(playlists.parse_xspf) From a5e26f9b54b1f655f17f4dc671bf438a85536e93 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 16:57:32 +0200 Subject: [PATCH 21/60] audio: flake8 fixes --- mopidy/audio/playlists.py | 57 ++++++++++++++--------------------- tests/audio/playlists_test.py | 15 ++++----- 2 files changed, 31 insertions(+), 41 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 096348a0..2f309e30 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -218,14 +218,12 @@ class M3uDecoder(BasePlaylistElement): 'Convert .m3u to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate('sink', - gst.PAD_SINK, - gst.PAD_ALWAYS, + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-mpegurl')) - srcpad_template = gst.PadTemplate('src', - gst.PAD_SRC, - gst.PAD_ALWAYS, + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) __gsttemplates__ = (sinkpad_template, srcpad_template) @@ -240,14 +238,12 @@ class PlsDecoder(BasePlaylistElement): 'Convert .pls to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate('sink', - gst.PAD_SINK, - gst.PAD_ALWAYS, + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-scpls')) - srcpad_template = gst.PadTemplate('src', - gst.PAD_SRC, - gst.PAD_ALWAYS, + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) __gsttemplates__ = (sinkpad_template, srcpad_template) @@ -262,14 +258,12 @@ class XspfDecoder(BasePlaylistElement): 'Convert .pls to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate('sink', - gst.PAD_SINK, - gst.PAD_ALWAYS, + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('application/xspf+xml')) - srcpad_template = gst.PadTemplate('src', - gst.PAD_SRC, - gst.PAD_ALWAYS, + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) __gsttemplates__ = (sinkpad_template, srcpad_template) @@ -284,14 +278,12 @@ class AsxDecoder(BasePlaylistElement): 'Convert .asx to text/uri-list', 'Mopidy') - sinkpad_template = gst.PadTemplate('sink', - gst.PAD_SINK, - gst.PAD_ALWAYS, + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('audio/x-ms-asx')) - srcpad_template = gst.PadTemplate('src', - gst.PAD_SRC, - gst.PAD_ALWAYS, + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) __gsttemplates__ = (sinkpad_template, srcpad_template) @@ -306,14 +298,12 @@ class UriListElement(BasePlaylistElement): 'Convert a text/uri-list to a stream', 'Mopidy') - sinkpad_template = gst.PadTemplate('sink', - gst.PAD_SINK, - gst.PAD_ALWAYS, + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, gst.caps_from_string('text/uri-list')) - srcpad_template = gst.PadTemplate('src', - gst.PAD_SRC, - gst.PAD_ALWAYS, + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_new_any()) ghost_srcpad = True # We need to hook this up to our internal decodebin @@ -338,7 +328,7 @@ class UriListElement(BasePlaylistElement): b'Nested playlists not supported.') message = b'Playlists pointing to other playlists is not supported' self.post_message(gst.message_new_error(self, error, message)) - return True + return 1 # GST_PAD_PROBE_OK def handle(self, uris): struct = gst.Structure('urilist-played') @@ -364,9 +354,8 @@ class IcySrc(gst.Bin, gst.URIHandler): 'HTTP src wrapper for icy:// support.', 'Mopidy') - srcpad_template = gst.PadTemplate('src', - gst.PAD_SRC, - gst.PAD_ALWAYS, + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, gst.caps_new_any()) __gsttemplates__ = (srcpad_template,) diff --git a/tests/audio/playlists_test.py b/tests/audio/playlists_test.py index 9f28527e..0f031736 100644 --- a/tests/audio/playlists_test.py +++ b/tests/audio/playlists_test.py @@ -7,13 +7,6 @@ import unittest from mopidy.audio import playlists -class TypeFind(object): - def __init__(self, data): - self.data = data - - def peek(self, start, end): - return self.data[start:end] - BAD = b'foobarbaz' @@ -77,6 +70,14 @@ XSPF = b""" """ +class TypeFind(object): + def __init__(self, data): + self.data = data + + def peek(self, start, end): + return self.data[start:end] + + class BasePlaylistTest(object): valid = None invalid = None From c235f7259a4065111f3125bd7b11a3a627c696a4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 19:56:03 +0200 Subject: [PATCH 22/60] audo: Remove stale TODOs --- mopidy/audio/playlists.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 2f309e30..e3f51e41 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -80,7 +80,6 @@ def parse_pls(data): def parse_xspf(data): - # TODO: handle parser errors try: for event, element in elementtree.iterparse(data): element.tag = element.tag.lower() # normalize @@ -93,7 +92,6 @@ def parse_xspf(data): def parse_asx(data): - # TODO: handle parser errors try: for event, element in elementtree.iterparse(data): element.tag = element.tag.lower() # normalize From 5246ab6bf01af8d5b838d18fd18d222549179f49 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 6 Oct 2013 20:38:52 +0200 Subject: [PATCH 23/60] travis: Send test coverage data to coveralls.io --- .travis.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0b68eb8f..b53a8734 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,14 +5,17 @@ install: - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" - - "pip install flake8" + - "pip install coveralls flake8" before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" script: - "flake8 $(find . -iname '*.py')" - - "nosetests" + - "nosetests --with-coverage --coverage-package=mopidy" + +after_success: + - "coveralls" notifications: irc: From 6f3597d6a9a8531f371caa281cf5882d49a1a8ea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 6 Oct 2013 20:41:09 +0200 Subject: [PATCH 24/60] travis: Fix nosetests argument --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b53a8734..b793e530 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ before_script: script: - "flake8 $(find . -iname '*.py')" - - "nosetests --with-coverage --coverage-package=mopidy" + - "nosetests --with-coverage --cover-package=mopidy" after_success: - "coveralls" From 36a0a649411ae2c43ad33a1beb08759e087885b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 6 Oct 2013 20:49:11 +0200 Subject: [PATCH 25/60] coveralls: Filter out non-Mopidy sources --- .coveragerc | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..e77617cb --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[report] +omit = + */pyshared/* + */python?.?/* + */site-packages/nose/* From 06ae86b9f85662fb8d0d17d5b8f2feb2455b8f57 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 21:35:49 +0200 Subject: [PATCH 26/60] docs: Update changelog with playlist in gstreamer changes --- docs/changelog.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fb05f8c..24656add 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,18 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.16.0 (UNRELEASED) +==================== + +**Audio** + +- Added support for parsing and playback of playlists in GStreamer. What this + means for end users is basically that you can now add an radio playlist to + Mopidy and we will automatically download it and play the stream inside it. + Currently we support M3U, PLS, XSPF and ASX files, also note that we can + currently only play the first stream in the playlist. + + v0.15.0 (2013-09-19) ==================== From 2e1971af89361d15e283f8ad49ad36762f25f6cc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 22:24:32 +0200 Subject: [PATCH 27/60] audio: Handle min=max when scaling volumes (fixes: #525) Also add gobject.threads_init() so we can run the audio actor test on its own. --- mopidy/audio/actor.py | 2 ++ tests/audio/actor_test.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6f539707..747c2a00 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -544,6 +544,8 @@ class Audio(pykka.ThreadingActor): """Convert value between scales.""" new_min, new_max = new old_min, old_max = old + if old_min == old_max: + return old_max scaling = float(new_max - new_min) / (old_max - old_min) return int(round(scaling * (value - old_min) + new_min)) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 617131cc..e44c5e12 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -6,6 +6,9 @@ import pygst pygst.require('0.10') import gst +import gobject +gobject.threads_init() + import pykka from mopidy import audio @@ -80,6 +83,18 @@ class AudioTest(unittest.TestCase): self.assertTrue(self.audio.set_volume(value).get()) self.assertEqual(value, self.audio.get_volume().get()) + def test_set_volume_with_mixer_min_equal_max(self): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=0', + 'mixer_track': None, + 'output': 'fakesink', + 'visualizer': None, + } + } + self.audio = audio.Audio.start(config=config).proxy() + self.assertEqual(0, self.audio.get_volume().get()) + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO From 8c8fdb0b0134af83228d0be9a8839e4eb785dc01 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 22:30:16 +0200 Subject: [PATCH 28/60] docs: Add bug fix to changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 24656add..b37b24c5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,10 @@ v0.16.0 (UNRELEASED) Currently we support M3U, PLS, XSPF and ASX files, also note that we can currently only play the first stream in the playlist. +- We now handle the rare case where an audio track has max volume equal to min. + This was causing divide by zero errors when scaling volumes to a zero to + hundred scale. (Fixes: :issue:`525`) + v0.15.0 (2013-09-19) ==================== From 6400a45a0e21c76c88b8453d871688cfae5141db Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 00:02:36 +0200 Subject: [PATCH 29/60] scrobbler: Move to external extension --- docs/api/frontends.rst | 1 - docs/changelog.rst | 7 +++ docs/ext/index.rst | 15 +++++ docs/ext/scrobbler.rst | 55 ----------------- docs/glossary.rst | 6 +- mopidy/frontends/scrobbler/__init__.py | 33 ----------- mopidy/frontends/scrobbler/actor.py | 81 -------------------------- mopidy/frontends/scrobbler/ext.conf | 4 -- setup.py | 3 +- 9 files changed, 26 insertions(+), 179 deletions(-) delete mode 100644 docs/ext/scrobbler.rst delete mode 100644 mopidy/frontends/scrobbler/__init__.py delete mode 100644 mopidy/frontends/scrobbler/actor.py delete mode 100644 mopidy/frontends/scrobbler/ext.conf diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 6da5d337..96d8266e 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -50,4 +50,3 @@ Frontend implementations * :mod:`mopidy.frontends.http` * :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpris` -* :mod:`mopidy.frontends.scrobbler` diff --git a/docs/changelog.rst b/docs/changelog.rst index b37b24c5..244ed592 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,13 @@ This changelog is used to track all major changes to Mopidy. v0.16.0 (UNRELEASED) ==================== +**Extensions** + +- The Last.fm scrobbler has been moved to its own external extension, + `Mopidy-Scrobbler `. You'll need + to install it in addition to Mopidy if you want it to continue to work as it + used to. + **Audio** - Added support for parsing and playback of playlists in GStreamer. What this diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 736f2fb6..f3d3f7c2 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -77,6 +77,21 @@ Issues: https://github.com/mopidy/mopidy/issues +Mopidy-Scrobbler +---------------- + +Extension for scrobbling played tracks to Last.fm. + +Author: + Stein Magnus Jodal +PyPI: + `Mopidy-Scrobbler `_ +GitHub: + `mopidy/mopidy-scrobbler `_ +Issues: + https://github.com/mopidy/mopidy-scrobbler/issues + + Mopidy-SomaFM ------------- diff --git a/docs/ext/scrobbler.rst b/docs/ext/scrobbler.rst deleted file mode 100644 index 84188d02..00000000 --- a/docs/ext/scrobbler.rst +++ /dev/null @@ -1,55 +0,0 @@ -.. _ext-scrobbler: - -**************** -Mopidy-Scrobbler -**************** - -This extension scrobbles the music you play to your `Last.fm -`_ profile. - -.. note:: - - This extension requires a free user account at Last.fm. - - -Dependencies -============ - -.. literalinclude:: ../../requirements/scrobbler.txt - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/scrobbler/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: scrobbler/enabled - - If the scrobbler extension should be enabled or not. - -.. confval:: scrobbler/username - - Your Last.fm username. - -.. confval:: scrobbler/password - - Your Last.fm password. - - -Usage -===== - -The extension is enabled by default if all dependencies are available. You just -need to add your Last.fm username and password to the -``~/.config/mopidy/mopidy.conf`` file: - -.. code-block:: ini - - [scrobbler] - username = myusername - password = mysecret diff --git a/docs/glossary.rst b/docs/glossary.rst index 2aa63887..102af3b6 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -25,9 +25,9 @@ Glossary frontend A part of Mopidy *using* the :term:`core` API. Existing frontends include the :ref:`MPD server `, the :ref:`MPRIS/D-Bus - integration `, the :ref:`Last.fm scrobbler `, - and the :ref:`HTTP server ` with JavaScript API. See - :ref:`frontend-api` for details. + integration `, the Last.fm scrobbler, and the :ref:`HTTP + server ` with JavaScript API. See :ref:`frontend-api` for + details. mixer A GStreamer element that controls audio volume. diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py deleted file mode 100644 index c08bc15e..00000000 --- a/mopidy/frontends/scrobbler/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, exceptions, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Scrobbler' - ext_name = 'scrobbler' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['username'] = config.String() - schema['password'] = config.Secret() - return schema - - def validate_environment(self): - try: - import pylast # noqa - except ImportError as e: - raise exceptions.ExtensionError('pylast library not found', e) - - def get_frontend_classes(self): - from .actor import ScrobblerFrontend - return [ScrobblerFrontend] diff --git a/mopidy/frontends/scrobbler/actor.py b/mopidy/frontends/scrobbler/actor.py deleted file mode 100644 index 2343e0cb..00000000 --- a/mopidy/frontends/scrobbler/actor.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import unicode_literals - -import logging -import time - -import pykka -import pylast - -from mopidy.core import CoreListener - - -logger = logging.getLogger('mopidy.frontends.scrobbler') - -API_KEY = '2236babefa8ebb3d93ea467560d00d04' -API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' - - -class ScrobblerFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, config, core): - super(ScrobblerFrontend, self).__init__() - self.config = config - self.lastfm = None - self.last_start_time = None - - def on_start(self): - try: - self.lastfm = pylast.LastFMNetwork( - api_key=API_KEY, api_secret=API_SECRET, - username=self.config['scrobbler']['username'], - password_hash=pylast.md5(self.config['scrobbler']['password'])) - logger.info('Scrobbler connected to Last.fm') - except (pylast.NetworkError, pylast.MalformedResponseError, - pylast.WSError) as e: - logger.error('Error during Last.fm setup: %s', e) - self.stop() - - def track_playback_started(self, tl_track): - track = tl_track.track - artists = ', '.join([a.name for a in track.artists]) - duration = track.length and track.length // 1000 or 0 - self.last_start_time = int(time.time()) - logger.debug('Now playing track: %s - %s', artists, track.name) - try: - self.lastfm.update_now_playing( - artists, - (track.name or ''), - album=(track.album and track.album.name or ''), - duration=str(duration), - track_number=str(track.track_no), - mbid=(track.musicbrainz_id or '')) - except (pylast.ScrobblingError, pylast.NetworkError, - pylast.MalformedResponseError, pylast.WSError) as e: - logger.warning('Error submitting playing track to Last.fm: %s', e) - - def track_playback_ended(self, tl_track, time_position): - track = tl_track.track - artists = ', '.join([a.name for a in track.artists]) - duration = track.length and track.length // 1000 or 0 - time_position = time_position // 1000 - if duration < 30: - logger.debug('Track too short to scrobble. (30s)') - return - if time_position < duration // 2 and time_position < 240: - logger.debug( - 'Track not played long enough to scrobble. (50% or 240s)') - return - if self.last_start_time is None: - self.last_start_time = int(time.time()) - duration - logger.debug('Scrobbling track: %s - %s', artists, track.name) - try: - self.lastfm.scrobble( - artists, - (track.name or ''), - str(self.last_start_time), - album=(track.album and track.album.name or ''), - track_number=str(track.track_no), - duration=str(duration), - mbid=(track.musicbrainz_id or '')) - except (pylast.ScrobblingError, pylast.NetworkError, - pylast.MalformedResponseError, pylast.WSError) as e: - logger.warning('Error submitting played track to Last.fm: %s', e) diff --git a/mopidy/frontends/scrobbler/ext.conf b/mopidy/frontends/scrobbler/ext.conf deleted file mode 100644 index 4fded92f..00000000 --- a/mopidy/frontends/scrobbler/ext.conf +++ /dev/null @@ -1,4 +0,0 @@ -[scrobbler] -enabled = true -username = -password = diff --git a/setup.py b/setup.py index c5eea724..7cfb7409 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( ], extras_require={ 'spotify': ['pyspotify >= 1.9, < 2'], - 'scrobbler': ['pylast >= 0.5.7'], + 'scrobbler': ['Mopidy-Scrobbler'], 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], }, test_suite='nose.collector', @@ -45,7 +45,6 @@ setup( ], 'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', - 'scrobbler = mopidy.frontends.scrobbler:Extension [scrobbler]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'mpris = mopidy.frontends.mpris:Extension', From decda983e68ce4dcc6b99b881ef6d872e421962b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 00:06:35 +0200 Subject: [PATCH 30/60] docs: Simplify extension page --- docs/ext/index.rst | 77 +++++++++------------------------------------- 1 file changed, 14 insertions(+), 63 deletions(-) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index f3d3f7c2..3163a6c0 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -33,108 +33,59 @@ developers. Mopidy-Beets ------------ +https://github.com/mopidy/mopidy-beets + Provides a backend for playing music from your `Beets `_ music library through Beets' web extension. -Author: - Janez Troha -PyPI: - `Mopidy-Beets `_ -GitHub: - `dz0ny/mopidy-beets `_ -Issues: - https://github.com/dz0ny/mopidy-beets/issues - Mopidy-GMusic ------------- +https://github.com/hechtus/mopidy-gmusic + Provides a backend for playing music from `Google Play Music `_. -Author: - Ronald Hecht -PyPI: - `Mopidy-GMusic `_ -GitHub: - `hechtus/mopidy-gmusic `_ -Issues: - https://github.com/hechtus/mopidy-gmusic/issues - Mopidy-NAD ---------- -Extension for controlling volume using an external NAD amplifier. +https://github.com/mopidy/mopidy-nad -Author: - Stein Magnus Jodal -PyPI: - `Mopidy-NAD `_ -GitHub: - `mopidy/mopidy-nad `_ -Issues: - https://github.com/mopidy/mopidy/issues +Extension for controlling volume using an external NAD amplifier. Mopidy-Scrobbler ---------------- -Extension for scrobbling played tracks to Last.fm. +https://github.com/mopidy/mopidy-scrobbler -Author: - Stein Magnus Jodal -PyPI: - `Mopidy-Scrobbler `_ -GitHub: - `mopidy/mopidy-scrobbler `_ -Issues: - https://github.com/mopidy/mopidy-scrobbler/issues +Extension for scrobbling played tracks to Last.fm. Mopidy-SomaFM ------------- +https://github.com/AlexandrePTJ/mopidy-somafm/ + Provides a backend for playing music from the `SomaFM `_ service. -Author: - Alexandre Petitjean -PyPI: - `Mopidy-SomaFM `_ -GitHub: - `AlexandrePTJ/mopidy-somafm `_ -Issues: - https://github.com/AlexandrePTJ/mopidy-somafm/issues - Mopidy-SoundCloud ----------------- +https://github.com/mopidy/mopidy-soundcloud + Provides a backend for playing music from the `SoundCloud `_ service. -Author: - Janez Troha -PyPI: - `Mopidy-SoundCloud `_ -GitHub: - `dz0ny/mopidy-soundcloud `_ -Issues: - https://github.com/dz0ny/mopidy-soundcloud/issues - Mopidy-Subsonic --------------- +https://github.com/rattboi/mopidy-subsonic + Provides a backend for playing music from a `Subsonic Music Streamer `_ library. - -Author: - Bradon Kanyid -PyPI: - `Mopidy-Subsonic `_ -GitHub: - `rattboi/mopidy-subsonic `_ -Issues: - https://github.com/rattboi/mopidy-subsonic/issues From 7e31fdcbec763700b11d3a5067765146bc4e7f84 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Mon, 7 Oct 2013 12:39:23 +0200 Subject: [PATCH 31/60] Deleting unused import --- mopidy/frontends/mpd/protocol/audio_output.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 03289cd3..4dfb148e 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_request(r'^disableoutput "(?P\d+)"$') From 5b5bd342b662d57423cba2e9aa33b3e4e15d74bf Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Mon, 7 Oct 2013 13:11:13 +0200 Subject: [PATCH 32/60] Updating tests to be compliant with the new response (ok) --- tests/frontends/mpd/protocol/audio_output_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 560e935f..9a7cd69c 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -6,11 +6,11 @@ from tests.frontends.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): self.sendRequest('enableoutput "0"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('OK') def test_disableoutput(self): self.sendRequest('disableoutput "0"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('OK') def test_outputs(self): self.sendRequest('outputs') From 5d02b1a3655f591614f17b93befdbb7d6d60d0a4 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Mon, 7 Oct 2013 13:12:20 +0200 Subject: [PATCH 33/60] Putting full name of the variable, as Jodal asked --- mopidy/frontends/mpd/protocol/audio_output.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 4dfb148e..5a4d45c1 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -38,9 +38,9 @@ def outputs(context): Shows information about all outputs. """ - ena = 0 if context.core.playback.get_mute().get() else 1 + enabled = 0 if context.core.playback.get_mute().get() else 1 return [ ('outputid', 0), ('outputname', 'Default'), - ('outputenabled', ena), + ('outputenabled', enabled), ] From 79a8768c53f49ed6c6ec2bf7d909864b14010927 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 21:08:55 +0200 Subject: [PATCH 34/60] docs: Unbreak docs building --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index f3e4166c..56ddbf92 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,8 @@ class Mock(object): elif (name[0] == name[0].upper() # gst.interfaces.MIXER_TRACK_* and not name.startswith('MIXER_TRACK_') + # gst.PadTemplate + and not name.startswith('PadTemplate') # dbus.String() and not name == 'String'): return type(name, (), {}) From aaef6b867e4d4ba78280bc1259ef849d1d34fd71 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 21:09:29 +0200 Subject: [PATCH 35/60] docs: Fix build warning --- mopidy/ext.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 5db7c093..e6cfbb7c 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -83,8 +83,7 @@ class Extension(object): """List of library updater classes :returns: list of - :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` - subclasses + :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` subclasses """ return [] From 971d84467faa38f47d1bc8812eabb2645223cd7b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 22:20:23 +0200 Subject: [PATCH 36/60] docs: Add cookiecutter, update extension examples --- docs/extensiondev.rst | 82 +++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 51168312..428751de 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -62,6 +62,20 @@ extension, Mopidy-Soundspot:: Example content for the most important files follows below. +cookiecutter project template +============================= + +We've also made a `cookiecutter `_ +project template for `creating new Mopidy extensions +`_. If you install +cookiecutter and run a single command, you're asked a few questions about the +name of your extension, etc. This is used to create a folder structure similar +to the above, with all the needed files and most of the details filled in for +you. This saves you a lot of tedious work and copy-pasting from this howto. See +the readme of `cookiecutter-mopidy-ext +`_ for further details. + + Example README.rst ================== @@ -73,24 +87,30 @@ installation using ``pip install Mopidy-Something==dev`` to work. .. code-block:: rst + **************** Mopidy-Soundspot - ================ + **************** `Mopidy `_ extension for playing music from `Soundspot `_. - Usage - ----- - Requires a Soundspot Platina subscription and the pysoundspot library. + + Installation + ============ + Install by running:: sudo pip install Mopidy-Soundspot - Or install the Debian/Ubuntu package from `apt.mopidy.com + Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. + + Configuration + ============= + Before starting Mopidy, you must add your Soundspot username and password to the Mopidy configuration file:: @@ -98,34 +118,46 @@ installation using ``pip install Mopidy-Something==dev`` to work. username = alice password = secret + Project resources - ----------------- + ================= - `Source code `_ - `Issue tracker `_ - - `Download development snapshot `_ + - `Download development snapshot `_ + + + Changelog + ========= + + v0.1.0 (2013-09-17) + ------------------- + + - Initial release. Example setup.py ================ -The ``setup.py`` file must use setuptools/distribute, and not distutils. This -is because Mopidy extensions use setuptools' entry point functionality to -register themselves as available Mopidy extensions when they are installed on -your system. +The ``setup.py`` file must use setuptools, and not distutils. This is because +Mopidy extensions use setuptools' entry point functionality to register +themselves as available Mopidy extensions when they are installed on your +system. The example below also includes a couple of convenient tricks for reading the package version from the source code so that it is defined in a single place, and to reuse the README file as the long description of the package for the PyPI registration. -The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in -addition to any other dependencies required by your extension. The -``entry_points`` part must be included. The ``mopidy.ext`` part cannot be -changed, but the innermost string should be changed. It's format is -``ext_name = package_name:Extension``. ``ext_name`` should be a short -name for your extension, typically the part after "Mopidy-" in lowercase. This -name is used e.g. to name the config section for your extension. The +The package must have ``install_requires`` on ``setuptools`` and ``Mopidy >= +0.14`` (or a newer version, if your extension requires it), in addition to any +other dependencies required by your extension. If you implement a Mopidy +frontend or backend, you'll need to include ``Pykka >= 1.1`` in the +requirements. The ``entry_points`` part must be included. The ``mopidy.ext`` +part cannot be changed, but the innermost string should be changed. It's format +is ``ext_name = package_name:Extension``. ``ext_name`` should be a short name +for your extension, typically the part after "Mopidy-" in lowercase. This name +is used e.g. to name the config section for your extension. The ``package_name:Extension`` part is simply the Python path to the extension class that will connect the rest of the dots. @@ -134,7 +166,7 @@ class that will connect the rest of the dots. from __future__ import unicode_literals import re - from setuptools import setup + from setuptools import setup, find_packages def get_version(filename): @@ -146,20 +178,26 @@ class that will connect the rest of the dots. setup( name='Mopidy-Soundspot', version=get_version('mopidy_soundspot/__init__.py'), - url='http://example.com/mopidy-soundspot/', + url='https://github.com/your-account/mopidy-soundspot', license='Apache License, Version 2.0', author='Your Name', author_email='your-email@example.com', description='Very short description', long_description=open('README.rst').read(), - packages=['mopidy_soundspot'], + packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', - 'Mopidy', + 'Mopidy >= 0.14', + 'Pykka >= 1.1', 'pysoundspot', ], + test_suite='nose.collector', + tests_require=[ + 'nose', + 'mock >= 1.0', + ], entry_points={ 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', From f479d86838fff3fc7f15dc77a4056c496e976101 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 22:21:45 +0200 Subject: [PATCH 37/60] docs: Update changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 244ed592..1c7ea6d8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,10 @@ v0.16.0 (UNRELEASED) **Extensions** +- A cookiecutter project for quickly creating new Mopidy extensions have been + created. You can find it at `cookiecutter-mopidy-ext + `_. (Fixes: :issue:`522`) + - The Last.fm scrobbler has been moved to its own external extension, `Mopidy-Scrobbler `. You'll need to install it in addition to Mopidy if you want it to continue to work as it From 3be74a47b02d61467d79a59f81188da068a5a91c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 22:21:56 +0200 Subject: [PATCH 38/60] docs: Update authors --- AUTHORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 052865b7..28b8ebd2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,3 +24,5 @@ - Alli Witheford - Alexandre Petitjean - Pavol Babincak +- Javier Domingo +- Lasse Bigum From de3e4254d7583bd51cf5d75e68a6b187d7d2394f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 22:38:07 +0200 Subject: [PATCH 39/60] docs: Fix syntax error --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1c7ea6d8..7f919f79 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ v0.16.0 (UNRELEASED) `_. (Fixes: :issue:`522`) - The Last.fm scrobbler has been moved to its own external extension, - `Mopidy-Scrobbler `. You'll need + `Mopidy-Scrobbler `_. You'll need to install it in addition to Mopidy if you want it to continue to work as it used to. From 509afdbb025c685be43ad0d2199e764516d4e448 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 23:00:37 +0200 Subject: [PATCH 40/60] scrobbler: Remove requirements file --- requirements/scrobbler.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 requirements/scrobbler.txt diff --git a/requirements/scrobbler.txt b/requirements/scrobbler.txt deleted file mode 100644 index c52256c3..00000000 --- a/requirements/scrobbler.txt +++ /dev/null @@ -1,3 +0,0 @@ -pylast >= 0.5.7 -# Available as python-pylast in newer Debian/Ubuntu and from apt.mopidy.com for -# older releases of Debian/Ubuntu From c589583b743afef59989ed8881373aa0d555b199 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 23:41:15 +0200 Subject: [PATCH 41/60] mpris: Move to external extension --- docs/api/frontends.rst | 1 - docs/clients/mpris.rst | 3 +- docs/clients/upnp.rst | 26 +- docs/ext/index.rst | 11 +- docs/ext/mpris.rst | 105 --- docs/glossary.rst | 7 +- docs/installation/index.rst | 4 +- mopidy/frontends/mpris/__init__.py | 36 - mopidy/frontends/mpris/actor.py | 110 --- mopidy/frontends/mpris/ext.conf | 3 - mopidy/frontends/mpris/objects.py | 498 ---------- setup.py | 1 - tests/frontends/mpris/__init__.py | 1 - tests/frontends/mpris/events_test.py | 92 -- .../frontends/mpris/player_interface_test.py | 869 ------------------ .../mpris/playlists_interface_test.py | 172 ---- tests/frontends/mpris/root_interface_test.py | 87 -- 17 files changed, 31 insertions(+), 1995 deletions(-) delete mode 100644 docs/ext/mpris.rst delete mode 100644 mopidy/frontends/mpris/__init__.py delete mode 100644 mopidy/frontends/mpris/actor.py delete mode 100644 mopidy/frontends/mpris/ext.conf delete mode 100644 mopidy/frontends/mpris/objects.py delete mode 100644 tests/frontends/mpris/__init__.py delete mode 100644 tests/frontends/mpris/events_test.py delete mode 100644 tests/frontends/mpris/player_interface_test.py delete mode 100644 tests/frontends/mpris/playlists_interface_test.py delete mode 100644 tests/frontends/mpris/root_interface_test.py diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 96d8266e..70bd73cf 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -49,4 +49,3 @@ Frontend implementations * :mod:`mopidy.frontends.http` * :mod:`mopidy.frontends.mpd` -* :mod:`mopidy.frontends.mpris` diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 141a2371..e1bd4bff 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -8,7 +8,8 @@ MPRIS clients Specification. It's a spec that describes a standard D-Bus interface for making media players available to other applications on the same system. -Mopidy's :ref:`MPRIS frontend ` currently implements all required +The MPRIS frontend provided by the `Mopidy-MPRIS extension +`_ currently implements all required parts of the MPRIS spec, plus the optional playlist interface. It does not implement the optional tracklist interface. diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 9f30bd1c..7f21a6c6 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -36,19 +36,21 @@ How to make Mopidy available as an UPnP MediaRenderer ===================================================== With the help of `the Rygel project `_ Mopidy can -be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's -:ref:`MPRIS frontend `, and make Mopidy available as a MediaRenderer -on the local network. Since this depends on the MPRIS frontend, which again -depends on D-Bus being available, this will only work on Linux, and not OS X. -MPRIS/D-Bus is only available to other applications on the same host, so Rygel -must be running on the same machine as Mopidy. +be made available as an UPnP MediaRenderer. Rygel will interface with the MPRIS +interface provided by the `Mopidy-MPRIS extension +`_, and make Mopidy available as a +MediaRenderer on the local network. Since this depends on the MPRIS frontend, +which again depends on D-Bus being available, this will only work on Linux, and +not OS X. MPRIS/D-Bus is only available to other applications on the same +host, so Rygel must be running on the same machine as Mopidy. -1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is working. - It is activated by default, but you may miss dependencies or be using OS X, - in which case it will not work. Check the console output when Mopidy is - started for any errors related to the MPRIS frontend. If you're unsure it is - working, there are instructions for how to test it on the :ref:`MPRIS - frontend ` page. +1. Start Mopidy and make sure the MPRIS frontend is working. It is activated + by default when the Mopidy-MPRIS extension is installed, but you may miss + dependencies or be using OS X, in which case it will not work. Check the + console output when Mopidy is started for any errors related to the MPRIS + frontend. If you're unsure it is working, there are instructions for how to + test it on in the `Mopidy-MPRIS readme + `_. 2. Install Rygel. On Debian/Ubuntu:: diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 3163a6c0..bdc1efe8 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -48,6 +48,15 @@ Provides a backend for playing music from `Google Play Music `_. +Mopidy-MPRIS +------------ + +https://github.com/mopidy/mopidy-mpris + +Extension for controlling Mopidy through the `MPRIS `_ +D-Bus interface, for example using the Ubuntu Sound Menu. + + Mopidy-NAD ---------- @@ -67,7 +76,7 @@ Extension for scrobbling played tracks to Last.fm. Mopidy-SomaFM ------------- -https://github.com/AlexandrePTJ/mopidy-somafm/ +https://github.com/AlexandrePTJ/mopidy-somafm Provides a backend for playing music from the `SomaFM `_ service. diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst deleted file mode 100644 index 125f8fec..00000000 --- a/docs/ext/mpris.rst +++ /dev/null @@ -1,105 +0,0 @@ -.. _ext-mpris: - -************ -Mopidy-MPRIS -************ - -This extension lets you control Mopidy through the Media Player Remote -Interfacing Specification (`MPRIS `_) D-Bus interface. - -An example of an MPRIS client is the :ref:`ubuntu-sound-menu`. - - -Dependencies -============ - -- D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - -- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - -- An ``.desktop`` file for Mopidy installed at the path set in the - :confval:`mpris/desktop_file` config value. See usage section below for - details. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/mpris/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: mpris/enabled - - If the MPRIS extension should be enabled or not. - -.. confval:: mpris/desktop_file - - Location of the Mopidy ``.desktop`` file. - - -Usage -===== - -The extension is enabled by default if all dependencies are available. - - -Controlling Mopidy through the Ubuntu Sound Menu ------------------------------------------------- - -If you are running Ubuntu and installed Mopidy using the Debian package from -APT you should be able to control Mopidy through the :ref:`ubuntu-sound-menu` -without any changes. - -If you installed Mopidy in any other way and want to control Mopidy through the -Ubuntu Sound Menu, you must install the ``mopidy.desktop`` file which can be -found in the ``data/`` dir of the Mopidy source repo into the -``/usr/share/applications`` dir by hand:: - - cd /path/to/mopidy/source - sudo cp data/mopidy.desktop /usr/share/applications/ - -If the correct path to the installed ``mopidy.desktop`` file on your system -isn't ``/usr/share/applications/mopidy.conf``, you'll need to set the -:confval:`mpris/desktop_file` config value. - -After you have installed the file, start Mopidy in any way, and Mopidy should -appear in the Ubuntu Sound Menu. When you quit Mopidy, it will still be listed -in the Ubuntu Sound Menu, and may be restarted by selecting it there. - -The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend. The MPRIS -frontend supports the minimum requirements of the `MPRIS specification -`_. The ``TrackList`` interface of the spec is not -supported. - - -Testing the MPRIS API directly ------------------------------- - -To use the MPRIS API directly, start Mopidy, and then run the following in a -Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - -Now you can control Mopidy through the player object. Examples: - -- To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - -- To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - -For details on the API, please refer to the `MPRIS specification -`_. diff --git a/docs/glossary.rst b/docs/glossary.rst index 102af3b6..2acb9981 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -24,10 +24,9 @@ Glossary frontend A part of Mopidy *using* the :term:`core` API. Existing frontends - include the :ref:`MPD server `, the :ref:`MPRIS/D-Bus - integration `, the Last.fm scrobbler, and the :ref:`HTTP - server ` with JavaScript API. See :ref:`frontend-api` for - details. + include the :ref:`MPD server `, the MPRIS/D-Bus integration, + the Last.fm scrobbler, and the :ref:`HTTP server ` with + JavaScript API. See :ref:`frontend-api` for details. mixer A GStreamer element that controls audio volume. diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 85e07c9d..369e3e29 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -250,8 +250,8 @@ can install Mopidy from PyPI using Pip. sudo pip-python install -U cherrypy ws4py -#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound - Menu or from an UPnP client via Rygel, you need some additional +#. Optional: To use Mopidy-MPRIS, e.g. for controlling Mopidy from the Ubuntu + Sound Menu or from an UPnP client via Rygel, you need some additional dependencies: the Python bindings for libindicate, and the Python bindings for libdbus, the reference D-Bus library. diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py deleted file mode 100644 index 1fd258b5..00000000 --- a/mopidy/frontends/mpris/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, exceptions, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-MPRIS' - ext_name = 'mpris' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['desktop_file'] = config.Path() - return schema - - def validate_environment(self): - if 'DISPLAY' not in os.environ: - raise exceptions.ExtensionError( - 'An X11 $DISPLAY is needed to use D-Bus') - - try: - import dbus # noqa - except ImportError as e: - raise exceptions.ExtensionError('dbus library not found', e) - - def get_frontend_classes(self): - from .actor import MprisFrontend - return [MprisFrontend] diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py deleted file mode 100644 index d44e9262..00000000 --- a/mopidy/frontends/mpris/actor.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os - -import pykka - -from mopidy.core import CoreListener -from mopidy.frontends.mpris import objects - -logger = logging.getLogger('mopidy.frontends.mpris') - -try: - indicate = None - if 'DISPLAY' in os.environ: - import indicate -except ImportError: - pass - -if indicate is None: - logger.debug('Startup notification will not be sent') - - -class MprisFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, config, core): - super(MprisFrontend, self).__init__() - self.config = config - self.core = core - self.indicate_server = None - self.mpris_object = None - - def on_start(self): - try: - self.mpris_object = objects.MprisObject(self.config, self.core) - self._send_startup_notification() - except Exception as e: - logger.warning('MPRIS frontend setup failed (%s)', e) - self.stop() - - def on_stop(self): - logger.debug('Removing MPRIS object from D-Bus connection...') - if self.mpris_object: - self.mpris_object.remove_from_connection() - self.mpris_object = None - logger.debug('Removed MPRIS object from D-Bus connection') - - def _send_startup_notification(self): - """ - Send startup notification using libindicate to make Mopidy appear in - e.g. `Ubunt's sound menu `_. - - A reference to the libindicate server is kept for as long as Mopidy is - running. When Mopidy exits, the server will be unreferenced and Mopidy - will automatically be unregistered from e.g. the sound menu. - """ - if not indicate: - return - logger.debug('Sending startup notification...') - self.indicate_server = indicate.Server() - self.indicate_server.set_type('music.mopidy') - self.indicate_server.set_desktop_file( - self.config['mpris']['desktop_file']) - self.indicate_server.show() - logger.debug('Startup notification sent') - - def _emit_properties_changed(self, interface, changed_properties): - if self.mpris_object is None: - return - props_with_new_values = [ - (p, self.mpris_object.Get(interface, p)) - for p in changed_properties] - self.mpris_object.PropertiesChanged( - interface, dict(props_with_new_values), []) - - def track_playback_paused(self, tl_track, time_position): - logger.debug('Received track_playback_paused event') - self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - - def track_playback_resumed(self, tl_track, time_position): - logger.debug('Received track_playback_resumed event') - self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - - def track_playback_started(self, tl_track): - logger.debug('Received track_playback_started event') - self._emit_properties_changed( - objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - - def track_playback_ended(self, tl_track, time_position): - logger.debug('Received track_playback_ended event') - self._emit_properties_changed( - objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - - def volume_changed(self, volume): - logger.debug('Received volume_changed event') - self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) - - def seeked(self, time_position_in_ms): - logger.debug('Received seeked event') - self.mpris_object.Seeked(time_position_in_ms * 1000) - - def playlists_loaded(self): - logger.debug('Received playlists_loaded event') - self._emit_properties_changed( - objects.PLAYLISTS_IFACE, ['PlaylistCount']) - - def playlist_changed(self, playlist): - logger.debug('Received playlist_changed event') - playlist_id = self.mpris_object.get_playlist_id(playlist.uri) - playlist = (playlist_id, playlist.name, '') - self.mpris_object.PlaylistChanged(playlist) diff --git a/mopidy/frontends/mpris/ext.conf b/mopidy/frontends/mpris/ext.conf deleted file mode 100644 index b83411c2..00000000 --- a/mopidy/frontends/mpris/ext.conf +++ /dev/null @@ -1,3 +0,0 @@ -[mpris] -enabled = true -desktop_file = /usr/share/applications/mopidy.desktop diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py deleted file mode 100644 index 15be1eea..00000000 --- a/mopidy/frontends/mpris/objects.py +++ /dev/null @@ -1,498 +0,0 @@ -from __future__ import unicode_literals - -import base64 -import logging -import os - -import dbus -import dbus.mainloop.glib -import dbus.service -import gobject - -from mopidy.core import PlaybackState -from mopidy.utils.process import exit_process - - -logger = logging.getLogger('mopidy.frontends.mpris') - -# Must be done before dbus.SessionBus() is called -gobject.threads_init() -dbus.mainloop.glib.threads_init() - -BUS_NAME = 'org.mpris.MediaPlayer2.mopidy' -OBJECT_PATH = '/org/mpris/MediaPlayer2' -ROOT_IFACE = 'org.mpris.MediaPlayer2' -PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' -PLAYLISTS_IFACE = 'org.mpris.MediaPlayer2.Playlists' - - -class MprisObject(dbus.service.Object): - """Implements http://www.mpris.org/2.2/spec/""" - - properties = None - - def __init__(self, config, core): - self.config = config - self.core = core - self.properties = { - ROOT_IFACE: self._get_root_iface_properties(), - PLAYER_IFACE: self._get_player_iface_properties(), - PLAYLISTS_IFACE: self._get_playlists_iface_properties(), - } - bus_name = self._connect_to_dbus() - dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) - - def _get_root_iface_properties(self): - return { - 'CanQuit': (True, None), - 'Fullscreen': (False, None), - 'CanSetFullscreen': (False, None), - 'CanRaise': (False, None), - # NOTE Change if adding optional track list support - 'HasTrackList': (False, None), - 'Identity': ('Mopidy', None), - 'DesktopEntry': (self.get_DesktopEntry, None), - 'SupportedUriSchemes': (self.get_SupportedUriSchemes, None), - # NOTE Return MIME types supported by local backend if support for - # reporting supported MIME types is added - 'SupportedMimeTypes': (dbus.Array([], signature='s'), None), - } - - def _get_player_iface_properties(self): - return { - 'PlaybackStatus': (self.get_PlaybackStatus, None), - 'LoopStatus': (self.get_LoopStatus, self.set_LoopStatus), - 'Rate': (1.0, self.set_Rate), - 'Shuffle': (self.get_Shuffle, self.set_Shuffle), - 'Metadata': (self.get_Metadata, None), - 'Volume': (self.get_Volume, self.set_Volume), - 'Position': (self.get_Position, None), - 'MinimumRate': (1.0, None), - 'MaximumRate': (1.0, None), - 'CanGoNext': (self.get_CanGoNext, None), - 'CanGoPrevious': (self.get_CanGoPrevious, None), - 'CanPlay': (self.get_CanPlay, None), - 'CanPause': (self.get_CanPause, None), - 'CanSeek': (self.get_CanSeek, None), - 'CanControl': (self.get_CanControl, None), - } - - def _get_playlists_iface_properties(self): - return { - 'PlaylistCount': (self.get_PlaylistCount, None), - 'Orderings': (self.get_Orderings, None), - 'ActivePlaylist': (self.get_ActivePlaylist, None), - } - - def _connect_to_dbus(self): - logger.debug('Connecting to D-Bus...') - mainloop = dbus.mainloop.glib.DBusGMainLoop() - bus_name = dbus.service.BusName( - BUS_NAME, dbus.SessionBus(mainloop=mainloop)) - logger.info('MPRIS server connected to D-Bus') - return bus_name - - def get_playlist_id(self, playlist_uri): - # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use - # base64. Luckily, D-Bus does not limit the length of object paths. - # Since base32 pads trailing bytes with "=" chars, we need to replace - # them with an allowed character such as "_". - encoded_uri = base64.b32encode(playlist_uri).replace('=', '_') - return '/com/mopidy/playlist/%s' % encoded_uri - - def get_playlist_uri(self, playlist_id): - encoded_uri = playlist_id.split('/')[-1].replace('_', '=') - return base64.b32decode(encoded_uri) - - def get_track_id(self, tl_track): - return '/com/mopidy/track/%d' % tl_track.tlid - - def get_track_tlid(self, track_id): - assert track_id.startswith('/com/mopidy/track/') - return track_id.split('/')[-1] - - ### Properties interface - - @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ss', out_signature='v') - def Get(self, interface, prop): - logger.debug( - '%s.Get(%s, %s) called', - dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) - (getter, _) = self.properties[interface][prop] - if callable(getter): - return getter() - else: - return getter - - @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='s', out_signature='a{sv}') - def GetAll(self, interface): - logger.debug( - '%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) - getters = {} - for key, (getter, _) in self.properties[interface].iteritems(): - getters[key] = getter() if callable(getter) else getter - return getters - - @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ssv', out_signature='') - def Set(self, interface, prop, value): - logger.debug( - '%s.Set(%s, %s, %s) called', - dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) - _, setter = self.properties[interface][prop] - if setter is not None: - setter(value) - self.PropertiesChanged( - interface, {prop: self.Get(interface, prop)}, []) - - @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, - signature='sa{sv}as') - def PropertiesChanged(self, interface, changed_properties, - invalidated_properties): - logger.debug( - '%s.PropertiesChanged(%s, %s, %s) signaled', - dbus.PROPERTIES_IFACE, interface, changed_properties, - invalidated_properties) - - ### Root interface methods - - @dbus.service.method(dbus_interface=ROOT_IFACE) - def Raise(self): - logger.debug('%s.Raise called', ROOT_IFACE) - # Do nothing, as we do not have a GUI - - @dbus.service.method(dbus_interface=ROOT_IFACE) - def Quit(self): - logger.debug('%s.Quit called', ROOT_IFACE) - exit_process() - - ### Root interface properties - - def get_DesktopEntry(self): - return os.path.splitext(os.path.basename( - self.config['mpris']['desktop_file']))[0] - - def get_SupportedUriSchemes(self): - return dbus.Array(self.core.uri_schemes.get(), signature='s') - - ### Player interface methods - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Next(self): - logger.debug('%s.Next called', PLAYER_IFACE) - if not self.get_CanGoNext(): - logger.debug('%s.Next not allowed', PLAYER_IFACE) - return - self.core.playback.next().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Previous(self): - logger.debug('%s.Previous called', PLAYER_IFACE) - if not self.get_CanGoPrevious(): - logger.debug('%s.Previous not allowed', PLAYER_IFACE) - return - self.core.playback.previous().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Pause(self): - logger.debug('%s.Pause called', PLAYER_IFACE) - if not self.get_CanPause(): - logger.debug('%s.Pause not allowed', PLAYER_IFACE) - return - self.core.playback.pause().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def PlayPause(self): - logger.debug('%s.PlayPause called', PLAYER_IFACE) - if not self.get_CanPause(): - logger.debug('%s.PlayPause not allowed', PLAYER_IFACE) - return - state = self.core.playback.state.get() - if state == PlaybackState.PLAYING: - self.core.playback.pause().get() - elif state == PlaybackState.PAUSED: - self.core.playback.resume().get() - elif state == PlaybackState.STOPPED: - self.core.playback.play().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Stop(self): - logger.debug('%s.Stop called', PLAYER_IFACE) - if not self.get_CanControl(): - logger.debug('%s.Stop not allowed', PLAYER_IFACE) - return - self.core.playback.stop().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Play(self): - logger.debug('%s.Play called', PLAYER_IFACE) - if not self.get_CanPlay(): - logger.debug('%s.Play not allowed', PLAYER_IFACE) - return - state = self.core.playback.state.get() - if state == PlaybackState.PAUSED: - self.core.playback.resume().get() - else: - self.core.playback.play().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Seek(self, offset): - logger.debug('%s.Seek called', PLAYER_IFACE) - if not self.get_CanSeek(): - logger.debug('%s.Seek not allowed', PLAYER_IFACE) - return - offset_in_milliseconds = offset // 1000 - current_position = self.core.playback.time_position.get() - new_position = current_position + offset_in_milliseconds - self.core.playback.seek(new_position) - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def SetPosition(self, track_id, position): - logger.debug('%s.SetPosition called', PLAYER_IFACE) - if not self.get_CanSeek(): - logger.debug('%s.SetPosition not allowed', PLAYER_IFACE) - return - position = position // 1000 - current_tl_track = self.core.playback.current_tl_track.get() - if current_tl_track is None: - return - if track_id != self.get_track_id(current_tl_track): - return - if position < 0: - return - if current_tl_track.track.length < position: - return - self.core.playback.seek(position) - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def OpenUri(self, uri): - logger.debug('%s.OpenUri called', PLAYER_IFACE) - if not self.get_CanPlay(): - # NOTE The spec does not explictly require this check, but guarding - # the other methods doesn't help much if OpenUri is open for use. - logger.debug('%s.Play not allowed', PLAYER_IFACE) - return - # NOTE Check if URI has MIME type known to the backend, if MIME support - # is added to the backend. - tl_tracks = self.core.tracklist.add(uri=uri).get() - if tl_tracks: - self.core.playback.play(tl_tracks[0]) - else: - logger.debug('Track with URI "%s" not found in library.', uri) - - ### Player interface signals - - @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') - def Seeked(self, position): - logger.debug('%s.Seeked signaled', PLAYER_IFACE) - # Do nothing, as just calling the method is enough to emit the signal. - - ### Player interface properties - - def get_PlaybackStatus(self): - state = self.core.playback.state.get() - if state == PlaybackState.PLAYING: - return 'Playing' - elif state == PlaybackState.PAUSED: - return 'Paused' - elif state == PlaybackState.STOPPED: - return 'Stopped' - - def get_LoopStatus(self): - repeat = self.core.playback.repeat.get() - single = self.core.playback.single.get() - if not repeat: - return 'None' - else: - if single: - return 'Track' - else: - return 'Playlist' - - def set_LoopStatus(self, value): - if not self.get_CanControl(): - logger.debug('Setting %s.LoopStatus not allowed', PLAYER_IFACE) - return - if value == 'None': - self.core.playback.repeat = False - self.core.playback.single = False - elif value == 'Track': - self.core.playback.repeat = True - self.core.playback.single = True - elif value == 'Playlist': - self.core.playback.repeat = True - self.core.playback.single = False - - def set_Rate(self, value): - if not self.get_CanControl(): - # NOTE The spec does not explictly require this check, but it was - # added to be consistent with all the other property setters. - logger.debug('Setting %s.Rate not allowed', PLAYER_IFACE) - return - if value == 0: - self.Pause() - - def get_Shuffle(self): - return self.core.playback.random.get() - - def set_Shuffle(self, value): - if not self.get_CanControl(): - logger.debug('Setting %s.Shuffle not allowed', PLAYER_IFACE) - return - if value: - self.core.playback.random = True - else: - self.core.playback.random = False - - def get_Metadata(self): - current_tl_track = self.core.playback.current_tl_track.get() - if current_tl_track is None: - return {'mpris:trackid': ''} - else: - (_, track) = current_tl_track - metadata = {'mpris:trackid': self.get_track_id(current_tl_track)} - if track.length: - metadata['mpris:length'] = track.length * 1000 - if track.uri: - metadata['xesam:url'] = track.uri - if track.name: - metadata['xesam:title'] = track.name - if track.artists: - artists = list(track.artists) - artists.sort(key=lambda a: a.name) - metadata['xesam:artist'] = dbus.Array( - [a.name for a in artists if a.name], signature='s') - if track.album and track.album.name: - metadata['xesam:album'] = track.album.name - if track.album and track.album.artists: - artists = list(track.album.artists) - artists.sort(key=lambda a: a.name) - metadata['xesam:albumArtist'] = dbus.Array( - [a.name for a in artists if a.name], signature='s') - if track.album and track.album.images: - url = list(track.album.images)[0] - if url: - metadata['mpris:artUrl'] = url - if track.disc_no: - metadata['xesam:discNumber'] = track.disc_no - if track.track_no: - metadata['xesam:trackNumber'] = track.track_no - return dbus.Dictionary(metadata, signature='sv') - - def get_Volume(self): - volume = self.core.playback.volume.get() - if volume is None: - return 0 - return volume / 100.0 - - def set_Volume(self, value): - if not self.get_CanControl(): - logger.debug('Setting %s.Volume not allowed', PLAYER_IFACE) - return - if value is None: - return - elif value < 0: - self.core.playback.volume = 0 - elif value > 1: - self.core.playback.volume = 100 - elif 0 <= value <= 1: - self.core.playback.volume = int(value * 100) - - def get_Position(self): - return self.core.playback.time_position.get() * 1000 - - def get_CanGoNext(self): - if not self.get_CanControl(): - return False - return ( - self.core.playback.tl_track_at_next.get() != - self.core.playback.current_tl_track.get()) - - def get_CanGoPrevious(self): - if not self.get_CanControl(): - return False - return ( - self.core.playback.tl_track_at_previous.get() != - self.core.playback.current_tl_track.get()) - - def get_CanPlay(self): - if not self.get_CanControl(): - return False - return ( - self.core.playback.current_tl_track.get() is not None or - self.core.playback.tl_track_at_next.get() is not None) - - def get_CanPause(self): - if not self.get_CanControl(): - return False - # NOTE Should be changed to vary based on capabilities of the current - # track if Mopidy starts supporting non-seekable media, like streams. - return True - - def get_CanSeek(self): - if not self.get_CanControl(): - return False - # NOTE Should be changed to vary based on capabilities of the current - # track if Mopidy starts supporting non-seekable media, like streams. - return True - - def get_CanControl(self): - # NOTE This could be a setting for the end user to change. - return True - - ### Playlists interface methods - - @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) - def ActivatePlaylist(self, playlist_id): - logger.debug( - '%s.ActivatePlaylist(%r) called', PLAYLISTS_IFACE, playlist_id) - playlist_uri = self.get_playlist_uri(playlist_id) - playlist = self.core.playlists.lookup(playlist_uri).get() - if playlist and playlist.tracks: - tl_tracks = self.core.tracklist.add(playlist.tracks).get() - self.core.playback.play(tl_tracks[0]) - - @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) - def GetPlaylists(self, index, max_count, order, reverse): - logger.debug( - '%s.GetPlaylists(%r, %r, %r, %r) called', - PLAYLISTS_IFACE, index, max_count, order, reverse) - playlists = self.core.playlists.playlists.get() - if order == 'Alphabetical': - playlists.sort(key=lambda p: p.name, reverse=reverse) - elif order == 'Modified': - playlists.sort(key=lambda p: p.last_modified, reverse=reverse) - elif order == 'User' and reverse: - playlists.reverse() - slice_end = index + max_count - playlists = playlists[index:slice_end] - results = [ - (self.get_playlist_id(p.uri), p.name, '') - for p in playlists] - return dbus.Array(results, signature='(oss)') - - ### Playlists interface signals - - @dbus.service.signal(dbus_interface=PLAYLISTS_IFACE, signature='(oss)') - def PlaylistChanged(self, playlist): - logger.debug('%s.PlaylistChanged signaled', PLAYLISTS_IFACE) - # Do nothing, as just calling the method is enough to emit the signal. - - ### Playlists interface properties - - def get_PlaylistCount(self): - return len(self.core.playlists.playlists.get()) - - def get_Orderings(self): - return [ - 'Alphabetical', # Order by playlist.name - 'Modified', # Order by playlist.last_modified - 'User', # Don't change order - ] - - def get_ActivePlaylist(self): - playlist_is_valid = False - playlist = ('/', 'None', '') - return (playlist_is_valid, playlist) diff --git a/setup.py b/setup.py index 7cfb7409..ff6d49de 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,6 @@ setup( 'http = mopidy.frontends.http:Extension [http]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', - 'mpris = mopidy.frontends.mpris:Extension', 'spotify = mopidy.backends.spotify:Extension [spotify]', 'stream = mopidy.backends.stream:Extension', ], diff --git a/tests/frontends/mpris/__init__.py b/tests/frontends/mpris/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/frontends/mpris/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py deleted file mode 100644 index 0a4bc79f..00000000 --- a/tests/frontends/mpris/events_test.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -try: - import dbus -except ImportError: - dbus = False - -from mopidy.models import Playlist, TlTrack - -if dbus: - from mopidy.frontends.mpris import actor, objects - - -@unittest.skipUnless(dbus, 'dbus not found') -class BackendEventsTest(unittest.TestCase): - def setUp(self): - # As a plain class, not an actor: - self.mpris_frontend = actor.MprisFrontend(config=None, core=None) - self.mpris_object = mock.Mock(spec=objects.MprisObject) - self.mpris_frontend.mpris_object = self.mpris_object - - def test_track_playback_paused_event_changes_playback_status(self): - self.mpris_object.Get.return_value = 'Paused' - self.mpris_frontend.track_playback_paused(TlTrack(), 0) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, {'PlaybackStatus': 'Paused'}, []) - - def test_track_playback_resumed_event_changes_playback_status(self): - self.mpris_object.Get.return_value = 'Playing' - self.mpris_frontend.track_playback_resumed(TlTrack(), 0) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) - - def test_track_playback_started_changes_playback_status_and_metadata(self): - self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_started(TlTrack()) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ((objects.PLAYER_IFACE, 'Metadata'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, - {'Metadata': '...', 'PlaybackStatus': '...'}, []) - - def test_track_playback_ended_changes_playback_status_and_metadata(self): - self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_ended(TlTrack(), 0) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ((objects.PLAYER_IFACE, 'Metadata'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, - {'Metadata': '...', 'PlaybackStatus': '...'}, []) - - def test_volume_changed_event_changes_volume(self): - self.mpris_object.Get.return_value = 1.0 - self.mpris_frontend.volume_changed(volume=100) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'Volume'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, {'Volume': 1.0}, []) - - def test_seeked_event_causes_mpris_seeked_event(self): - self.mpris_frontend.seeked(31000) - self.mpris_object.Seeked.assert_called_with(31000000) - - def test_playlists_loaded_event_changes_playlist_count(self): - self.mpris_object.Get.return_value = 17 - self.mpris_frontend.playlists_loaded() - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYLISTS_IFACE, 'PlaylistCount'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYLISTS_IFACE, {'PlaylistCount': 17}, []) - - def test_playlist_changed_event_causes_mpris_playlist_changed_event(self): - self.mpris_object.get_playlist_id.return_value = 'id-for-dummy:foo' - playlist = Playlist(uri='dummy:foo', name='foo') - self.mpris_frontend.playlist_changed(playlist) - self.mpris_object.PlaylistChanged.assert_called_with( - ('id-for-dummy:foo', 'foo', '')) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py deleted file mode 100644 index 52cd964b..00000000 --- a/tests/frontends/mpris/player_interface_test.py +++ /dev/null @@ -1,869 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -import pykka - -try: - import dbus -except ImportError: - dbus = False - -from mopidy import core -from mopidy.backends import dummy -from mopidy.core import PlaybackState -from mopidy.models import Album, Artist, Track - -if dbus: - from mopidy.frontends.mpris import objects - -PLAYING = PlaybackState.PLAYING -PAUSED = PlaybackState.PAUSED -STOPPED = PlaybackState.STOPPED - - -@unittest.skipUnless(dbus, 'dbus not found') -class PlayerInterfaceTest(unittest.TestCase): - def setUp(self): - objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(config={}, core=self.core) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_get_playback_status_is_playing_when_playing(self): - self.core.playback.state = PLAYING - result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') - self.assertEqual('Playing', result) - - def test_get_playback_status_is_paused_when_paused(self): - self.core.playback.state = PAUSED - result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') - self.assertEqual('Paused', result) - - def test_get_playback_status_is_stopped_when_stopped(self): - self.core.playback.state = STOPPED - result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') - self.assertEqual('Stopped', result) - - def test_get_loop_status_is_none_when_not_looping(self): - self.core.playback.repeat = False - self.core.playback.single = False - result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') - self.assertEqual('None', result) - - def test_get_loop_status_is_track_when_looping_a_single_track(self): - self.core.playback.repeat = True - self.core.playback.single = True - result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') - self.assertEqual('Track', result) - - def test_get_loop_status_is_playlist_when_looping_tracklist(self): - self.core.playback.repeat = True - self.core.playback.single = False - result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') - self.assertEqual('Playlist', result) - - def test_set_loop_status_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.playback.repeat = True - self.core.playback.single = True - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEqual(self.core.playback.repeat.get(), True) - self.assertEqual(self.core.playback.single.get(), True) - - def test_set_loop_status_to_none_unsets_repeat_and_single(self): - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEqual(self.core.playback.repeat.get(), False) - self.assertEqual(self.core.playback.single.get(), False) - - def test_set_loop_status_to_track_sets_repeat_and_single(self): - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEqual(self.core.playback.repeat.get(), True) - self.assertEqual(self.core.playback.single.get(), True) - - def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEqual(self.core.playback.repeat.get(), True) - self.assertEqual(self.core.playback.single.get(), False) - - def test_get_rate_is_greater_or_equal_than_minimum_rate(self): - rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') - minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assertGreaterEqual(rate, minimum_rate) - - def test_get_rate_is_less_or_equal_than_maximum_rate(self): - rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') - maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assertGreaterEqual(rate, maximum_rate) - - def test_set_rate_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_set_rate_to_zero_pauses_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_get_shuffle_returns_true_if_random_is_active(self): - self.core.playback.random = True - result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') - self.assertTrue(result) - - def test_get_shuffle_returns_false_if_random_is_inactive(self): - self.core.playback.random = False - result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') - self.assertFalse(result) - - def test_set_shuffle_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.playback.random = False - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.core.playback.random.get()) - - def test_set_shuffle_to_true_activates_random_mode(self): - self.core.playback.random = False - self.assertFalse(self.core.playback.random.get()) - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.core.playback.random.get()) - - def test_set_shuffle_to_false_deactivates_random_mode(self): - self.core.playback.random = True - self.assertTrue(self.core.playback.random.get()) - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.core.playback.random.get()) - - def test_get_metadata_has_trackid_even_when_no_current_track(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:trackid', result.keys()) - self.assertEqual(result['mpris:trackid'], '') - - def test_get_metadata_has_trackid_based_on_tlid(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.play() - (tlid, track) = self.core.playback.current_tl_track.get() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:trackid', result.keys()) - self.assertEqual( - result['mpris:trackid'], '/com/mopidy/track/%d' % tlid) - - def test_get_metadata_has_track_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:length', result.keys()) - self.assertEqual(result['mpris:length'], 40000000) - - def test_get_metadata_has_track_uri(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:url', result.keys()) - self.assertEqual(result['xesam:url'], 'dummy:a') - - def test_get_metadata_has_track_title(self): - self.core.tracklist.add([Track(name='a')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:title', result.keys()) - self.assertEqual(result['xesam:title'], 'a') - - def test_get_metadata_has_track_artists(self): - self.core.tracklist.add([Track(artists=[ - Artist(name='a'), Artist(name='b'), Artist(name=None)])]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:artist', result.keys()) - self.assertEqual(result['xesam:artist'], ['a', 'b']) - - def test_get_metadata_has_track_album(self): - self.core.tracklist.add([Track(album=Album(name='a'))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:album', result.keys()) - self.assertEqual(result['xesam:album'], 'a') - - def test_get_metadata_has_track_album_artists(self): - self.core.tracklist.add([Track(album=Album(artists=[ - Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:albumArtist', result.keys()) - self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) - - def test_get_metadata_use_first_album_image_as_art_url(self): - # XXX Currently, the album image order isn't preserved because they - # are stored as a frozenset(). We pick the first in the set, which is - # sorted alphabetically, thus we get 'bar.jpg', not 'foo.jpg', which - # would probably make more sense. - self.core.tracklist.add([Track(album=Album(images=[ - 'http://example.com/foo.jpg', 'http://example.com/bar.jpg']))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:artUrl', result.keys()) - self.assertEqual(result['mpris:artUrl'], 'http://example.com/bar.jpg') - - def test_get_metadata_has_no_art_url_if_no_album(self): - self.core.tracklist.add([Track()]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertNotIn('mpris:artUrl', result.keys()) - - def test_get_metadata_has_no_art_url_if_no_album_images(self): - self.core.tracklist.add([Track(Album(images=[]))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertNotIn('mpris:artUrl', result.keys()) - - def test_get_metadata_has_disc_number_in_album(self): - self.core.tracklist.add([Track(disc_no=2)]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:discNumber', result.keys()) - self.assertEqual(result['xesam:discNumber'], 2) - - def test_get_metadata_has_track_number_in_album(self): - self.core.tracklist.add([Track(track_no=7)]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:trackNumber', result.keys()) - self.assertEqual(result['xesam:trackNumber'], 7) - - def test_get_volume_should_return_volume_between_zero_and_one(self): - self.core.playback.volume = None - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 0) - - self.core.playback.volume = 0 - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 0) - - self.core.playback.volume = 50 - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 0.5) - - self.core.playback.volume = 100 - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 1) - - def test_set_volume_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.playback.volume = 0 - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEqual(self.core.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.assertEqual(self.core.playback.volume.get(), 100) - - def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEqual(self.core.playback.volume.get(), 100) - - def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.core.playback.volume = 10 - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEqual(self.core.playback.volume.get(), 10) - - def test_get_position_returns_time_position_in_microseconds(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(10000) - result_in_microseconds = self.mpris.Get( - objects.PLAYER_IFACE, 'Position') - result_in_milliseconds = result_in_microseconds // 1000 - self.assertGreaterEqual(result_in_milliseconds, 10000) - - def test_get_position_when_no_current_track_should_be_zero(self): - result_in_microseconds = self.mpris.Get( - objects.PLAYER_IFACE, 'Position') - result_in_milliseconds = result_in_microseconds // 1000 - self.assertEqual(result_in_milliseconds, 0) - - def test_get_minimum_rate_is_one_or_less(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assertLessEqual(result, 1.0) - - def test_get_maximum_rate_is_one_or_more(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assertGreaterEqual(result, 1.0) - - def test_can_go_next_is_true_if_can_control_and_other_next_track(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') - self.assertTrue(result) - - def test_can_go_next_is_false_if_next_track_is_the_same(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.repeat = True - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') - self.assertFalse(result) - - def test_can_go_next_is_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') - self.assertFalse(result) - - def test_can_go_previous_is_true_if_can_control_and_previous_track(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') - self.assertTrue(result) - - def test_can_go_previous_is_false_if_previous_track_is_the_same(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.repeat = True - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') - self.assertFalse(result) - - def test_can_go_previous_is_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') - self.assertFalse(result) - - def test_can_play_is_true_if_can_control_and_current_track(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.play() - self.assertTrue(self.core.playback.current_track.get()) - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') - self.assertTrue(result) - - def test_can_play_is_false_if_no_current_track(self): - self.mpris.get_CanControl = lambda *_: True - self.assertFalse(self.core.playback.current_track.get()) - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') - self.assertFalse(result) - - def test_can_play_if_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') - self.assertFalse(result) - - def test_can_pause_is_true_if_can_control_and_track_can_be_paused(self): - self.mpris.get_CanControl = lambda *_: True - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') - self.assertTrue(result) - - def test_can_pause_if_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') - self.assertFalse(result) - - def test_can_seek_is_true_if_can_control_is_true(self): - self.mpris.get_CanControl = lambda *_: True - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') - self.assertTrue(result) - - def test_can_seek_is_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') - self.assertFalse(result) - - def test_can_control_is_true(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanControl') - self.assertTrue(result) - - def test_next_is_ignored_if_can_go_next_is_false(self): - self.mpris.get_CanGoNext = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_next_when_playing_skips_to_next_track_and_keep_playing(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_next_when_at_end_of_list_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Next() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.stop() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_previous_is_ignored_if_can_go_previous_is_false(self): - self.mpris.get_CanGoPrevious = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - - def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_previous_when_at_start_of_list_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Previous() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_previous_when_paused_skips_to_previous_track_and_pause(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.core.playback.pause() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_previous_when_stopped_skips_to_previous_track_and_stops(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.core.playback.stop() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_pause_is_ignored_if_can_pause_is_false(self): - self.mpris.get_CanPause = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_pause_when_playing_should_pause_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_pause_when_paused_has_no_effect(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_playpause_is_ignored_if_can_pause_is_false(self): - self.mpris.get_CanPause = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.PlayPause() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_playpause_when_playing_should_pause_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.PlayPause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_playpause_when_paused_should_resume_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - - self.assertEqual(self.core.playback.state.get(), PAUSED) - at_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(at_pause, 0) - - self.mpris.PlayPause() - - self.assertEqual(self.core.playback.state.get(), PLAYING) - after_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(after_pause, at_pause) - - def test_playpause_when_stopped_should_start_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.PlayPause() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_stop_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Stop() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_stop_when_playing_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Stop() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_stop_when_paused_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Stop() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_play_is_ignored_if_can_play_is_false(self): - self.mpris.get_CanPlay = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_play_when_stopped_starts_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_play_after_pause_resumes_from_same_position(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(before_pause, 0) - - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - at_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(at_pause, before_pause) - - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - after_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(after_pause, at_pause) - - def test_play_when_there_is_no_track_has_no_effect(self): - self.core.tracklist.clear() - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_seek_is_ignored_if_can_seek_is_false(self): - self.mpris.get_CanSeek = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 0) - - milliseconds_to_seek = 10000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - after_seek = self.core.playback.time_position.get() - self.assertLessEqual(before_seek, after_seek) - self.assertLess(after_seek, before_seek + milliseconds_to_seek) - - def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 0) - - milliseconds_to_seek = 10000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) - - def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 20000) - - milliseconds_to_seek = -10000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) - self.assertLess(after_seek, before_seek) - - def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 20000) - - milliseconds_to_seek = -30000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) - self.assertLess(after_seek, before_seek) - self.assertGreaterEqual(after_seek, 0) - - def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): - self.core.tracklist.add([ - Track(uri='dummy:a', length=40000), - Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 20000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - milliseconds_to_seek = 50000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, 0) - self.assertLess(after_seek, before_seek) - - def test_set_position_is_ignored_if_can_seek_is_false(self): - self.mpris.get_CanSeek = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_set_position = self.core.playback.time_position.get() - self.assertLessEqual(before_set_position, 5000) - - track_id = 'a' - - position_to_set_in_millisec = 20000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertLessEqual(before_set_position, after_set_position) - self.assertLess(after_set_position, position_to_set_in_millisec) - - def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_set_position = self.core.playback.time_position.get() - self.assertLessEqual(before_set_position, 5000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - - track_id = '/com/mopidy/track/0' - - position_to_set_in_millisec = 20000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual( - after_set_position, position_to_set_in_millisec) - - def test_set_position_does_nothing_if_the_position_is_negative(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(before_set_position, 20000) - self.assertLessEqual(before_set_position, 25000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - track_id = '/com/mopidy/track/0' - - position_to_set_in_millisec = -1000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_set_position_does_nothing_if_position_is_gt_track_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(before_set_position, 20000) - self.assertLessEqual(before_set_position, 25000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - track_id = 'a' - - position_to_set_in_millisec = 50000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_set_position_is_noop_if_track_id_isnt_current_track(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(before_set_position, 20000) - self.assertLessEqual(before_set_position, 25000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - track_id = 'b' - - position_to_set_in_millisec = 0 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_open_uri_is_ignored_if_can_play_is_false(self): - self.mpris.get_CanPlay = lambda *_: False - self.backend.library.dummy_library = [ - Track(uri='dummy:/test/uri')] - self.mpris.OpenUri('dummy:/test/uri') - self.assertEqual(len(self.core.tracklist.tracks.get()), 0) - - def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): - self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='notdummy:/test/uri')] - self.mpris.OpenUri('notdummy:/test/uri') - self.assertEqual(len(self.core.tracklist.tracks.get()), 0) - - def test_open_uri_adds_uri_to_tracklist(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.mpris.OpenUri('dummy:/test/uri') - self.assertEqual( - self.core.tracklist.tracks.get()[0].uri, 'dummy:/test/uri') - - def test_open_uri_starts_playback_of_new_track_if_stopped(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - - self.mpris.OpenUri('dummy:/test/uri') - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual( - self.core.playback.current_track.get().uri, 'dummy:/test/uri') - - def test_open_uri_starts_playback_of_new_track_if_paused(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - self.mpris.OpenUri('dummy:/test/uri') - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual( - self.core.playback.current_track.get().uri, 'dummy:/test/uri') - - def test_open_uri_starts_playback_of_new_track_if_playing(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - self.mpris.OpenUri('dummy:/test/uri') - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual( - self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py deleted file mode 100644 index f8e2cf3e..00000000 --- a/tests/frontends/mpris/playlists_interface_test.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import mock -import unittest - -import pykka - -try: - import dbus -except ImportError: - dbus = False - -from mopidy import core -from mopidy.audio import PlaybackState -from mopidy.backends import dummy -from mopidy.models import Track - -if dbus: - from mopidy.frontends.mpris import objects - - -@unittest.skipUnless(dbus, 'dbus not found') -class PlayerInterfaceTest(unittest.TestCase): - def setUp(self): - objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(config={}, core=self.core) - - foo = self.core.playlists.create('foo').get() - foo = foo.copy(last_modified=datetime.datetime(2012, 3, 1, 6, 0, 0)) - foo = self.core.playlists.save(foo).get() - - bar = self.core.playlists.create('bar').get() - bar = bar.copy(last_modified=datetime.datetime(2012, 2, 1, 6, 0, 0)) - bar = self.core.playlists.save(bar).get() - - baz = self.core.playlists.create('baz').get() - baz = baz.copy(last_modified=datetime.datetime(2012, 1, 1, 6, 0, 0)) - baz = self.core.playlists.save(baz).get() - self.playlist = baz - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_activate_playlist_appends_tracks_to_tracklist(self): - self.core.tracklist.add([ - Track(uri='dummy:old-a'), - Track(uri='dummy:old-b'), - ]) - self.playlist = self.playlist.copy(tracks=[ - Track(uri='dummy:baz-a'), - Track(uri='dummy:baz-b'), - Track(uri='dummy:baz-c'), - ]) - self.playlist = self.core.playlists.save(self.playlist).get() - - self.assertEqual(2, self.core.tracklist.length.get()) - - playlists = self.mpris.GetPlaylists(0, 100, 'User', False) - playlist_id = playlists[2][0] - self.mpris.ActivatePlaylist(playlist_id) - - self.assertEqual(5, self.core.tracklist.length.get()) - self.assertEqual( - PlaybackState.PLAYING, self.core.playback.state.get()) - self.assertEqual( - self.playlist.tracks[0], self.core.playback.current_track.get()) - - def test_activate_empty_playlist_is_harmless(self): - self.assertEqual(0, self.core.tracklist.length.get()) - - playlists = self.mpris.GetPlaylists(0, 100, 'User', False) - playlist_id = playlists[2][0] - self.mpris.ActivatePlaylist(playlist_id) - - self.assertEqual(0, self.core.tracklist.length.get()) - self.assertEqual( - PlaybackState.STOPPED, self.core.playback.state.get()) - self.assertIsNone(self.core.playback.current_track.get()) - - def test_get_playlists_in_alphabetical_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', False) - - self.assertEqual(3, len(result)) - - self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC4Q_', result[0][0]) - self.assertEqual('bar', result[0][1]) - - self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC6Q_', result[1][0]) - self.assertEqual('baz', result[1][1]) - - self.assertEqual('/com/mopidy/playlist/MR2W23LZHJTG63Y_', result[2][0]) - self.assertEqual('foo', result[2][1]) - - def test_get_playlists_in_reverse_alphabetical_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', True) - - self.assertEqual(3, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('baz', result[1][1]) - self.assertEqual('bar', result[2][1]) - - def test_get_playlists_in_modified_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Modified', False) - - self.assertEqual(3, len(result)) - self.assertEqual('baz', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('foo', result[2][1]) - - def test_get_playlists_in_reverse_modified_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Modified', True) - - self.assertEqual(3, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('baz', result[2][1]) - - def test_get_playlists_in_user_order(self): - result = self.mpris.GetPlaylists(0, 100, 'User', False) - - self.assertEqual(3, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('baz', result[2][1]) - - def test_get_playlists_in_reverse_user_order(self): - result = self.mpris.GetPlaylists(0, 100, 'User', True) - - self.assertEqual(3, len(result)) - self.assertEqual('baz', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('foo', result[2][1]) - - def test_get_playlists_slice_on_start_of_list(self): - result = self.mpris.GetPlaylists(0, 2, 'User', False) - - self.assertEqual(2, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('bar', result[1][1]) - - def test_get_playlists_slice_later_in_list(self): - result = self.mpris.GetPlaylists(2, 2, 'User', False) - - self.assertEqual(1, len(result)) - self.assertEqual('baz', result[0][1]) - - def test_get_playlist_count_returns_number_of_playlists(self): - result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'PlaylistCount') - - self.assertEqual(3, result) - - def test_get_orderings_includes_alpha_modified_and_user(self): - result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'Orderings') - - self.assertIn('Alphabetical', result) - self.assertNotIn('Created', result) - self.assertIn('Modified', result) - self.assertNotIn('Played', result) - self.assertIn('User', result) - - def test_get_active_playlist_does_not_return_a_playlist(self): - result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'ActivePlaylist') - valid, playlist = result - playlist_id, playlist_name, playlist_icon_uri = playlist - - self.assertEqual(False, valid) - self.assertEqual('/', playlist_id) - self.assertEqual('None', playlist_name) - self.assertEqual('', playlist_icon_uri) diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py deleted file mode 100644 index f95f0969..00000000 --- a/tests/frontends/mpris/root_interface_test.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -import pykka - -try: - import dbus -except ImportError: - dbus = False - -from mopidy import core -from mopidy.backends import dummy - -if dbus: - from mopidy.frontends.mpris import objects - - -@unittest.skipUnless(dbus, 'dbus not found') -class RootInterfaceTest(unittest.TestCase): - def setUp(self): - config = { - 'mpris': { - 'desktop_file': '/tmp/foo.desktop', - } - } - - objects.exit_process = mock.Mock() - objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(config=config, core=self.core) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_constructor_connects_to_dbus(self): - self.assert_(self.mpris._connect_to_dbus.called) - - def test_fullscreen_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'Fullscreen') - self.assertFalse(result) - - def test_setting_fullscreen_fails_and_returns_none(self): - result = self.mpris.Set(objects.ROOT_IFACE, 'Fullscreen', 'True') - self.assertIsNone(result) - - def test_can_set_fullscreen_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'CanSetFullscreen') - self.assertFalse(result) - - def test_can_raise_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'CanRaise') - self.assertFalse(result) - - def test_raise_does_nothing(self): - self.mpris.Raise() - - def test_can_quit_returns_true(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'CanQuit') - self.assertTrue(result) - - def test_quit_should_stop_all_actors(self): - self.mpris.Quit() - self.assert_(objects.exit_process.called) - - def test_has_track_list_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'HasTrackList') - self.assertFalse(result) - - def test_identify_is_mopidy(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'Identity') - self.assertEquals(result, 'Mopidy') - - def test_desktop_entry_is_based_on_DESKTOP_FILE_setting(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') - self.assertEquals(result, 'foo') - - def test_supported_uri_schemes_includes_backend_uri_schemes(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes') - self.assertEquals(len(result), 1) - self.assertEquals(result[0], 'dummy') - - def test_supported_mime_types_is_empty(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedMimeTypes') - self.assertEquals(len(result), 0) From ec66bf1f1eed7061686a8bae1a5326d34e6958da Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 7 Oct 2013 23:44:41 +0200 Subject: [PATCH 42/60] docs: Update changelog --- docs/changelog.rst | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7f919f79..85f35a91 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,29 +7,36 @@ This changelog is used to track all major changes to Mopidy. v0.16.0 (UNRELEASED) ==================== -**Extensions** - -- A cookiecutter project for quickly creating new Mopidy extensions have been - created. You can find it at `cookiecutter-mopidy-ext - `_. (Fixes: :issue:`522`) +**Dependencies** - The Last.fm scrobbler has been moved to its own external extension, `Mopidy-Scrobbler `_. You'll need to install it in addition to Mopidy if you want it to continue to work as it used to. +- The MPRIS frontend has been moved to its own external extension, + `Mopidy-MPRIS `_. You'll need to + install it in addition to Mopidy if you want it to continue to work as it + used to. + **Audio** -- Added support for parsing and playback of playlists in GStreamer. What this - means for end users is basically that you can now add an radio playlist to - Mopidy and we will automatically download it and play the stream inside it. - Currently we support M3U, PLS, XSPF and ASX files, also note that we can +- Added support for parsing and playback of playlists in GStreamer. For end + users this basically means that you can now add a radio playlist to Mopidy + and we will automatically download it and play the stream inside it. + Currently we support M3U, PLS, XSPF and ASX files. Also note that we can currently only play the first stream in the playlist. - We now handle the rare case where an audio track has max volume equal to min. This was causing divide by zero errors when scaling volumes to a zero to hundred scale. (Fixes: :issue:`525`) +**Extension support** + +- A cookiecutter project for quickly creating new Mopidy extensions have been + created. You can find it at `cookiecutter-mopidy-ext + `_. (Fixes: :issue:`522`) + v0.15.0 (2013-09-19) ==================== From f9a6fa525acc646c93940b0359e284e1bfedd632 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 8 Oct 2013 08:51:33 +0200 Subject: [PATCH 43/60] Bump version number for compat with extracted extensions --- 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 6ef80b0f..8ba54f4e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.15.0' +__version__ = '0.16.0a1' diff --git a/tests/version_test.py b/tests/version_test.py index 6503ef39..94fe4544 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -39,5 +39,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.13.0'), SV('0.14.0')) self.assertLess(SV('0.14.0'), SV('0.14.1')) self.assertLess(SV('0.14.1'), SV('0.14.2')) - self.assertLess(SV('0.14.2'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.15.1')) + self.assertLess(SV('0.14.2'), SV('0.15.0')) + self.assertLess(SV('0.15.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.16.1')) From 623f9605522f32e8c10297ca7b11d353bef468f8 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 8 Oct 2013 09:20:02 +0200 Subject: [PATCH 44/60] Improving a little the mute code, but still don't know how to mute at mixer level --- mopidy/audio/actor.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4fc4b91b..912cdfd3 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -538,10 +538,35 @@ class Audio(pykka.ThreadingActor): return self._mixer.get_volume(self._mixer_track) == volumes def get_mute(self): - return self._playbin.get_property('mute') + """ + Get mute status + + Example values: + + True: + Muted. + False: + Unmuted. + + :rtype: :class:`True` if muted, else :class:`False` + """ + if self._software_mixing: + return self._playbin.get_property('mute') + else: + pass def set_mute(self, status): - self._playbin.set_property('mute', bool(status)) + """ + Set mute level of the configured element. + + :param status: The new value for mute + :type status: bool + :rtype: :class:`True` if successful, else :class:`False` + """ + if self._software_mixing: + return self._playbin.set_property('mute', bool(status)) + else: + return False def _rescale(self, value, old=None, new=None): """Convert value between scales.""" From 4305afb81d630891acff344d7e1503d40f7ad019 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 8 Oct 2013 10:19:25 +0200 Subject: [PATCH 45/60] Now it's also prepared for mixer level, thanks adam --- mopidy/audio/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 912cdfd3..f274c380 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -553,7 +553,7 @@ class Audio(pykka.ThreadingActor): if self._software_mixing: return self._playbin.get_property('mute') else: - pass + return bool(self._mixer_track & gst.interfaces.MIXER_TRACK_MUTE) def set_mute(self, status): """ @@ -566,7 +566,7 @@ class Audio(pykka.ThreadingActor): if self._software_mixing: return self._playbin.set_property('mute', bool(status)) else: - return False + return self._mixer.set_mute(self._mixer_track, bool(status)) def _rescale(self, value, old=None, new=None): """Convert value between scales.""" From 7d20f372bd474d85f14a900c69a2ceb77f923e1b Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 8 Oct 2013 11:51:02 +0200 Subject: [PATCH 46/60] Following thomas' suggestions, correct mixer mute --- mopidy/audio/actor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f274c380..83640415 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -552,8 +552,9 @@ class Audio(pykka.ThreadingActor): """ if self._software_mixing: return self._playbin.get_property('mute') - else: - return bool(self._mixer_track & gst.interfaces.MIXER_TRACK_MUTE) + elif self._mixer_track is not None: + return bool(self._mixer_track.flags & + gst.interfaces.MIXER_TRACK_MUTE) def set_mute(self, status): """ @@ -565,7 +566,7 @@ class Audio(pykka.ThreadingActor): """ if self._software_mixing: return self._playbin.set_property('mute', bool(status)) - else: + elif self._mixer_track is not None: return self._mixer.set_mute(self._mixer_track, bool(status)) def _rescale(self, value, old=None, new=None): From e7d6a995e8c395759e8f72254adb776b387871a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 8 Oct 2013 22:42:26 +0200 Subject: [PATCH 47/60] spotify: Move to external extension --- MANIFEST.in | 1 - docs/changelog.rst | 19 +- docs/ext/index.rst | 11 +- docs/ext/spotify.rst | 83 -------- docs/index.rst | 6 +- mopidy/backends/spotify/__init__.py | 36 ---- mopidy/backends/spotify/actor.py | 37 ---- mopidy/backends/spotify/container_manager.py | 51 ----- mopidy/backends/spotify/ext.conf | 7 - mopidy/backends/spotify/library.py | 211 ------------------- mopidy/backends/spotify/playback.py | 94 --------- mopidy/backends/spotify/playlist_manager.py | 105 --------- mopidy/backends/spotify/playlists.py | 22 -- mopidy/backends/spotify/session_manager.py | 201 ------------------ mopidy/backends/spotify/spotify_appkey.key | Bin 321 -> 0 bytes mopidy/backends/spotify/translator.py | 97 --------- requirements/spotify.txt | 8 - setup.py | 3 - 18 files changed, 24 insertions(+), 968 deletions(-) delete mode 100644 docs/ext/spotify.rst delete mode 100644 mopidy/backends/spotify/__init__.py delete mode 100644 mopidy/backends/spotify/actor.py delete mode 100644 mopidy/backends/spotify/container_manager.py delete mode 100644 mopidy/backends/spotify/ext.conf delete mode 100644 mopidy/backends/spotify/library.py delete mode 100644 mopidy/backends/spotify/playback.py delete mode 100644 mopidy/backends/spotify/playlist_manager.py delete mode 100644 mopidy/backends/spotify/playlists.py delete mode 100644 mopidy/backends/spotify/session_manager.py delete mode 100644 mopidy/backends/spotify/spotify_appkey.key delete mode 100644 mopidy/backends/spotify/translator.py delete mode 100644 requirements/spotify.txt diff --git a/MANIFEST.in b/MANIFEST.in index 6385e4ff..84122dcc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,6 @@ include *.rst include LICENSE include MANIFEST.in include data/mopidy.desktop -include mopidy/backends/spotify/spotify_appkey.key include pylintrc recursive-include docs * diff --git a/docs/changelog.rst b/docs/changelog.rst index 85f35a91..e3fa167d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,15 +9,18 @@ v0.16.0 (UNRELEASED) **Dependencies** -- The Last.fm scrobbler has been moved to its own external extension, - `Mopidy-Scrobbler `_. You'll need - to install it in addition to Mopidy if you want it to continue to work as it - used to. +Parts of Mopidy have been moved to their own external extensions. If you want +Mopidy to continue to work like it used to, you may have to install one or more +of the following extensions as well: -- The MPRIS frontend has been moved to its own external extension, - `Mopidy-MPRIS `_. You'll need to - install it in addition to Mopidy if you want it to continue to work as it - used to. +- The Spotify backend has been moved to + `Mopidy-Scrobbler `_. + +- The Last.fm scrobbler has been moved to + `Mopidy-Scrobbler `_. + +- The MPRIS frontend has been moved to + `Mopidy-MPRIS `_. **Audio** diff --git a/docs/ext/index.rst b/docs/ext/index.rst index bdc1efe8..a909883d 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -87,10 +87,19 @@ Mopidy-SoundCloud https://github.com/mopidy/mopidy-soundcloud -Provides a backend for playing music from the `SoundCloud +rovides a backend for playing music from the `SoundCloud `_ service. +Mopidy-Spotify +-------------- + +https://github.com/mopidy/mopidy-spotify + +Extension for playing music from the `Spotify `_ music +streaming service. + + Mopidy-Subsonic --------------- diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst deleted file mode 100644 index 4bb5b7a3..00000000 --- a/docs/ext/spotify.rst +++ /dev/null @@ -1,83 +0,0 @@ -.. _ext-spotify: - -************** -Mopidy-Spotify -************** - -An extension for playing music from Spotify. - -`Spotify `_ is a music streaming service. The backend -uses the official `libspotify -`_ library and the -`pyspotify `_ Python bindings for -libspotify. This backend handles URIs starting with ``spotify:``. - -.. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - - -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=Spotify+backend - - -Dependencies -============ - -.. literalinclude:: ../../requirements/spotify.txt - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/spotify/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: spotify/enabled - - If the Spotify extension should be enabled or not. - -.. confval:: spotify/username - - Your Spotify Premium username. - -.. confval:: spotify/password - - Your Spotify Premium password. - -.. confval:: spotify/bitrate - - The preferred audio bitrate. Valid values are 96, 160, 320. - -.. confval:: spotify/timeout - - Max number of seconds to wait for Spotify operations to complete. - -.. confval:: spotify/cache_dir - - Path to the Spotify data cache. Cannot be shared with other Spotify apps. - - -Usage -===== - -If you are using the Spotify backend, which is the default, enter your Spotify -Premium account's username and password into ``~/.config/mopidy/mopidy.conf``, -like this: - -.. code-block:: ini - - [spotify] - username = myusername - password = mysecret - -This will only work if you have the Spotify Premium subscription. Spotify -Unlimited will not work. diff --git a/docs/index.rst b/docs/index.rst index ca40c96c..c5183471 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,9 +4,9 @@ Mopidy Mopidy is a music server which can play music both from multiple sources, like your :ref:`local hard drive `, :ref:`radio streams `, -and from :ref:`Spotify ` and SoundCloud. Searches combines results -from all music sources, and you can mix tracks from all sources in your play -queue. Your playlists from Spotify or SoundCloud are also available for use. +and from Spotify and SoundCloud. Searches combines results from all music +sources, and you can mix tracks from all sources in your play queue. Your +playlists from Spotify or SoundCloud are also available for use. To control your Mopidy music server, you can use one of Mopidy's :ref:`web clients `, the :ref:`Ubuntu Sound Menu `, any diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py deleted file mode 100644 index 3cee609a..00000000 --- a/mopidy/backends/spotify/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, exceptions, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Spotify' - ext_name = 'spotify' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['username'] = config.String() - schema['password'] = config.Secret() - schema['bitrate'] = config.Integer(choices=(96, 160, 320)) - schema['timeout'] = config.Integer(minimum=0) - schema['cache_dir'] = config.Path() - return schema - - def validate_environment(self): - try: - import spotify # noqa - except ImportError as e: - raise exceptions.ExtensionError('pyspotify library not found', e) - - def get_backend_classes(self): - from .actor import SpotifyBackend - return [SpotifyBackend] diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py deleted file mode 100644 index 1f90ba51..00000000 --- a/mopidy/backends/spotify/actor.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -import logging - -import pykka - -from mopidy.backends import base -from mopidy.backends.spotify.library import SpotifyLibraryProvider -from mopidy.backends.spotify.playback import SpotifyPlaybackProvider -from mopidy.backends.spotify.session_manager import SpotifySessionManager -from mopidy.backends.spotify.playlists import SpotifyPlaylistsProvider - -logger = logging.getLogger('mopidy.backends.spotify') - - -class SpotifyBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(SpotifyBackend, self).__init__() - - self.config = config - - self.library = SpotifyLibraryProvider(backend=self) - self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) - self.playlists = SpotifyPlaylistsProvider(backend=self) - - self.uri_schemes = ['spotify'] - - self.spotify = SpotifySessionManager( - config, audio=audio, backend_ref=self.actor_ref) - - def on_start(self): - logger.info('Mopidy uses SPOTIFY(R) CORE') - logger.debug('Connecting to Spotify') - self.spotify.start() - - def on_stop(self): - self.spotify.logout() diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py deleted file mode 100644 index e8d1ed0b..00000000 --- a/mopidy/backends/spotify/container_manager.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import unicode_literals - -import logging - -from spotify.manager import SpotifyContainerManager as \ - PyspotifyContainerManager - -logger = logging.getLogger('mopidy.backends.spotify') - - -class SpotifyContainerManager(PyspotifyContainerManager): - def __init__(self, session_manager): - PyspotifyContainerManager.__init__(self) - self.session_manager = session_manager - - def container_loaded(self, container, userdata): - """Callback used by pyspotify""" - logger.debug('Callback called: playlist container loaded') - - self.session_manager.refresh_playlists() - - count = 0 - for playlist in self.session_manager.session.playlist_container(): - if playlist.type() == 'playlist': - self.session_manager.playlist_manager.watch(playlist) - count += 1 - logger.debug('Watching %d playlist(s) for changes', count) - - def playlist_added(self, container, playlist, position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: playlist added at position %d', position) - # container_loaded() is called after this callback, so we do not need - # to handle this callback. - - def playlist_moved(self, container, playlist, old_position, new_position, - userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: playlist "%s" moved from position %d to %d', - playlist.name(), old_position, new_position) - # container_loaded() is called after this callback, so we do not need - # to handle this callback. - - def playlist_removed(self, container, playlist, position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: playlist "%s" removed from position %d', - playlist.name(), position) - # container_loaded() is called after this callback, so we do not need - # to handle this callback. diff --git a/mopidy/backends/spotify/ext.conf b/mopidy/backends/spotify/ext.conf deleted file mode 100644 index 83bf191a..00000000 --- a/mopidy/backends/spotify/ext.conf +++ /dev/null @@ -1,7 +0,0 @@ -[spotify] -enabled = true -username = -password = -bitrate = 160 -timeout = 10 -cache_dir = $XDG_CACHE_DIR/mopidy/spotify diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py deleted file mode 100644 index 49caa709..00000000 --- a/mopidy/backends/spotify/library.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import unicode_literals - -import logging -import time -import urllib - -import pykka -from spotify import Link, SpotifyError - -from mopidy.backends import base -from mopidy.models import Track, SearchResult - -from . import translator - -logger = logging.getLogger('mopidy.backends.spotify') - -TRACK_AVAILABLE = 1 - - -class SpotifyTrack(Track): - """Proxy object for unloaded Spotify tracks.""" - def __init__(self, uri=None, track=None): - super(SpotifyTrack, self).__init__() - if (uri and track) or (not uri and not track): - raise AttributeError('uri or track must be provided') - elif uri: - self._spotify_track = Link.from_string(uri).as_track() - elif track: - self._spotify_track = track - self._unloaded_track = Track(uri=uri, name='[loading...]') - self._track = None - - @property - def _proxy(self): - if self._track: - return self._track - elif self._spotify_track.is_loaded(): - self._track = translator.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): - return hash(self._proxy.uri) - - def __eq__(self, other): - if not isinstance(other, Track): - return False - return self._proxy.uri == other.uri - - def copy(self, **values): - return self._proxy.copy(**values) - - -class SpotifyLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(SpotifyLibraryProvider, self).__init__(*args, **kwargs) - self._timeout = self.backend.config['spotify']['timeout'] - - def find_exact(self, query=None, uris=None): - return self.search(query=query, uris=uris) - - def lookup(self, uri): - try: - link = Link.from_string(uri) - if link.type() == Link.LINK_TRACK: - return self._lookup_track(uri) - if link.type() == Link.LINK_ALBUM: - return self._lookup_album(uri) - elif link.type() == Link.LINK_ARTIST: - return self._lookup_artist(uri) - elif link.type() == Link.LINK_PLAYLIST: - return self._lookup_playlist(uri) - else: - return [] - except SpotifyError as error: - logger.debug(u'Failed to lookup "%s": %s', uri, error) - return [] - - def _lookup_track(self, uri): - track = Link.from_string(uri).as_track() - self._wait_for_object_to_load(track) - if track.is_loaded(): - if track.availability() == TRACK_AVAILABLE: - return [SpotifyTrack(track=track)] - else: - return [] - else: - return [SpotifyTrack(uri=uri)] - - def _lookup_album(self, uri): - album = Link.from_string(uri).as_album() - album_browser = self.backend.spotify.session.browse_album(album) - self._wait_for_object_to_load(album_browser) - return [ - SpotifyTrack(track=t) - for t in album_browser if t.availability() == TRACK_AVAILABLE] - - def _lookup_artist(self, uri): - artist = Link.from_string(uri).as_artist() - artist_browser = self.backend.spotify.session.browse_artist(artist) - self._wait_for_object_to_load(artist_browser) - return [ - SpotifyTrack(track=t) - for t in artist_browser if t.availability() == TRACK_AVAILABLE] - - def _lookup_playlist(self, uri): - playlist = Link.from_string(uri).as_playlist() - self._wait_for_object_to_load(playlist) - return [ - SpotifyTrack(track=t) - for t in playlist if t.availability() == TRACK_AVAILABLE] - - def _wait_for_object_to_load(self, spotify_obj, timeout=None): - # XXX Sleeping to wait for the Spotify object to load is an ugly hack, - # but it works. We should look into other solutions for this. - if timeout is None: - timeout = self._timeout - wait_until = time.time() + timeout - while not spotify_obj.is_loaded(): - time.sleep(0.1) - if time.time() > wait_until: - logger.debug( - 'Timeout: Spotify object did not load in %ds', timeout) - return - - def refresh(self, uri=None): - pass # TODO - - def search(self, query=None, uris=None): - # TODO Only return results within URI roots given by ``uris`` - - if not query: - return self._get_all_tracks() - - uris = query.get('uri', []) - if uris: - tracks = [] - for uri in uris: - tracks += self.lookup(uri) - if len(uris) == 1: - uri = uris[0] - else: - uri = 'spotify:search' - return SearchResult(uri=uri, tracks=tracks) - - spotify_query = self._translate_search_query(query) - logger.debug('Spotify search query: %s' % spotify_query) - - future = pykka.ThreadingFuture() - - def callback(results, userdata=None): - search_result = SearchResult( - uri='spotify:search:%s' % ( - urllib.quote(results.query().encode('utf-8'))), - albums=[ - translator.to_mopidy_album(a) for a in results.albums()], - artists=[ - translator.to_mopidy_artist(a) for a in results.artists()], - tracks=[ - translator.to_mopidy_track(t) for t in results.tracks()]) - future.set(search_result) - - if not self.backend.spotify.connected.is_set(): - logger.debug('Not connected: Spotify search cancelled') - return SearchResult(uri='spotify:search') - - self.backend.spotify.session.search( - spotify_query, callback, - album_count=200, artist_count=200, track_count=200) - - try: - return future.get(timeout=self._timeout) - except pykka.Timeout: - logger.debug( - 'Timeout: Spotify search did not return in %ds', self._timeout) - return SearchResult(uri='spotify:search') - - def _get_all_tracks(self): - # Since we can't search for the entire Spotify library, we return - # all tracks in the playlists when the query is empty. - tracks = [] - for playlist in self.backend.playlists.playlists: - tracks += playlist.tracks - return SearchResult(uri='spotify:search', tracks=tracks) - - def _translate_search_query(self, mopidy_query): - spotify_query = [] - for (field, values) in mopidy_query.iteritems(): - if field == 'date': - field = 'year' - if not hasattr(values, '__iter__'): - values = [values] - for value in values: - if field == 'any': - spotify_query.append(value) - elif field == 'year': - value = int(value.split('-')[0]) # Extract year - spotify_query.append('%s:%d' % (field, value)) - else: - spotify_query.append('%s:"%s"' % (field, value)) - spotify_query = ' '.join(spotify_query) - return spotify_query diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py deleted file mode 100644 index bda17634..00000000 --- a/mopidy/backends/spotify/playback.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import unicode_literals - -import logging -import functools - -from spotify import Link, SpotifyError - -from mopidy import audio -from mopidy.backends import base - - -logger = logging.getLogger('mopidy.backends.spotify') - - -def need_data_callback(spotify_backend, length_hint): - spotify_backend.playback.on_need_data(length_hint) - - -def enough_data_callback(spotify_backend): - spotify_backend.playback.on_enough_data() - - -def seek_data_callback(spotify_backend, time_position): - spotify_backend.playback.on_seek_data(time_position) - - -class SpotifyPlaybackProvider(base.BasePlaybackProvider): - # These GStreamer caps matches the audio data provided by libspotify - _caps = ( - 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' - 'width=(int)16, depth=(int)16, signed=(boolean)true, ' - 'rate=(int)44100') - - def __init__(self, *args, **kwargs): - super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - self._first_seek = False - - def play(self, track): - if track.uri is None: - return False - - spotify_backend = self.backend.actor_ref.proxy() - need_data_callback_bound = functools.partial( - need_data_callback, spotify_backend) - enough_data_callback_bound = functools.partial( - enough_data_callback, spotify_backend) - seek_data_callback_bound = functools.partial( - seek_data_callback, spotify_backend) - - self._first_seek = True - - try: - self.backend.spotify.session.load( - Link.from_string(track.uri).as_track()) - self.backend.spotify.session.play(1) - self.backend.spotify.buffer_timestamp = 0 - - self.audio.prepare_change() - self.audio.set_appsrc( - self._caps, - need_data=need_data_callback_bound, - enough_data=enough_data_callback_bound, - seek_data=seek_data_callback_bound) - self.audio.start_playback() - self.audio.set_metadata(track) - - return True - except SpotifyError as e: - logger.info('Playback of %s failed: %s', track.uri, e) - return False - - def stop(self): - self.backend.spotify.session.play(0) - return super(SpotifyPlaybackProvider, self).stop() - - def on_need_data(self, length_hint): - logger.debug('playback.on_need_data(%d) called', length_hint) - self.backend.spotify.push_audio_data = True - - def on_enough_data(self): - logger.debug('playback.on_enough_data() called') - self.backend.spotify.push_audio_data = False - - def on_seek_data(self, time_position): - logger.debug('playback.on_seek_data(%d) called', time_position) - - if time_position == 0 and self._first_seek: - self._first_seek = False - logger.debug('Skipping seek due to issue #300') - return - - self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime( - time_position) - self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py deleted file mode 100644 index 6cd6d4ed..00000000 --- a/mopidy/backends/spotify/playlist_manager.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import logging - -from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager - -logger = logging.getLogger('mopidy.backends.spotify') - - -class SpotifyPlaylistManager(PyspotifyPlaylistManager): - def __init__(self, session_manager): - PyspotifyPlaylistManager.__init__(self) - self.session_manager = session_manager - - def tracks_added(self, playlist, tracks, position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: ' - '%d track(s) added to position %d in playlist "%s"', - len(tracks), position, playlist.name()) - self.session_manager.refresh_playlists() - - def tracks_moved(self, playlist, tracks, new_position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: ' - '%d track(s) moved to position %d in playlist "%s"', - len(tracks), new_position, playlist.name()) - self.session_manager.refresh_playlists() - - def tracks_removed(self, playlist, tracks, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: ' - '%d track(s) removed from playlist "%s"', - len(tracks), playlist.name()) - self.session_manager.refresh_playlists() - - def playlist_renamed(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Playlist renamed to "%s"', playlist.name()) - self.session_manager.refresh_playlists() - - def playlist_state_changed(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: The state of playlist "%s" changed', - playlist.name()) - - def playlist_update_in_progress(self, playlist, done, userdata): - """Callback used by pyspotify""" - if done: - logger.debug( - 'Callback called: Update of playlist "%s" done', - playlist.name()) - else: - logger.debug( - 'Callback called: Update of playlist "%s" in progress', - playlist.name()) - - def playlist_metadata_updated(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Metadata updated for playlist "%s"', - playlist.name()) - - def track_created_changed(self, playlist, position, user, when, userdata): - """Callback used by pyspotify""" - when = datetime.datetime.fromtimestamp(when) - logger.debug( - 'Callback called: Created by/when for track %d in playlist ' - '"%s" changed to user "N/A" and time "%s"', - position, playlist.name(), when) - - def track_message_changed(self, playlist, position, message, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Message for track %d in playlist ' - '"%s" changed to "%s"', position, playlist.name(), message) - - def track_seen_changed(self, playlist, position, seen, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Seen attribute for track %d in playlist ' - '"%s" changed to "%s"', position, playlist.name(), seen) - - def description_changed(self, playlist, description, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Description changed for playlist "%s" to "%s"', - playlist.name(), description) - - def subscribers_changed(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Subscribers changed for playlist "%s"', - playlist.name()) - - def image_changed(self, playlist, image, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Image changed for playlist "%s"', - playlist.name()) diff --git a/mopidy/backends/spotify/playlists.py b/mopidy/backends/spotify/playlists.py deleted file mode 100644 index bd201179..00000000 --- a/mopidy/backends/spotify/playlists.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.backends import base - - -class SpotifyPlaylistsProvider(base.BasePlaylistsProvider): - def create(self, name): - pass # TODO - - def delete(self, uri): - pass # TODO - - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass # TODO - - def save(self, playlist): - pass # TODO diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py deleted file mode 100644 index 3ab4498b..00000000 --- a/mopidy/backends/spotify/session_manager.py +++ /dev/null @@ -1,201 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import threading - -from spotify.manager import SpotifySessionManager as PyspotifySessionManager - -from mopidy import audio -from mopidy.backends.listener import BackendListener -from mopidy.utils import process, versioning - -from . import translator -from .container_manager import SpotifyContainerManager -from .playlist_manager import SpotifyPlaylistManager - -logger = logging.getLogger('mopidy.backends.spotify') - -BITRATES = {96: 2, 160: 0, 320: 1} - - -class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): - cache_location = None - settings_location = None - appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') - user_agent = 'Mopidy %s' % versioning.get_version() - - def __init__(self, config, audio, backend_ref): - - self.cache_location = config['spotify']['cache_dir'] - self.settings_location = config['spotify']['cache_dir'] - - full_proxy = '' - if config['proxy']['hostname']: - full_proxy = config['proxy']['hostname'] - if config['proxy']['port']: - full_proxy += ':' + str(config['proxy']['port']) - if config['proxy']['scheme']: - full_proxy = config['proxy']['scheme'] + "://" + full_proxy - - PyspotifySessionManager.__init__( - self, config['spotify']['username'], config['spotify']['password'], - proxy=full_proxy, - proxy_username=config['proxy']['username'], - proxy_password=config['proxy']['password']) - - process.BaseThread.__init__(self) - self.name = 'SpotifyThread' - - self.audio = audio - self.backend = None - self.backend_ref = backend_ref - - self.bitrate = config['spotify']['bitrate'] - - self.connected = threading.Event() - self.push_audio_data = True - self.buffer_timestamp = 0 - - self.container_manager = None - self.playlist_manager = None - - self._initial_data_receive_completed = False - - def run_inside_try(self): - self.backend = self.backend_ref.proxy() - self.connect() - - def logged_in(self, session, error): - """Callback used by pyspotify""" - if error: - logger.error('Spotify login error: %s', error) - return - - logger.info('Connected to Spotify') - - # To work with both pyspotify 1.9 and 1.10 - if not hasattr(self, 'session'): - self.session = session - - logger.debug('Preferred Spotify bitrate is %d kbps', self.bitrate) - session.set_preferred_bitrate(BITRATES[self.bitrate]) - - self.container_manager = SpotifyContainerManager(self) - self.playlist_manager = SpotifyPlaylistManager(self) - - self.container_manager.watch(session.playlist_container()) - - self.connected.set() - - def logged_out(self, session): - """Callback used by pyspotify""" - logger.info('Disconnected from Spotify') - self.connected.clear() - - def metadata_updated(self, session): - """Callback used by pyspotify""" - logger.debug('Callback called: Metadata updated') - - def connection_error(self, session, error): - """Callback used by pyspotify""" - if error is None: - logger.info('Spotify connection OK') - else: - logger.error('Spotify connection error: %s', error) - if self.audio.state.get() == audio.PlaybackState.PLAYING: - self.backend.playback.pause() - - def message_to_user(self, session, message): - """Callback used by pyspotify""" - logger.debug('User message: %s', message.strip()) - - def music_delivery(self, session, frames, frame_size, num_frames, - sample_type, sample_rate, channels): - """Callback used by pyspotify""" - if not self.push_audio_data: - return 0 - - assert sample_type == 0, 'Expects 16-bit signed integer samples' - capabilites = """ - audio/x-raw-int, - endianness=(int)1234, - channels=(int)%(channels)d, - width=(int)16, - depth=(int)16, - signed=(boolean)true, - rate=(int)%(sample_rate)d - """ % { - 'sample_rate': sample_rate, - 'channels': channels, - } - - duration = audio.calculate_duration(num_frames, sample_rate) - buffer_ = audio.create_buffer(bytes(frames), - capabilites=capabilites, - timestamp=self.buffer_timestamp, - duration=duration) - - self.buffer_timestamp += duration - - if self.audio.emit_data(buffer_).get(): - return num_frames - else: - return 0 - - def play_token_lost(self, session): - """Callback used by pyspotify""" - logger.debug('Play token lost') - self.backend.playback.pause() - - def log_message(self, session, data): - """Callback used by pyspotify""" - logger.debug('System message: %s' % data.strip()) - if 'offline-mgr' in data and 'files unlocked' in data: - # XXX This is a very very fragile and ugly hack, but we get no - # proper event when libspotify is done with initial data loading. - # We delay the expensive refresh of Mopidy's playlists until this - # message arrives. This way, we avoid doing the refresh once for - # every playlist or other change. This reduces the time from - # startup until the Spotify backend is ready from 35s to 12s in one - # test with clean Spotify cache. In cases with an outdated cache - # the time improvements should be a lot greater. - if not self._initial_data_receive_completed: - self._initial_data_receive_completed = True - self.refresh_playlists() - - def end_of_track(self, session): - """Callback used by pyspotify""" - logger.debug('End of data stream reached') - self.audio.emit_end_of_stream() - - def refresh_playlists(self): - """Refresh the playlists in the backend with data from Spotify""" - if not self._initial_data_receive_completed: - logger.debug('Still getting data; skipped refresh of playlists') - return - playlists = [] - folders = [] - for spotify_playlist in self.session.playlist_container(): - if spotify_playlist.type() == 'folder_start': - folders.append(spotify_playlist) - if spotify_playlist.type() == 'folder_end': - folders.pop() - playlists.append(translator.to_mopidy_playlist( - spotify_playlist, folders=folders, - bitrate=self.bitrate, username=self.username)) - playlists.append(translator.to_mopidy_playlist( - self.session.starred(), - bitrate=self.bitrate, username=self.username)) - playlists = filter(None, playlists) - self.backend.playlists.playlists = playlists - logger.info('Loaded %d Spotify playlists', len(playlists)) - BackendListener.send('playlists_loaded') - - def logout(self): - """Log out from spotify""" - logger.debug('Logging out from Spotify') - - # To work with both pyspotify 1.9 and 1.10 - if getattr(self, 'session', None): - self.session.logout() diff --git a/mopidy/backends/spotify/spotify_appkey.key b/mopidy/backends/spotify/spotify_appkey.key deleted file mode 100644 index 1f840b962d9245820e73803ae5995650b4f84f62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 321 zcmV-H0lxkL&xsG-pVlEz7LL?2e{+JtQpZk(M<9(;xguUY#VZNv&txxTh0nuFe(N{} zC?#&u)&58KeoT-KpSTN{8Wb)hzuj?jZNaN?^McImAMP|w&4GR8DyOK-#=V!cSw`&V5lyby`QwVzk}bWQ#Ui#m2fN)=wRSqK33~=D8OATMF|fdmT#G0B?yVov-+)u7w0gkTjyb{I{VGW`-;#R z$iCRsr@I8@9i#w7y@Y$>dnR3OOhWI%a!F~QeP*7Os+7-($V~m!LFZ(l=H!@+PtT&9 diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py deleted file mode 100644 index f35cad2e..00000000 --- a/mopidy/backends/spotify/translator.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import unicode_literals - -import logging - -import spotify - -from mopidy.models import Artist, Album, Track, Playlist - -logger = logging.getLogger('mopidy.backends.spotify') - - -artist_cache = {} -album_cache = {} -track_cache = {} - - -def to_mopidy_artist(spotify_artist): - if spotify_artist is None: - return - uri = str(spotify.Link.from_artist(spotify_artist)) - if uri in artist_cache: - return artist_cache[uri] - if not spotify_artist.is_loaded(): - return Artist(uri=uri, name='[loading...]') - artist_cache[uri] = Artist(uri=uri, name=spotify_artist.name()) - return artist_cache[uri] - - -def to_mopidy_album(spotify_album): - if spotify_album is None: - return - uri = str(spotify.Link.from_album(spotify_album)) - if uri in album_cache: - return album_cache[uri] - if not spotify_album.is_loaded(): - return Album(uri=uri, name='[loading...]') - album_cache[uri] = Album( - uri=uri, - name=spotify_album.name(), - artists=[to_mopidy_artist(spotify_album.artist())], - date=spotify_album.year()) - return album_cache[uri] - - -def to_mopidy_track(spotify_track, bitrate=None): - if spotify_track is None: - return - uri = str(spotify.Link.from_track(spotify_track, 0)) - if uri in track_cache: - return track_cache[uri] - if not spotify_track.is_loaded(): - return Track(uri=uri, name='[loading...]') - spotify_album = spotify_track.album() - if spotify_album is not None and spotify_album.is_loaded(): - date = spotify_album.year() - else: - date = None - track_cache[uri] = Track( - uri=uri, - name=spotify_track.name(), - artists=[to_mopidy_artist(a) for a in spotify_track.artists()], - album=to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=date, - length=spotify_track.duration(), - bitrate=bitrate) - return track_cache[uri] - - -def to_mopidy_playlist( - spotify_playlist, folders=None, bitrate=None, username=None): - if spotify_playlist is None or spotify_playlist.type() != 'playlist': - return - try: - uri = str(spotify.Link.from_playlist(spotify_playlist)) - except spotify.SpotifyError as e: - logger.debug('Spotify playlist translation error: %s', e) - return - if not spotify_playlist.is_loaded(): - return Playlist(uri=uri, name='[loading...]') - name = spotify_playlist.name() - if folders: - folder_names = '/'.join(folder.name() for folder in folders) - name = folder_names + '/' + name - tracks = [ - to_mopidy_track(spotify_track, bitrate=bitrate) - for spotify_track in spotify_playlist - if not spotify_track.is_local() - ] - if not name: - name = 'Starred' - # Tracks in the Starred playlist are in reverse order from the official - # client. - tracks.reverse() - if spotify_playlist.owner().canonical_name() != username: - name += ' by ' + spotify_playlist.owner().canonical_name() - return Playlist(uri=uri, name=name, tracks=tracks) diff --git a/requirements/spotify.txt b/requirements/spotify.txt deleted file mode 100644 index d11a5c04..00000000 --- a/requirements/spotify.txt +++ /dev/null @@ -1,8 +0,0 @@ -pyspotify >= 1.9, < 2 -# The libspotify Python wrapper -# Available as the python-spotify package from apt.mopidy.com - -# libspotify >= 12, < 13 -# The libspotify C library from -# https://developer.spotify.com/technologies/libspotify/ -# Available as the libspotify12 package from apt.mopidy.com diff --git a/setup.py b/setup.py index ff6d49de..a448a029 100644 --- a/setup.py +++ b/setup.py @@ -28,8 +28,6 @@ setup( 'Pykka >= 1.1', ], extras_require={ - 'spotify': ['pyspotify >= 1.9, < 2'], - 'scrobbler': ['Mopidy-Scrobbler'], 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], }, test_suite='nose.collector', @@ -47,7 +45,6 @@ setup( 'http = mopidy.frontends.http:Extension [http]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', - 'spotify = mopidy.backends.spotify:Extension [spotify]', 'stream = mopidy.backends.stream:Extension', ], }, From 076dd56d6b36931be06d6ba9e3e10a09d895badd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:06:01 +0200 Subject: [PATCH 48/60] audio: Tweak mute docs, fix set_mute() return type if no mixer_track --- mopidy/audio/actor.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 431df562..ea186894 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -542,35 +542,34 @@ class Audio(pykka.ThreadingActor): def get_mute(self): """ - Get mute status + Get mute status of the installed mixer. - Example values: - - True: - Muted. - False: - Unmuted. - - :rtype: :class:`True` if muted, else :class:`False` + :rtype: :class:`True` if muted, :class:`False` if unmuted, + :class:`None` if no mixer is installed. """ if self._software_mixing: return self._playbin.get_property('mute') - elif self._mixer_track is not None: - return bool(self._mixer_track.flags & - gst.interfaces.MIXER_TRACK_MUTE) - def set_mute(self, status): + if self._mixer_track is None: + return None + + return bool(self._mixer_track.flags & gst.interfaces.MIXER_TRACK_MUTE) + + def set_mute(self, mute): """ - Set mute level of the configured element. + Mute or unmute of the installed mixer. - :param status: The new value for mute - :type status: bool + :param mute: Wether to mute the mixer or not. + :type mute: bool :rtype: :class:`True` if successful, else :class:`False` """ if self._software_mixing: - return self._playbin.set_property('mute', bool(status)) - elif self._mixer_track is not None: - return self._mixer.set_mute(self._mixer_track, bool(status)) + return self._playbin.set_property('mute', bool(mute)) + + if self._mixer_track is None: + return False + + return self._mixer.set_mute(self._mixer_track, bool(mute)) def _rescale(self, value, old=None, new=None): """Convert value between scales.""" From c2173954c8f177b1dc7cda24b280d8ec67e2caea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:06:18 +0200 Subject: [PATCH 49/60] audio: Reorder methods --- mopidy/audio/actor.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ea186894..5c931865 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -540,6 +540,15 @@ class Audio(pykka.ThreadingActor): return self._mixer.get_volume(self._mixer_track) == volumes + def _rescale(self, value, old=None, new=None): + """Convert value between scales.""" + new_min, new_max = new + old_min, old_max = old + if old_min == old_max: + return old_max + scaling = float(new_max - new_min) / (old_max - old_min) + return int(round(scaling * (value - old_min) + new_min)) + def get_mute(self): """ Get mute status of the installed mixer. @@ -571,15 +580,6 @@ class Audio(pykka.ThreadingActor): return self._mixer.set_mute(self._mixer_track, bool(mute)) - def _rescale(self, value, old=None, new=None): - """Convert value between scales.""" - new_min, new_max = new - old_min, old_max = old - if old_min == old_max: - return old_max - scaling = float(new_max - new_min) / (old_max - old_min) - return int(round(scaling * (value - old_min) + new_min)) - def set_metadata(self, track): """ Set track metadata for currently playing song. From 6a3e32284554630cc7448e3421c050e686949f88 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:24:17 +0200 Subject: [PATCH 50/60] core: Tweak mute docs, add simple test case --- mopidy/core/playback.py | 6 ++++-- tests/core/playback_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 69195bad..a9561894 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -24,7 +24,7 @@ class PlaybackController(object): self._shuffled = [] self._first_shuffle = True self._volume = None - self._mute = None + self._mute = False def _get_backend(self): if self.current_tl_track is None: @@ -293,6 +293,7 @@ class PlaybackController(object): if self.audio: return self.audio.get_mute().get() else: + # For testing return self._mute def set_mute(self, value): @@ -300,10 +301,11 @@ class PlaybackController(object): if self.audio: self.audio.set_mute(value) else: + # For testing self._mute = value mute = property(get_mute, set_mute) - """Let the audio get muted, maintaining previous volume""" + """Mute state as a :class:`True` if muted, :class:`False` otherwise""" ### Methods diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index 74f8a105..f3374547 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -177,3 +177,10 @@ class CorePlaybackTest(unittest.TestCase): self.assertEqual(result, 0) self.assertFalse(self.playback1.get_time_position.called) self.assertFalse(self.playback2.get_time_position.called) + + def test_mute(self): + self.assertEqual(self.core.playback.mute, False) + + self.core.playback.mute = True + + self.assertEqual(self.core.playback.mute, True) From c3e88993964f1c775829fc2f14aea2e84d33c099 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:28:01 +0200 Subject: [PATCH 51/60] mpd: Test that output enabling/disabling unmutes/mutes audio --- .../mpd/protocol/audio_output_test.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 9a7cd69c..3c6e5463 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -5,16 +5,37 @@ from tests.frontends.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): + self.core.playback.mute = True + self.sendRequest('enableoutput "0"') + self.assertInResponse('OK') + self.assertEqual(self.core.playback.mute.get(), False) def test_disableoutput(self): - self.sendRequest('disableoutput "0"') - self.assertInResponse('OK') + self.core.playback.mute = False + + self.sendRequest('disableoutput "0"') + + self.assertInResponse('OK') + self.assertEqual(self.core.playback.mute.get(), True) + + def test_outputs_when_unmuted(self): + self.core.playback.mute = False - def test_outputs(self): self.sendRequest('outputs') + self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Default') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.playback.mute = True + + self.sendRequest('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Default') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') From 158b2344ff3f2c856bd528c828014e20ac272406 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:29:43 +0200 Subject: [PATCH 52/60] audio: Add test TODO --- tests/audio/actor_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index e44c5e12..eac299cf 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -95,6 +95,10 @@ class AudioTest(unittest.TestCase): self.audio = audio.Audio.start(config=config).proxy() self.assertEqual(0, self.audio.get_volume().get()) + @unittest.SkipTest + def test_set_mute(self): + pass # TODO Probably needs a fakemixer with a mixer track + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO From 447864774e4b820542a3c79f1727292d4debbea3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:51:01 +0200 Subject: [PATCH 53/60] core: Add volume arg to volume_changed() event It was already called with the argument, and both the MPD and HTTP frontends handled it/expected it. It was just the default implementation in CoreListener that lacked the argument. --- mopidy/core/listener.py | 5 ++++- tests/core/listener_test.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index c93fc39e..5afb3f4f 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -132,11 +132,14 @@ class CoreListener(object): """ pass - def volume_changed(self): + def volume_changed(self, volume): """ Called whenever the volume is changed. *MAY* be implemented by actor. + + :param volume: the new volume in the range [0..100] + :type volume: int """ pass diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index bf3a235d..d1773a12 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -49,7 +49,7 @@ class CoreListenerTest(unittest.TestCase): self.listener.options_changed() def test_listener_has_default_impl_for_volume_changed(self): - self.listener.volume_changed() + self.listener.volume_changed(70) def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) From 863f7e0430cde72c0bfef89c362b11e662f0129d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 9 Oct 2013 23:52:46 +0200 Subject: [PATCH 54/60] mpd: Trigger 'output' idle event on mute_changed() This is required for e.g. ncmpcpp to detect that an enableoutput/disableoutput command worked, making it possible to toggle the output back without restarting ncmpcpp. --- mopidy/core/listener.py | 11 +++++++++++ mopidy/core/playback.py | 6 ++++++ mopidy/frontends/mpd/actor.py | 3 +++ tests/core/listener_test.py | 3 +++ 4 files changed, 23 insertions(+) diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 5afb3f4f..40c78540 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -143,6 +143,17 @@ class CoreListener(object): """ pass + def mute_changed(self, mute): + """ + Called whenever the mute state is changed. + + *MAY* be implemented by actor. + + :param mute: the new mute state + :type mute: boolean + """ + pass + def seeked(self, time_position): """ Called whenever the time position changes by an unexpected amount, e.g. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index a9561894..3dc6d0aa 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -304,6 +304,8 @@ class PlaybackController(object): # For testing self._mute = value + self._trigger_mute_changed(value) + mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" @@ -537,6 +539,10 @@ class PlaybackController(object): logger.debug('Triggering volume changed event') listener.CoreListener.send('volume_changed', volume=volume) + def _trigger_mute_changed(self, mute): + logger.debug('Triggering mute changed event') + listener.CoreListener.send('mute_changed', mute=mute) + def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index f1fefae4..4d983b73 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -55,3 +55,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def volume_changed(self, volume): self.send_idle('mixer') + + def mute_changed(self, mute): + self.send_idle('output') diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index d1773a12..3678451d 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -51,5 +51,8 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_volume_changed(self): self.listener.volume_changed(70) + def test_listener_has_default_impl_for_mute_changed(self): + self.listener.mute_changed(True) + def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) From c69f9f7af43546ff6e70b476fcf683c238fe736f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 10 Oct 2013 00:00:05 +0200 Subject: [PATCH 55/60] docs: Update changelog --- docs/changelog.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e3fa167d..fa34ceff 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,22 @@ of the following extensions as well: This was causing divide by zero errors when scaling volumes to a zero to hundred scale. (Fixes: :issue:`525`) +- Added support for muting audio without setting the volume to 0. This works + both for the software and hardware mixers. (Fixes: :issue:`186`) + +**Core** + +- Added :attr:`mopidy.core.PlaybackController.mute` for muting and unmuting + audio. (Fixes: :issue:`186`) + +- Added :meth:`mopidy.core.CoreListener.mute_changed` event that is triggered + when the mute state changes. + +**MPD frontend** + +- Made the formerly unused commands ``outputs``, ``enableoutput``, and + ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) + **Extension support** - A cookiecutter project for quickly creating new Mopidy extensions have been From b65293d2bc21a0385a6170e4fbd9ee7c4ce1c631 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 10 Oct 2013 00:03:19 +0200 Subject: [PATCH 56/60] mpd: Add TODO for handling unknown outpitid --- mopidy/frontends/mpd/protocol/audio_output.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 5a4d45c1..f8863459 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -14,6 +14,7 @@ def disableoutput(context, outputid): """ if int(outputid) == 0: context.core.playback.set_mute(True) + # TODO Return proper error on unknown outputid @handle_request(r'^enableoutput "(?P\d+)"$') @@ -27,6 +28,7 @@ def enableoutput(context, outputid): """ if int(outputid) == 0: context.core.playback.set_mute(False) + # TODO Return proper error on unknown outputid @handle_request(r'^outputs$') From db892e697475ec426a2163a5815babd4035a56e9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 10 Oct 2013 09:49:15 +0200 Subject: [PATCH 57/60] mpd: Rename muting output to 'Mute' --- mopidy/frontends/mpd/protocol/audio_output.py | 6 +++--- tests/frontends/mpd/protocol/audio_output_test.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index f8863459..657140d1 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -40,9 +40,9 @@ def outputs(context): Shows information about all outputs. """ - enabled = 0 if context.core.playback.get_mute().get() else 1 + muted = 1 if context.core.playback.get_mute().get() else 0 return [ ('outputid', 0), - ('outputname', 'Default'), - ('outputenabled', enabled), + ('outputname', 'Mute'), + ('outputenabled', muted), ] diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 3c6e5463..5675ebd4 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -26,8 +26,8 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.sendRequest('outputs') self.assertInResponse('outputid: 0') - self.assertInResponse('outputname: Default') - self.assertInResponse('outputenabled: 1') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') def test_outputs_when_muted(self): @@ -36,6 +36,6 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.sendRequest('outputs') self.assertInResponse('outputid: 0') - self.assertInResponse('outputname: Default') - self.assertInResponse('outputenabled: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') From b539a9c0949748de1e5bcd9684d03a6d5ae55a49 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 10 Oct 2013 10:02:34 +0200 Subject: [PATCH 58/60] mpd: Handle unknown outputid --- mopidy/frontends/mpd/protocol/audio_output.py | 7 +++++-- tests/frontends/mpd/protocol/audio_output_test.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 657140d1..65e693ec 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from mopidy.frontends.mpd.exceptions import MpdNoExistError from mopidy.frontends.mpd.protocol import handle_request @@ -14,7 +15,8 @@ def disableoutput(context, outputid): """ if int(outputid) == 0: context.core.playback.set_mute(True) - # TODO Return proper error on unknown outputid + else: + raise MpdNoExistError('No such audio output', command='disableoutput') @handle_request(r'^enableoutput "(?P\d+)"$') @@ -28,7 +30,8 @@ def enableoutput(context, outputid): """ if int(outputid) == 0: context.core.playback.set_mute(False) - # TODO Return proper error on unknown outputid + else: + raise MpdNoExistError('No such audio output', command='enableoutput') @handle_request(r'^outputs$') diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 5675ebd4..cbfb5043 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -12,6 +12,11 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') self.assertEqual(self.core.playback.mute.get(), False) + def test_enableoutput_unknown_outputid(self): + self.sendRequest('enableoutput "7"') + + self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') + def test_disableoutput(self): self.core.playback.mute = False @@ -20,6 +25,12 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') self.assertEqual(self.core.playback.mute.get(), True) + def test_disableoutput_unknown_outputid(self): + self.sendRequest('disableoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {disableoutput} No such audio output') + def test_outputs_when_unmuted(self): self.core.playback.mute = False From 5745682400da301e47e299e44cdbdc4d2a730954 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 11 Oct 2013 10:05:28 +0200 Subject: [PATCH 59/60] docs: Add Mopidy-radio-de to extension list --- docs/ext/index.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index a909883d..07ffb087 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -65,6 +65,16 @@ https://github.com/mopidy/mopidy-nad Extension for controlling volume using an external NAD amplifier. +Mopidy-radio-de +--------------- + +https://github.com/hechtus/mopidy-radio-de + +Extension for listening to Internet radio stations and podcasts listed at +`radio.de `_, `rad.io `_, +`radio.fr `_, and `radio.at `_. + + Mopidy-Scrobbler ---------------- From d9d9a57df489d656591e1d328687aa9f76fab20e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 11 Oct 2013 10:09:19 +0200 Subject: [PATCH 60/60] docs: Add Mopidy-Arcam to extension list --- docs/ext/index.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 07ffb087..a4f376b2 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -30,6 +30,15 @@ These extensions are maintained outside Mopidy's core, often by other developers. +Mopidy-Arcam +------------ + +https://github.com/mopidy/mopidy-arcam + +Extension for controlling volume using an external Arcam amplifier. Developed +and tested with an Arcam AVR-300. + + Mopidy-Beets ------------