From c376ac4183e290449f032fb0f7ea289386bdacbe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 May 2013 22:58:13 +0200 Subject: [PATCH 001/175] 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 af0a0c6863..a943b5674b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -11,13 +11,14 @@ 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 0000000000..5235d4dd69 --- /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 002/175] 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 5235d4dd69..b7821ed76d 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -4,6 +4,8 @@ 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 003/175] 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 b7821ed76d..59877939f8 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -5,6 +5,7 @@ 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 004/175] 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 a943b5674b..c68a5417b1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -18,7 +18,9 @@ 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 59877939f8..4432887ddd 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -3,6 +3,7 @@ import pygst pygst.require('0.10') import gst +import gobject import ConfigParser as configparser import io @@ -129,3 +130,82 @@ def _event(self, pad, event): 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 005/175] 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 4432887ddd..764ec785c2 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -198,6 +198,49 @@ def convert(self, data): 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 006/175] 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 764ec785c2..7c1a2d4c76 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 @@ def _event(self, pad, event): 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 @@ def __init__(self): 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 007/175] 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 7c1a2d4c76..ea7ff04282 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -277,6 +277,55 @@ def convert(self, data): 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 008/175] 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 ea7ff04282..71c76ceff9 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 @@ def __init__(self): 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 @@ def _event(self, pad, event): 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 @@ def convert(self, data): 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 @@ def convert(self, data): 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 @@ def __init__(self): 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 @@ def convert(self, data): 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 009/175] 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 71c76ceff9..ca807ed9d8 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 @@ def convert(self, data): 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 @@ def pad_added(self, src, 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. + # 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 010/175] 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 ca807ed9d8..aa02d11295 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -7,8 +7,11 @@ 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 @@ def handle(self, uris): :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 011/175] 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 aa02d11295..17b94b97cc 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 012/175] 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 17b94b97cc..011326ee22 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -306,8 +306,21 @@ def __init__(self): 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 013/175] 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 ae8f6b011f..72035f56f1 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 4db3999371934bb1b2f54a7e5505595718e83415 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Wed, 7 Aug 2013 10:44:22 +0200 Subject: [PATCH 014/175] Cleanup of PlaybackController to be more specific on the current track and moved those functions to TracklistController, which may control all related to the tracks. Updated tests too. --- mopidy/core/playback.py | 210 +----------------- mopidy/core/tracklist.py | 195 +++++++++++++++- mopidy/frontends/mpd/protocol/playback.py | 18 +- mopidy/frontends/mpd/protocol/status.py | 24 +- mopidy/frontends/mpris/objects.py | 28 +-- tests/backends/base/playback.py | 132 +++++------ tests/frontends/mpd/protocol/playback_test.py | 32 +-- tests/frontends/mpd/status_test.py | 6 +- .../frontends/mpris/player_interface_test.py | 56 ++--- tests/utils/jsonrpc_test.py | 30 +-- 10 files changed, 367 insertions(+), 364 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 2e79827a2d..8b33140ad4 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -24,8 +24,6 @@ def __init__(self, audio, backends, core): self.core = core self._state = PlaybackState.STOPPED - self._shuffled = [] - self._first_shuffle = True self._volume = None def _get_backend(self): @@ -37,22 +35,6 @@ def _get_backend(self): ### Properties - def get_consume(self): - return getattr(self, '_consume', False) - - def set_consume(self, value): - if self.get_consume() != value: - self._trigger_options_changed() - return setattr(self, '_consume', value) - - consume = property(get_consume, set_consume) - """ - :class:`True` - Tracks are removed from the playlist when they have been played. - :class:`False` - Tracks are not removed from the playlist. - """ - def get_current_tl_track(self): return self.current_tl_track @@ -72,56 +54,6 @@ def get_current_track(self): Read-only. Extracted from :attr:`current_tl_track` for convenience. """ - def get_random(self): - return getattr(self, '_random', False) - - def set_random(self, value): - if self.get_random() != value: - self._trigger_options_changed() - return setattr(self, '_random', value) - - random = property(get_random, set_random) - """ - :class:`True` - Tracks are selected at random from the playlist. - :class:`False` - Tracks are played in the order of the playlist. - """ - - def get_repeat(self): - return getattr(self, '_repeat', False) - - def set_repeat(self, value): - if self.get_repeat() != value: - self._trigger_options_changed() - return setattr(self, '_repeat', value) - - repeat = property(get_repeat, set_repeat) - """ - :class:`True` - The current playlist is played repeatedly. To repeat a single track, - select both :attr:`repeat` and :attr:`single`. - :class:`False` - The current playlist is played once. - """ - - def get_single(self): - return getattr(self, '_single', False) - - def set_single(self, value): - if self.get_single() != value: - self._trigger_options_changed() - return setattr(self, '_single', value) - - single = property(get_single, set_single) - """ - :class:`True` - Playback is stopped after current song, unless in :attr:`repeat` - mode. - :class:`False` - Playback continues after current song. - """ - def get_state(self): return self._state @@ -159,122 +91,6 @@ def get_time_position(self): time_position = property(get_time_position) """Time position in milliseconds.""" - def get_tracklist_position(self): - if self.current_tl_track is None: - return None - try: - return self.core.tracklist.tl_tracks.index(self.current_tl_track) - except ValueError: - return None - - tracklist_position = property(get_tracklist_position) - """ - The position of the current track in the tracklist. - - Read-only. - """ - - def get_tl_track_at_eot(self): - # pylint: disable = R0911 - # Too many return statements - - tl_tracks = self.core.tracklist.tl_tracks - - if not tl_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = tl_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_tl_track is None: - return tl_tracks[0] - - if self.repeat and self.single: - return tl_tracks[self.tracklist_position] - - if self.repeat and not self.single: - return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] - - try: - return tl_tracks[self.tracklist_position + 1] - except IndexError: - return None - - tl_track_at_eot = property(get_tl_track_at_eot) - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.TlTrack`. - - Not necessarily the same track as :attr:`tl_track_at_next`. - """ - - def get_tl_track_at_next(self): - tl_tracks = self.core.tracklist.tl_tracks - - if not tl_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = tl_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_tl_track is None: - return tl_tracks[0] - - if self.repeat: - return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] - - try: - return tl_tracks[self.tracklist_position + 1] - except IndexError: - return None - - tl_track_at_next = property(get_tl_track_at_next) - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.TlTrack`. - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - - def get_tl_track_at_previous(self): - if self.repeat or self.consume or self.random: - return self.current_tl_track - - if self.tracklist_position in (None, 0): - return None - - return self.core.tracklist.tl_tracks[self.tracklist_position - 1] - - tl_track_at_previous = property(get_tl_track_at_previous) - """ - The track that will be played if calling :meth:`previous()`. - - A :class:`mopidy.models.TlTrack`. - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ - def get_volume(self): if self.audio: return self.audio.get_volume().get() @@ -325,13 +141,13 @@ def on_end_of_track(self): original_tl_track = self.current_tl_track - if self.tl_track_at_eot: + if self.core.tracklist.tl_track_at_eot: self._trigger_track_playback_ended() - self.play(self.tl_track_at_eot) + self.play(self.core.tracklist.tl_track_at_eot) else: self.stop(clear_current_track=True) - if self.consume: + if self.core.tracklist.consume: self.core.tracklist.remove(tlid=original_tl_track.tlid) def on_tracklist_change(self): @@ -340,8 +156,6 @@ def on_tracklist_change(self): Used by :class:`mopidy.core.TracklistController`. """ - self._first_shuffle = True - self._shuffled = [] if (not self.core.tracklist.tl_tracks or self.current_tl_track not in @@ -355,9 +169,9 @@ def next(self): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - if self.tl_track_at_next: + if self.core.tracklist.tl_track_at_next: self._trigger_track_playback_ended() - self.change_track(self.tl_track_at_next) + self.change_track(self.core.tracklist.tl_track_at_next) else: self.stop(clear_current_track=True) @@ -388,9 +202,9 @@ def play(self, tl_track=None, on_error_step=1): elif self.current_tl_track is not None: tl_track = self.current_tl_track elif self.current_tl_track is None and on_error_step == 1: - tl_track = self.tl_track_at_next + tl_track = self.core.tracklist.tl_track_at_next elif self.current_tl_track is None and on_error_step == -1: - tl_track = self.tl_track_at_previous + tl_track = self.core.tracklist.tl_track_at_previous if tl_track is not None: self.current_tl_track = tl_track @@ -398,16 +212,16 @@ def play(self, tl_track=None, on_error_step=1): backend = self._get_backend() if not backend or not backend.playback.play(tl_track.track).get(): logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.random and self._shuffled: - self._shuffled.remove(tl_track) + if self.core.tracklist.random and self.core.tracklist._shuffled: + self.core.tracklist._shuffled.remove(tl_track) if on_error_step == 1: self.next() elif on_error_step == -1: self.previous() return - if self.random and self.current_tl_track in self._shuffled: - self._shuffled.remove(self.current_tl_track) + if self.core.tracklist.random and self.current_tl_track in self.core.tracklist._shuffled: + self.core.tracklist._shuffled.remove(self.current_tl_track) self._trigger_track_playback_started() @@ -419,7 +233,7 @@ def previous(self): will continue. If it was paused, it will still be paused, etc. """ self._trigger_track_playback_ended() - self.change_track(self.tl_track_at_previous, on_error_step=-1) + self.change_track(self.core.tracklist.tl_track_at_previous, on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 1c8f437fcd..33bebdeed4 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -15,11 +15,16 @@ class TracklistController(object): pykka_traversable = True def __init__(self, core): - self._core = core + self.core = core self._next_tlid = 0 self._tl_tracks = [] self._version = 0 + self._shuffled = [] + self._first_shuffle = True + + ### Properties + def get_tl_tracks(self): return self._tl_tracks[:] @@ -51,7 +56,7 @@ def get_version(self): def _increase_version(self): self._version += 1 - self._core.playback.on_tracklist_change() + self.core.playback.on_tracklist_change() self._trigger_tracklist_changed() version = property(get_version) @@ -62,6 +67,188 @@ def _increase_version(self): Is not reset before Mopidy is restarted. """ + def get_consume(self): + return getattr(self, '_consume', False) + + def set_consume(self, value): + if self.get_consume() != value: + self.core.playback._trigger_options_changed() + return setattr(self, '_consume', value) + + consume = property(get_consume, set_consume) + """ + :class:`True` + Tracks are removed from the playlist when they have been played. + :class:`False` + Tracks are not removed from the playlist. + """ + + def get_random(self): + return getattr(self, '_random', False) + + def set_random(self, value): + if self.get_random() != value: + self.core.playback._trigger_options_changed() + return setattr(self, '_random', value) + + random = property(get_random, set_random) + """ + :class:`True` + Tracks are selected at random from the playlist. + :class:`False` + Tracks are played in the order of the playlist. + """ + + def get_repeat(self): + return getattr(self, '_repeat', False) + + def set_repeat(self, value): + if self.get_repeat() != value: + self.core.playback._trigger_options_changed() + return setattr(self, '_repeat', value) + + repeat = property(get_repeat, set_repeat) + """ + :class:`True` + The current playlist is played repeatedly. To repeat a single track, + select both :attr:`repeat` and :attr:`single`. + :class:`False` + The current playlist is played once. + """ + + def get_single(self): + return getattr(self, '_single', False) + + def set_single(self, value): + if self.get_single() != value: + self.core.playback._trigger_options_changed() + return setattr(self, '_single', value) + + single = property(get_single, set_single) + """ + :class:`True` + Playback is stopped after current song, unless in :attr:`repeat` + mode. + :class:`False` + Playback continues after current song. + """ + + def get_tracklist_position(self): + if self.core.playback.current_tl_track is None: + return None + try: + return self.core.tracklist.tl_tracks.index(self.core.playback.current_tl_track) + except ValueError: + return None + + tracklist_position = property(get_tracklist_position) + """ + The position of the current track in the tracklist. + + Read-only. + """ + + def get_tl_track_at_eot(self): + # pylint: disable = R0911 + # Too many return statements + + tl_tracks = self.core.tracklist.tl_tracks + + if not tl_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = tl_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.core.playback.current_tl_track is None: + return tl_tracks[0] + + if self.repeat and self.single: + return tl_tracks[self.tracklist_position] + + if self.repeat and not self.single: + return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] + + try: + return tl_tracks[self.tracklist_position + 1] + except IndexError: + return None + + tl_track_at_eot = property(get_tl_track_at_eot) + """ + The track that will be played at the end of the current track. + + Read-only. A :class:`mopidy.models.TlTrack`. + + Not necessarily the same track as :attr:`tl_track_at_next`. + """ + + def get_tl_track_at_next(self): + tl_tracks = self.core.tracklist.tl_tracks + + if not tl_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = tl_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.core.playback.current_tl_track is None: + return tl_tracks[0] + + if self.repeat: + return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] + + try: + return tl_tracks[self.tracklist_position + 1] + except IndexError: + return None + + tl_track_at_next = property(get_tl_track_at_next) + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.TlTrack`. + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ + + def get_tl_track_at_previous(self): + if self.repeat or self.core.tracklist.consume or self.random: + return self.core.playback.current_tl_track + + if self.tracklist_position in (None, 0): + return None + + return self.core.tracklist.tl_tracks[self.tracklist_position - 1] + + tl_track_at_previous = property(get_tl_track_at_previous) + """ + The track that will be played if calling :meth:`previous()`. + + A :class:`mopidy.models.TlTrack`. + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ + def add(self, tracks=None, at_position=None, uri=None): """ Add the track or list of tracks to the tracklist. @@ -87,7 +274,7 @@ def add(self, tracks=None, at_position=None, uri=None): 'tracks or uri must be provided' if tracks is None and uri is not None: - tracks = self._core.library.lookup(uri) + tracks = self.core.library.lookup(uri) tl_tracks = [] @@ -260,5 +447,7 @@ def slice(self, start, end): return self._tl_tracks[start:end] def _trigger_tracklist_changed(self): + self._first_shuffle = True + self._shuffled = [] logger.debug('Triggering event: tracklist_changed()') listener.CoreListener.send('tracklist_changed') diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 8e08585f84..fcc465aa8a 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -19,9 +19,9 @@ def consume(context, state): playlist. """ if int(state): - context.core.playback.consume = True + context.core.tracklist.consume = True else: - context.core.playback.consume = False + context.core.tracklist.consume = False @handle_request(r'^crossfade "(?P\d+)"$') @@ -263,9 +263,9 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.core.playback.random = True + context.core.tracklist.random = True else: - context.core.playback.random = False + context.core.tracklist.random = False @handle_request(r'^repeat (?P[01])$') @@ -279,9 +279,9 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.core.playback.repeat = True + context.core.tracklist.repeat = True else: - context.core.playback.repeat = False + context.core.tracklist.repeat = False @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') @@ -329,7 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.core.playback.tracklist_position.get() != int(songpos): + if context.core.tracklist.tracklist_position.get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -404,9 +404,9 @@ def single(context, state): song is repeated if the ``repeat`` mode is enabled. """ if int(state): - context.core.playback.single = True + context.core.tracklist.single = True else: - context.core.playback.single = False + context.core.tracklist.single = False @handle_request(r'^stop$') diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 34e2fa64e7..f42b3531bc 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -38,7 +38,7 @@ def currentsong(context): """ current_tl_track = context.core.playback.current_tl_track.get() if current_tl_track is not None: - position = context.core.playback.tracklist_position.get() + position = context.core.tracklist.tracklist_position.get() return track_to_mpd_format(current_tl_track, position=position) @@ -178,14 +178,14 @@ def status(context): 'tracklist.length': context.core.tracklist.length, 'tracklist.version': context.core.tracklist.version, 'playback.volume': context.core.playback.volume, - 'playback.consume': context.core.playback.consume, - 'playback.random': context.core.playback.random, - 'playback.repeat': context.core.playback.repeat, - 'playback.single': context.core.playback.single, + 'tracklist.consume': context.core.tracklist.consume, + 'tracklist.random': context.core.tracklist.random, + 'tracklist.repeat': context.core.tracklist.repeat, + 'tracklist.single': context.core.tracklist.single, 'playback.state': context.core.playback.state, 'playback.current_tl_track': context.core.playback.current_tl_track, - 'playback.tracklist_position': ( - context.core.playback.tracklist_position), + 'tracklist.tracklist_position': ( + context.core.tracklist.tracklist_position), 'playback.time_position': context.core.playback.time_position, } pykka.get_all(futures.values()) @@ -218,7 +218,7 @@ def _status_bitrate(futures): def _status_consume(futures): - if futures['playback.consume'].get(): + if futures['tracklist.consume'].get(): return 1 else: return 0 @@ -233,15 +233,15 @@ def _status_playlist_version(futures): def _status_random(futures): - return int(futures['playback.random'].get()) + return int(futures['tracklist.random'].get()) def _status_repeat(futures): - return int(futures['playback.repeat'].get()) + return int(futures['tracklist.repeat'].get()) def _status_single(futures): - return int(futures['playback.single'].get()) + return int(futures['tracklist.single'].get()) def _status_songid(futures): @@ -253,7 +253,7 @@ def _status_songid(futures): def _status_songpos(futures): - return futures['playback.tracklist_position'].get() + return futures['tracklist.tracklist_position'].get() def _status_state(futures): diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 15be1eea2f..d4f28a839d 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -301,8 +301,8 @@ def get_PlaybackStatus(self): return 'Stopped' def get_LoopStatus(self): - repeat = self.core.playback.repeat.get() - single = self.core.playback.single.get() + repeat = self.core.tracklist.repeat.get() + single = self.core.tracklist.single.get() if not repeat: return 'None' else: @@ -316,14 +316,14 @@ def set_LoopStatus(self, value): logger.debug('Setting %s.LoopStatus not allowed', PLAYER_IFACE) return if value == 'None': - self.core.playback.repeat = False - self.core.playback.single = False + self.core.tracklist.repeat = False + self.core.tracklist.single = False elif value == 'Track': - self.core.playback.repeat = True - self.core.playback.single = True + self.core.tracklist.repeat = True + self.core.tracklist.single = True elif value == 'Playlist': - self.core.playback.repeat = True - self.core.playback.single = False + self.core.tracklist.repeat = True + self.core.tracklist.single = False def set_Rate(self, value): if not self.get_CanControl(): @@ -335,16 +335,16 @@ def set_Rate(self, value): self.Pause() def get_Shuffle(self): - return self.core.playback.random.get() + return self.core.tracklist.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 + self.core.tracklist.random = True else: - self.core.playback.random = False + self.core.tracklist.random = False def get_Metadata(self): current_tl_track = self.core.playback.current_tl_track.get() @@ -407,14 +407,14 @@ def get_CanGoNext(self): if not self.get_CanControl(): return False return ( - self.core.playback.tl_track_at_next.get() != + self.core.tracklist.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.tracklist.tl_track_at_previous.get() != self.core.playback.current_tl_track.get()) def get_CanPlay(self): @@ -422,7 +422,7 @@ def get_CanPlay(self): 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) + self.core.tracklist.tl_track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 44ae40f95e..11e2162811 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -179,13 +179,13 @@ def test_previous_skips_to_previous_track_on_failure(self): def test_next(self): self.playback.play() - old_position = self.playback.tracklist_position + old_position = self.tracklist.tracklist_position old_uri = self.playback.current_track.uri self.playback.next() self.assertEqual( - self.playback.tracklist_position, old_position + 1) + self.tracklist.tracklist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist @@ -205,7 +205,7 @@ def test_next_at_end_of_playlist(self): for i, track in enumerate(self.tracks): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.tracklist_position, i) + self.assertEqual(self.tracklist.tracklist_position, i) self.playback.next() @@ -241,55 +241,55 @@ def test_next_skips_to_next_track_on_failure(self): @populate_tracklist def test_next_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_next_track_after_previous(self): self.playback.play() self.playback.next() self.playback.previous() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) def test_next_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_next, None) + self.assertEqual(self.tracklist.tl_track_at_next, None) @populate_tracklist def test_next_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, None) + self.assertEqual(self.tracklist.tl_track_at_next, None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): - self.playback.repeat = True + self.tracklist.repeat = True self.playback.play() for _ in self.tracks[1:]: self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_next_track_with_random(self): random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + self.tracklist.random = True + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) @populate_tracklist def test_next_with_consume(self): - self.playback.consume = True + self.tracklist.consume = True self.playback.play() self.playback.next() self.assertIn(self.tracks[0], self.tracklist.tracks) @populate_tracklist def test_next_with_single_and_repeat(self): - self.playback.single = True - self.playback.repeat = True + self.tracklist.single = True + self.tracklist.repeat = True self.playback.play() self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -298,7 +298,7 @@ def test_next_with_single_and_repeat(self): def test_next_with_random(self): # FIXME feels very fragile random.seed(1) - self.playback.random = True + self.tracklist.random = True self.playback.play() self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -306,22 +306,22 @@ def test_next_with_random(self): @populate_tracklist def test_next_track_with_random_after_append_playlist(self): random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + self.tracklist.random = True + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_end_of_track(self): self.playback.play() - old_position = self.playback.tracklist_position + old_position = self.tracklist.tracklist_position old_uri = self.playback.current_track.uri self.playback.on_end_of_track() self.assertEqual( - self.playback.tracklist_position, old_position + 1) + self.tracklist.tracklist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist @@ -341,7 +341,7 @@ def test_end_of_track_at_end_of_playlist(self): for i, track in enumerate(self.tracks): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.tracklist_position, i) + self.assertEqual(self.tracklist.tracklist_position, i) self.playback.on_end_of_track() @@ -377,47 +377,47 @@ def test_end_of_track_skips_to_next_track_on_failure(self): @populate_tracklist def test_end_of_track_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() self.playback.on_end_of_track() self.playback.previous() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) def test_end_of_track_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_next, None) + self.assertEqual(self.tracklist.tl_track_at_next, None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.on_end_of_track() - self.assertEqual(self.playback.tl_track_at_next, None) + self.assertEqual(self.tracklist.tl_track_at_next, None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): - self.playback.repeat = True + self.tracklist.repeat = True self.playback.play() for _ in self.tracks[1:]: self.playback.on_end_of_track() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + self.tracklist.random = True + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) @populate_tracklist def test_end_of_track_with_consume(self): - self.playback.consume = True + self.tracklist.consume = True self.playback.play() self.playback.on_end_of_track() self.assertNotIn(self.tracks[0], self.tracklist.tracks) @@ -426,7 +426,7 @@ def test_end_of_track_with_consume(self): def test_end_of_track_with_random(self): # FIXME feels very fragile random.seed(1) - self.playback.random = True + self.tracklist.random = True self.playback.play() self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -434,25 +434,25 @@ def test_end_of_track_with_random(self): @populate_tracklist def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + self.tracklist.random = True + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_previous_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_previous, None) + self.assertEqual(self.tracklist.tl_track_at_previous, None) @populate_tracklist def test_previous_track_after_play(self): self.playback.play() - self.assertEqual(self.playback.tl_track_at_previous, None) + self.assertEqual(self.tracklist.tl_track_at_previous, None) @populate_tracklist def test_previous_track_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) + self.assertEqual(self.tracklist.tl_track_at_previous, self.tl_tracks[0]) @populate_tracklist def test_previous_track_after_previous(self): @@ -460,27 +460,27 @@ def test_previous_track_after_previous(self): self.playback.next() # At track 1 self.playback.next() # At track 2 self.playback.previous() # At track 1 - self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) + self.assertEqual(self.tracklist.tl_track_at_previous, self.tl_tracks[0]) def test_previous_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_previous, None) + self.assertEqual(self.tracklist.tl_track_at_previous, None) @populate_tracklist def test_previous_track_with_consume(self): - self.playback.consume = True + self.tracklist.consume = True for _ in self.tracks: self.playback.next() self.assertEqual( - self.playback.tl_track_at_previous, + self.tracklist.tl_track_at_previous, self.playback.current_tl_track) @populate_tracklist def test_previous_track_with_random(self): - self.playback.random = True + self.tracklist.random = True for _ in self.tracks: self.playback.next() self.assertEqual( - self.playback.tl_track_at_previous, + self.tracklist.tl_track_at_previous, self.playback.current_tl_track) @populate_tracklist @@ -500,24 +500,24 @@ def test_current_track_after_next(self): @populate_tracklist def test_initial_tracklist_position(self): - self.assertEqual(self.playback.tracklist_position, None) + self.assertEqual(self.tracklist.tracklist_position, None) @populate_tracklist def test_tracklist_position_during_play(self): self.playback.play() - self.assertEqual(self.playback.tracklist_position, 0) + self.assertEqual(self.tracklist.tracklist_position, 0) @populate_tracklist def test_tracklist_position_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.playback.tracklist_position, 1) + self.assertEqual(self.tracklist.tracklist_position, 1) @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.tracklist_position, None) + self.assertEqual(self.tracklist.tracklist_position, None) def test_on_tracklist_change_gets_called(self): callback = self.playback.on_tracklist_change @@ -775,13 +775,13 @@ def test_time_position_when_paused(self): @populate_tracklist def test_play_with_consume(self): - self.playback.consume = True + self.tracklist.consume = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) @populate_tracklist def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): - self.playback.consume = True + self.tracklist.consume = True self.playback.play() for _ in range(len(self.tracklist.tracks)): self.playback.on_end_of_track() @@ -790,14 +790,14 @@ def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): @populate_tracklist def test_play_with_random(self): random.seed(1) - self.playback.random = True + self.tracklist.random = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[2]) @populate_tracklist def test_previous_with_random(self): random.seed(1) - self.playback.random = True + self.tracklist.random = True self.playback.play() self.playback.next() current_track = self.playback.current_track @@ -812,8 +812,8 @@ def test_end_of_song_starts_next_track(self): @populate_tracklist def test_end_of_song_with_single_and_repeat_starts_same(self): - self.playback.single = True - self.playback.repeat = True + self.tracklist.single = True + self.tracklist.repeat = True self.playback.play() self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[0]) @@ -825,44 +825,44 @@ def test_end_of_playlist_stops(self): self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): - self.assertEqual(self.playback.repeat, False) + self.assertEqual(self.tracklist.repeat, False) def test_random_off_by_default(self): - self.assertEqual(self.playback.random, False) + self.assertEqual(self.tracklist.random, False) def test_consume_off_by_default(self): - self.assertEqual(self.playback.consume, False) + self.assertEqual(self.tracklist.consume, False) @populate_tracklist def test_random_until_end_of_playlist(self): - self.playback.random = True + self.tracklist.random = True self.playback.play() for _ in self.tracks[1:]: self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, None) + self.assertEqual(self.tracklist.tl_track_at_next, None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): - self.playback.repeat = True + self.tracklist.repeat = True for _ in self.tracks: self.playback.next() - self.assertNotEqual(self.playback.tl_track_at_next, None) + self.assertNotEqual(self.tracklist.tl_track_at_next, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_tracklist def test_random_until_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.random = True + self.tracklist.repeat = True + self.tracklist.random = True self.playback.play() for _ in self.tracks: self.playback.next() - self.assertNotEqual(self.playback.tl_track_at_next, None) + self.assertNotEqual(self.tracklist.tl_track_at_next, None) @populate_tracklist def test_played_track_during_random_not_played_again(self): - self.playback.random = True + self.tracklist.random = True self.playback.play() played = [] for _ in self.tracks: diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 2cfc1b9822..fc91c09c52 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -16,22 +16,22 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.sendRequest('consume "0"') - self.assertFalse(self.core.playback.consume.get()) + self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_off_without_quotes(self): self.sendRequest('consume 0') - self.assertFalse(self.core.playback.consume.get()) + self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on(self): self.sendRequest('consume "1"') - self.assertTrue(self.core.playback.consume.get()) + self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on_without_quotes(self): self.sendRequest('consume 1') - self.assertTrue(self.core.playback.consume.get()) + self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_crossfade(self): @@ -40,42 +40,42 @@ def test_crossfade(self): def test_random_off(self): self.sendRequest('random "0"') - self.assertFalse(self.core.playback.random.get()) + self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_off_without_quotes(self): self.sendRequest('random 0') - self.assertFalse(self.core.playback.random.get()) + self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on(self): self.sendRequest('random "1"') - self.assertTrue(self.core.playback.random.get()) + self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on_without_quotes(self): self.sendRequest('random 1') - self.assertTrue(self.core.playback.random.get()) + self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_repeat_off(self): self.sendRequest('repeat "0"') - self.assertFalse(self.core.playback.repeat.get()) + self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_off_without_quotes(self): self.sendRequest('repeat 0') - self.assertFalse(self.core.playback.repeat.get()) + self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on(self): self.sendRequest('repeat "1"') - self.assertTrue(self.core.playback.repeat.get()) + self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on_without_quotes(self): self.sendRequest('repeat 1') - self.assertTrue(self.core.playback.repeat.get()) + self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_setvol_below_min(self): @@ -115,22 +115,22 @@ def test_setvol_without_quotes(self): def test_single_off(self): self.sendRequest('single "0"') - self.assertFalse(self.core.playback.single.get()) + self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_off_without_quotes(self): self.sendRequest('single 0') - self.assertFalse(self.core.playback.single.get()) + self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on(self): self.sendRequest('single "1"') - self.assertTrue(self.core.playback.single.get()) + self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on_without_quotes(self): self.sendRequest('single 1') - self.assertTrue(self.core.playback.single.get()) + self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_replay_gain_mode_off(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index ded0c3b251..d86f7dcd96 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -64,7 +64,7 @@ def test_status_method_contains_repeat_is_0(self): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.core.playback.repeat = 1 + self.core.tracklist.repeat = 1 result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -75,7 +75,7 @@ def test_status_method_contains_random_is_0(self): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.core.playback.random = 1 + self.core.tracklist.random = 1 result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -91,7 +91,7 @@ def test_status_method_contains_consume_is_0(self): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.core.playback.consume = 1 + self.core.tracklist.consume = 1 result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 52cd964b9a..4cd903e64b 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -50,45 +50,45 @@ def test_get_playback_status_is_stopped_when_stopped(self): 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 + self.core.tracklist.repeat = False + self.core.tracklist.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 + self.core.tracklist.repeat = True + self.core.tracklist.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 + self.core.tracklist.repeat = True + self.core.tracklist.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.core.tracklist.repeat = True + self.core.tracklist.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) + self.assertEqual(self.core.tracklist.repeat.get(), True) + self.assertEqual(self.core.tracklist.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) + self.assertEqual(self.core.tracklist.repeat.get(), False) + self.assertEqual(self.core.tracklist.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) + self.assertEqual(self.core.tracklist.repeat.get(), True) + self.assertEqual(self.core.tracklist.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) + self.assertEqual(self.core.tracklist.repeat.get(), True) + self.assertEqual(self.core.tracklist.single.get(), False) def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') @@ -116,32 +116,32 @@ def test_set_rate_to_zero_pauses_playback(self): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_get_shuffle_returns_true_if_random_is_active(self): - self.core.playback.random = True + self.core.tracklist.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 + self.core.tracklist.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.core.tracklist.random = False self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.core.playback.random.get()) + self.assertFalse(self.core.tracklist.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.core.tracklist.random = False + self.assertFalse(self.core.tracklist.random.get()) self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.core.playback.random.get()) + self.assertTrue(self.core.tracklist.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.core.tracklist.random = True + self.assertTrue(self.core.tracklist.random.get()) self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.core.playback.random.get()) + self.assertFalse(self.core.tracklist.random.get()) def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -308,7 +308,7 @@ def test_can_go_next_is_true_if_can_control_and_other_next_track(self): 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.tracklist.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) @@ -331,7 +331,7 @@ def test_can_go_previous_is_true_if_can_control_and_previous_track(self): 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.tracklist.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 5dccbe050a..a0709ebc78 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -266,12 +266,12 @@ def test_notification_unknown_method_returns_nothing(self): class JsonRpcBatchTest(JsonRpcTestBase): def test_batch_of_only_commands_returns_all(self): - self.core.playback.set_random(True).get() + self.core.tracklist.set_random(True).get() request = [ - {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat', 'id': 1}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single', 'id': 3}, ] response = self.jrw.handle_data(request) @@ -283,12 +283,12 @@ def test_batch_of_only_commands_returns_all(self): self.assertEqual(response[3]['result'], False) def test_batch_of_commands_and_notifications_returns_some(self): - self.core.playback.set_random(True).get() + self.core.tracklist.set_random(True).get() request = [ - {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single', 'id': 3}, ] response = self.jrw.handle_data(request) @@ -300,12 +300,12 @@ def test_batch_of_commands_and_notifications_returns_some(self): self.assertEqual(response[3]['result'], False) def test_batch_of_only_notifications_returns_nothing(self): - self.core.playback.set_random(True).get() + self.core.tracklist.set_random(True).get() request = [ - {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_random'}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_single'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single'}, ] response = self.jrw.handle_data(request) @@ -522,10 +522,10 @@ def test_batch_of_both_successfull_and_failing_requests(self): {'jsonrpc': '2.0', 'method': 'core.playback.set_volume', 'params': [47], 'id': '1'}, # Notification - {'jsonrpc': '2.0', 'method': 'core.playback.set_consume', + {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', 'params': [True]}, # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.playback.set_repeat', + {'jsonrpc': '2.0', 'method': 'core.tracklist.set_repeat', 'params': [False], 'id': '2'}, # Invalid request {'foo': 'boo'}, @@ -533,7 +533,7 @@ def test_batch_of_both_successfull_and_failing_requests(self): {'jsonrpc': '2.0', 'method': 'foo.get', 'params': {'name': 'myself'}, 'id': '5'}, # Call without params - {'jsonrpc': '2.0', 'method': 'core.playback.get_random', + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': '9'}, ] response = self.jrw.handle_data(request) From 68ea483c7ba2dcf8e8254a8a2f9fb40f0536136a Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Wed, 7 Aug 2013 17:30:46 +0200 Subject: [PATCH 015/175] Converted tracklist_position() to be called with the track one wants to have information of, /mopidy/frontends/mpd/protocol/status.py@189 should be checked for gathering a value before the rest. --- mopidy/core/tracklist.py | 43 +++++++++++++---------- mopidy/frontends/mpd/protocol/playback.py | 3 +- mopidy/frontends/mpd/protocol/status.py | 11 +++--- tests/backends/base/playback.py | 34 +++++++++++------- 4 files changed, 54 insertions(+), 37 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 33bebdeed4..ded8a3c498 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -133,25 +133,25 @@ def set_single(self, value): Playback continues after current song. """ - def get_tracklist_position(self): - if self.core.playback.current_tl_track is None: + def tracklist_position(self, tl_track): + """ + The position of the current track in the tracklist. + + Read-only. + """ + if tl_track is None: return None try: - return self.core.tracklist.tl_tracks.index(self.core.playback.current_tl_track) + return self.core.tracklist.tl_tracks.index(tl_track) except ValueError: return None - tracklist_position = property(get_tracklist_position) - """ - The position of the current track in the tracklist. - - Read-only. - """ def get_tl_track_at_eot(self): # pylint: disable = R0911 # Too many return statements + current_tl_track = self.core.playback.current_tl_track tl_tracks = self.core.tracklist.tl_tracks if not tl_tracks: @@ -167,17 +167,18 @@ def get_tl_track_at_eot(self): if self.random and self._shuffled: return self._shuffled[0] - if self.core.playback.current_tl_track is None: + if current_tl_track is None: return tl_tracks[0] + position = self.tracklist_position(current_tl_track) if self.repeat and self.single: - return tl_tracks[self.tracklist_position] + return tl_tracks[position] if self.repeat and not self.single: - return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] + return tl_tracks[(position + 1) % len(tl_tracks)] try: - return tl_tracks[self.tracklist_position + 1] + return tl_tracks[position + 1] except IndexError: return None @@ -192,6 +193,7 @@ def get_tl_track_at_eot(self): def get_tl_track_at_next(self): tl_tracks = self.core.tracklist.tl_tracks + current_tl_track = self.core.playback.current_tl_track if not tl_tracks: return None @@ -206,14 +208,15 @@ def get_tl_track_at_next(self): if self.random and self._shuffled: return self._shuffled[0] - if self.core.playback.current_tl_track is None: + if current_tl_track is None: return tl_tracks[0] + position = self.tracklist_position(current_tl_track) if self.repeat: - return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] + return tl_tracks[(position + 1) % len(tl_tracks)] try: - return tl_tracks[self.tracklist_position + 1] + return tl_tracks[position + 1] except IndexError: return None @@ -230,13 +233,15 @@ def get_tl_track_at_next(self): """ def get_tl_track_at_previous(self): + current_tl_track = self.core.playback.current_tl_track if self.repeat or self.core.tracklist.consume or self.random: - return self.core.playback.current_tl_track + return current_tl_track - if self.tracklist_position in (None, 0): + position = self.tracklist_position(current_tl_track) + if position in (None, 0): return None - return self.core.tracklist.tl_tracks[self.tracklist_position - 1] + return self.core.tracklist.tl_tracks[position - 1] tl_track_at_previous = property(get_tl_track_at_previous) """ diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index fcc465aa8a..06a456d92c 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,7 +329,8 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.core.tracklist.tracklist_position.get() != int(songpos): + tl_track = context.core.playback.current_tl_track.get() + if context.core.tracklist.tracklist_position(tl_track).get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index f42b3531bc..8f2207f652 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -36,10 +36,10 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - current_tl_track = context.core.playback.current_tl_track.get() - if current_tl_track is not None: - position = context.core.tracklist.tracklist_position.get() - return track_to_mpd_format(current_tl_track, position=position) + tl_track = context.core.playback.current_tl_track.get() + if tl_track is not None: + position = context.core.tracklist.tracklist_position(tl_track).get() + return track_to_mpd_format(tl_track, position=position) @handle_request(r'^idle$') @@ -185,7 +185,8 @@ def status(context): 'playback.state': context.core.playback.state, 'playback.current_tl_track': context.core.playback.current_tl_track, 'tracklist.tracklist_position': ( - context.core.tracklist.tracklist_position), + context.core.tracklist.tracklist_position( + context.core.playback.current_tl_track.get())), 'playback.time_position': context.core.playback.time_position, } pykka.get_all(futures.values()) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 11e2162811..32c0abf72e 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -179,13 +179,15 @@ def test_previous_skips_to_previous_track_on_failure(self): def test_next(self): self.playback.play() - old_position = self.tracklist.tracklist_position - old_uri = self.playback.current_track.uri + tl_track = self.playback.current_tl_track + old_position = self.tracklist.tracklist_position(tl_track) + old_uri = tl_track.track.uri self.playback.next() + tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tracklist_position, old_position + 1) + self.tracklist.tracklist_position(tl_track), old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist @@ -205,7 +207,8 @@ def test_next_at_end_of_playlist(self): for i, track in enumerate(self.tracks): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.tracklist.tracklist_position, i) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tracklist_position(tl_track), i) self.playback.next() @@ -315,13 +318,15 @@ def test_next_track_with_random_after_append_playlist(self): def test_end_of_track(self): self.playback.play() - old_position = self.tracklist.tracklist_position - old_uri = self.playback.current_track.uri + tl_track = self.playback.current_tl_track + old_position = self.tracklist.tracklist_position(tl_track) + old_uri = tl_track.track.uri self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tracklist_position, old_position + 1) + self.tracklist.tracklist_position(tl_track), old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist @@ -341,7 +346,8 @@ def test_end_of_track_at_end_of_playlist(self): for i, track in enumerate(self.tracks): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.tracklist.tracklist_position, i) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tracklist_position(tl_track), i) self.playback.on_end_of_track() @@ -500,24 +506,28 @@ def test_current_track_after_next(self): @populate_tracklist def test_initial_tracklist_position(self): - self.assertEqual(self.tracklist.tracklist_position, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tracklist_position(tl_track), None) @populate_tracklist def test_tracklist_position_during_play(self): self.playback.play() - self.assertEqual(self.tracklist.tracklist_position, 0) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tracklist_position(tl_track), 0) @populate_tracklist def test_tracklist_position_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.tracklist.tracklist_position, 1) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tracklist_position(tl_track), 1) @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.tracklist.tracklist_position, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tracklist_position(tl_track), None) def test_on_tracklist_change_gets_called(self): callback = self.playback.on_tracklist_change From 5a87d219ff3bf0353bc639640a3716617a616d25 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Wed, 7 Aug 2013 18:00:33 +0200 Subject: [PATCH 016/175] Correcting some self-arounds that were innecesary. --- mopidy/core/tracklist.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index ded8a3c498..d4f6f28d3b 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -142,7 +142,7 @@ def tracklist_position(self, tl_track): if tl_track is None: return None try: - return self.core.tracklist.tl_tracks.index(tl_track) + return self.tl_tracks.index(tl_track) except ValueError: return None @@ -152,15 +152,14 @@ def get_tl_track_at_eot(self): # Too many return statements current_tl_track = self.core.playback.current_tl_track - tl_tracks = self.core.tracklist.tl_tracks - if not tl_tracks: + if not self.tl_tracks: return None if self.random and not self._shuffled: if self.repeat or self._first_shuffle: logger.debug('Shuffling tracks') - self._shuffled = tl_tracks + self._shuffled = self.tl_tracks random.shuffle(self._shuffled) self._first_shuffle = False @@ -168,17 +167,17 @@ def get_tl_track_at_eot(self): return self._shuffled[0] if current_tl_track is None: - return tl_tracks[0] + return self.tl_tracks[0] position = self.tracklist_position(current_tl_track) if self.repeat and self.single: - return tl_tracks[position] + return self.tl_tracks[position] if self.repeat and not self.single: - return tl_tracks[(position + 1) % len(tl_tracks)] + return self.tl_tracks[(position + 1) % len(self.tl_tracks)] try: - return tl_tracks[position + 1] + return self.tl_tracks[position + 1] except IndexError: return None @@ -192,7 +191,7 @@ def get_tl_track_at_eot(self): """ def get_tl_track_at_next(self): - tl_tracks = self.core.tracklist.tl_tracks + tl_tracks = self.tl_tracks current_tl_track = self.core.playback.current_tl_track if not tl_tracks: @@ -234,14 +233,14 @@ def get_tl_track_at_next(self): def get_tl_track_at_previous(self): current_tl_track = self.core.playback.current_tl_track - if self.repeat or self.core.tracklist.consume or self.random: + if self.repeat or self.consume or self.random: return current_tl_track position = self.tracklist_position(current_tl_track) if position in (None, 0): return None - return self.core.tracklist.tl_tracks[position - 1] + return self.tl_tracks[position - 1] tl_track_at_previous = property(get_tl_track_at_previous) """ From ec716fba82747d33ea40c5448241a1a0431c3dae Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Wed, 7 Aug 2013 19:44:00 +0200 Subject: [PATCH 017/175] Converting tl_track_at_eot property in function with the track having to be given as an argument --- mopidy/core/playback.py | 5 +++-- mopidy/core/tracklist.py | 26 +++++++++----------------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8b33140ad4..ed02e8ac44 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -140,10 +140,11 @@ def on_end_of_track(self): return original_tl_track = self.current_tl_track + next_track = self.core.tracklist.tl_track_at_eot(original_tl_track) - if self.core.tracklist.tl_track_at_eot: + if next_track: self._trigger_track_playback_ended() - self.play(self.core.tracklist.tl_track_at_eot) + self.play(next_track) else: self.stop(clear_current_track=True) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index d4f6f28d3b..42180799d8 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -135,9 +135,7 @@ def set_single(self, value): def tracklist_position(self, tl_track): """ - The position of the current track in the tracklist. - - Read-only. + The position of the given track in the tracklist. """ if tl_track is None: return None @@ -147,12 +145,15 @@ def tracklist_position(self, tl_track): return None - def get_tl_track_at_eot(self): + def tl_track_at_eot(self, tl_track): + """ + The track that will be played after the given track. + + Not necessarily the same track as :meth:`tl_track_at_next`. + """ # pylint: disable = R0911 # Too many return statements - current_tl_track = self.core.playback.current_tl_track - if not self.tl_tracks: return None @@ -166,10 +167,10 @@ def get_tl_track_at_eot(self): if self.random and self._shuffled: return self._shuffled[0] - if current_tl_track is None: + if tl_track is None: return self.tl_tracks[0] - position = self.tracklist_position(current_tl_track) + position = self.tracklist_position(tl_track) if self.repeat and self.single: return self.tl_tracks[position] @@ -181,15 +182,6 @@ def get_tl_track_at_eot(self): except IndexError: return None - tl_track_at_eot = property(get_tl_track_at_eot) - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.TlTrack`. - - Not necessarily the same track as :attr:`tl_track_at_next`. - """ - def get_tl_track_at_next(self): tl_tracks = self.tl_tracks current_tl_track = self.core.playback.current_tl_track From 6abcad3e5509e05ea76f23ec4630746b5ebdb902 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Wed, 7 Aug 2013 21:38:16 +0200 Subject: [PATCH 018/175] Converting tl_track_at_next into a function that takes the track argument. Rewrote tests too. --- mopidy/core/playback.py | 7 ++-- mopidy/core/tracklist.py | 38 ++++++++----------- mopidy/frontends/mpris/objects.py | 6 ++- tests/backends/base/playback.py | 63 ++++++++++++++++++++----------- 4 files changed, 66 insertions(+), 48 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ed02e8ac44..742864b5e4 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -170,9 +170,10 @@ def next(self): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - if self.core.tracklist.tl_track_at_next: + tl_track = self.core.tracklist.tl_track_at_next(self.current_tl_track) + if tl_track: self._trigger_track_playback_ended() - self.change_track(self.core.tracklist.tl_track_at_next) + self.change_track(tl_track) else: self.stop(clear_current_track=True) @@ -203,7 +204,7 @@ def play(self, tl_track=None, on_error_step=1): elif self.current_tl_track is not None: tl_track = self.current_tl_track elif self.current_tl_track is None and on_error_step == 1: - tl_track = self.core.tracklist.tl_track_at_next + tl_track = self.core.tracklist.tl_track_at_next(tl_track) elif self.current_tl_track is None and on_error_step == -1: tl_track = self.core.tracklist.tl_track_at_previous diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 42180799d8..aa2f18ef5e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -182,47 +182,41 @@ def tl_track_at_eot(self, tl_track): except IndexError: return None - def get_tl_track_at_next(self): - tl_tracks = self.tl_tracks - current_tl_track = self.core.playback.current_tl_track + def tl_track_at_next(self, tl_track): + """ + The track that will be played if calling :meth:`next()`. - if not tl_tracks: + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ + + if not self.tl_tracks: return None if self.random and not self._shuffled: if self.repeat or self._first_shuffle: logger.debug('Shuffling tracks') - self._shuffled = tl_tracks + self._shuffled = self.tl_tracks random.shuffle(self._shuffled) self._first_shuffle = False if self.random and self._shuffled: return self._shuffled[0] - if current_tl_track is None: - return tl_tracks[0] + if tl_track is None: + return self.tl_tracks[0] - position = self.tracklist_position(current_tl_track) + position = self.tracklist_position(tl_track) if self.repeat: - return tl_tracks[(position + 1) % len(tl_tracks)] + return self.tl_tracks[(position + 1) % len(self.tl_tracks)] try: - return tl_tracks[position + 1] + return self.tl_tracks[position + 1] except IndexError: return None - tl_track_at_next = property(get_tl_track_at_next) - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.TlTrack`. - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - def get_tl_track_at_previous(self): current_tl_track = self.core.playback.current_tl_track if self.repeat or self.consume or self.random: diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index d4f28a839d..5253f6095a 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -406,8 +406,9 @@ def get_Position(self): def get_CanGoNext(self): if not self.get_CanControl(): return False + tl_track = self.core.playback.current_tl_track.get() return ( - self.core.tracklist.tl_track_at_next.get() != + self.core.tracklist.tl_track_at_next(tl_track).get() != self.core.playback.current_tl_track.get()) def get_CanGoPrevious(self): @@ -420,9 +421,10 @@ def get_CanGoPrevious(self): def get_CanPlay(self): if not self.get_CanControl(): return False + tl_track = self.core.playback.current_tl_track.get() return ( self.core.playback.current_tl_track.get() is not None or - self.core.tracklist.tl_track_at_next.get() is not None) + self.core.tracklist.tl_track_at_next(tl_track).get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 32c0abf72e..8f57e21936 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -244,29 +244,34 @@ def test_next_skips_to_next_track_on_failure(self): @populate_tracklist def test_next_track_before_play(self): - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play() - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) @populate_tracklist def test_next_track_after_previous(self): self.playback.play() self.playback.next() self.playback.previous() - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) def test_next_track_empty_playlist(self): - self.assertEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) @populate_tracklist def test_next_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.next() - self.assertEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): @@ -274,13 +279,15 @@ def test_next_track_at_end_of_playlist_with_repeat(self): self.playback.play() for _ in self.tracks[1:]: self.playback.next() - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) @populate_tracklist def test_next_track_with_random(self): random.seed(1) self.tracklist.random = True - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) @populate_tracklist def test_next_with_consume(self): @@ -310,9 +317,11 @@ def test_next_with_random(self): def test_next_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) @populate_tracklist def test_end_of_track(self): @@ -383,29 +392,34 @@ def test_end_of_track_skips_to_next_track_on_failure(self): @populate_tracklist def test_end_of_track_track_before_play(self): - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() self.playback.on_end_of_track() self.playback.previous() - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) def test_end_of_track_track_empty_playlist(self): - self.assertEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.on_end_of_track() - self.assertEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): @@ -413,13 +427,15 @@ def test_end_of_track_track_at_end_of_playlist_with_repeat(self): self.playback.play() for _ in self.tracks[1:]: self.playback.on_end_of_track() - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[0]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) self.tracklist.random = True - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) @populate_tracklist def test_end_of_track_with_consume(self): @@ -441,9 +457,11 @@ def test_end_of_track_with_random(self): def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[2]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.tracklist.tl_track_at_next, self.tl_tracks[1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) @populate_tracklist def test_previous_track_before_play(self): @@ -849,14 +867,16 @@ def test_random_until_end_of_playlist(self): self.playback.play() for _ in self.tracks[1:]: self.playback.next() - self.assertEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): self.tracklist.repeat = True for _ in self.tracks: self.playback.next() - self.assertNotEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertNotEqual(self.tracklist.tl_track_at_next(tl_track), None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) @@ -868,7 +888,8 @@ def test_random_until_end_of_playlist_with_repeat(self): self.playback.play() for _ in self.tracks: self.playback.next() - self.assertNotEqual(self.tracklist.tl_track_at_next, None) + tl_track = self.playback.current_tl_track + self.assertNotEqual(self.tracklist.tl_track_at_next(tl_track), None) @populate_tracklist def test_played_track_during_random_not_played_again(self): From 6e61f2ef85b01f110d3f5623bbfba6a1899258cb Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Wed, 7 Aug 2013 21:53:46 +0200 Subject: [PATCH 019/175] Refactoring code to convert tl_track_at_previous() in a function, also recoded tests. --- mopidy/core/playback.py | 5 +++-- mopidy/core/tracklist.py | 26 ++++++++++++-------------- mopidy/frontends/mpris/objects.py | 5 +++-- tests/backends/base/playback.py | 23 ++++++++++++++--------- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 742864b5e4..8ae7b4d942 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -206,7 +206,7 @@ def play(self, tl_track=None, on_error_step=1): elif self.current_tl_track is None and on_error_step == 1: tl_track = self.core.tracklist.tl_track_at_next(tl_track) elif self.current_tl_track is None and on_error_step == -1: - tl_track = self.core.tracklist.tl_track_at_previous + tl_track = self.core.tracklist.tl_track_at_previous(tl_track) if tl_track is not None: self.current_tl_track = tl_track @@ -235,7 +235,8 @@ def previous(self): will continue. If it was paused, it will still be paused, etc. """ self._trigger_track_playback_ended() - self.change_track(self.core.tracklist.tl_track_at_previous, on_error_step=-1) + tl_track = self.current_tl_track + self.change_track(self.core.tracklist.tl_track_at_previous(tl_track), on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index aa2f18ef5e..7207f1a9f0 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -217,27 +217,25 @@ def tl_track_at_next(self, tl_track): except IndexError: return None - def get_tl_track_at_previous(self): - current_tl_track = self.core.playback.current_tl_track + def tl_track_at_previous(self, tl_track): + """ + Returns the track that will be played if calling :meth:`previous()`. + + A :class:`mopidy.models.TlTrack`. + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ if self.repeat or self.consume or self.random: - return current_tl_track + return tl_track - position = self.tracklist_position(current_tl_track) + position = self.tracklist_position(tl_track) if position in (None, 0): return None return self.tl_tracks[position - 1] - tl_track_at_previous = property(get_tl_track_at_previous) - """ - The track that will be played if calling :meth:`previous()`. - - A :class:`mopidy.models.TlTrack`. - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ def add(self, tracks=None, at_position=None, uri=None): """ diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 5253f6095a..2ab4dd4a98 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -414,9 +414,10 @@ def get_CanGoNext(self): def get_CanGoPrevious(self): if not self.get_CanControl(): return False + tl_track = self.core.playback.current_tl_track return ( - self.core.tracklist.tl_track_at_previous.get() != - self.core.playback.current_tl_track.get()) + self.core.tracklist.tl_track_at_previous(tl_track).get() != + tl_track) def get_CanPlay(self): if not self.get_CanControl(): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 8f57e21936..61db2d716f 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -465,18 +465,21 @@ def test_end_of_track_track_with_random_after_append_playlist(self): @populate_tracklist def test_previous_track_before_play(self): - self.assertEqual(self.tracklist.tl_track_at_previous, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), None) @populate_tracklist def test_previous_track_after_play(self): self.playback.play() - self.assertEqual(self.tracklist.tl_track_at_previous, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), None) @populate_tracklist def test_previous_track_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.tracklist.tl_track_at_previous, self.tl_tracks[0]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), self.tl_tracks[0]) @populate_tracklist def test_previous_track_after_previous(self): @@ -484,28 +487,30 @@ def test_previous_track_after_previous(self): self.playback.next() # At track 1 self.playback.next() # At track 2 self.playback.previous() # At track 1 - self.assertEqual(self.tracklist.tl_track_at_previous, self.tl_tracks[0]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), self.tl_tracks[0]) def test_previous_track_empty_playlist(self): - self.assertEqual(self.tracklist.tl_track_at_previous, None) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), None) @populate_tracklist def test_previous_track_with_consume(self): self.tracklist.consume = True for _ in self.tracks: self.playback.next() + tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tl_track_at_previous, - self.playback.current_tl_track) + self.tracklist.tl_track_at_previous(tl_track), tl_track) @populate_tracklist def test_previous_track_with_random(self): self.tracklist.random = True for _ in self.tracks: self.playback.next() + tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tl_track_at_previous, - self.playback.current_tl_track) + self.tracklist.tl_track_at_previous(tl_track), tl_track) @populate_tracklist def test_initial_current_track(self): From 2c83225a1ec4b175ff92366c5dd0b4e427275469 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 11:56:35 +0200 Subject: [PATCH 020/175] Created a TracklistController to let it control wether if a track must be consumed or not --- mopidy/core/playback.py | 3 +-- mopidy/core/tracklist.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8ae7b4d942..6a5b0dc7db 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -148,8 +148,7 @@ def on_end_of_track(self): else: self.stop(clear_current_track=True) - if self.core.tracklist.consume: - self.core.tracklist.remove(tlid=original_tl_track.tlid) + self.core.tracklist.mark_consumed(tlid=original_tl_track.tlid) def on_tracklist_change(self): """ diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 7207f1a9f0..8971745e19 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -434,6 +434,19 @@ def slice(self, start, end): """ return self._tl_tracks[start:end] + def mark_consumed(self, **kwargs): + """ + Marks the given track as played. + + :param tl_track: Track to mark + :type tl_track: :class:`mopidy.models.TlTrack` + :rtype: True if the track was actually removed from the tracklist + """ + if not self.consume: + return False + self.remove(**kwargs) + return True + def _trigger_tracklist_changed(self): self._first_shuffle = True self._shuffled = [] From ab85dd9d6284aae3bfba2bffca5bea91d388df34 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 12:12:37 +0200 Subject: [PATCH 021/175] Changed mark_consumed to a flexible mark() function that currently allows to mark songs as consumed (that have been played full time), played (that have been played for some time) and unplayable --- mopidy/core/playback.py | 8 +++----- mopidy/core/tracklist.py | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 6a5b0dc7db..5125452111 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -148,7 +148,7 @@ def on_end_of_track(self): else: self.stop(clear_current_track=True) - self.core.tracklist.mark_consumed(tlid=original_tl_track.tlid) + self.core.tracklist.mark("consumed", original_tl_track) def on_tracklist_change(self): """ @@ -213,16 +213,14 @@ def play(self, tl_track=None, on_error_step=1): backend = self._get_backend() if not backend or not backend.playback.play(tl_track.track).get(): logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.core.tracklist.random and self.core.tracklist._shuffled: - self.core.tracklist._shuffled.remove(tl_track) + self.core.tracklist.mark("unplayable", tl_track) if on_error_step == 1: self.next() elif on_error_step == -1: self.previous() return - if self.core.tracklist.random and self.current_tl_track in self.core.tracklist._shuffled: - self.core.tracklist._shuffled.remove(self.current_tl_track) + self.core.tracklist.mark("played", tl_track) self._trigger_track_playback_started() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 8971745e19..60a7684513 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -434,18 +434,25 @@ def slice(self, start, end): """ return self._tl_tracks[start:end] - def mark_consumed(self, **kwargs): + def mark(self, what, tl_track): """ - Marks the given track as played. + Marks the given track as specified. :param tl_track: Track to mark :type tl_track: :class:`mopidy.models.TlTrack` :rtype: True if the track was actually removed from the tracklist """ - if not self.consume: - return False - self.remove(**kwargs) - return True + if what == "consumed": + if not self.consume: + return False + self.remove(tlid=tl_track.tlid) + return True + elif what == "played": + if self.random and tl_track in self._shuffled: + self._shuffled.remove(tl_track) + elif what == "unplayable": + if self.random and self._shuffled: + self._shuffled.remove(tl_track) def _trigger_tracklist_changed(self): self._first_shuffle = True From 826084d9b8dea0257c4374c6d0c33bd2d4c8006c Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 12:34:46 +0200 Subject: [PATCH 022/175] Ignoring editor temporary files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6ef1ff32d6..dca43a7dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ docs/_build/ mopidy.log* node_modules/ nosetests.xml +*~ From 2cb64b365dcccfa34fc45bd1fc189f602f7beb8c Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 13:07:42 +0200 Subject: [PATCH 023/175] docs: Documenting changed and created functions --- mopidy/core/tracklist.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 60a7684513..05f8fa880f 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -150,6 +150,9 @@ def tl_track_at_eot(self, tl_track): The track that will be played after the given track. Not necessarily the same track as :meth:`tl_track_at_next`. + + :param tl_track: The reference track + :type tl_track: :class:`mopidy.models.TlTrack` """ # pylint: disable = R0911 # Too many return statements @@ -184,12 +187,16 @@ def tl_track_at_eot(self, tl_track): def tl_track_at_next(self, tl_track): """ - The track that will be played if calling :meth:`next()`. + The track that will be played if calling + :meth:`mopidy.core.PlaybackController.next()`. For normal playback this is the next track in the playlist. If repeat is enabled the next track can loop around the playlist. When random is enabled this should be a random track, all tracks should be played once before the list repeats. + + :param tl_track: The reference track + :type tl_track: :class:`mopidy.models.TlTrack` """ if not self.tl_tracks: @@ -219,13 +226,17 @@ def tl_track_at_next(self, tl_track): def tl_track_at_previous(self, tl_track): """ - Returns the track that will be played if calling :meth:`previous()`. + Returns the track that will be played if calling + :meth:`mopidy.core.PlaybackController.previous()`. A :class:`mopidy.models.TlTrack`. For normal playback this is the previous track in the playlist. If random and/or consume is enabled it should return the current track instead. + + :param tl_track: The reference track + :type tl_track: :class:`mopidy.models.TlTrack` """ if self.repeat or self.consume or self.random: return tl_track @@ -436,8 +447,13 @@ def slice(self, start, end): def mark(self, what, tl_track): """ - Marks the given track as specified. + Marks the given track as specified. Currently supports:: + * `consumed` The track has been completely played. + * `played` The track has been played, at least a piece of it. + * `unplayable` The track is unplayable + :param what: What to be marked as + :type what: string :param tl_track: Track to mark :type tl_track: :class:`mopidy.models.TlTrack` :rtype: True if the track was actually removed from the tracklist From b15c4f198b63870e1364309d1145195e08f658cc Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 13:39:18 +0200 Subject: [PATCH 024/175] docs: Updating changelog.rst to reflect updates in core --- docs/changelog.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 43e993503d..cf3980477d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,22 @@ v0.15.0 (UNRELEASED) - Mopidy no longer supports Python 2.6. Currently, the only Python version supported by Mopidy is Python 2.7. (Fixes: :issue:`344`) +**Core** +- Tracklist has now the power to make decisions on which is the next/previous + song, along with previously playback associated features, such as randomness, + consumption, repeat and single. For that, a new method has been created to + mark songs, some Playback properties have been converted into functions and + both functions and properties have been moved into Tracklist to have more + modularity: + - Properties converted into functions that need arguments: + :meth:`get_tl_track_at_eot`, :meth:`get_tl_track_at_next`, + :meth:`get_tl_track_at_previous` and :meth:`tracklist_position` + - Properties moved: :attr:`random`, :attr:`repeat`, :attr:`consume` and + :attr:`single` + - Method created: :meth:`mark` +- Tracklist's get_tl_track_at_* and tracklist_position now need a tl_track as a + reference to give an answer. + **Command line options** - Converted from the optparse to the argparse library for handling command line From cb4130c2a7c951c12968f7a24f00e9de820f0540 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 13:45:36 +0200 Subject: [PATCH 025/175] core: Moving the trigger activation from one playback to tracklist --- mopidy/core/playback.py | 4 ---- mopidy/core/tracklist.py | 12 ++++++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 7ec7ff2a51..8bdf8fd05c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -330,10 +330,6 @@ def _trigger_playback_state_changed(self, old_state, new_state): 'playback_state_changed', old_state=old_state, new_state=new_state) - def _trigger_options_changed(self): - logger.debug('Triggering options changed event') - listener.CoreListener.send('options_changed') - def _trigger_volume_changed(self, volume): logger.debug('Triggering volume changed event') listener.CoreListener.send('volume_changed', volume=volume) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 05f8fa880f..3364356b10 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -72,7 +72,7 @@ def get_consume(self): def set_consume(self, value): if self.get_consume() != value: - self.core.playback._trigger_options_changed() + self._trigger_options_changed() return setattr(self, '_consume', value) consume = property(get_consume, set_consume) @@ -88,7 +88,7 @@ def get_random(self): def set_random(self, value): if self.get_random() != value: - self.core.playback._trigger_options_changed() + self._trigger_options_changed() return setattr(self, '_random', value) random = property(get_random, set_random) @@ -104,7 +104,7 @@ def get_repeat(self): def set_repeat(self, value): if self.get_repeat() != value: - self.core.playback._trigger_options_changed() + self._trigger_options_changed() return setattr(self, '_repeat', value) repeat = property(get_repeat, set_repeat) @@ -121,7 +121,7 @@ def get_single(self): def set_single(self, value): if self.get_single() != value: - self.core.playback._trigger_options_changed() + self._trigger_options_changed() return setattr(self, '_single', value) single = property(get_single, set_single) @@ -475,3 +475,7 @@ def _trigger_tracklist_changed(self): self._shuffled = [] logger.debug('Triggering event: tracklist_changed()') listener.CoreListener.send('tracklist_changed') + + def _trigger_options_changed(self): + logger.debug('Triggering options changed event') + listener.CoreListener.send('options_changed') From 782a6a7d1f6385991cbe65ef5f9ee00a46a4d46f Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 8 Aug 2013 17:01:24 +0200 Subject: [PATCH 026/175] Renamed tl_track_at_* to more readable names --- docs/changelog.rst | 8 +++-- mopidy/core/playback.py | 10 +++--- mopidy/core/tracklist.py | 8 ++--- mopidy/frontends/mpris/objects.py | 6 ++-- tests/backends/base/playback.py | 56 +++++++++++++++---------------- 5 files changed, 45 insertions(+), 43 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cf3980477d..c997d1f420 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,11 +22,13 @@ v0.15.0 (UNRELEASED) both functions and properties have been moved into Tracklist to have more modularity: - Properties converted into functions that need arguments: - :meth:`get_tl_track_at_eot`, :meth:`get_tl_track_at_next`, - :meth:`get_tl_track_at_previous` and :meth:`tracklist_position` + :meth:`tracklist_position` - Properties moved: :attr:`random`, :attr:`repeat`, :attr:`consume` and :attr:`single` - - Method created: :meth:`mark` + - Method created from properties: :meth:`next_track` from + `tl_track_at_next`, :meth:`eot_track` from ´tl_track_at_eot` and + :meth:`previous_track` from `tl_track_at_previous` + - Method created to separe functionality: :meth:`mark` - Tracklist's get_tl_track_at_* and tracklist_position now need a tl_track as a reference to give an answer. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8bdf8fd05c..bb797f09a3 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -140,7 +140,7 @@ def on_end_of_track(self): return original_tl_track = self.current_tl_track - next_track = self.core.tracklist.tl_track_at_eot(original_tl_track) + next_track = self.core.tracklist.eot_track(original_tl_track) if next_track: self._trigger_track_playback_ended() @@ -169,7 +169,7 @@ def next(self): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - tl_track = self.core.tracklist.tl_track_at_next(self.current_tl_track) + tl_track = self.core.tracklist.next_track(self.current_tl_track) if tl_track: self._trigger_track_playback_ended() self.change_track(tl_track) @@ -203,9 +203,9 @@ def play(self, tl_track=None, on_error_step=1): elif self.current_tl_track is not None: tl_track = self.current_tl_track elif self.current_tl_track is None and on_error_step == 1: - tl_track = self.core.tracklist.tl_track_at_next(tl_track) + tl_track = self.core.tracklist.next_track(tl_track) elif self.current_tl_track is None and on_error_step == -1: - tl_track = self.core.tracklist.tl_track_at_previous(tl_track) + tl_track = self.core.tracklist.previous_track(tl_track) if tl_track is not None: self.current_tl_track = tl_track @@ -234,7 +234,7 @@ def previous(self): """ self._trigger_track_playback_ended() tl_track = self.current_tl_track - self.change_track(self.core.tracklist.tl_track_at_previous(tl_track), on_error_step=-1) + self.change_track(self.core.tracklist.previous_track(tl_track), on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 3364356b10..3a5775b3fa 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -145,11 +145,11 @@ def tracklist_position(self, tl_track): return None - def tl_track_at_eot(self, tl_track): + def eot_track(self, tl_track): """ The track that will be played after the given track. - Not necessarily the same track as :meth:`tl_track_at_next`. + Not necessarily the same track as :meth:`next_track`. :param tl_track: The reference track :type tl_track: :class:`mopidy.models.TlTrack` @@ -185,7 +185,7 @@ def tl_track_at_eot(self, tl_track): except IndexError: return None - def tl_track_at_next(self, tl_track): + def next_track(self, tl_track): """ The track that will be played if calling :meth:`mopidy.core.PlaybackController.next()`. @@ -224,7 +224,7 @@ def tl_track_at_next(self, tl_track): except IndexError: return None - def tl_track_at_previous(self, tl_track): + def previous_track(self, tl_track): """ Returns the track that will be played if calling :meth:`mopidy.core.PlaybackController.previous()`. diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 2ab4dd4a98..22535261ac 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -408,7 +408,7 @@ def get_CanGoNext(self): return False tl_track = self.core.playback.current_tl_track.get() return ( - self.core.tracklist.tl_track_at_next(tl_track).get() != + self.core.tracklist.next_track(tl_track).get() != self.core.playback.current_tl_track.get()) def get_CanGoPrevious(self): @@ -416,7 +416,7 @@ def get_CanGoPrevious(self): return False tl_track = self.core.playback.current_tl_track return ( - self.core.tracklist.tl_track_at_previous(tl_track).get() != + self.core.tracklist.previous_track(tl_track).get() != tl_track) def get_CanPlay(self): @@ -425,7 +425,7 @@ def get_CanPlay(self): tl_track = self.core.playback.current_tl_track.get() return ( self.core.playback.current_tl_track.get() is not None or - self.core.tracklist.tl_track_at_next(tl_track).get() is not None) + self.core.tracklist.next_track(tl_track).get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 61db2d716f..4436ac6c25 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -245,13 +245,13 @@ def test_next_skips_to_next_track_on_failure(self): @populate_tracklist def test_next_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_next_track_after_previous(self): @@ -259,11 +259,11 @@ def test_next_track_after_previous(self): self.playback.next() self.playback.previous() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) def test_next_track_empty_playlist(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_next_track_at_end_of_playlist(self): @@ -271,7 +271,7 @@ def test_next_track_at_end_of_playlist(self): for _ in self.tracklist.tl_tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): @@ -280,14 +280,14 @@ def test_next_track_at_end_of_playlist_with_repeat(self): for _ in self.tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_next_track_with_random(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) @populate_tracklist def test_next_with_consume(self): @@ -318,10 +318,10 @@ def test_next_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_end_of_track(self): @@ -393,13 +393,13 @@ def test_end_of_track_skips_to_next_track_on_failure(self): @populate_tracklist def test_end_of_track_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_after_previous(self): @@ -407,11 +407,11 @@ def test_end_of_track_track_after_previous(self): self.playback.on_end_of_track() self.playback.previous() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) def test_end_of_track_track_empty_playlist(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): @@ -419,7 +419,7 @@ def test_end_of_track_track_at_end_of_playlist(self): for _ in self.tracklist.tl_tracks[1:]: self.playback.on_end_of_track() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): @@ -428,14 +428,14 @@ def test_end_of_track_track_at_end_of_playlist_with_repeat(self): for _ in self.tracks[1:]: self.playback.on_end_of_track() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) @populate_tracklist def test_end_of_track_with_consume(self): @@ -458,28 +458,28 @@ def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_previous_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), None) + self.assertEqual(self.tracklist.previous_track(tl_track), None) @populate_tracklist def test_previous_track_after_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), None) + self.assertEqual(self.tracklist.previous_track(tl_track), None) @populate_tracklist def test_previous_track_after_next(self): self.playback.play() self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_previous_track_after_previous(self): @@ -488,11 +488,11 @@ def test_previous_track_after_previous(self): self.playback.next() # At track 2 self.playback.previous() # At track 1 tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) def test_previous_track_empty_playlist(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_previous(tl_track), None) + self.assertEqual(self.tracklist.previous_track(tl_track), None) @populate_tracklist def test_previous_track_with_consume(self): @@ -501,7 +501,7 @@ def test_previous_track_with_consume(self): self.playback.next() tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tl_track_at_previous(tl_track), tl_track) + self.tracklist.previous_track(tl_track), tl_track) @populate_tracklist def test_previous_track_with_random(self): @@ -510,7 +510,7 @@ def test_previous_track_with_random(self): self.playback.next() tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tl_track_at_previous(tl_track), tl_track) + self.tracklist.previous_track(tl_track), tl_track) @populate_tracklist def test_initial_current_track(self): @@ -873,7 +873,7 @@ def test_random_until_end_of_playlist(self): for _ in self.tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): @@ -881,7 +881,7 @@ def test_random_until_end_of_playlist_and_play_from_start(self): for _ in self.tracks: self.playback.next() tl_track = self.playback.current_tl_track - self.assertNotEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertNotEqual(self.tracklist.next_track(tl_track), None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) @@ -894,7 +894,7 @@ def test_random_until_end_of_playlist_with_repeat(self): for _ in self.tracks: self.playback.next() tl_track = self.playback.current_tl_track - self.assertNotEqual(self.tracklist.tl_track_at_next(tl_track), None) + self.assertNotEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_played_track_during_random_not_played_again(self): From fac2c8af7d1173553ee9706769dc6f01cedecf93 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Fri, 9 Aug 2013 08:31:32 +0200 Subject: [PATCH 027/175] format: Correcting flake8 messages --- mopidy/core/playback.py | 4 ++-- mopidy/core/tracklist.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index bb797f09a3..eaa926eb29 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import logging -import random import urlparse from mopidy.audio import PlaybackState @@ -234,7 +233,8 @@ def previous(self): """ self._trigger_track_playback_ended() tl_track = self.current_tl_track - self.change_track(self.core.tracklist.previous_track(tl_track), on_error_step=-1) + self.change_track(self.core.tracklist.previous_track(tl_track), + on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 3a5775b3fa..51d45e814c 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -144,7 +144,6 @@ def tracklist_position(self, tl_track): except ValueError: return None - def eot_track(self, tl_track): """ The track that will be played after the given track. @@ -187,7 +186,7 @@ def eot_track(self, tl_track): def next_track(self, tl_track): """ - The track that will be played if calling + The track that will be played if calling :meth:`mopidy.core.PlaybackController.next()`. For normal playback this is the next track in the playlist. If repeat @@ -247,7 +246,6 @@ def previous_track(self, tl_track): return self.tl_tracks[position - 1] - def add(self, tracks=None, at_position=None, uri=None): """ Add the track or list of tracks to the tracklist. From 11d82056a9928b0c7a14a5d0662e0f682c93b095 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Fri, 9 Aug 2013 09:14:28 +0200 Subject: [PATCH 028/175] core: Merged tracklist_position to index inside TracklistController docs: Updated changelog.rst --- docs/changelog.rst | 4 ++-- mopidy/core/tracklist.py | 22 ++++-------------- mopidy/frontends/mpd/protocol/playback.py | 2 +- mopidy/frontends/mpd/protocol/status.py | 8 +++---- tests/backends/base/playback.py | 28 +++++++++++------------ tests/backends/base/tracklist.py | 6 ++--- tests/utils/jsonrpc_test.py | 16 ++++--------- 7 files changed, 34 insertions(+), 52 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c997d1f420..b95b6e0088 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,8 +21,8 @@ v0.15.0 (UNRELEASED) mark songs, some Playback properties have been converted into functions and both functions and properties have been moved into Tracklist to have more modularity: - - Properties converted into functions that need arguments: - :meth:`tracklist_position` + - Properties merged into functions: :attr:`tracklist_position` merged to + :meth:`index` - Properties moved: :attr:`random`, :attr:`repeat`, :attr:`consume` and :attr:`single` - Method created from properties: :meth:`next_track` from diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 51d45e814c..ebac89e839 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -133,14 +133,14 @@ def set_single(self, value): Playback continues after current song. """ - def tracklist_position(self, tl_track): + def index(self, tl_track): """ The position of the given track in the tracklist. """ if tl_track is None: return None try: - return self.tl_tracks.index(tl_track) + return self._tl_tracks.index(tl_track) except ValueError: return None @@ -172,7 +172,7 @@ def eot_track(self, tl_track): if tl_track is None: return self.tl_tracks[0] - position = self.tracklist_position(tl_track) + position = self.index(tl_track) if self.repeat and self.single: return self.tl_tracks[position] @@ -214,7 +214,7 @@ def next_track(self, tl_track): if tl_track is None: return self.tl_tracks[0] - position = self.tracklist_position(tl_track) + position = self.index(tl_track) if self.repeat: return self.tl_tracks[(position + 1) % len(self.tl_tracks)] @@ -240,7 +240,7 @@ def previous_track(self, tl_track): if self.repeat or self.consume or self.random: return tl_track - position = self.tracklist_position(tl_track) + position = self.index(tl_track) if position in (None, 0): return None @@ -335,18 +335,6 @@ def filter(self, criteria=None, **kwargs): lambda ct: getattr(ct.track, key) == value, matches) return matches - def index(self, tl_track): - """ - Get index of the given :class:`mopidy.models.TlTrack` in the tracklist. - - Raises :exc:`ValueError` if not found. - - :param tl_track: track to find the index of - :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: int - """ - return self._tl_tracks.index(tl_track) - def move(self, start, end, to_position): """ Move the tracks in the slice ``[start:end]`` to ``to_position``. diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 06a456d92c..b9289d8a3f 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -330,7 +330,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ tl_track = context.core.playback.current_tl_track.get() - if context.core.tracklist.tracklist_position(tl_track).get() != int(songpos): + if context.core.tracklist.index(tl_track).get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 8f2207f652..49e08ee8ed 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -38,7 +38,7 @@ def currentsong(context): """ tl_track = context.core.playback.current_tl_track.get() if tl_track is not None: - position = context.core.tracklist.tracklist_position(tl_track).get() + position = context.core.tracklist.index(tl_track).get() return track_to_mpd_format(tl_track, position=position) @@ -184,8 +184,8 @@ def status(context): 'tracklist.single': context.core.tracklist.single, 'playback.state': context.core.playback.state, 'playback.current_tl_track': context.core.playback.current_tl_track, - 'tracklist.tracklist_position': ( - context.core.tracklist.tracklist_position( + 'tracklist.index': ( + context.core.tracklist.index( context.core.playback.current_tl_track.get())), 'playback.time_position': context.core.playback.time_position, } @@ -254,7 +254,7 @@ def _status_songid(futures): def _status_songpos(futures): - return futures['tracklist.tracklist_position'].get() + return futures['tracklist.index'].get() def _status_state(futures): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 4436ac6c25..0b40f8cf89 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -180,14 +180,14 @@ def test_next(self): self.playback.play() tl_track = self.playback.current_tl_track - old_position = self.tracklist.tracklist_position(tl_track) + old_position = self.tracklist.index(tl_track) old_uri = tl_track.track.uri self.playback.next() tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tracklist_position(tl_track), old_position + 1) + self.tracklist.index(tl_track), old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist @@ -208,7 +208,7 @@ def test_next_at_end_of_playlist(self): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tracklist_position(tl_track), i) + self.assertEqual(self.tracklist.index(tl_track), i) self.playback.next() @@ -328,14 +328,14 @@ def test_end_of_track(self): self.playback.play() tl_track = self.playback.current_tl_track - old_position = self.tracklist.tracklist_position(tl_track) + old_position = self.tracklist.index(tl_track) old_uri = tl_track.track.uri self.playback.on_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.tracklist_position(tl_track), old_position + 1) + self.tracklist.index(tl_track), old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist @@ -356,7 +356,7 @@ def test_end_of_track_at_end_of_playlist(self): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tracklist_position(tl_track), i) + self.assertEqual(self.tracklist.index(tl_track), i) self.playback.on_end_of_track() @@ -528,29 +528,29 @@ def test_current_track_after_next(self): self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_tracklist - def test_initial_tracklist_position(self): + def test_initial_index(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tracklist_position(tl_track), None) + self.assertEqual(self.tracklist.index(tl_track), None) @populate_tracklist - def test_tracklist_position_during_play(self): + def test_index_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tracklist_position(tl_track), 0) + self.assertEqual(self.tracklist.index(tl_track), 0) @populate_tracklist - def test_tracklist_position_after_next(self): + def test_index_after_next(self): self.playback.play() self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tracklist_position(tl_track), 1) + self.assertEqual(self.tracklist.index(tl_track), 1) @populate_tracklist - def test_tracklist_position_at_end_of_playlist(self): + def test_index_at_end_of_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.tracklist_position(tl_track), None) + self.assertEqual(self.tracklist.index(tl_track), None) def test_on_tracklist_change_gets_called(self): callback = self.playback.on_tracklist_change diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 5140d3aa26..dd56e9e385 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -165,9 +165,9 @@ def test_index_returns_index_of_track(self): self.assertEquals(1, self.controller.index(tl_tracks[1])) self.assertEquals(2, self.controller.index(tl_tracks[2])) - def test_index_raises_value_error_if_item_not_found(self): - test = lambda: self.controller.index(TlTrack(0, Track())) - self.assertRaises(ValueError, test) + def test_index_returns_none_if_item_not_found(self): + index = self.controller.index(TlTrack(0, Track())) + self.assertEquals(None, index) @populate_tracklist def test_move_single(self): diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index a0709ebc78..2df8b0ba74 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -313,7 +313,7 @@ def test_batch_of_only_notifications_returns_nothing(self): class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): - def test_application_error_response(self): + def test_application_error_response_is_none(self): request = { 'jsonrpc': '2.0', 'method': 'core.tracklist.index', @@ -322,17 +322,11 @@ def test_application_error_response(self): } response = self.jrw.handle_data(request) - self.assertNotIn('result', response) + print response + self.assertIn('result', response) - error = response['error'] - self.assertEqual(error['code'], 0) - self.assertEqual(error['message'], 'Application error') - - data = error['data'] - self.assertEqual(data['type'], 'ValueError') - self.assertIn('not in list', data['message']) - self.assertIn('traceback', data) - self.assertIn('Traceback (most recent call last):', data['traceback']) + result = response['result'] + self.assertEqual(result, None) def test_missing_jsonrpc_member_causes_invalid_request_error(self): request = { From cd83d7a0d937e9cb0c393aac5d1d230c6a2f6c6c Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Fri, 9 Aug 2013 10:08:52 +0200 Subject: [PATCH 029/175] tests: Removing accidental print statement in testcase --- tests/utils/jsonrpc_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 2df8b0ba74..7abaa512a1 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -322,7 +322,6 @@ def test_application_error_response_is_none(self): } response = self.jrw.handle_data(request) - print response self.assertIn('result', response) result = response['result'] From 21f3a8784aa3bab528e5db4aa47e832ddc97067d Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Mon, 12 Aug 2013 15:40:50 +0200 Subject: [PATCH 030/175] mpris: Correcting get_CanGoPrevious to get() the future instead of passing it. The failure was invisible to testcases --- mopidy/frontends/mpris/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 22535261ac..624caa99ae 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -414,7 +414,7 @@ def get_CanGoNext(self): def get_CanGoPrevious(self): if not self.get_CanControl(): return False - tl_track = self.core.playback.current_tl_track + tl_track = self.core.playback.current_tl_track.get() return ( self.core.tracklist.previous_track(tl_track).get() != tl_track) From 157556a001065eefd287c78dbe11e52402567c87 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 13 Aug 2013 09:17:21 +0200 Subject: [PATCH 031/175] docs: Documenting further the TracklistController functions --- mopidy/core/tracklist.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index ebac89e839..135f3e73fb 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -136,6 +136,10 @@ def set_single(self, value): def index(self, tl_track): """ The position of the given track in the tracklist. + + :param tl_track: The reference track + :type tl_track: :class:`mopidy.models.TlTrack` + :rtype: int """ if tl_track is None: return None @@ -152,6 +156,7 @@ def eot_track(self, tl_track): :param tl_track: The reference track :type tl_track: :class:`mopidy.models.TlTrack` + :rtype: :class:`mopidy.models.TlTrack` """ # pylint: disable = R0911 # Too many return statements @@ -196,6 +201,7 @@ def next_track(self, tl_track): :param tl_track: The reference track :type tl_track: :class:`mopidy.models.TlTrack` + :rtype: :class:`mopidy.models.TlTrack` """ if not self.tl_tracks: @@ -236,6 +242,7 @@ def previous_track(self, tl_track): :param tl_track: The reference track :type tl_track: :class:`mopidy.models.TlTrack` + :rtype: :class:`mopidy.models.TlTrack` """ if self.repeat or self.consume or self.random: return tl_track From a7d8af544d202041d4c709c43653ed92e2c5f9a6 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 13 Aug 2013 09:41:15 +0200 Subject: [PATCH 032/175] tests: tracklist tests renaming the TracklistController holder from controller to tracklist for test uniformity --- tests/backends/base/tracklist.py | 194 +++++++++++++++---------------- 1 file changed, 97 insertions(+), 97 deletions(-) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index dd56e9e385..4c20bb3627 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -20,7 +20,7 @@ def setUp(self): self.backend = self.backend_class.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(audio=self.audio, backends=[self.backend]) - self.controller = self.core.tracklist + self.tracklist = self.core.tracklist self.playback = self.core.playback assert len(self.tracks) == 3, 'Need three tracks to run tests.' @@ -29,212 +29,212 @@ def tearDown(self): pykka.ActorRegistry.stop_all() def test_length(self): - self.assertEqual(0, len(self.controller.tl_tracks)) - self.assertEqual(0, self.controller.length) - self.controller.add(self.tracks) - self.assertEqual(3, len(self.controller.tl_tracks)) - self.assertEqual(3, self.controller.length) + self.assertEqual(0, len(self.tracklist.tl_tracks)) + self.assertEqual(0, self.tracklist.length) + self.tracklist.add(self.tracks) + self.assertEqual(3, len(self.tracklist.tl_tracks)) + self.assertEqual(3, self.tracklist.length) def test_add(self): for track in self.tracks: - tl_tracks = self.controller.add([track]) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + tl_tracks = self.tracklist.add([track]) + self.assertEqual(track, self.tracklist.tracks[-1]) + self.assertEqual(tl_tracks[0], self.tracklist.tl_tracks[-1]) self.assertEqual(track, tl_tracks[0].track) def test_add_at_position(self): for track in self.tracks[:-1]: - tl_tracks = self.controller.add([track], 0) - self.assertEqual(track, self.controller.tracks[0]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) + tl_tracks = self.tracklist.add([track], 0) + self.assertEqual(track, self.tracklist.tracks[0]) + self.assertEqual(tl_tracks[0], self.tracklist.tl_tracks[0]) self.assertEqual(track, tl_tracks[0].track) @populate_tracklist def test_add_at_position_outside_of_playlist(self): for track in self.tracks: - tl_tracks = self.controller.add([track], len(self.tracks) + 2) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + tl_tracks = self.tracklist.add([track], len(self.tracks) + 2) + self.assertEqual(track, self.tracklist.tracks[-1]) + self.assertEqual(tl_tracks[0], self.tracklist.tl_tracks[-1]) self.assertEqual(track, tl_tracks[0].track) @populate_tracklist def test_filter_by_tlid(self): - tl_track = self.controller.tl_tracks[1] + tl_track = self.tracklist.tl_tracks[1] self.assertEqual( - [tl_track], self.controller.filter(tlid=tl_track.tlid)) + [tl_track], self.tracklist.filter(tlid=tl_track.tlid)) @populate_tracklist def test_filter_by_uri(self): - tl_track = self.controller.tl_tracks[1] + tl_track = self.tracklist.tl_tracks[1] self.assertEqual( - [tl_track], self.controller.filter(uri=tl_track.track.uri)) + [tl_track], self.tracklist.filter(uri=tl_track.track.uri)) @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): - self.assertEqual([], self.controller.filter(uri='foobar')) + self.assertEqual([], self.tracklist.filter(uri='foobar')) def test_filter_by_uri_returns_single_match(self): track = Track(uri='a') - self.controller.add([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.filter(uri='a')[0].track) + self.tracklist.add([Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.tracklist.filter(uri='a')[0].track) def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri='a') - self.controller.add([Track(uri='z'), track, track]) - tl_tracks = self.controller.filter(uri='a') + self.tracklist.add([Track(uri='z'), track, track]) + tl_tracks = self.tracklist.filter(uri='a') self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[1].track) def test_filter_by_uri_returns_nothing_if_no_match(self): - self.controller.playlist = Playlist( + self.tracklist.playlist = Playlist( tracks=[Track(uri='z'), Track(uri='y')]) - self.assertEqual([], self.controller.filter(uri='a')) + self.assertEqual([], self.tracklist.filter(uri='a')) def test_filter_by_multiple_criteria_returns_elements_matching_all(self): track1 = Track(uri='a', name='x') track2 = Track(uri='b', name='x') track3 = Track(uri='b', name='y') - self.controller.add([track1, track2, track3]) + self.tracklist.add([track1, track2, track3]) self.assertEqual( - track1, self.controller.filter(uri='a', name='x')[0].track) + track1, self.tracklist.filter(uri='a', name='x')[0].track) self.assertEqual( - track2, self.controller.filter(uri='b', name='x')[0].track) + track2, self.tracklist.filter(uri='b', name='x')[0].track) self.assertEqual( - track3, self.controller.filter(uri='b', name='y')[0].track) + track3, self.tracklist.filter(uri='b', name='y')[0].track) def test_filter_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri='b') track3 = Track() - self.controller.add([track1, track2, track3]) - self.assertEqual(track2, self.controller.filter(uri='b')[0].track) + self.tracklist.add([track1, track2, track3]) + self.assertEqual(track2, self.tracklist.filter(uri='b')[0].track) @populate_tracklist def test_clear(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) + self.tracklist.clear() + self.assertEqual(len(self.tracklist.tracks), 0) def test_clear_empty_playlist(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) + self.tracklist.clear() + self.assertEqual(len(self.tracklist.tracks), 0) @populate_tracklist def test_clear_when_playing(self): self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.controller.clear() + self.tracklist.clear() self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_add_appends_to_the_tracklist(self): - self.controller.add([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.controller.tracks), 2) - self.controller.add([Track(uri='c'), Track(uri='d')]) - self.assertEqual(len(self.controller.tracks), 4) - self.assertEqual(self.controller.tracks[0].uri, 'a') - self.assertEqual(self.controller.tracks[1].uri, 'b') - self.assertEqual(self.controller.tracks[2].uri, 'c') - self.assertEqual(self.controller.tracks[3].uri, 'd') + self.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.tracklist.tracks), 2) + self.tracklist.add([Track(uri='c'), Track(uri='d')]) + self.assertEqual(len(self.tracklist.tracks), 4) + self.assertEqual(self.tracklist.tracks[0].uri, 'a') + self.assertEqual(self.tracklist.tracks[1].uri, 'b') + self.assertEqual(self.tracklist.tracks[2].uri, 'c') + self.assertEqual(self.tracklist.tracks[3].uri, 'd') def test_add_does_not_reset_version(self): - version = self.controller.version - self.controller.add([]) - self.assertEqual(self.controller.version, version) + version = self.tracklist.version + self.tracklist.add([]) + self.assertEqual(self.tracklist.version, version) @populate_tracklist def test_add_preserves_playing_state(self): self.playback.play() track = self.playback.current_track - self.controller.add(self.controller.tracks[1:2]) + self.tracklist.add(self.tracklist.tracks[1:2]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) @populate_tracklist def test_add_preserves_stopped_state(self): - self.controller.add(self.controller.tracks[1:2]) + self.tracklist.add(self.tracklist.tracks[1:2]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_tracklist def test_add_returns_the_tl_tracks_that_was_added(self): - tl_tracks = self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) + tl_tracks = self.tracklist.add(self.tracklist.tracks[1:2]) + self.assertEqual(tl_tracks[0].track, self.tracklist.tracks[1]) def test_index_returns_index_of_track(self): - tl_tracks = self.controller.add(self.tracks) - self.assertEquals(0, self.controller.index(tl_tracks[0])) - self.assertEquals(1, self.controller.index(tl_tracks[1])) - self.assertEquals(2, self.controller.index(tl_tracks[2])) + tl_tracks = self.tracklist.add(self.tracks) + self.assertEquals(0, self.tracklist.index(tl_tracks[0])) + self.assertEquals(1, self.tracklist.index(tl_tracks[1])) + self.assertEquals(2, self.tracklist.index(tl_tracks[2])) def test_index_returns_none_if_item_not_found(self): - index = self.controller.index(TlTrack(0, Track())) + index = self.tracklist.index(TlTrack(0, Track())) self.assertEquals(None, index) @populate_tracklist def test_move_single(self): - self.controller.move(0, 0, 2) + self.tracklist.move(0, 0, 2) - tracks = self.controller.tracks + tracks = self.tracklist.tracks self.assertEqual(tracks[2], self.tracks[0]) @populate_tracklist def test_move_group(self): - self.controller.move(0, 2, 1) + self.tracklist.move(0, 2, 1) - tracks = self.controller.tracks + tracks = self.tracklist.tracks self.assertEqual(tracks[1], self.tracks[0]) self.assertEqual(tracks[2], self.tracks[1]) @populate_tracklist def test_moving_track_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks + 5) + tracks = len(self.tracklist.tracks) + test = lambda: self.tracklist.move(0, 0, tracks + 5) self.assertRaises(AssertionError, test) @populate_tracklist def test_move_group_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks + 5) + tracks = len(self.tracklist.tracks) + test = lambda: self.tracklist.move(0, 2, tracks + 5) self.assertRaises(AssertionError, test) @populate_tracklist def test_move_group_out_of_range(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) + tracks = len(self.tracklist.tracks) + test = lambda: self.tracklist.move(tracks + 2, tracks + 3, 0) self.assertRaises(AssertionError, test) @populate_tracklist def test_move_group_invalid_group(self): - test = lambda: self.controller.move(2, 1, 0) + test = lambda: self.tracklist.move(2, 1, 0) self.assertRaises(AssertionError, test) def test_tracks_attribute_is_immutable(self): - tracks1 = self.controller.tracks - tracks2 = self.controller.tracks + tracks1 = self.tracklist.tracks + tracks2 = self.tracklist.tracks self.assertNotEqual(id(tracks1), id(tracks2)) @populate_tracklist def test_remove(self): - track1 = self.controller.tracks[1] - track2 = self.controller.tracks[2] - version = self.controller.version - self.controller.remove(uri=track1.uri) - self.assertLess(version, self.controller.version) - self.assertNotIn(track1, self.controller.tracks) - self.assertEqual(track2, self.controller.tracks[1]) + track1 = self.tracklist.tracks[1] + track2 = self.tracklist.tracks[2] + version = self.tracklist.version + self.tracklist.remove(uri=track1.uri) + self.assertLess(version, self.tracklist.version) + self.assertNotIn(track1, self.tracklist.tracks) + self.assertEqual(track2, self.tracklist.tracks[1]) @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): - self.controller.remove(uri='/nonexistant') + self.tracklist.remove(uri='/nonexistant') def test_removing_from_empty_playlist_does_nothing(self): - self.controller.remove(uri='/nonexistant') + self.tracklist.remove(uri='/nonexistant') @populate_tracklist def test_shuffle(self): random.seed(1) - self.controller.shuffle() + self.tracklist.shuffle() - shuffled_tracks = self.controller.tracks + shuffled_tracks = self.tracklist.tracks self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(set(self.tracks), set(shuffled_tracks)) @@ -242,9 +242,9 @@ def test_shuffle(self): @populate_tracklist def test_shuffle_subset(self): random.seed(1) - self.controller.shuffle(1, 3) + self.tracklist.shuffle(1, 3) - shuffled_tracks = self.controller.tracks + shuffled_tracks = self.tracklist.tracks self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(self.tracks[0], shuffled_tracks[0]) @@ -252,21 +252,21 @@ def test_shuffle_subset(self): @populate_tracklist def test_shuffle_invalid_subset(self): - test = lambda: self.controller.shuffle(3, 1) + test = lambda: self.tracklist.shuffle(3, 1) self.assertRaises(AssertionError, test) @populate_tracklist def test_shuffle_superset(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks + 5) + tracks = len(self.tracklist.tracks) + test = lambda: self.tracklist.shuffle(1, tracks + 5) self.assertRaises(AssertionError, test) @populate_tracklist def test_shuffle_open_subset(self): random.seed(1) - self.controller.shuffle(1) + self.tracklist.shuffle(1) - shuffled_tracks = self.controller.tracks + shuffled_tracks = self.tracklist.tracks self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(self.tracks[0], shuffled_tracks[0]) @@ -274,22 +274,22 @@ def test_shuffle_open_subset(self): @populate_tracklist def test_slice_returns_a_subset_of_tracks(self): - track_slice = self.controller.slice(1, 3) + track_slice = self.tracklist.slice(1, 3) self.assertEqual(2, len(track_slice)) self.assertEqual(self.tracks[1], track_slice[0].track) self.assertEqual(self.tracks[2], track_slice[1].track) @populate_tracklist def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): - self.assertEqual(0, len(self.controller.slice(7, 8))) - self.assertEqual(0, len(self.controller.slice(-1, 1))) + self.assertEqual(0, len(self.tracklist.slice(7, 8))) + self.assertEqual(0, len(self.tracklist.slice(-1, 1))) def test_version_does_not_change_when_adding_nothing(self): - version = self.controller.version - self.controller.add([]) - self.assertEquals(version, self.controller.version) + version = self.tracklist.version + self.tracklist.add([]) + self.assertEquals(version, self.tracklist.version) def test_version_increases_when_adding_something(self): - version = self.controller.version - self.controller.add([Track()]) - self.assertLess(version, self.controller.version) + version = self.tracklist.version + self.tracklist.add([Track()]) + self.assertLess(version, self.tracklist.version) From b70cd9e787f8f266bf327e1e34dd80e6f7928707 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 13 Aug 2013 09:58:10 +0200 Subject: [PATCH 033/175] tests: moving more tests from playback to tracklist --- tests/backends/base/playback.py | 203 ------------------------------- tests/backends/base/tracklist.py | 203 +++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 203 deletions(-) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 0b40f8cf89..7ee4609789 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -242,87 +242,6 @@ def test_next_skips_to_next_track_on_failure(self): self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) - @populate_tracklist - def test_next_track_before_play(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) - - @populate_tracklist - def test_next_track_during_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) - - @populate_tracklist - def test_next_track_after_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) - - def test_next_track_empty_playlist(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) - - @populate_tracklist - def test_next_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) - - @populate_tracklist - def test_next_track_at_end_of_playlist_with_repeat(self): - self.tracklist.repeat = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) - - @populate_tracklist - def test_next_track_with_random(self): - random.seed(1) - self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) - - @populate_tracklist - def test_next_with_consume(self): - self.tracklist.consume = True - self.playback.play() - self.playback.next() - self.assertIn(self.tracks[0], self.tracklist.tracks) - - @populate_tracklist - def test_next_with_single_and_repeat(self): - self.tracklist.single = True - self.tracklist.repeat = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_next_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.tracklist.random = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_next_track_with_random_after_append_playlist(self): - random.seed(1) - self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) - self.tracklist.add(self.tracks[:1]) - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) - @populate_tracklist def test_end_of_track(self): self.playback.play() @@ -390,128 +309,6 @@ def test_end_of_track_skips_to_next_track_on_failure(self): self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) - @populate_tracklist - def test_end_of_track_track_before_play(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) - - @populate_tracklist - def test_end_of_track_track_during_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) - - @populate_tracklist - def test_end_of_track_track_after_previous(self): - self.playback.play() - self.playback.on_end_of_track() - self.playback.previous() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) - - def test_end_of_track_track_empty_playlist(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) - - @populate_tracklist - def test_end_of_track_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.on_end_of_track() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) - - @populate_tracklist - def test_end_of_track_track_at_end_of_playlist_with_repeat(self): - self.tracklist.repeat = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.on_end_of_track() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) - - @populate_tracklist - def test_end_of_track_track_with_random(self): - random.seed(1) - self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) - - @populate_tracklist - def test_end_of_track_with_consume(self): - self.tracklist.consume = True - self.playback.play() - self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.tracklist.tracks) - - @populate_tracklist - def test_end_of_track_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.tracklist.random = True - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_end_of_track_track_with_random_after_append_playlist(self): - random.seed(1) - self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) - self.tracklist.add(self.tracks[:1]) - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) - - @populate_tracklist - def test_previous_track_before_play(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), None) - - @populate_tracklist - def test_previous_track_after_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), None) - - @populate_tracklist - def test_previous_track_after_next(self): - self.playback.play() - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) - - @populate_tracklist - def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) - - def test_previous_track_empty_playlist(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), None) - - @populate_tracklist - def test_previous_track_with_consume(self): - self.tracklist.consume = True - for _ in self.tracks: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.previous_track(tl_track), tl_track) - - @populate_tracklist - def test_previous_track_with_random(self): - self.tracklist.random = True - for _ in self.tracks: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.previous_track(tl_track), tl_track) - @populate_tracklist def test_initial_current_track(self): self.assertEqual(self.playback.current_track, None) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 4c20bb3627..33991f2ac1 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -125,6 +125,209 @@ def test_clear_when_playing(self): self.tracklist.clear() self.assertEqual(self.playback.state, PlaybackState.STOPPED) + @populate_tracklist + def test_next_track_before_play(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_next_track_during_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + @populate_tracklist + def test_next_track_after_previous(self): + self.playback.play() + self.playback.next() + self.playback.previous() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + def test_next_track_empty_playlist(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_next_track_at_end_of_playlist(self): + self.playback.play() + for _ in self.tracklist.tl_tracks[1:]: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_next_track_at_end_of_playlist_with_repeat(self): + self.tracklist.repeat = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_next_track_with_random(self): + random.seed(1) + self.tracklist.random = True + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + + @populate_tracklist + def test_next_with_consume(self): + self.tracklist.consume = True + self.playback.play() + self.playback.next() + self.assertIn(self.tracks[0], self.tracklist.tracks) + + @populate_tracklist + def test_next_with_single_and_repeat(self): + self.tracklist.single = True + self.tracklist.repeat = True + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_next_with_random(self): + # FIXME feels very fragile + random.seed(1) + self.tracklist.random = True + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_next_track_with_random_after_append_playlist(self): + random.seed(1) + self.tracklist.random = True + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.tracklist.add(self.tracks[:1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + @populate_tracklist + def test_end_of_track_track_before_play(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_end_of_track_track_during_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + @populate_tracklist + def test_end_of_track_track_after_previous(self): + self.playback.play() + self.playback.on_end_of_track() + self.playback.previous() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + def test_end_of_track_track_empty_playlist(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_end_of_track_track_at_end_of_playlist(self): + self.playback.play() + for _ in self.tracklist.tl_tracks[1:]: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_end_of_track_track_at_end_of_playlist_with_repeat(self): + self.tracklist.repeat = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_end_of_track_track_with_random(self): + random.seed(1) + self.tracklist.random = True + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + + @populate_tracklist + def test_end_of_track_with_consume(self): + self.tracklist.consume = True + self.playback.play() + self.playback.on_end_of_track() + self.assertNotIn(self.tracks[0], self.tracklist.tracks) + + @populate_tracklist + def test_end_of_track_with_random(self): + # FIXME feels very fragile + random.seed(1) + self.tracklist.random = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_end_of_track_track_with_random_after_append_playlist(self): + random.seed(1) + self.tracklist.random = True + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.tracklist.add(self.tracks[:1]) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + @populate_tracklist + def test_previous_track_before_play(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), None) + + @populate_tracklist + def test_previous_track_after_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), None) + + @populate_tracklist + def test_previous_track_after_next(self): + self.playback.play() + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_previous_track_after_previous(self): + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + + def test_previous_track_empty_playlist(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), None) + + @populate_tracklist + def test_previous_track_with_consume(self): + self.tracklist.consume = True + for _ in self.tracks: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.previous_track(tl_track), tl_track) + + @populate_tracklist + def test_previous_track_with_random(self): + self.tracklist.random = True + for _ in self.tracks: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.previous_track(tl_track), tl_track) + def test_add_appends_to_the_tracklist(self): self.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.tracklist.tracks), 2) From 268c3b787986c6bf5dbc0082e3f987bd93f278c1 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 13 Aug 2013 10:05:01 +0200 Subject: [PATCH 034/175] tests: correcting indentation --- tests/backends/base/tracklist.py | 48 +++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 33991f2ac1..f1786ef98d 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -128,13 +128,15 @@ def test_clear_when_playing(self): @populate_tracklist def test_next_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[1]) @populate_tracklist def test_next_track_after_previous(self): @@ -142,7 +144,8 @@ def test_next_track_after_previous(self): self.playback.next() self.playback.previous() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[1]) def test_next_track_empty_playlist(self): tl_track = self.playback.current_tl_track @@ -163,14 +166,16 @@ def test_next_track_at_end_of_playlist_with_repeat(self): for _ in self.tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[0]) @populate_tracklist def test_next_track_with_random(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[2]) @populate_tracklist def test_next_with_consume(self): @@ -201,21 +206,25 @@ def test_next_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_after_previous(self): @@ -223,7 +232,8 @@ def test_end_of_track_track_after_previous(self): self.playback.on_end_of_track() self.playback.previous() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[1]) def test_end_of_track_track_empty_playlist(self): tl_track = self.playback.current_tl_track @@ -244,14 +254,16 @@ def test_end_of_track_track_at_end_of_playlist_with_repeat(self): for _ in self.tracks[1:]: self.playback.on_end_of_track() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[2]) @populate_tracklist def test_end_of_track_with_consume(self): @@ -274,10 +286,12 @@ def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.assertEqual(self.tracklist.next_track(tl_track), + self.tl_tracks[1]) @populate_tracklist def test_previous_track_before_play(self): @@ -295,7 +309,8 @@ def test_previous_track_after_next(self): self.playback.play() self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.previous_track(tl_track), + self.tl_tracks[0]) @populate_tracklist def test_previous_track_after_previous(self): @@ -304,7 +319,8 @@ def test_previous_track_after_previous(self): self.playback.next() # At track 2 self.playback.previous() # At track 1 tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + self.assertEqual(self.tracklist.previous_track(tl_track), + self.tl_tracks[0]) def test_previous_track_empty_playlist(self): tl_track = self.playback.current_tl_track From 72890bbe8db3847b6d6bab56520613a7cbd0d35b Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Thu, 12 Sep 2013 11:38:15 +0200 Subject: [PATCH 035/175] docs: Fixing changelog syntax --- docs/changelog.rst | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2a05801af3..92de24d148 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,20 +15,26 @@ v0.15.0 (UNRELEASED) supported by Mopidy is Python 2.7. (Fixes: :issue:`344`) **Core** + - Tracklist has now the power to make decisions on which is the next/previous song, along with previously playback associated features, such as randomness, consumption, repeat and single. For that, a new method has been created to mark songs, some Playback properties have been converted into functions and both functions and properties have been moved into Tracklist to have more modularity: - - Properties merged into functions: :attr:`tracklist_position` merged to - :meth:`index` - - Properties moved: :attr:`random`, :attr:`repeat`, :attr:`consume` and - :attr:`single` - - Method created from properties: :meth:`next_track` from - `tl_track_at_next`, :meth:`eot_track` from ´tl_track_at_eot` and - :meth:`previous_track` from `tl_track_at_previous` - - Method created to separe functionality: :meth:`mark` + + - Properties merged into functions: :attr:`tracklist_position` merged to + :meth:`index` + + - Properties moved: :attr:`random`, :attr:`repeat`, :attr:`consume` and + :attr:`single` + + - Method created from properties: :meth:`next_track` from + `tl_track_at_next`, :meth:`eot_track` from `tl_track_at_eot` and + :meth:`previous_track` from `tl_track_at_previous` + + - Method created to separe functionality: :meth:`mark` + - Tracklist's get_tl_track_at_* and tracklist_position now need a tl_track as a reference to give an answer. From aec164ed482ac61de4a9810a8ec50cc433283b04 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Fri, 13 Sep 2013 12:47:28 +0200 Subject: [PATCH 036/175] git: Ignoring permanently *.orig files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dca43a7dfa..7923011093 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ mopidy.log* node_modules/ nosetests.xml *~ +*.orig From 390194afc338af1da107e7f03d1d9a5c9476214b Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 17 Sep 2013 12:33:27 +0200 Subject: [PATCH 037/175] 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 6a1d7f6b65..4fc4b91bdd 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -537,6 +537,12 @@ def set_volume(self, volume): 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 ea849dbf3c..69195badc8 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -24,6 +24,7 @@ def __init__(self, audio, backends, core): 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 @@ def set_volume(self, volume): 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 038/175] 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 01982a711f..03289cd303 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 9661cb46502aa4675837dc29ec4757c9f5eefdbb Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 22 Sep 2013 01:42:26 +0200 Subject: [PATCH 039/175] doc: Remove duplicate "Extension configuration" section in config.rst --- docs/config.rst | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 6fd7579d36..5b8f5de104 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -147,24 +147,6 @@ Core configuration values .. _the Python logging docs: http://docs.python.org/2/library/logging.config.html -Extension configuration -======================= - -Mopidy's extensions have their own config values that you may want to tweak. -For the available config values, please refer to the docs for each extension. -Most, if not all, can be found at :ref:`ext`. - -Mopidy extensions are enabled by default when they are installed. If you want -to disable an extension without uninstalling it, all extensions support the -``enabled`` config value even if it isn't explicitly documented by all -extensions. If the ``enabled`` config value is set to ``false`` the extension -will not be started. For example, to disable the Spotify extension, add the -following to your ``mopidy.conf``:: - - [spotify] - enabled = false - - Extension configuration ======================= From de86274cea97523126a84b92d1879bb3593a8993 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 6 Oct 2013 13:58:05 +0200 Subject: [PATCH 040/175] readme: Add crate.io sheilds to readme. --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index c9db495e3b..a34b1bb69e 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 041/175] 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 a34b1bb69e..515fa3ba88 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 042/175] 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 011326ee22..9f56ea2789 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 043/175] 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 9f56ea2789..6f4fdd46af 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -317,8 +317,8 @@ 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) + 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 044/175] 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 6f4fdd46af..096348a0a7 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 0000000000..9f28527ed2 --- /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 045/175] 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 096348a0a7..2f309e304d 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 @@ def pad_event(self, pad, event): 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 9f28527ed2..0f031736cf 100644 --- a/tests/audio/playlists_test.py +++ b/tests/audio/playlists_test.py @@ -7,13 +7,6 @@ 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 @@ def peek(self, start, end): """ +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 046/175] 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 2f309e304d..e3f51e4139 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 047/175] 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 0b68eb8f02..b53a87348f 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 048/175] travis: Fix nosetests argument --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b53a87348f..b793e530b1 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 049/175] 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 0000000000..e77617cb15 --- /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 050/175] 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 0fb05f8caf..24656add3a 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 051/175] 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 6f53970725..747c2a0097 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -544,6 +544,8 @@ 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)) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 617131cc87..e44c5e1287 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -6,6 +6,9 @@ pygst.require('0.10') import gst +import gobject +gobject.threads_init() + import pykka from mopidy import audio @@ -80,6 +83,18 @@ def test_set_volume_with_mixer_max_below_100(self): 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 052/175] 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 24656add3a..b37b24c5f1 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 053/175] 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 6da5d3370f..96d8266e4d 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 b37b24c5f1..244ed592d7 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 736f2fb67b..f3d3f7c266 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 84188d02a9..0000000000 --- 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 2aa63887f4..102af3b69f 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 c08bc15ea2..0000000000 --- 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 2343e0cb67..0000000000 --- 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 4fded92fd7..0000000000 --- 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 c5eea724c8..7cfb74094b 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ def get_version(filename): ], 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 @@ def get_version(filename): ], '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 054/175] 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 f3d3f7c266..3163a6c044 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 055/175] 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 03289cd303..4dfb148e62 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 056/175] 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 560e935fc7..9a7cd69c64 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -6,11 +6,11 @@ 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 057/175] 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 4dfb148e62..5a4d45c121 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 058/175] docs: Unbreak docs building --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index f3e4166c9b..56ddbf92ce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,8 @@ def __getattr__(self, name): 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 059/175] 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 5db7c093a1..e6cfbb7cc4 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -83,8 +83,7 @@ def get_library_updaters(self): """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 060/175] 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 5116831268..428751de91 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 061/175] docs: Update changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 244ed592d7..1c7ea6d8c5 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 062/175] docs: Update authors --- AUTHORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 052865b7f1..28b8ebd2b9 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 063/175] 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 1c7ea6d8c5..7f919f79ea 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 064/175] 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 c52256c345..0000000000 --- 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 065/175] mpris: Move to external extension --- docs/api/frontends.rst | 1 - docs/clients/mpris.rst | 3 +- docs/clients/upnp.rst | 28 +- 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, 32 insertions(+), 1996 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 96d8266e4d..70bd73cfa5 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 141a2371b2..e1bd4bffca 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 9f30bd1c95..7f21a6c6b0 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. - -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. +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 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 3163a6c044..bdc1efe8f4 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 125f8fec15..0000000000 --- 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 102af3b69f..2acb998112 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 85e07c9d8c..369e3e29b8 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 1fd258b5ab..0000000000 --- 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 d44e926208..0000000000 --- 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 b83411c2cb..0000000000 --- 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 15be1eea2f..0000000000 --- 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 7cfb74094b..ff6d49dedb 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,6 @@ def get_version(filename): '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 baffc48825..0000000000 --- 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 0a4bc79fd6..0000000000 --- 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 52cd964b9a..0000000000 --- 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 f8e2cf3e41..0000000000 --- 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 f95f0969cb..0000000000 --- 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 066/175] 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 7f919f79ea..85f35a9128 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 067/175] 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 6ef80b0fab..8ba54f4ead 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ 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 6503ef39e9..94fe4544b9 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -39,5 +39,6 @@ def test_versions_can_be_strictly_ordered(self): 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 068/175] 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 4fc4b91bdd..912cdfd3c2 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -538,10 +538,35 @@ def set_volume(self, volume): 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 069/175] 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 912cdfd3c2..f274c380bc 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -553,7 +553,7 @@ def get_mute(self): 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 @@ def set_mute(self, status): 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 070/175] 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 f274c380bc..836404157e 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -552,8 +552,9 @@ def get_mute(self): """ 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 @@ def set_mute(self, status): """ 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 071/175] spotify: Move to external extension --- MANIFEST.in | 1 - docs/changelog.rst | 21 +- 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, 25 insertions(+), 969 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 6385e4ffdf..84122dcc6f 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 85f35a9128..e3fa167ded 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. - -- 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. +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 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 bdc1efe8f4..a909883dd1 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 4bb5b7a3ec..0000000000 --- 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 ca40c96cb7..c51834711a 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 3cee609a7f..0000000000 --- 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 1f90ba515c..0000000000 --- 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 e8d1ed0b82..0000000000 --- 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 83bf191ae1..0000000000 --- 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 49caa709e7..0000000000 --- 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 bda1763492..0000000000 --- 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 6cd6d4ed99..0000000000 --- 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 bd201179ce..0000000000 --- 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 3ab4498be0..0000000000 --- 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 f35cad2ed4..0000000000 --- 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 d11a5c0447..0000000000 --- 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 ff6d49dedb..a448a029f3 100644 --- a/setup.py +++ b/setup.py @@ -28,8 +28,6 @@ def get_version(filename): '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 @@ def get_version(filename): '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 072/175] 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 431df5625a..ea1868946a 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -542,35 +542,34 @@ def set_volume(self, volume): 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 073/175] 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 ea1868946a..5c931865b1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -540,6 +540,15 @@ def set_volume(self, volume): 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 @@ def set_mute(self, mute): 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 074/175] 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 69195badc8..a956189480 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -24,7 +24,7 @@ def __init__(self, audio, backends, core): 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 @@ def get_mute(self): if self.audio: return self.audio.get_mute().get() else: + # For testing return self._mute def set_mute(self, value): @@ -300,10 +301,11 @@ def set_mute(self, value): 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 74f8a1054a..f3374547fb 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -177,3 +177,10 @@ def test_time_position_returns_0_if_track_is_unplayable(self): 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 075/175] mpd: Test that output enabling/disabling unmutes/mutes audio --- .../mpd/protocol/audio_output_test.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 9a7cd69c64..3c6e5463a9 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -5,16 +5,37 @@ 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.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 076/175] 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 e44c5e1287..eac299cf80 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -95,6 +95,10 @@ def test_set_volume_with_mixer_min_equal_max(self): 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 077/175] 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 c93fc39ec6..5afb3f4f57 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -132,11 +132,14 @@ def options_changed(self): """ 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 bf3a235d99..d1773a1259 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -49,7 +49,7 @@ def test_listener_has_default_impl_for_options_changed(self): 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 078/175] 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 5afb3f4f57..40c78540dd 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -143,6 +143,17 @@ def volume_changed(self, volume): """ 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 a956189480..3dc6d0aaa7 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -304,6 +304,8 @@ def set_mute(self, value): # 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 @@ def _trigger_volume_changed(self, volume): 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 f1fefae455..4d983b73f9 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -55,3 +55,6 @@ def options_changed(self): 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 d1773a1259..3678451d4f 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -51,5 +51,8 @@ def test_listener_has_default_impl_for_options_changed(self): 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 079/175] docs: Update changelog --- docs/changelog.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e3fa167ded..fa34cefff0 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 080/175] 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 5a4d45c121..f8863459dd 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 081/175] 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 f8863459dd..657140d189 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 3c6e5463a9..5675ebd4b3 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -26,8 +26,8 @@ def test_outputs_when_unmuted(self): 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 @@ def test_outputs_when_muted(self): 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 082/175] 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 657140d189..65e693ecc4 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 5675ebd4b3..cbfb504316 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -12,6 +12,11 @@ def test_enableoutput(self): 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 @@ def test_disableoutput(self): 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 083/175] 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 a909883dd1..07ffb087a5 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 084/175] 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 07ffb087a5..a4f376b203 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 ------------ From a14a19447bd9ff4b2b2f9f52ba5845afe5f3a92a Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Fri, 11 Oct 2013 12:41:11 +0200 Subject: [PATCH 085/175] Updating tidy-up-core to jodal specs and changing one name. Still need to update docs --- mopidy/core/playback.py | 6 +++--- mopidy/core/tracklist.py | 37 +++++++++++++------------------------ 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 7eedb75083..0709c688cb 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -165,7 +165,7 @@ def on_end_of_track(self): else: self.stop(clear_current_track=True) - self.core.tracklist.mark("consumed", original_tl_track) + self.core.tracklist.mark_consumed(original_tl_track) def on_tracklist_change(self): """ @@ -230,7 +230,7 @@ def play(self, tl_track=None, on_error_step=1): backend = self._get_backend() if not backend or not backend.playback.play(tl_track.track).get(): logger.warning('Track is not playable: %s', tl_track.track.uri) - self.core.tracklist.mark("unplayable", tl_track) + self.core.tracklist.mark_unplayable(tl_track) if on_error_step == 1: # TODO: can cause an endless loop for single track repeat. self.next() @@ -238,7 +238,7 @@ def play(self, tl_track=None, on_error_step=1): self.previous() return - self.core.tracklist.mark("played", tl_track) + self.core.tracklist.mark_starting(tl_track) self._trigger_track_playback_started() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 135f3e73fb..154eb0d557 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -438,30 +438,19 @@ def slice(self, start, end): """ return self._tl_tracks[start:end] - def mark(self, what, tl_track): - """ - Marks the given track as specified. Currently supports:: - * `consumed` The track has been completely played. - * `played` The track has been played, at least a piece of it. - * `unplayable` The track is unplayable - - :param what: What to be marked as - :type what: string - :param tl_track: Track to mark - :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: True if the track was actually removed from the tracklist - """ - if what == "consumed": - if not self.consume: - return False - self.remove(tlid=tl_track.tlid) - return True - elif what == "played": - if self.random and tl_track in self._shuffled: - self._shuffled.remove(tl_track) - elif what == "unplayable": - if self.random and self._shuffled: - self._shuffled.remove(tl_track) + def mark_consumed(self, tl_track): + if not self.consume: + return False + self.remove(tlid=tl_track.tlid) + return True + + def mark_starting(self, tl_track): + if self.random and tl_track in self._shuffled: + self._shuffled.remove(tl_track) + + def mark_unplayable(self, tl_track): + if self.random and self._shuffled: + self._shuffled.remove(tl_track) def _trigger_tracklist_changed(self): self._first_shuffle = True From 2082e5ec0ce56f411ddd32ffac80c1a9f03c7ef1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 11 Oct 2013 20:08:11 +0200 Subject: [PATCH 086/175] docs: Fix error in changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fa34cefff0..ef84c64fbb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ Mopidy to continue to work like it used to, you may have to install one or more of the following extensions as well: - The Spotify backend has been moved to - `Mopidy-Scrobbler `_. + `Mopidy-Spotify `_. - The Last.fm scrobbler has been moved to `Mopidy-Scrobbler `_. From 3c53c1ddcd51e6c8de91f1c64bb5ce74e19e571c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 11 Oct 2013 22:17:27 +0200 Subject: [PATCH 087/175] tests: Merge BaseBackend and LocalBackend tests --- tests/backends/base/__init__.py | 11 - tests/backends/base/events.py | 26 - tests/backends/base/library.py | 202 ------ tests/backends/base/playback.py | 876 ------------------------- tests/backends/base/playlists.py | 102 --- tests/backends/base/tracklist.py | 295 --------- tests/backends/local/__init__.py | 13 +- tests/backends/local/events_test.py | 24 +- tests/backends/local/library_test.py | 201 +++++- tests/backends/local/playback_test.py | 870 +++++++++++++++++++++++- tests/backends/local/playlists_test.py | 99 ++- tests/backends/local/tracklist_test.py | 292 ++++++++- 12 files changed, 1475 insertions(+), 1536 deletions(-) delete mode 100644 tests/backends/base/__init__.py delete mode 100644 tests/backends/base/events.py delete mode 100644 tests/backends/base/library.py delete mode 100644 tests/backends/base/playback.py delete mode 100644 tests/backends/base/playlists.py delete mode 100644 tests/backends/base/tracklist.py diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py deleted file mode 100644 index 7dc4bcf6c4..0000000000 --- a/tests/backends/base/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import unicode_literals - - -def populate_tracklist(func): - def wrapper(self): - self.tl_tracks = self.core.tracklist.add(self.tracks) - return func(self) - - wrapper.__name__ = func.__name__ - wrapper.__doc__ = func.__doc__ - return wrapper diff --git a/tests/backends/base/events.py b/tests/backends/base/events.py deleted file mode 100644 index a5d9fa7b87..0000000000 --- a/tests/backends/base/events.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import unicode_literals - -import mock -import pykka - -from mopidy import core, audio -from mopidy.backends import listener - - -@mock.patch.object(listener.BackendListener, 'send') -class BackendEventsTest(object): - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_playlists_refresh_sends_playlists_loaded_event(self, send): - send.reset_mock() - self.core.playlists.refresh().get() - self.assertEqual(send.call_args[0][0], 'playlists_loaded') diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py deleted file mode 100644 index 23c76f38ee..0000000000 --- a/tests/backends/base/library.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -import pykka - -from mopidy import core -from mopidy.models import Track, Album, Artist - - -class LibraryControllerTest(object): - artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] - albums = [ - Album(name='album1', artists=artists[:1]), - Album(name='album2', artists=artists[1:2]), - Album()] - tracks = [ - Track(uri='local:track:path1', name='track1', artists=artists[:1], - album=albums[0], date='2001-02-03', length=4000), - Track(uri='local:track:path2', name='track2', artists=artists[1:2], - album=albums[1], date='2002', length=4000), - Track()] - config = {} - - def setUp(self): - self.backend = self.backend_class.start( - config=self.config, audio=None).proxy() - self.core = core.Core(backends=[self.backend]) - self.library = self.core.library - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_refresh(self): - self.library.refresh() - - @unittest.SkipTest - def test_refresh_uri(self): - pass - - @unittest.SkipTest - def test_refresh_missing_uri(self): - pass - - def test_lookup(self): - tracks = self.library.lookup(self.tracks[0].uri) - self.assertEqual(tracks, self.tracks[0:1]) - - def test_lookup_unknown_track(self): - tracks = self.library.lookup('fake uri') - self.assertEqual(tracks, []) - - def test_find_exact_no_hits(self): - result = self.library.find_exact(track=['unknown track']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - def test_find_exact_uri(self): - track_1_uri = 'local:track:path1' - result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - track_2_uri = 'local:track:path2' - result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_track(self): - result = self.library.find_exact(track=['track1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(track=['track2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(artist=['artist2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_album(self): - result = self.library.find_exact(album=['album1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(album=['album2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_date(self): - result = self.library.find_exact(date=['2001']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.find_exact(date=['2001-02-03']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(date=['2002']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_wrong_type(self): - test = lambda: self.library.find_exact(wrong=['test']) - self.assertRaises(LookupError, test) - - def test_find_exact_with_empty_query(self): - test = lambda: self.library.find_exact(artist=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.find_exact(track=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.find_exact(album=['']) - self.assertRaises(LookupError, test) - - def test_search_no_hits(self): - result = self.library.search(track=['unknown track']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(artist=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(album=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(uri=['unknown']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(any=['unknown']) - self.assertEqual(list(result[0].tracks), []) - - def test_search_uri(self): - result = self.library.search(uri=['TH1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(uri=['TH2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_track(self): - result = self.library.search(track=['Rack1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(track=['Rack2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_artist(self): - result = self.library.search(artist=['Tist1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(artist=['Tist2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_album(self): - result = self.library.search(album=['Bum1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(album=['Bum2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_date(self): - result = self.library.search(date=['2001']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(date=['2001-02-03']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(date=['2001-02-04']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(date=['2002']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_any(self): - result = self.library.search(any=['Tist1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['Rack1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['Bum1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['TH1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - def test_search_wrong_type(self): - test = lambda: self.library.search(wrong=['test']) - self.assertRaises(LookupError, test) - - def test_search_with_empty_query(self): - test = lambda: self.library.search(artist=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(track=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(album=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(uri=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(any=['']) - self.assertRaises(LookupError, test) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py deleted file mode 100644 index 44ae40f95e..0000000000 --- a/tests/backends/base/playback.py +++ /dev/null @@ -1,876 +0,0 @@ -from __future__ import unicode_literals - -import mock -import random -import time -import unittest - -import pykka - -from mopidy import audio, core -from mopidy.core import PlaybackState -from mopidy.models import Track - -from tests.backends.base import populate_tracklist - -# TODO Test 'playlist repeat', e.g. repeat=1,single=0 - - -class PlaybackControllerTest(object): - tracks = [] - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) - self.playback = self.core.playback - self.tracklist = self.core.tracklist - - assert len(self.tracks) >= 3, \ - 'Need at least three tracks to run tests.' - assert self.tracks[0].length >= 2000, \ - 'First song needs to be at least 2000 miliseconds' - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_initial_state_is_stopped(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_play_with_empty_playlist(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_play_with_empty_playlist_return_value(self): - self.assertEqual(self.playback.play(), None) - - @populate_tracklist - def test_play_state(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_play_return_value(self): - self.assertEqual(self.playback.play(), None) - - @populate_tracklist - def test_play_track_state(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play(self.tracklist.tl_tracks[-1]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_play_track_return_value(self): - self.assertEqual(self.playback.play( - self.tracklist.tl_tracks[-1]), None) - - @populate_tracklist - def test_play_when_playing(self): - self.playback.play() - track = self.playback.current_track - self.playback.play() - self.assertEqual(track, self.playback.current_track) - - @populate_tracklist - def test_play_when_paused(self): - self.playback.play() - track = self.playback.current_track - self.playback.pause() - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(track, self.playback.current_track) - - @populate_tracklist - def test_play_when_pause_after_next(self): - self.playback.play() - self.playback.next() - self.playback.next() - track = self.playback.current_track - self.playback.pause() - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(track, self.playback.current_track) - - @populate_tracklist - def test_play_sets_current_track(self): - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_play_track_sets_current_track(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.assertEqual(self.playback.current_track, self.tracks[-1]) - - @populate_tracklist - def test_play_skips_to_next_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[0] - self.playback.play() - self.assertNotEqual(self.playback.current_track, self.tracks[0]) - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_current_track_after_completed_playlist(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_previous_return_value(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.previous(), None) - - @populate_tracklist - def test_previous_does_not_trigger_playback(self): - self.playback.play() - self.playback.next() - self.playback.stop() - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_previous_at_start_of_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - def test_previous_for_empty_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_previous_skips_to_previous_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play(self.tracklist.tl_tracks[2]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - self.playback.previous() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_next(self): - self.playback.play() - - old_position = self.playback.tracklist_position - old_uri = self.playback.current_track.uri - - self.playback.next() - - self.assertEqual( - self.playback.tracklist_position, old_position + 1) - self.assertNotEqual(self.playback.current_track.uri, old_uri) - - @populate_tracklist - def test_next_return_value(self): - self.playback.play() - self.assertEqual(self.playback.next(), None) - - @populate_tracklist - def test_next_does_not_trigger_playback(self): - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_next_at_end_of_playlist(self): - self.playback.play() - - for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.tracklist_position, i) - - self.playback.next() - - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_next_until_end_of_playlist_and_play_from_start(self): - self.playback.play() - - for _ in self.tracks: - self.playback.next() - - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[0]) - - def test_next_for_empty_playlist(self): - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_next_skips_to_next_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.next() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - - @populate_tracklist - def test_next_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_next_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_next_track_after_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - def test_next_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_next_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_next_track_at_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_next_track_with_random(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - - @populate_tracklist - def test_next_with_consume(self): - self.playback.consume = True - self.playback.play() - self.playback.next() - self.assertIn(self.tracks[0], self.tracklist.tracks) - - @populate_tracklist - def test_next_with_single_and_repeat(self): - self.playback.single = True - self.playback.repeat = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_next_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_next_track_with_random_after_append_playlist(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_end_of_track(self): - self.playback.play() - - old_position = self.playback.tracklist_position - old_uri = self.playback.current_track.uri - - self.playback.on_end_of_track() - - self.assertEqual( - self.playback.tracklist_position, old_position + 1) - self.assertNotEqual(self.playback.current_track.uri, old_uri) - - @populate_tracklist - def test_end_of_track_return_value(self): - self.playback.play() - self.assertEqual(self.playback.on_end_of_track(), None) - - @populate_tracklist - def test_end_of_track_does_not_trigger_playback(self): - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_end_of_track_at_end_of_playlist(self): - self.playback.play() - - for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.tracklist_position, i) - - self.playback.on_end_of_track() - - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_end_of_track_until_end_of_playlist_and_play_from_start(self): - self.playback.play() - - for _ in self.tracks: - self.playback.on_end_of_track() - - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[0]) - - def test_end_of_track_for_empty_playlist(self): - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_end_of_track_skips_to_next_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - - @populate_tracklist - def test_end_of_track_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_end_of_track_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_end_of_track_track_after_previous(self): - self.playback.play() - self.playback.on_end_of_track() - self.playback.previous() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - def test_end_of_track_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_end_of_track_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.on_end_of_track() - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_end_of_track_track_at_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.on_end_of_track() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_end_of_track_track_with_random(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - - @populate_tracklist - def test_end_of_track_with_consume(self): - self.playback.consume = True - self.playback.play() - self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.tracklist.tracks) - - @populate_tracklist - def test_end_of_track_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_end_of_track_track_with_random_after_append_playlist(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_previous_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_previous, None) - - @populate_tracklist - def test_previous_track_after_play(self): - self.playback.play() - self.assertEqual(self.playback.tl_track_at_previous, None) - - @populate_tracklist - def test_previous_track_after_next(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) - - @populate_tracklist - def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) - - def test_previous_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_previous, None) - - @populate_tracklist - def test_previous_track_with_consume(self): - self.playback.consume = True - for _ in self.tracks: - self.playback.next() - self.assertEqual( - self.playback.tl_track_at_previous, - self.playback.current_tl_track) - - @populate_tracklist - def test_previous_track_with_random(self): - self.playback.random = True - for _ in self.tracks: - self.playback.next() - self.assertEqual( - self.playback.tl_track_at_previous, - self.playback.current_tl_track) - - @populate_tracklist - def test_initial_current_track(self): - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_current_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_current_track_after_next(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_initial_tracklist_position(self): - self.assertEqual(self.playback.tracklist_position, None) - - @populate_tracklist - def test_tracklist_position_during_play(self): - self.playback.play() - self.assertEqual(self.playback.tracklist_position, 0) - - @populate_tracklist - def test_tracklist_position_after_next(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.tracklist_position, 1) - - @populate_tracklist - def test_tracklist_position_at_end_of_playlist(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() - self.assertEqual(self.playback.tracklist_position, None) - - def test_on_tracklist_change_gets_called(self): - callback = self.playback.on_tracklist_change - - def wrapper(): - wrapper.called = True - return callback() - wrapper.called = False - - self.playback.on_tracklist_change = wrapper - self.tracklist.add([Track()]) - - self.assert_(wrapper.called) - - @unittest.SkipTest # Blocks for 10ms - @populate_tracklist - def test_end_of_track_callback_gets_called(self): - self.playback.play() - result = self.playback.seek(self.tracks[0].length - 10) - self.assertTrue(result, 'Seek failed') - message = self.core_queue.get(True, 1) - self.assertEqual('end_of_track', message['command']) - - @populate_tracklist - def test_on_tracklist_change_when_playing(self): - self.playback.play() - current_track = self.playback.current_track - self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, current_track) - - @populate_tracklist - def test_on_tracklist_change_when_stopped(self): - self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_on_tracklist_change_when_paused(self): - self.playback.play() - self.playback.pause() - current_track = self.playback.current_track - self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - self.assertEqual(self.playback.current_track, current_track) - - @populate_tracklist - def test_pause_when_stopped(self): - self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - - @populate_tracklist - def test_pause_when_playing(self): - self.playback.play() - self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - - @populate_tracklist - def test_pause_when_paused(self): - self.playback.play() - self.playback.pause() - self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - - @populate_tracklist - def test_pause_return_value(self): - self.playback.play() - self.assertEqual(self.playback.pause(), None) - - @populate_tracklist - def test_resume_when_stopped(self): - self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_resume_when_playing(self): - self.playback.play() - self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_resume_when_paused(self): - self.playback.play() - self.playback.pause() - self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_resume_return_value(self): - self.playback.play() - self.playback.pause() - self.assertEqual(self.playback.resume(), None) - - @unittest.SkipTest # Uses sleep and might not work with LocalBackend - @populate_tracklist - def test_resume_continues_from_right_position(self): - self.playback.play() - time.sleep(0.2) - self.playback.pause() - self.playback.resume() - self.assertNotEqual(self.playback.time_position, 0) - - @populate_tracklist - def test_seek_when_stopped(self): - result = self.playback.seek(1000) - self.assert_(result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_when_stopped_updates_position(self): - self.playback.seek(1000) - position = self.playback.time_position - self.assertGreaterEqual(position, 990) - - def test_seek_on_empty_playlist(self): - self.assertFalse(self.playback.seek(0)) - - def test_seek_on_empty_playlist_updates_position(self): - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_seek_when_stopped_triggers_play(self): - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_seek_when_playing(self): - self.playback.play() - result = self.playback.seek(self.tracks[0].length - 1000) - self.assert_(result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_when_playing_updates_position(self): - length = self.tracklist.tracks[0].length - self.playback.play() - self.playback.seek(length - 1000) - position = self.playback.time_position - self.assertGreaterEqual(position, length - 1010) - - @populate_tracklist - def test_seek_when_paused(self): - self.playback.play() - self.playback.pause() - result = self.playback.seek(self.tracks[0].length - 1000) - self.assert_(result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_when_paused_updates_position(self): - length = self.tracklist.tracks[0].length - self.playback.play() - self.playback.pause() - self.playback.seek(length - 1000) - position = self.playback.time_position - self.assertGreaterEqual(position, length - 1010) - - @populate_tracklist - def test_seek_when_paused_triggers_play(self): - self.playback.play() - self.playback.pause() - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @unittest.SkipTest - @populate_tracklist - def test_seek_beyond_end_of_song(self): - # FIXME need to decide return value - self.playback.play() - result = self.playback.seek(self.tracks[0].length * 100) - self.assert_(not result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_beyond_end_of_song_jumps_to_next_song(self): - self.playback.play() - self.playback.seek(self.tracks[0].length * 100) - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_seek_beyond_end_of_song_for_last_track(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.seek(self.tracklist.tracks[-1].length * 100) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @unittest.SkipTest - @populate_tracklist - def test_seek_beyond_start_of_song(self): - # FIXME need to decide return value - self.playback.play() - result = self.playback.seek(-1000) - self.assert_(not result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_beyond_start_of_song_update_postion(self): - self.playback.play() - self.playback.seek(-1000) - position = self.playback.time_position - self.assertGreaterEqual(position, 0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_stop_when_stopped(self): - self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_stop_when_playing(self): - self.playback.play() - self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_stop_when_paused(self): - self.playback.play() - self.playback.pause() - self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_stop_return_value(self): - self.playback.play() - self.assertEqual(self.playback.stop(), None) - - def test_time_position_when_stopped(self): - future = mock.Mock() - future.get = mock.Mock(return_value=0) - self.audio.get_position = mock.Mock(return_value=future) - - self.assertEqual(self.playback.time_position, 0) - - @populate_tracklist - def test_time_position_when_stopped_with_playlist(self): - future = mock.Mock() - future.get = mock.Mock(return_value=0) - self.audio.get_position = mock.Mock(return_value=future) - - self.assertEqual(self.playback.time_position, 0) - - @unittest.SkipTest # Uses sleep and does might not work with LocalBackend - @populate_tracklist - def test_time_position_when_playing(self): - self.playback.play() - first = self.playback.time_position - time.sleep(1) - second = self.playback.time_position - self.assertGreater(second, first) - - @unittest.SkipTest # Uses sleep - @populate_tracklist - def test_time_position_when_paused(self): - self.playback.play() - time.sleep(0.2) - self.playback.pause() - time.sleep(0.2) - first = self.playback.time_position - second = self.playback.time_position - self.assertEqual(first, second) - - @populate_tracklist - def test_play_with_consume(self): - self.playback.consume = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): - self.playback.consume = True - self.playback.play() - for _ in range(len(self.tracklist.tracks)): - self.playback.on_end_of_track() - self.assertEqual(len(self.tracklist.tracks), 0) - - @populate_tracklist - def test_play_with_random(self): - random.seed(1) - self.playback.random = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[2]) - - @populate_tracklist - def test_previous_with_random(self): - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.next() - current_track = self.playback.current_track - self.playback.previous() - self.assertEqual(self.playback.current_track, current_track) - - @populate_tracklist - def test_end_of_song_starts_next_track(self): - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_end_of_song_with_single_and_repeat_starts_same(self): - self.playback.single = True - self.playback.repeat = True - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_end_of_playlist_stops(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_repeat_off_by_default(self): - self.assertEqual(self.playback.repeat, False) - - def test_random_off_by_default(self): - self.assertEqual(self.playback.random, False) - - def test_consume_off_by_default(self): - self.assertEqual(self.playback.consume, False) - - @populate_tracklist - def test_random_until_end_of_playlist(self): - self.playback.random = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_random_until_end_of_playlist_and_play_from_start(self): - self.playback.repeat = True - for _ in self.tracks: - self.playback.next() - self.assertNotEqual(self.playback.tl_track_at_next, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_random_until_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.random = True - self.playback.play() - for _ in self.tracks: - self.playback.next() - self.assertNotEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_played_track_during_random_not_played_again(self): - self.playback.random = True - self.playback.play() - played = [] - for _ in self.tracks: - self.assertNotIn(self.playback.current_track, played) - played.append(self.playback.current_track) - self.playback.next() - - @populate_tracklist - def test_playing_track_that_isnt_in_playlist(self): - test = lambda: self.playback.play((17, Track())) - self.assertRaises(AssertionError, test) diff --git a/tests/backends/base/playlists.py b/tests/backends/base/playlists.py deleted file mode 100644 index 139c21c6d2..0000000000 --- a/tests/backends/base/playlists.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -import pykka - -from mopidy import audio, core -from mopidy.models import Playlist - - -class PlaylistsControllerTest(object): - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_create_returns_playlist_with_name_set(self): - playlist = self.core.playlists.create('test') - self.assertEqual(playlist.name, 'test') - - def test_create_returns_playlist_with_uri_set(self): - playlist = self.core.playlists.create('test') - self.assert_(playlist.uri) - - def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.core.playlists.create('test') - self.assert_(self.core.playlists.playlists) - self.assertIn(playlist, self.core.playlists.playlists) - - def test_playlists_empty_to_start_with(self): - self.assert_(not self.core.playlists.playlists) - - def test_delete_non_existant_playlist(self): - self.core.playlists.delete('file:///unknown/playlist') - - def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) - - self.core.playlists.delete(playlist.uri) - - self.assertNotIn(playlist, self.core.playlists.playlists) - - def test_filter_without_criteria(self): - self.assertEqual( - self.core.playlists.playlists, self.core.playlists.filter()) - - def test_filter_with_wrong_criteria(self): - self.assertEqual([], self.core.playlists.filter(name='foo')) - - def test_filter_with_right_criteria(self): - playlist = self.core.playlists.create('test') - playlists = self.core.playlists.filter(name='test') - self.assertEqual([playlist], playlists) - - def test_filter_by_name_returns_single_match(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [Playlist(name='a'), playlist] - self.assertEqual([playlist], self.core.playlists.filter(name='b')) - - def test_filter_by_name_returns_multiple_matches(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [ - playlist, Playlist(name='a'), Playlist(name='b')] - playlists = self.core.playlists.filter(name='b') - self.assertIn(playlist, playlists) - self.assertEqual(2, len(playlists)) - - def test_filter_by_name_returns_no_matches(self): - self.backend.playlists.playlists = [ - Playlist(name='a'), Playlist(name='b')] - self.assertEqual([], self.core.playlists.filter(name='c')) - - def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.core.playlists.create('test') - - looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) - - self.assertEqual(original_playlist, looked_up_playlist) - - @unittest.SkipTest - def test_refresh(self): - pass - - def test_save_replaces_existing_playlist_with_updated_playlist(self): - playlist1 = self.core.playlists.create('test1') - self.assertIn(playlist1, self.core.playlists.playlists) - - playlist2 = playlist1.copy(name='test2') - playlist2 = self.core.playlists.save(playlist2) - self.assertNotIn(playlist1, self.core.playlists.playlists) - self.assertIn(playlist2, self.core.playlists.playlists) - - @unittest.SkipTest - def test_playlist_with_unknown_track(self): - pass diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py deleted file mode 100644 index 5140d3aa26..0000000000 --- a/tests/backends/base/tracklist.py +++ /dev/null @@ -1,295 +0,0 @@ -from __future__ import unicode_literals - -import random - -import pykka - -from mopidy import audio, core -from mopidy.core import PlaybackState -from mopidy.models import TlTrack, Playlist, Track - -from tests.backends.base import populate_tracklist - - -class TracklistControllerTest(object): - tracks = [] - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) - self.controller = self.core.tracklist - self.playback = self.core.playback - - assert len(self.tracks) == 3, 'Need three tracks to run tests.' - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_length(self): - self.assertEqual(0, len(self.controller.tl_tracks)) - self.assertEqual(0, self.controller.length) - self.controller.add(self.tracks) - self.assertEqual(3, len(self.controller.tl_tracks)) - self.assertEqual(3, self.controller.length) - - def test_add(self): - for track in self.tracks: - tl_tracks = self.controller.add([track]) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_tracks[0].track) - - def test_add_at_position(self): - for track in self.tracks[:-1]: - tl_tracks = self.controller.add([track], 0) - self.assertEqual(track, self.controller.tracks[0]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) - self.assertEqual(track, tl_tracks[0].track) - - @populate_tracklist - def test_add_at_position_outside_of_playlist(self): - for track in self.tracks: - tl_tracks = self.controller.add([track], len(self.tracks) + 2) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_tracks[0].track) - - @populate_tracklist - def test_filter_by_tlid(self): - tl_track = self.controller.tl_tracks[1] - self.assertEqual( - [tl_track], self.controller.filter(tlid=tl_track.tlid)) - - @populate_tracklist - def test_filter_by_uri(self): - tl_track = self.controller.tl_tracks[1] - self.assertEqual( - [tl_track], self.controller.filter(uri=tl_track.track.uri)) - - @populate_tracklist - def test_filter_by_uri_returns_nothing_for_invalid_uri(self): - self.assertEqual([], self.controller.filter(uri='foobar')) - - def test_filter_by_uri_returns_single_match(self): - track = Track(uri='a') - self.controller.add([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.filter(uri='a')[0].track) - - def test_filter_by_uri_returns_multiple_matches(self): - track = Track(uri='a') - self.controller.add([Track(uri='z'), track, track]) - tl_tracks = self.controller.filter(uri='a') - self.assertEqual(track, tl_tracks[0].track) - self.assertEqual(track, tl_tracks[1].track) - - def test_filter_by_uri_returns_nothing_if_no_match(self): - self.controller.playlist = Playlist( - tracks=[Track(uri='z'), Track(uri='y')]) - self.assertEqual([], self.controller.filter(uri='a')) - - def test_filter_by_multiple_criteria_returns_elements_matching_all(self): - track1 = Track(uri='a', name='x') - track2 = Track(uri='b', name='x') - track3 = Track(uri='b', name='y') - self.controller.add([track1, track2, track3]) - self.assertEqual( - track1, self.controller.filter(uri='a', name='x')[0].track) - self.assertEqual( - track2, self.controller.filter(uri='b', name='x')[0].track) - self.assertEqual( - track3, self.controller.filter(uri='b', name='y')[0].track) - - def test_filter_by_criteria_that_is_not_present_in_all_elements(self): - track1 = Track() - track2 = Track(uri='b') - track3 = Track() - self.controller.add([track1, track2, track3]) - self.assertEqual(track2, self.controller.filter(uri='b')[0].track) - - @populate_tracklist - def test_clear(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) - - def test_clear_empty_playlist(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) - - @populate_tracklist - def test_clear_when_playing(self): - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.controller.clear() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_add_appends_to_the_tracklist(self): - self.controller.add([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.controller.tracks), 2) - self.controller.add([Track(uri='c'), Track(uri='d')]) - self.assertEqual(len(self.controller.tracks), 4) - self.assertEqual(self.controller.tracks[0].uri, 'a') - self.assertEqual(self.controller.tracks[1].uri, 'b') - self.assertEqual(self.controller.tracks[2].uri, 'c') - self.assertEqual(self.controller.tracks[3].uri, 'd') - - def test_add_does_not_reset_version(self): - version = self.controller.version - self.controller.add([]) - self.assertEqual(self.controller.version, version) - - @populate_tracklist - def test_add_preserves_playing_state(self): - self.playback.play() - track = self.playback.current_track - self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - - @populate_tracklist - def test_add_preserves_stopped_state(self): - self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_add_returns_the_tl_tracks_that_was_added(self): - tl_tracks = self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) - - def test_index_returns_index_of_track(self): - tl_tracks = self.controller.add(self.tracks) - self.assertEquals(0, self.controller.index(tl_tracks[0])) - self.assertEquals(1, self.controller.index(tl_tracks[1])) - self.assertEquals(2, self.controller.index(tl_tracks[2])) - - def test_index_raises_value_error_if_item_not_found(self): - test = lambda: self.controller.index(TlTrack(0, Track())) - self.assertRaises(ValueError, test) - - @populate_tracklist - def test_move_single(self): - self.controller.move(0, 0, 2) - - tracks = self.controller.tracks - self.assertEqual(tracks[2], self.tracks[0]) - - @populate_tracklist - def test_move_group(self): - self.controller.move(0, 2, 1) - - tracks = self.controller.tracks - self.assertEqual(tracks[1], self.tracks[0]) - self.assertEqual(tracks[2], self.tracks[1]) - - @populate_tracklist - def test_moving_track_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks + 5) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_move_group_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks + 5) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_move_group_out_of_range(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_move_group_invalid_group(self): - test = lambda: self.controller.move(2, 1, 0) - self.assertRaises(AssertionError, test) - - def test_tracks_attribute_is_immutable(self): - tracks1 = self.controller.tracks - tracks2 = self.controller.tracks - self.assertNotEqual(id(tracks1), id(tracks2)) - - @populate_tracklist - def test_remove(self): - track1 = self.controller.tracks[1] - track2 = self.controller.tracks[2] - version = self.controller.version - self.controller.remove(uri=track1.uri) - self.assertLess(version, self.controller.version) - self.assertNotIn(track1, self.controller.tracks) - self.assertEqual(track2, self.controller.tracks[1]) - - @populate_tracklist - def test_removing_track_that_does_not_exist_does_nothing(self): - self.controller.remove(uri='/nonexistant') - - def test_removing_from_empty_playlist_does_nothing(self): - self.controller.remove(uri='/nonexistant') - - @populate_tracklist - def test_shuffle(self): - random.seed(1) - self.controller.shuffle() - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_tracklist - def test_shuffle_subset(self): - random.seed(1) - self.controller.shuffle(1, 3) - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(self.tracks[0], shuffled_tracks[0]) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_tracklist - def test_shuffle_invalid_subset(self): - test = lambda: self.controller.shuffle(3, 1) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_shuffle_superset(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks + 5) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_shuffle_open_subset(self): - random.seed(1) - self.controller.shuffle(1) - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(self.tracks[0], shuffled_tracks[0]) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_tracklist - def test_slice_returns_a_subset_of_tracks(self): - track_slice = self.controller.slice(1, 3) - self.assertEqual(2, len(track_slice)) - self.assertEqual(self.tracks[1], track_slice[0].track) - self.assertEqual(self.tracks[2], track_slice[1].track) - - @populate_tracklist - def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): - self.assertEqual(0, len(self.controller.slice(7, 8))) - self.assertEqual(0, len(self.controller.slice(-1, 1))) - - def test_version_does_not_change_when_adding_nothing(self): - version = self.controller.version - self.controller.add([]) - self.assertEquals(version, self.controller.version) - - def test_version_increases_when_adding_something(self): - version = self.controller.version - self.controller.add([Track()]) - self.assertLess(version, self.controller.version) diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py index 1738722f63..f408139f67 100644 --- a/tests/backends/local/__init__.py +++ b/tests/backends/local/__init__.py @@ -1,4 +1,15 @@ from __future__ import unicode_literals -generate_song = lambda i: 'local:track:song%s.wav' % i +def generate_song(i): + return 'local:track:song%s.wav' % i + + +def populate_tracklist(func): + def wrapper(self): + self.tl_tracks = self.core.tracklist.add(self.tracks) + return func(self) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 7b7ceadd2f..725c580f1c 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -2,14 +2,18 @@ import unittest +import mock +import pykka + +from mopidy import core, audio +from mopidy.backends import listener from mopidy.backends.local import actor from tests import path_to_data_dir -from tests.backends.base import events -class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): - backend_class = actor.LocalBackend +@mock.patch.object(listener.BackendListener, 'send') +class LocalBackendEventsTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -17,3 +21,17 @@ class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } + + def setUp(self): + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_playlists_refresh_sends_playlists_loaded_event(self, send): + send.reset_mock() + self.core.playlists.refresh().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index c249a10e6e..8f9885039f 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -2,14 +2,30 @@ import unittest +import pykka + +from mopidy import core from mopidy.backends.local import actor +from mopidy.models import Track, Album, Artist from tests import path_to_data_dir -from tests.backends.base.library import LibraryControllerTest -class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend +class LocalLibraryControllerTest(unittest.TestCase): + artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] + + albums = [ + Album(name='album1', artists=artists[:1]), + Album(name='album2', artists=artists[1:2]), + Album()] + + tracks = [ + Track(uri='local:track:path1', name='track1', artists=artists[:1], + album=albums[0], date='2001-02-03', length=4000), + Track(uri='local:track:path2', name='track2', artists=artists[1:2], + album=albums[1], date='2002', length=4000), + Track()] + config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -17,3 +33,182 @@ class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): 'tag_cache_file': path_to_data_dir('library_tag_cache'), } } + + def setUp(self): + self.backend = actor.LocalBackend.start( + config=self.config, audio=None).proxy() + self.core = core.Core(backends=[self.backend]) + self.library = self.core.library + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_refresh(self): + self.library.refresh() + + @unittest.SkipTest + def test_refresh_uri(self): + pass + + @unittest.SkipTest + def test_refresh_missing_uri(self): + pass + + def test_lookup(self): + tracks = self.library.lookup(self.tracks[0].uri) + self.assertEqual(tracks, self.tracks[0:1]) + + def test_lookup_unknown_track(self): + tracks = self.library.lookup('fake uri') + self.assertEqual(tracks, []) + + def test_find_exact_no_hits(self): + result = self.library.find_exact(track=['unknown track']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(artist=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(album=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + def test_find_exact_uri(self): + track_1_uri = 'local:track:path1' + result = self.library.find_exact(uri=track_1_uri) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + track_2_uri = 'local:track:path2' + result = self.library.find_exact(uri=track_2_uri) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_track(self): + result = self.library.find_exact(track=['track1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(track=['track2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_artist(self): + result = self.library.find_exact(artist=['artist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(artist=['artist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_album(self): + result = self.library.find_exact(album=['album1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(album=['album2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_date(self): + result = self.library.find_exact(date=['2001']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(date=['2001-02-03']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(date=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_wrong_type(self): + test = lambda: self.library.find_exact(wrong=['test']) + self.assertRaises(LookupError, test) + + def test_find_exact_with_empty_query(self): + test = lambda: self.library.find_exact(artist=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(track=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(album=['']) + self.assertRaises(LookupError, test) + + def test_search_no_hits(self): + result = self.library.search(track=['unknown track']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(artist=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(album=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(uri=['unknown']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(any=['unknown']) + self.assertEqual(list(result[0].tracks), []) + + def test_search_uri(self): + result = self.library.search(uri=['TH1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(uri=['TH2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_track(self): + result = self.library.search(track=['Rack1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(track=['Rack2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_artist(self): + result = self.library.search(artist=['Tist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(artist=['Tist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_album(self): + result = self.library.search(album=['Bum1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(album=['Bum2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_date(self): + result = self.library.search(date=['2001']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(date=['2001-02-03']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(date=['2001-02-04']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(date=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_any(self): + result = self.library.search(any=['Tist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.search(any=['Rack1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.search(any=['Bum1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.search(any=['TH1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + def test_search_wrong_type(self): + test = lambda: self.library.search(wrong=['test']) + self.assertRaises(LookupError, test) + + def test_search_with_empty_query(self): + test = lambda: self.library.search(artist=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(track=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(album=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(uri=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(any=['']) + self.assertRaises(LookupError, test) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 530f09c8d8..c37eb91dfd 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,18 +1,25 @@ from __future__ import unicode_literals +import mock +import random +import time import unittest +import pykka + +from mopidy import audio, core from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track from tests import path_to_data_dir -from tests.backends.base.playback import PlaybackControllerTest -from tests.backends.local import generate_song +from tests.backends.local import generate_song, populate_tracklist + +# TODO Test 'playlist repeat', e.g. repeat=1,single=0 -class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend + +class LocalPlaybackControllerTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -27,6 +34,22 @@ def add_track(self, uri): track = Track(uri=uri, length=4464) self.tracklist.add([track]) + def setUp(self): + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core(backends=[self.backend]) + self.playback = self.core.playback + self.tracklist = self.core.tracklist + + assert len(self.tracks) >= 3, \ + 'Need at least three tracks to run tests.' + assert self.tracks[0].length >= 2000, \ + 'First song needs to be at least 2000 miliseconds' + + def tearDown(self): + pykka.ActorRegistry.stop_all() + def test_uri_scheme(self): self.assertNotIn('file', self.core.uri_schemes) self.assertIn('local', self.core.uri_schemes) @@ -45,3 +68,842 @@ def test_play_flac(self): self.add_track('local:track:blank.flac') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + def test_initial_state_is_stopped(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_play_with_empty_playlist(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_play_with_empty_playlist_return_value(self): + self.assertEqual(self.playback.play(), None) + + @populate_tracklist + def test_play_state(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_play_return_value(self): + self.assertEqual(self.playback.play(), None) + + @populate_tracklist + def test_play_track_state(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play(self.tracklist.tl_tracks[-1]) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_play_track_return_value(self): + self.assertEqual(self.playback.play( + self.tracklist.tl_tracks[-1]), None) + + @populate_tracklist + def test_play_when_playing(self): + self.playback.play() + track = self.playback.current_track + self.playback.play() + self.assertEqual(track, self.playback.current_track) + + @populate_tracklist + def test_play_when_paused(self): + self.playback.play() + track = self.playback.current_track + self.playback.pause() + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(track, self.playback.current_track) + + @populate_tracklist + def test_play_when_pause_after_next(self): + self.playback.play() + self.playback.next() + self.playback.next() + track = self.playback.current_track + self.playback.pause() + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(track, self.playback.current_track) + + @populate_tracklist + def test_play_sets_current_track(self): + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_play_track_sets_current_track(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.assertEqual(self.playback.current_track, self.tracks[-1]) + + @populate_tracklist + def test_play_skips_to_next_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[0] + self.playback.play() + self.assertNotEqual(self.playback.current_track, self.tracks[0]) + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_current_track_after_completed_playlist(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.next() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_previous(self): + self.playback.play() + self.playback.next() + self.playback.previous() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_previous_more(self): + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_previous_return_value(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.previous(), None) + + @populate_tracklist + def test_previous_does_not_trigger_playback(self): + self.playback.play() + self.playback.next() + self.playback.stop() + self.playback.previous() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_previous_at_start_of_playlist(self): + self.playback.previous() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + def test_previous_for_empty_playlist(self): + self.playback.previous() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_previous_skips_to_previous_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] + self.playback.play(self.tracklist.tl_tracks[2]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + self.playback.previous() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_next(self): + self.playback.play() + + old_position = self.playback.tracklist_position + old_uri = self.playback.current_track.uri + + self.playback.next() + + self.assertEqual( + self.playback.tracklist_position, old_position + 1) + self.assertNotEqual(self.playback.current_track.uri, old_uri) + + @populate_tracklist + def test_next_return_value(self): + self.playback.play() + self.assertEqual(self.playback.next(), None) + + @populate_tracklist + def test_next_does_not_trigger_playback(self): + self.playback.next() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_next_at_end_of_playlist(self): + self.playback.play() + + for i, track in enumerate(self.tracks): + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, track) + self.assertEqual(self.playback.tracklist_position, i) + + self.playback.next() + + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_next_until_end_of_playlist_and_play_from_start(self): + self.playback.play() + + for _ in self.tracks: + self.playback.next() + + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + def test_next_for_empty_playlist(self): + self.playback.next() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_next_skips_to_next_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.next() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + + @populate_tracklist + def test_next_track_before_play(self): + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + + @populate_tracklist + def test_next_track_during_play(self): + self.playback.play() + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + + @populate_tracklist + def test_next_track_after_previous(self): + self.playback.play() + self.playback.next() + self.playback.previous() + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + + def test_next_track_empty_playlist(self): + self.assertEqual(self.playback.tl_track_at_next, None) + + @populate_tracklist + def test_next_track_at_end_of_playlist(self): + self.playback.play() + for _ in self.tracklist.tl_tracks[1:]: + self.playback.next() + self.assertEqual(self.playback.tl_track_at_next, None) + + @populate_tracklist + def test_next_track_at_end_of_playlist_with_repeat(self): + self.playback.repeat = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.next() + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + + @populate_tracklist + def test_next_track_with_random(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + + @populate_tracklist + def test_next_with_consume(self): + self.playback.consume = True + self.playback.play() + self.playback.next() + self.assertIn(self.tracks[0], self.tracklist.tracks) + + @populate_tracklist + def test_next_with_single_and_repeat(self): + self.playback.single = True + self.playback.repeat = True + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_next_with_random(self): + # FIXME feels very fragile + random.seed(1) + self.playback.random = True + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_next_track_with_random_after_append_playlist(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + self.tracklist.add(self.tracks[:1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + + @populate_tracklist + def test_end_of_track(self): + self.playback.play() + + old_position = self.playback.tracklist_position + old_uri = self.playback.current_track.uri + + self.playback.on_end_of_track() + + self.assertEqual( + self.playback.tracklist_position, old_position + 1) + self.assertNotEqual(self.playback.current_track.uri, old_uri) + + @populate_tracklist + def test_end_of_track_return_value(self): + self.playback.play() + self.assertEqual(self.playback.on_end_of_track(), None) + + @populate_tracklist + def test_end_of_track_does_not_trigger_playback(self): + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_at_end_of_playlist(self): + self.playback.play() + + for i, track in enumerate(self.tracks): + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, track) + self.assertEqual(self.playback.tracklist_position, i) + + self.playback.on_end_of_track() + + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_until_end_of_playlist_and_play_from_start(self): + self.playback.play() + + for _ in self.tracks: + self.playback.on_end_of_track() + + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + def test_end_of_track_for_empty_playlist(self): + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_skips_to_next_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.on_end_of_track() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + + @populate_tracklist + def test_end_of_track_track_before_play(self): + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + + @populate_tracklist + def test_end_of_track_track_during_play(self): + self.playback.play() + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + + @populate_tracklist + def test_end_of_track_track_after_previous(self): + self.playback.play() + self.playback.on_end_of_track() + self.playback.previous() + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + + def test_end_of_track_track_empty_playlist(self): + self.assertEqual(self.playback.tl_track_at_next, None) + + @populate_tracklist + def test_end_of_track_track_at_end_of_playlist(self): + self.playback.play() + for _ in self.tracklist.tl_tracks[1:]: + self.playback.on_end_of_track() + self.assertEqual(self.playback.tl_track_at_next, None) + + @populate_tracklist + def test_end_of_track_track_at_end_of_playlist_with_repeat(self): + self.playback.repeat = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.on_end_of_track() + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) + + @populate_tracklist + def test_end_of_track_track_with_random(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + + @populate_tracklist + def test_end_of_track_with_consume(self): + self.playback.consume = True + self.playback.play() + self.playback.on_end_of_track() + self.assertNotIn(self.tracks[0], self.tracklist.tracks) + + @populate_tracklist + def test_end_of_track_with_random(self): + # FIXME feels very fragile + random.seed(1) + self.playback.random = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_end_of_track_track_with_random_after_append_playlist(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) + self.tracklist.add(self.tracks[:1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) + + @populate_tracklist + def test_previous_track_before_play(self): + self.assertEqual(self.playback.tl_track_at_previous, None) + + @populate_tracklist + def test_previous_track_after_play(self): + self.playback.play() + self.assertEqual(self.playback.tl_track_at_previous, None) + + @populate_tracklist + def test_previous_track_after_next(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) + + @populate_tracklist + def test_previous_track_after_previous(self): + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 + self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) + + def test_previous_track_empty_playlist(self): + self.assertEqual(self.playback.tl_track_at_previous, None) + + @populate_tracklist + def test_previous_track_with_consume(self): + self.playback.consume = True + for _ in self.tracks: + self.playback.next() + self.assertEqual( + self.playback.tl_track_at_previous, + self.playback.current_tl_track) + + @populate_tracklist + def test_previous_track_with_random(self): + self.playback.random = True + for _ in self.tracks: + self.playback.next() + self.assertEqual( + self.playback.tl_track_at_previous, + self.playback.current_tl_track) + + @populate_tracklist + def test_initial_current_track(self): + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_current_track_during_play(self): + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_current_track_after_next(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_initial_tracklist_position(self): + self.assertEqual(self.playback.tracklist_position, None) + + @populate_tracklist + def test_tracklist_position_during_play(self): + self.playback.play() + self.assertEqual(self.playback.tracklist_position, 0) + + @populate_tracklist + def test_tracklist_position_after_next(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.tracklist_position, 1) + + @populate_tracklist + def test_tracklist_position_at_end_of_playlist(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.tracklist_position, None) + + def test_on_tracklist_change_gets_called(self): + callback = self.playback.on_tracklist_change + + def wrapper(): + wrapper.called = True + return callback() + wrapper.called = False + + self.playback.on_tracklist_change = wrapper + self.tracklist.add([Track()]) + + self.assert_(wrapper.called) + + @unittest.SkipTest # Blocks for 10ms + @populate_tracklist + def test_end_of_track_callback_gets_called(self): + self.playback.play() + result = self.playback.seek(self.tracks[0].length - 10) + self.assertTrue(result, 'Seek failed') + message = self.core_queue.get(True, 1) + self.assertEqual('end_of_track', message['command']) + + @populate_tracklist + def test_on_tracklist_change_when_playing(self): + self.playback.play() + current_track = self.playback.current_track + self.tracklist.add([self.tracks[2]]) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_on_tracklist_change_when_stopped(self): + self.tracklist.add([self.tracks[2]]) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_on_tracklist_change_when_paused(self): + self.playback.play() + self.playback.pause() + current_track = self.playback.current_track + self.tracklist.add([self.tracks[2]]) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_pause_when_stopped(self): + self.playback.pause() + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + + @populate_tracklist + def test_pause_when_playing(self): + self.playback.play() + self.playback.pause() + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + + @populate_tracklist + def test_pause_when_paused(self): + self.playback.play() + self.playback.pause() + self.playback.pause() + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + + @populate_tracklist + def test_pause_return_value(self): + self.playback.play() + self.assertEqual(self.playback.pause(), None) + + @populate_tracklist + def test_resume_when_stopped(self): + self.playback.resume() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_resume_when_playing(self): + self.playback.play() + self.playback.resume() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_resume_when_paused(self): + self.playback.play() + self.playback.pause() + self.playback.resume() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_resume_return_value(self): + self.playback.play() + self.playback.pause() + self.assertEqual(self.playback.resume(), None) + + @unittest.SkipTest # Uses sleep and might not work with LocalBackend + @populate_tracklist + def test_resume_continues_from_right_position(self): + self.playback.play() + time.sleep(0.2) + self.playback.pause() + self.playback.resume() + self.assertNotEqual(self.playback.time_position, 0) + + @populate_tracklist + def test_seek_when_stopped(self): + result = self.playback.seek(1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_when_stopped_updates_position(self): + self.playback.seek(1000) + position = self.playback.time_position + self.assertGreaterEqual(position, 990) + + def test_seek_on_empty_playlist(self): + self.assertFalse(self.playback.seek(0)) + + def test_seek_on_empty_playlist_updates_position(self): + self.playback.seek(0) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_seek_when_stopped_triggers_play(self): + self.playback.seek(0) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_seek_when_playing(self): + self.playback.play() + result = self.playback.seek(self.tracks[0].length - 1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_when_playing_updates_position(self): + length = self.tracklist.tracks[0].length + self.playback.play() + self.playback.seek(length - 1000) + position = self.playback.time_position + self.assertGreaterEqual(position, length - 1010) + + @populate_tracklist + def test_seek_when_paused(self): + self.playback.play() + self.playback.pause() + result = self.playback.seek(self.tracks[0].length - 1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_when_paused_updates_position(self): + length = self.tracklist.tracks[0].length + self.playback.play() + self.playback.pause() + self.playback.seek(length - 1000) + position = self.playback.time_position + self.assertGreaterEqual(position, length - 1010) + + @populate_tracklist + def test_seek_when_paused_triggers_play(self): + self.playback.play() + self.playback.pause() + self.playback.seek(0) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @unittest.SkipTest + @populate_tracklist + def test_seek_beyond_end_of_song(self): + # FIXME need to decide return value + self.playback.play() + result = self.playback.seek(self.tracks[0].length * 100) + self.assert_(not result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_beyond_end_of_song_jumps_to_next_song(self): + self.playback.play() + self.playback.seek(self.tracks[0].length * 100) + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_seek_beyond_end_of_song_for_last_track(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.seek(self.tracklist.tracks[-1].length * 100) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @unittest.SkipTest + @populate_tracklist + def test_seek_beyond_start_of_song(self): + # FIXME need to decide return value + self.playback.play() + result = self.playback.seek(-1000) + self.assert_(not result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_beyond_start_of_song_update_postion(self): + self.playback.play() + self.playback.seek(-1000) + position = self.playback.time_position + self.assertGreaterEqual(position, 0) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_stop_when_stopped(self): + self.playback.stop() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_stop_when_playing(self): + self.playback.play() + self.playback.stop() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_stop_when_paused(self): + self.playback.play() + self.playback.pause() + self.playback.stop() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_stop_return_value(self): + self.playback.play() + self.assertEqual(self.playback.stop(), None) + + def test_time_position_when_stopped(self): + future = mock.Mock() + future.get = mock.Mock(return_value=0) + self.audio.get_position = mock.Mock(return_value=future) + + self.assertEqual(self.playback.time_position, 0) + + @populate_tracklist + def test_time_position_when_stopped_with_playlist(self): + future = mock.Mock() + future.get = mock.Mock(return_value=0) + self.audio.get_position = mock.Mock(return_value=future) + + self.assertEqual(self.playback.time_position, 0) + + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend + @populate_tracklist + def test_time_position_when_playing(self): + self.playback.play() + first = self.playback.time_position + time.sleep(1) + second = self.playback.time_position + self.assertGreater(second, first) + + @unittest.SkipTest # Uses sleep + @populate_tracklist + def test_time_position_when_paused(self): + self.playback.play() + time.sleep(0.2) + self.playback.pause() + time.sleep(0.2) + first = self.playback.time_position + second = self.playback.time_position + self.assertEqual(first, second) + + @populate_tracklist + def test_play_with_consume(self): + self.playback.consume = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): + self.playback.consume = True + self.playback.play() + for _ in range(len(self.tracklist.tracks)): + self.playback.on_end_of_track() + self.assertEqual(len(self.tracklist.tracks), 0) + + @populate_tracklist + def test_play_with_random(self): + random.seed(1) + self.playback.random = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[2]) + + @populate_tracklist + def test_previous_with_random(self): + random.seed(1) + self.playback.random = True + self.playback.play() + self.playback.next() + current_track = self.playback.current_track + self.playback.previous() + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_end_of_song_starts_next_track(self): + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_end_of_song_with_single_and_repeat_starts_same(self): + self.playback.single = True + self.playback.repeat = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_end_of_playlist_stops(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_repeat_off_by_default(self): + self.assertEqual(self.playback.repeat, False) + + def test_random_off_by_default(self): + self.assertEqual(self.playback.random, False) + + def test_consume_off_by_default(self): + self.assertEqual(self.playback.consume, False) + + @populate_tracklist + def test_random_until_end_of_playlist(self): + self.playback.random = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.next() + self.assertEqual(self.playback.tl_track_at_next, None) + + @populate_tracklist + def test_random_until_end_of_playlist_and_play_from_start(self): + self.playback.repeat = True + for _ in self.tracks: + self.playback.next() + self.assertNotEqual(self.playback.tl_track_at_next, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_random_until_end_of_playlist_with_repeat(self): + self.playback.repeat = True + self.playback.random = True + self.playback.play() + for _ in self.tracks: + self.playback.next() + self.assertNotEqual(self.playback.tl_track_at_next, None) + + @populate_tracklist + def test_played_track_during_random_not_played_again(self): + self.playback.random = True + self.playback.play() + played = [] + for _ in self.tracks: + self.assertNotIn(self.playback.current_track, played) + played.append(self.playback.current_track) + self.playback.next() + + @populate_tracklist + def test_playing_track_that_isnt_in_playlist(self): + test = lambda: self.playback.play((17, Track())) + self.assertRaises(AssertionError, test) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index d405e8877b..4975d8aaff 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -5,18 +5,17 @@ import tempfile import unittest +import pykka + +from mopidy import audio, core from mopidy.backends.local import actor -from mopidy.models import Track +from mopidy.models import Playlist, Track from tests import path_to_data_dir -from tests.backends.base.playlists import ( - PlaylistsControllerTest) from tests.backends.local import generate_song -class LocalPlaylistsControllerTest( - PlaylistsControllerTest, unittest.TestCase): - +class LocalPlaylistsControllerTest(unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { @@ -29,10 +28,13 @@ def setUp(self): self.config['local']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['local']['playlists_dir'] - super(LocalPlaylistsControllerTest, self).setUp() + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core(backends=[self.backend]) def tearDown(self): - super(LocalPlaylistsControllerTest, self).tearDown() + pykka.ActorRegistry.stop_all() if os.path.exists(self.playlists_dir): shutil.rmtree(self.playlists_dir) @@ -121,3 +123,84 @@ def test_santitising_of_playlist_filenames(self): @unittest.SkipTest def test_playlist_dir_is_created(self): pass + + def test_create_returns_playlist_with_name_set(self): + playlist = self.core.playlists.create('test') + self.assertEqual(playlist.name, 'test') + + def test_create_returns_playlist_with_uri_set(self): + playlist = self.core.playlists.create('test') + self.assert_(playlist.uri) + + def test_create_adds_playlist_to_playlists_collection(self): + playlist = self.core.playlists.create('test') + self.assert_(self.core.playlists.playlists) + self.assertIn(playlist, self.core.playlists.playlists) + + def test_playlists_empty_to_start_with(self): + self.assert_(not self.core.playlists.playlists) + + def test_delete_non_existant_playlist(self): + self.core.playlists.delete('file:///unknown/playlist') + + def test_delete_playlist_removes_it_from_the_collection(self): + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) + + self.core.playlists.delete(playlist.uri) + + self.assertNotIn(playlist, self.core.playlists.playlists) + + def test_filter_without_criteria(self): + self.assertEqual( + self.core.playlists.playlists, self.core.playlists.filter()) + + def test_filter_with_wrong_criteria(self): + self.assertEqual([], self.core.playlists.filter(name='foo')) + + def test_filter_with_right_criteria(self): + playlist = self.core.playlists.create('test') + playlists = self.core.playlists.filter(name='test') + self.assertEqual([playlist], playlists) + + def test_filter_by_name_returns_single_match(self): + playlist = Playlist(name='b') + self.backend.playlists.playlists = [Playlist(name='a'), playlist] + self.assertEqual([playlist], self.core.playlists.filter(name='b')) + + def test_filter_by_name_returns_multiple_matches(self): + playlist = Playlist(name='b') + self.backend.playlists.playlists = [ + playlist, Playlist(name='a'), Playlist(name='b')] + playlists = self.core.playlists.filter(name='b') + self.assertIn(playlist, playlists) + self.assertEqual(2, len(playlists)) + + def test_filter_by_name_returns_no_matches(self): + self.backend.playlists.playlists = [ + Playlist(name='a'), Playlist(name='b')] + self.assertEqual([], self.core.playlists.filter(name='c')) + + def test_lookup_finds_playlist_by_uri(self): + original_playlist = self.core.playlists.create('test') + + looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) + + self.assertEqual(original_playlist, looked_up_playlist) + + @unittest.SkipTest + def test_refresh(self): + pass + + def test_save_replaces_existing_playlist_with_updated_playlist(self): + playlist1 = self.core.playlists.create('test1') + self.assertIn(playlist1, self.core.playlists.playlists) + + playlist2 = playlist1.copy(name='test2') + playlist2 = self.core.playlists.save(playlist2) + self.assertNotIn(playlist1, self.core.playlists.playlists) + self.assertIn(playlist2, self.core.playlists.playlists) + + @unittest.SkipTest + def test_playlist_with_unknown_track(self): + pass diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index c7650ac065..1993d246c6 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -1,17 +1,20 @@ from __future__ import unicode_literals +import random import unittest +import pykka + +from mopidy import audio, core from mopidy.backends.local import actor -from mopidy.models import Track +from mopidy.core import PlaybackState +from mopidy.models import Playlist, TlTrack, Track from tests import path_to_data_dir -from tests.backends.base.tracklist import TracklistControllerTest -from tests.backends.local import generate_song +from tests.backends.local import generate_song, populate_tracklist -class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend +class LocalTracklistControllerTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -21,3 +24,282 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): } tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + + def setUp(self): + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.controller = self.core.tracklist + self.playback = self.core.playback + + assert len(self.tracks) == 3, 'Need three tracks to run tests.' + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_length(self): + self.assertEqual(0, len(self.controller.tl_tracks)) + self.assertEqual(0, self.controller.length) + self.controller.add(self.tracks) + self.assertEqual(3, len(self.controller.tl_tracks)) + self.assertEqual(3, self.controller.length) + + def test_add(self): + for track in self.tracks: + tl_tracks = self.controller.add([track]) + self.assertEqual(track, self.controller.tracks[-1]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_tracks[0].track) + + def test_add_at_position(self): + for track in self.tracks[:-1]: + tl_tracks = self.controller.add([track], 0) + self.assertEqual(track, self.controller.tracks[0]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) + self.assertEqual(track, tl_tracks[0].track) + + @populate_tracklist + def test_add_at_position_outside_of_playlist(self): + for track in self.tracks: + tl_tracks = self.controller.add([track], len(self.tracks) + 2) + self.assertEqual(track, self.controller.tracks[-1]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_tracks[0].track) + + @populate_tracklist + def test_filter_by_tlid(self): + tl_track = self.controller.tl_tracks[1] + self.assertEqual( + [tl_track], self.controller.filter(tlid=tl_track.tlid)) + + @populate_tracklist + def test_filter_by_uri(self): + tl_track = self.controller.tl_tracks[1] + self.assertEqual( + [tl_track], self.controller.filter(uri=tl_track.track.uri)) + + @populate_tracklist + def test_filter_by_uri_returns_nothing_for_invalid_uri(self): + self.assertEqual([], self.controller.filter(uri='foobar')) + + def test_filter_by_uri_returns_single_match(self): + track = Track(uri='a') + self.controller.add([Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.controller.filter(uri='a')[0].track) + + def test_filter_by_uri_returns_multiple_matches(self): + track = Track(uri='a') + self.controller.add([Track(uri='z'), track, track]) + tl_tracks = self.controller.filter(uri='a') + self.assertEqual(track, tl_tracks[0].track) + self.assertEqual(track, tl_tracks[1].track) + + def test_filter_by_uri_returns_nothing_if_no_match(self): + self.controller.playlist = Playlist( + tracks=[Track(uri='z'), Track(uri='y')]) + self.assertEqual([], self.controller.filter(uri='a')) + + def test_filter_by_multiple_criteria_returns_elements_matching_all(self): + track1 = Track(uri='a', name='x') + track2 = Track(uri='b', name='x') + track3 = Track(uri='b', name='y') + self.controller.add([track1, track2, track3]) + self.assertEqual( + track1, self.controller.filter(uri='a', name='x')[0].track) + self.assertEqual( + track2, self.controller.filter(uri='b', name='x')[0].track) + self.assertEqual( + track3, self.controller.filter(uri='b', name='y')[0].track) + + def test_filter_by_criteria_that_is_not_present_in_all_elements(self): + track1 = Track() + track2 = Track(uri='b') + track3 = Track() + self.controller.add([track1, track2, track3]) + self.assertEqual(track2, self.controller.filter(uri='b')[0].track) + + @populate_tracklist + def test_clear(self): + self.controller.clear() + self.assertEqual(len(self.controller.tracks), 0) + + def test_clear_empty_playlist(self): + self.controller.clear() + self.assertEqual(len(self.controller.tracks), 0) + + @populate_tracklist + def test_clear_when_playing(self): + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.controller.clear() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_add_appends_to_the_tracklist(self): + self.controller.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.controller.tracks), 2) + self.controller.add([Track(uri='c'), Track(uri='d')]) + self.assertEqual(len(self.controller.tracks), 4) + self.assertEqual(self.controller.tracks[0].uri, 'a') + self.assertEqual(self.controller.tracks[1].uri, 'b') + self.assertEqual(self.controller.tracks[2].uri, 'c') + self.assertEqual(self.controller.tracks[3].uri, 'd') + + def test_add_does_not_reset_version(self): + version = self.controller.version + self.controller.add([]) + self.assertEqual(self.controller.version, version) + + @populate_tracklist + def test_add_preserves_playing_state(self): + self.playback.play() + track = self.playback.current_track + self.controller.add(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, track) + + @populate_tracklist + def test_add_preserves_stopped_state(self): + self.controller.add(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_add_returns_the_tl_tracks_that_was_added(self): + tl_tracks = self.controller.add(self.controller.tracks[1:2]) + self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) + + def test_index_returns_index_of_track(self): + tl_tracks = self.controller.add(self.tracks) + self.assertEquals(0, self.controller.index(tl_tracks[0])) + self.assertEquals(1, self.controller.index(tl_tracks[1])) + self.assertEquals(2, self.controller.index(tl_tracks[2])) + + def test_index_raises_value_error_if_item_not_found(self): + test = lambda: self.controller.index(TlTrack(0, Track())) + self.assertRaises(ValueError, test) + + @populate_tracklist + def test_move_single(self): + self.controller.move(0, 0, 2) + + tracks = self.controller.tracks + self.assertEqual(tracks[2], self.tracks[0]) + + @populate_tracklist + def test_move_group(self): + self.controller.move(0, 2, 1) + + tracks = self.controller.tracks + self.assertEqual(tracks[1], self.tracks[0]) + self.assertEqual(tracks[2], self.tracks[1]) + + @populate_tracklist + def test_moving_track_outside_of_playlist(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(0, 0, tracks + 5) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_move_group_outside_of_playlist(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(0, 2, tracks + 5) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_move_group_out_of_range(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_move_group_invalid_group(self): + test = lambda: self.controller.move(2, 1, 0) + self.assertRaises(AssertionError, test) + + def test_tracks_attribute_is_immutable(self): + tracks1 = self.controller.tracks + tracks2 = self.controller.tracks + self.assertNotEqual(id(tracks1), id(tracks2)) + + @populate_tracklist + def test_remove(self): + track1 = self.controller.tracks[1] + track2 = self.controller.tracks[2] + version = self.controller.version + self.controller.remove(uri=track1.uri) + self.assertLess(version, self.controller.version) + self.assertNotIn(track1, self.controller.tracks) + self.assertEqual(track2, self.controller.tracks[1]) + + @populate_tracklist + def test_removing_track_that_does_not_exist_does_nothing(self): + self.controller.remove(uri='/nonexistant') + + def test_removing_from_empty_playlist_does_nothing(self): + self.controller.remove(uri='/nonexistant') + + @populate_tracklist + def test_shuffle(self): + random.seed(1) + self.controller.shuffle() + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_tracklist + def test_shuffle_subset(self): + random.seed(1) + self.controller.shuffle(1, 3) + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(self.tracks[0], shuffled_tracks[0]) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_tracklist + def test_shuffle_invalid_subset(self): + test = lambda: self.controller.shuffle(3, 1) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_shuffle_superset(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.shuffle(1, tracks + 5) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_shuffle_open_subset(self): + random.seed(1) + self.controller.shuffle(1) + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(self.tracks[0], shuffled_tracks[0]) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_tracklist + def test_slice_returns_a_subset_of_tracks(self): + track_slice = self.controller.slice(1, 3) + self.assertEqual(2, len(track_slice)) + self.assertEqual(self.tracks[1], track_slice[0].track) + self.assertEqual(self.tracks[2], track_slice[1].track) + + @populate_tracklist + def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): + self.assertEqual(0, len(self.controller.slice(7, 8))) + self.assertEqual(0, len(self.controller.slice(-1, 1))) + + def test_version_does_not_change_when_adding_nothing(self): + version = self.controller.version + self.controller.add([]) + self.assertEquals(version, self.controller.version) + + def test_version_increases_when_adding_something(self): + version = self.controller.version + self.controller.add([Track()]) + self.assertLess(version, self.controller.version) From ccbae310c67c3e79a6399e565246657d525ac90d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 1 Sep 2013 23:10:36 +0200 Subject: [PATCH 088/175] local: Add albumartist support to search/find_exact --- mopidy/backends/local/library.py | 10 +++++ tests/backends/local/library_test.py | 59 +++++++++++++++++++++++----- tests/data/library_tag_cache | 6 ++- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 9dd112e9a7..319049f0ad 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -62,6 +62,9 @@ def find_exact(self, query=None, uris=None): album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) + albumartist_filter = lambda t: any([ + q == a.name + for a in getattr(t.album, 'artists', [])]) date_filter = lambda t: q == t.date any_filter = lambda t: ( track_filter(t) or album_filter(t) or @@ -75,6 +78,8 @@ def find_exact(self, query=None, uris=None): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'albumartist': + result_tracks = filter(albumartist_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) elif field == 'any': @@ -105,6 +110,9 @@ def search(self, query=None, uris=None): t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) + albumartist_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) date_filter = lambda t: t.date and t.date.startswith(q) any_filter = lambda t: track_filter(t) or album_filter(t) or \ artist_filter(t) or uri_filter(t) @@ -117,6 +125,8 @@ def search(self, query=None, uris=None): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'albumartist': + result_tracks = filter(albumartist_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) elif field == 'any': diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 8f9885039f..149a9cb32e 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -12,19 +12,33 @@ class LocalLibraryControllerTest(unittest.TestCase): - artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] + artists = [ + Artist(name='artist1'), + Artist(name='artist2'), + Artist(name='artist3'), + Artist(name='artist4'), + ] albums = [ - Album(name='album1', artists=artists[:1]), - Album(name='album2', artists=artists[1:2]), - Album()] + Album(name='album1', artists=[artists[0]]), + Album(name='album2', artists=[artists[1]]), + Album(name='album3', artists=[artists[2]]), + ] tracks = [ - Track(uri='local:track:path1', name='track1', artists=artists[:1], - album=albums[0], date='2001-02-03', length=4000), - Track(uri='local:track:path2', name='track2', artists=artists[1:2], - album=albums[1], date='2002', length=4000), - Track()] + Track( + uri='local:track:path1', name='track1', + artists=[artists[0]], album=albums[0], + date='2001-02-03', length=4000), + Track( + uri='local:track:path2', name='track2', + artists=[artists[1]], album=albums[1], + date='2002', length=4000), + Track( + uri='local:track:path3', name='track3', + artists=[artists[3]], album=albums[2], + date='2003', length=4000), + ] config = { 'local': { @@ -35,6 +49,7 @@ class LocalLibraryControllerTest(unittest.TestCase): } def setUp(self): + self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) @@ -102,6 +117,19 @@ def test_find_exact_album(self): result = self.library.find_exact(album=['album2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_find_exact_albumartist(self): + # Artist is both track artist and album artist + result = self.library.find_exact(albumartist=['artist1']) + self.assertEqual(list(result[0].tracks), [self.tracks[0]]) + + # Artist is both track and album artist + result = self.library.find_exact(albumartist=['artist2']) + self.assertEqual(list(result[0].tracks), [self.tracks[1]]) + + # Artist is just album artist + result = self.library.find_exact(albumartist=['artist3']) + self.assertEqual(list(result[0].tracks), [self.tracks[2]]) + def test_find_exact_date(self): result = self.library.find_exact(date=['2001']) self.assertEqual(list(result[0].tracks), []) @@ -163,6 +191,19 @@ def test_search_artist(self): result = self.library.search(artist=['Tist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_search_albumartist(self): + # Artist is both track artist and album artist + result = self.library.search(albumartist=['Tist1']) + self.assertEqual(list(result[0].tracks), [self.tracks[0]]) + + # Artist is both track artist and album artist + result = self.library.search(albumartist=['Tist2']) + self.assertEqual(list(result[0].tracks), [self.tracks[1]]) + + # Artist is just album artist + result = self.library.search(albumartist=['Tist3']) + self.assertEqual(list(result[0].tracks), [self.tracks[2]]) + def test_search_album(self): result = self.library.search(album=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index 9dc11777d8..2272a114f3 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -10,7 +10,7 @@ Title: track1 Album: album1 Date: 2001-02-03 Time: 4 -key: key1 +key: key2 file: /path2 Artist: artist2 Title: track2 @@ -19,8 +19,10 @@ Date: 2002 Time: 4 key: key3 file: /path3 -Artist: artist3 +Artist: artist4 +AlbumArtist: artist3 Title: track3 Album: album3 +Date: 2003 Time: 4 songList end From 76b70aa0c557c63826c1adae5acb10ab94ef3a8c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Jul 2013 22:49:42 +0200 Subject: [PATCH 089/175] mpd: Make 'list' support 'albumartist' key --- mopidy/frontends/mpd/translator.py | 2 +- tests/frontends/mpd/protocol/music_db_test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 804f693a68..df3338ba81 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -166,7 +166,7 @@ def query_from_mpd_list_format(field, mpd_query): key = tokens[0].lower() value = tokens[1] tokens = tokens[2:] - if key not in ('artist', 'album', 'date', 'genre'): + if key not in ('artist', 'album', 'albumartist', 'date', 'genre'): raise MpdArgError('not able to parse args', command='list') if not value: raise ValueError diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 21c6721f96..eaa5da06a1 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -366,6 +366,10 @@ def test_list_album_by_album(self): self.sendRequest('list "album" "album" "analbum"') self.assertInResponse('OK') + def test_list_album_by_albumartist(self): + self.sendRequest('list "album" "albumartist" "anartist"') + self.assertInResponse('OK') + def test_list_album_by_full_date(self): self.sendRequest('list "album" "date" "2001-01-01"') self.assertInResponse('OK') From ded783e3945f501edfc91b5a2bcae9e50a4e8c4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 12 Oct 2013 00:21:25 +0200 Subject: [PATCH 090/175] manifest: pylintrc no longer exists, so don't include it in PyPI package --- MANIFEST.in | 1 - 1 file changed, 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 84122dcc6f..f1968205cb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,6 @@ include *.rst include LICENSE include MANIFEST.in include data/mopidy.desktop -include pylintrc recursive-include docs * prune docs/_build From 61f2d7314e9c7663b75b9f322573462e8afb8476 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 13 Oct 2013 19:50:04 +0200 Subject: [PATCH 091/175] docs: Fix typo --- docs/ext/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index a4f376b203..f798b962ff 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -106,7 +106,7 @@ Mopidy-SoundCloud https://github.com/mopidy/mopidy-soundcloud -rovides a backend for playing music from the `SoundCloud +Provides a backend for playing music from the `SoundCloud `_ service. From e9c20d2e59365881be7e02eab322d638c88fd1df Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Tue, 15 Oct 2013 12:11:01 +0200 Subject: [PATCH 092/175] Correcting flake error on unused import --- tests/backends/local/tracklist_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 6681c64524..043f0905f6 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -8,7 +8,7 @@ from mopidy import audio, core from mopidy.backends.local import actor from mopidy.core import PlaybackState -from mopidy.models import Playlist, TlTrack, Track +from mopidy.models import Playlist, Track from tests import path_to_data_dir from tests.backends.local import generate_song, populate_tracklist From 677c61f39867cea70eb5e786b81a639f5181faff Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Fri, 18 Oct 2013 16:12:38 +0200 Subject: [PATCH 093/175] tests: Rename local tests to describe that they are testing local providers and not core controllers --- tests/backends/local/library_test.py | 2 +- tests/backends/local/playback_test.py | 2 +- tests/backends/local/playlists_test.py | 2 +- tests/backends/local/tracklist_test.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 8f9885039f..17a7f82796 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -11,7 +11,7 @@ from tests import path_to_data_dir -class LocalLibraryControllerTest(unittest.TestCase): +class LocalLibraryProviderTest(unittest.TestCase): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] albums = [ diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index c37eb91dfd..5df86dd1b2 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -19,7 +19,7 @@ # TODO Test 'playlist repeat', e.g. repeat=1,single=0 -class LocalPlaybackControllerTest(unittest.TestCase): +class LocalPlaybackProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 4975d8aaff..a9f13974b9 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -15,7 +15,7 @@ from tests.backends.local import generate_song -class LocalPlaylistsControllerTest(unittest.TestCase): +class LocalPlaylistsProviderTest(unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 1993d246c6..a58bf5f6f1 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -14,7 +14,7 @@ from tests.backends.local import generate_song, populate_tracklist -class LocalTracklistControllerTest(unittest.TestCase): +class LocalTracklistProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), From b6346f1c8636f7d2dc48986db467331301bcff11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 13:14:52 +0200 Subject: [PATCH 094/175] core.playback: Rename next_{ => tl_}track To make the type of the variable obvious --- mopidy/core/playback.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0709c688cb..190608591d 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -157,11 +157,11 @@ def on_end_of_track(self): return original_tl_track = self.current_tl_track - next_track = self.core.tracklist.eot_track(original_tl_track) + next_tl_track = self.core.tracklist.eot_track(original_tl_track) - if next_track: + if next_tl_track: self._trigger_track_playback_ended() - self.play(next_track) + self.play(next_tl_track) else: self.stop(clear_current_track=True) From d636f0d2289d1244fab67debdb598ceeea09ec4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 13:15:25 +0200 Subject: [PATCH 095/175] core.playback: Simplify if stmt tracklist.tl_tracks is always a list --- mopidy/core/playback.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 190608591d..8573fba2a6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -173,10 +173,7 @@ def on_tracklist_change(self): Used by :class:`mopidy.core.TracklistController`. """ - - if (not self.core.tracklist.tl_tracks or - self.current_tl_track not in - self.core.tracklist.tl_tracks): + if self.current_tl_track not in self.core.tracklist.tl_tracks: self.stop(clear_current_track=True) def next(self): From ce55e0eca57f38bfd885c4552d81b6f8e02d9019 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 13:25:05 +0200 Subject: [PATCH 096/175] core.playback: Refactor play() logic --- mopidy/core/playback.py | 48 +++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8573fba2a6..dba1ce4d4b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -209,35 +209,41 @@ def play(self, tl_track=None, on_error_step=1): :type on_error_step: int, -1 or 1 """ - if tl_track is not None: - assert tl_track in self.core.tracklist.tl_tracks - elif tl_track is None: + assert on_error_step in (-1, 1) + + if tl_track is None: if self.state == PlaybackState.PAUSED: return self.resume() - elif self.current_tl_track is not None: - tl_track = self.current_tl_track - elif self.current_tl_track is None and on_error_step == 1: - tl_track = self.core.tracklist.next_track(tl_track) - elif self.current_tl_track is None and on_error_step == -1: - tl_track = self.core.tracklist.previous_track(tl_track) - if tl_track is not None: - self.current_tl_track = tl_track - self.state = PlaybackState.PLAYING - backend = self._get_backend() - if not backend or not backend.playback.play(tl_track.track).get(): - logger.warning('Track is not playable: %s', tl_track.track.uri) - self.core.tracklist.mark_unplayable(tl_track) + if self.current_tl_track is not None: + tl_track = self.current_tl_track + else: if on_error_step == 1: - # TODO: can cause an endless loop for single track repeat. - self.next() + tl_track = self.core.tracklist.next_track(tl_track) elif on_error_step == -1: - self.previous() + tl_track = self.core.tracklist.previous_track(tl_track) + + if tl_track is None: return - self.core.tracklist.mark_starting(tl_track) + assert tl_track in self.core.tracklist.tl_tracks - self._trigger_track_playback_started() + self.current_tl_track = tl_track + self.state = PlaybackState.PLAYING + backend = self._get_backend() + success = backend and backend.playback.play(tl_track.track).get() + + if success: + self.core.tracklist.mark_starting(tl_track) + self._trigger_track_playback_started() + else: + logger.warning('Track is not playable: %s', tl_track.track.uri) + self.core.tracklist.mark_unplayable(tl_track) + if on_error_step == 1: + # TODO: can cause an endless loop for single track repeat. + self.next() + elif on_error_step == -1: + self.previous() def previous(self): """ From e5e1b5fa63ed018f4e1346a6d16be83b2418638c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 13:35:15 +0200 Subject: [PATCH 097/175] core.playback: Formatting --- mopidy/core/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index dba1ce4d4b..fbbab4e512 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -254,8 +254,8 @@ def previous(self): """ self._trigger_track_playback_ended() tl_track = self.current_tl_track - self.change_track(self.core.tracklist.previous_track(tl_track), - on_error_step=-1) + self.change_track( + self.core.tracklist.previous_track(tl_track), on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" From ff89fc58a9b48bc60e62b80119cbff5d7b8035a3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 13:39:22 +0200 Subject: [PATCH 098/175] core.tracklist: Remove pylint comment --- mopidy/core/tracklist.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 154eb0d557..37428e5327 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -158,9 +158,6 @@ def eot_track(self, tl_track): :type tl_track: :class:`mopidy.models.TlTrack` :rtype: :class:`mopidy.models.TlTrack` """ - # pylint: disable = R0911 - # Too many return statements - if not self.tl_tracks: return None From d9921d91274fe22778aaef71cfbec46a22379e1b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 15:08:48 +0200 Subject: [PATCH 099/175] core.tracklist: Test changed index() behavior --- tests/backends/local/tracklist_test.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 043f0905f6..00d8f2081b 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -8,7 +8,7 @@ from mopidy import audio, core from mopidy.backends.local import actor from mopidy.core import PlaybackState -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack, Track from tests import path_to_data_dir from tests.backends.local import generate_song, populate_tracklist @@ -171,9 +171,13 @@ def test_add_returns_the_tl_tracks_that_was_added(self): def test_index_returns_index_of_track(self): tl_tracks = self.controller.add(self.tracks) - self.assertEquals(0, self.controller.index(tl_tracks[0])) - self.assertEquals(1, self.controller.index(tl_tracks[1])) - self.assertEquals(2, self.controller.index(tl_tracks[2])) + self.assertEqual(0, self.controller.index(tl_tracks[0])) + self.assertEqual(1, self.controller.index(tl_tracks[1])) + self.assertEqual(2, self.controller.index(tl_tracks[2])) + + def test_index_returns_none_if_item_not_found(self): + tl_track = TlTrack(0, Track()) + self.assertEqual(self.controller.index(tl_track), None) @populate_tracklist def test_move_single(self): From 9c2f6c2f2586cc7de7bc6505b7c56cc35f194fe1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 15:12:18 +0200 Subject: [PATCH 100/175] core.tracklist: Tweak docstrings --- mopidy/core/tracklist.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 37428e5327..86f4d42690 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -137,9 +137,9 @@ def index(self, tl_track): """ The position of the given track in the tracklist. - :param tl_track: The reference track + :param tl_track: the track to find the index of :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: int + :rtype: :class:`int` or :class:`None` """ if tl_track is None: return None @@ -154,9 +154,9 @@ def eot_track(self, tl_track): Not necessarily the same track as :meth:`next_track`. - :param tl_track: The reference track - :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: :class:`mopidy.models.TlTrack` + :param tl_track: the reference track + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ if not self.tl_tracks: return None @@ -196,9 +196,9 @@ def next_track(self, tl_track): enabled this should be a random track, all tracks should be played once before the list repeats. - :param tl_track: The reference track - :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: :class:`mopidy.models.TlTrack` + :param tl_track: the reference track + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ if not self.tl_tracks: @@ -231,15 +231,13 @@ def previous_track(self, tl_track): Returns the track that will be played if calling :meth:`mopidy.core.PlaybackController.previous()`. - A :class:`mopidy.models.TlTrack`. - For normal playback this is the previous track in the playlist. If random and/or consume is enabled it should return the current track instead. - :param tl_track: The reference track - :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: :class:`mopidy.models.TlTrack` + :param tl_track: the reference track + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ if self.repeat or self.consume or self.random: return tl_track From aaa3b2e93c0914be4bebdc238a768a206b99d139 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 15:12:38 +0200 Subject: [PATCH 101/175] core.tracklist: Remove redundant if stmt in index() --- mopidy/core/tracklist.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 86f4d42690..33315ac8b4 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -141,8 +141,6 @@ def index(self, tl_track): :type tl_track: :class:`mopidy.models.TlTrack` :rtype: :class:`int` or :class:`None` """ - if tl_track is None: - return None try: return self._tl_tracks.index(tl_track) except ValueError: From 0ea4fd6af038f0157fd8554a024a13e9948d6aee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 20:12:14 +0200 Subject: [PATCH 102/175] core.tracklist: Rename mark_{consumed => played} --- mopidy/core/playback.py | 2 +- mopidy/core/tracklist.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index fbbab4e512..a8c1a78abb 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -165,7 +165,7 @@ def on_end_of_track(self): else: self.stop(clear_current_track=True) - self.core.tracklist.mark_consumed(original_tl_track) + self.core.tracklist.mark_played(original_tl_track) def on_tracklist_change(self): """ diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 33315ac8b4..100a603f47 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -431,12 +431,6 @@ def slice(self, start, end): """ return self._tl_tracks[start:end] - def mark_consumed(self, tl_track): - if not self.consume: - return False - self.remove(tlid=tl_track.tlid) - return True - def mark_starting(self, tl_track): if self.random and tl_track in self._shuffled: self._shuffled.remove(tl_track) @@ -445,6 +439,12 @@ def mark_unplayable(self, tl_track): if self.random and self._shuffled: self._shuffled.remove(tl_track) + def mark_played(self, tl_track): + if not self.consume: + return False + self.remove(tlid=tl_track.tlid) + return True + def _trigger_tracklist_changed(self): self._first_shuffle = True self._shuffled = [] From 91e718e85f671203c4633d67618fd0378da13dd9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 20:13:25 +0200 Subject: [PATCH 103/175] core.tracklist: Rename mark_{starting => playing} --- mopidy/core/playback.py | 2 +- mopidy/core/tracklist.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index a8c1a78abb..b0e0f2e1c5 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -234,7 +234,7 @@ def play(self, tl_track=None, on_error_step=1): success = backend and backend.playback.play(tl_track.track).get() if success: - self.core.tracklist.mark_starting(tl_track) + self.core.tracklist.mark_playing(tl_track) self._trigger_track_playback_started() else: logger.warning('Track is not playable: %s', tl_track.track.uri) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 100a603f47..22e741da71 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -431,7 +431,7 @@ def slice(self, start, end): """ return self._tl_tracks[start:end] - def mark_starting(self, tl_track): + def mark_playing(self, tl_track): if self.random and tl_track in self._shuffled: self._shuffled.remove(tl_track) From 67a7e0021a619d3aaf750892477f9ad8d63b4ca8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 20:14:06 +0200 Subject: [PATCH 104/175] core.tracklist: Add docstrings to mark_* --- mopidy/core/tracklist.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 22e741da71..125843b203 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -432,14 +432,17 @@ def slice(self, start, end): return self._tl_tracks[start:end] def mark_playing(self, tl_track): + """Private method used by :class:`mopidy.core.PlaybackController`.""" if self.random and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_unplayable(self, tl_track): + """Private method used by :class:`mopidy.core.PlaybackController`.""" if self.random and self._shuffled: self._shuffled.remove(tl_track) def mark_played(self, tl_track): + """Private method used by :class:`mopidy.core.PlaybackController`.""" if not self.consume: return False self.remove(tlid=tl_track.tlid) From 46aeb3bfcc913c0ff01f31dbd0d9171129f2d296 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 20:15:38 +0200 Subject: [PATCH 105/175] core.tracklist: Move logging into mark_unplayable() --- mopidy/core/playback.py | 1 - mopidy/core/tracklist.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b0e0f2e1c5..d127fbbe69 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -237,7 +237,6 @@ def play(self, tl_track=None, on_error_step=1): self.core.tracklist.mark_playing(tl_track) self._trigger_track_playback_started() else: - logger.warning('Track is not playable: %s', tl_track.track.uri) self.core.tracklist.mark_unplayable(tl_track) if on_error_step == 1: # TODO: can cause an endless loop for single track repeat. diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 125843b203..940eb0ec3a 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -438,6 +438,7 @@ def mark_playing(self, tl_track): def mark_unplayable(self, tl_track): """Private method used by :class:`mopidy.core.PlaybackController`.""" + logger.warning('Track is not playable: %s', tl_track.track.uri) if self.random and self._shuffled: self._shuffled.remove(tl_track) From 9864f55b75a26c8c6c697bbd3ec5edd899d4ed6f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 20:52:21 +0200 Subject: [PATCH 106/175] core.tracklist: Improve if check in mark_unplayable() --- mopidy/core/tracklist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 940eb0ec3a..aedc278570 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -439,7 +439,7 @@ def mark_playing(self, tl_track): def mark_unplayable(self, tl_track): """Private method used by :class:`mopidy.core.PlaybackController`.""" logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.random and self._shuffled: + if self.random and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_played(self, tl_track): From 83db750e0a1071d54b7c8d45e9114d9ad343e84e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 15:12:58 +0200 Subject: [PATCH 107/175] core.tracklist: Formatting --- mopidy/core/tracklist.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index aedc278570..5d85e190f1 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -133,6 +133,8 @@ def set_single(self, value): Playback continues after current song. """ + ### Methods + def index(self, tl_track): """ The position of the given track in the tracklist. @@ -173,6 +175,7 @@ def eot_track(self, tl_track): return self.tl_tracks[0] position = self.index(tl_track) + if self.repeat and self.single: return self.tl_tracks[position] @@ -216,6 +219,7 @@ def next_track(self, tl_track): return self.tl_tracks[0] position = self.index(tl_track) + if self.repeat: return self.tl_tracks[(position + 1) % len(self.tl_tracks)] @@ -241,6 +245,7 @@ def previous_track(self, tl_track): return tl_track position = self.index(tl_track) + if position in (None, 0): return None @@ -452,6 +457,7 @@ def mark_played(self, tl_track): def _trigger_tracklist_changed(self): self._first_shuffle = True self._shuffled = [] + logger.debug('Triggering event: tracklist_changed()') listener.CoreListener.send('tracklist_changed') From d7552b2fe2b5f3adb86fd25db37575474c400927 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 15:08:32 +0200 Subject: [PATCH 108/175] tests: Formatting --- tests/backends/local/playback_test.py | 66 +++++++++++++-------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 662d277264..67b433740f 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -278,15 +278,15 @@ def test_next_skips_to_next_track_on_failure(self): @populate_tracklist def test_next_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[0]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[1]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_next_track_after_previous(self): @@ -294,13 +294,12 @@ def test_next_track_after_previous(self): self.playback.next() self.playback.previous() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[1]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) def test_next_track_empty_playlist(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_next_track_at_end_of_playlist(self): @@ -308,8 +307,7 @@ def test_next_track_at_end_of_playlist(self): for _ in self.tracklist.tl_tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - None) + self.assertEqual(self.tracklist.next_track(tl_track), None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): @@ -318,16 +316,16 @@ def test_next_track_at_end_of_playlist_with_repeat(self): for _ in self.tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[0]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_next_track_with_random(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[2]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[2]) @populate_tracklist def test_next_with_consume(self): @@ -362,8 +360,8 @@ def test_next_track_with_random_after_append_playlist(self): self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[1]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_end_of_track(self): @@ -435,15 +433,15 @@ def test_end_of_track_skips_to_next_track_on_failure(self): @populate_tracklist def test_end_of_track_track_before_play(self): tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[0]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[1]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_after_previous(self): @@ -451,8 +449,8 @@ def test_end_of_track_track_after_previous(self): self.playback.on_end_of_track() self.playback.previous() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[1]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) def test_end_of_track_track_empty_playlist(self): tl_track = self.playback.current_tl_track @@ -473,16 +471,16 @@ def test_end_of_track_track_at_end_of_playlist_with_repeat(self): for _ in self.tracks[1:]: self.playback.on_end_of_track() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[0]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[2]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[2]) @populate_tracklist def test_end_of_track_with_consume(self): @@ -505,12 +503,12 @@ def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) self.tracklist.random = True tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[2]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[2]) self.tracklist.add(self.tracks[:1]) tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[1]) + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) @populate_tracklist def test_previous_track_before_play(self): @@ -528,8 +526,8 @@ def test_previous_track_after_next(self): self.playback.play() self.playback.next() tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), - self.tl_tracks[0]) + self.assertEqual( + self.tracklist.previous_track(tl_track), self.tl_tracks[0]) @populate_tracklist def test_previous_track_after_previous(self): @@ -538,8 +536,8 @@ def test_previous_track_after_previous(self): self.playback.next() # At track 2 self.playback.previous() # At track 1 tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), - self.tl_tracks[0]) + self.assertEqual( + self.tracklist.previous_track(tl_track), self.tl_tracks[0]) def test_previous_track_empty_playlist(self): tl_track = self.playback.current_tl_track From 033e3ab813639b788efc9805f3f06f292886f408 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 20:02:26 +0200 Subject: [PATCH 109/175] jsonrpc: Test application error responses again The test was modified to not fail after a refactoring, making it not test what it was intended to test at all. This reverts the changes and updates the test in another way, keeping the original intention. --- tests/utils/jsonrpc_test.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 7abaa512a1..c6f516bb1c 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -34,6 +34,9 @@ def take_it_all(self, a, b, c=True, *args, **kwargs): def _secret(self): return 'Grand Unified Theory' + def fail(self): + raise ValueError('What did you expect?') + class JsonRpcTestBase(unittest.TestCase): def setUp(self): @@ -313,19 +316,26 @@ def test_batch_of_only_notifications_returns_nothing(self): class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): - def test_application_error_response_is_none(self): + def test_application_error_response(self): request = { 'jsonrpc': '2.0', - 'method': 'core.tracklist.index', - 'params': ['bogus'], + 'method': 'calc.fail', + 'params': [], 'id': 1, } response = self.jrw.handle_data(request) - self.assertIn('result', response) + self.assertNotIn('result', response) + + error = response['error'] + self.assertEqual(error['code'], 0) + self.assertEqual(error['message'], 'Application error') - result = response['result'] - self.assertEqual(result, None) + data = error['data'] + self.assertEqual(data['type'], 'ValueError') + self.assertIn('What did you expect?', data['message']) + self.assertIn('traceback', data) + self.assertIn('Traceback (most recent call last):', data['traceback']) def test_missing_jsonrpc_member_causes_invalid_request_error(self): request = { From 345be9d3edad5467f2d08d1e712d6b1a21da42a6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 13:00:41 +0200 Subject: [PATCH 110/175] docs: Update changelog for tidy-up-core changes --- docs/changelog.rst | 63 ++++++++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d28e41bc3c..1cbad5792d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,6 +39,45 @@ of the following extensions as well: **Core** +- Parts of the functionality in :class:`mopidy.core.PlaybackController` have + been moved to :class:`mopidy.core.TracklistController`: + + =================================== ================================== + Old location New location + =================================== ================================== + playback.get_consume() tracklist.get_consume() + playback.set_consume(v) tracklist.set_consume(v) + playback.consume tracklist.consume + + playback.get_random() tracklist.get_random() + playback.set_random(v) tracklist.set_random(v) + playback.random tracklist.random + + playback.get_repeat() tracklist.get_repeat() + playback.set_repeat(v) tracklist.set_repeat(v) + playback.repeat tracklist.repeat + + playback.get_single() tracklist.get_single() + playback.set_single(v) tracklist.set_single(v) + playback.single tracklist.single + + playback.get_tracklist_position() tracklist.index(tl_track) + playback.tracklist_position tracklist.index(tl_track) + + playback.get_tl_track_at_eot() tracklist.eot_track(tl_track) + playback.tl_track_at_eot tracklist.eot_track(tl_track) + + playback.get_tl_track_at_next() tracklist.next_track(tl_track) + playback.tl_track_at_next tracklist.next_track(tl_track) + + playback.get_tl_track_at_previous() tracklist.previous_track(tl_track) + playback.tl_track_at_previous tracklist.previous_track(tl_track) + =================================== ================================== + + The ``tl_track`` argument to the last four new functions are used as the + reference ``tl_track`` in the tracklist to find e.g. the next track. Usually, + this will be :attr:`~mopidy.core.PlaybackController.current_tl_track`. + - Added :attr:`mopidy.core.PlaybackController.mute` for muting and unmuting audio. (Fixes: :issue:`186`) @@ -68,30 +107,6 @@ A release with a number of small and medium fixes, with no specific focus. supported by Mopidy is Python 2.7. We're continuously working towards running Mopidy on Python 3. (Fixes: :issue:`344`) -**Core** - -- Tracklist has now the power to make decisions on which is the next/previous - song, along with previously playback associated features, such as randomness, - consumption, repeat and single. For that, a new method has been created to - mark songs, some Playback properties have been converted into functions and - both functions and properties have been moved into Tracklist to have more - modularity: - - - Properties merged into functions: :attr:`tracklist_position` merged to - :meth:`index` - - - Properties moved: :attr:`random`, :attr:`repeat`, :attr:`consume` and - :attr:`single` - - - Method created from properties: :meth:`next_track` from - `tl_track_at_next`, :meth:`eot_track` from `tl_track_at_eot` and - :meth:`previous_track` from `tl_track_at_previous` - - - Method created to separe functionality: :meth:`mark` - -- Tracklist's get_tl_track_at_* and tracklist_position now need a tl_track as a - reference to give an answer. - **Command line options** - Converted from the optparse to the argparse library for handling command line From 46bd5bc09659ab8dc9e5901cb42da50946144e1f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 21:09:49 +0200 Subject: [PATCH 111/175] docs: Reduce depth of ToC --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index c51834711a..17a40c326e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,7 +30,7 @@ Usage ===== .. toctree:: - :maxdepth: 3 + :maxdepth: 2 installation/index installation/raspberrypi From 9a5d177ddacfbbfd054d05075d1e48c811f9e58b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 21:10:16 +0200 Subject: [PATCH 112/175] docs: Update authors list --- AUTHORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 28b8ebd2b9..fdfb82fbf8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,8 @@ - Tobias Sauerwein - Alli Witheford - Alexandre Petitjean +- Terje Larsen - Pavol Babincak - Javier Domingo +- Javier Domingo Cansino - Lasse Bigum From 7d874873bd1aac6294f0c9a20e22f7fbb61ebf6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 22:14:53 +0200 Subject: [PATCH 113/175] local: Include albumartist in 'any' searches --- mopidy/backends/local/library.py | 15 ++++++++---- tests/backends/local/library_test.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 319049f0ad..0de63fafc6 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -67,8 +67,11 @@ def find_exact(self, query=None, uris=None): for a in getattr(t.album, 'artists', [])]) date_filter = lambda t: q == t.date any_filter = lambda t: ( - track_filter(t) or album_filter(t) or - artist_filter(t) or uri_filter(t)) + track_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + uri_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -114,8 +117,12 @@ def search(self, query=None, uris=None): q in a.name.lower() for a in getattr(t.album, 'artists', [])]) date_filter = lambda t: t.date and t.date.startswith(q) - any_filter = lambda t: track_filter(t) or album_filter(t) or \ - artist_filter(t) or uri_filter(t) + any_filter = lambda t: ( + track_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + uri_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 149a9cb32e..bc7181ebf6 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -140,6 +140,29 @@ def test_find_exact_date(self): result = self.library.find_exact(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_find_exact_any(self): + # Matches on track artist + result = self.library.find_exact(any=['artist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track + result = self.library.find_exact(any=['track1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track album + result = self.library.find_exact(any=['album1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track album artists + result = self.library.find_exact(any=['artist3']) + self.assertEqual(list(result[0].tracks), self.tracks[2:3]) + + # Matches on URI + result = self.library.find_exact(any=['local:track:path1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.find_exact(any=['local:track:path1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -225,12 +248,25 @@ def test_search_date(self): self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_any(self): + # Matches on track artist result = self.library.search(any=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track result = self.library.search(any=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track album result = self.library.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track album artists + result = self.library.search(any=['Tist3']) + self.assertEqual(list(result[0].tracks), self.tracks[2:3]) + + # Matches on URI + result = self.library.search(any=['TH1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) From 27a63b16888177a3747ffd5ba3bae12ba1d98ed8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 22:17:04 +0200 Subject: [PATCH 114/175] local: Remove redundant test asserts --- tests/backends/local/library_test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index c0843dd5e2..6b0cd6f6e7 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -160,8 +160,6 @@ def test_find_exact_any(self): # Matches on URI result = self.library.find_exact(any=['local:track:path1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(any=['local:track:path1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) @@ -267,8 +265,6 @@ def test_search_any(self): # Matches on URI result = self.library.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['TH1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): test = lambda: self.library.search(wrong=['test']) From 8edf81dca12498199432956cc59b837d0dd3d9b3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 22:57:13 +0200 Subject: [PATCH 115/175] docs: Update changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1cbad5792d..1151f422c3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -89,6 +89,9 @@ of the following extensions as well: - Made the formerly unused commands ``outputs``, ``enableoutput``, and ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) +- The MPD command ``list`` now works with ``"albumartist"`` as it's second + argument, e.g. ``"album" "albumartist" "anartist"``. (Fixes: :issue:`468`) + **Extension support** - A cookiecutter project for quickly creating new Mopidy extensions have been From 11a9b5b1f21efe4b04afc2ecd84e6ff7564a811b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 19 Oct 2013 23:15:13 +0200 Subject: [PATCH 116/175] docs: Add Mopidy-Notifier to extension list --- docs/ext/index.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index f798b962ff..940dd37ac1 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -74,6 +74,14 @@ https://github.com/mopidy/mopidy-nad Extension for controlling volume using an external NAD amplifier. +Mopidy-Notifier +--------------- + +https://github.com/sauberfred/mopidy-notifier + +Extension for displaying track info as User Notifications in Mac OS X. + + Mopidy-radio-de --------------- From 8561e9c815f162327310c5d60b94cb2311f54393 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 10:44:13 +0200 Subject: [PATCH 117/175] docs: Remove mopidy.backends.spotify --- docs/api/backends.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 45315b276c..ec78f2500a 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -54,5 +54,4 @@ Backend implementations * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.local` -* :mod:`mopidy.backends.spotify` * :mod:`mopidy.backends.stream` From a5a55952fb95aed298f9bc1b725f6e7a4d50047a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 13:09:39 +0200 Subject: [PATCH 118/175] scanner: Switch to gst.pbutils.Discoverer and refactor API - New API wraps gst.pbutils.Discoverer for mopidy use. - Custom scanner code removed. - Callback based async API switched with simpler synchronous API - Scanner is feed one and one URI and returns the result or raises an error. - Adds new ScannerError exception for error handling in scanner - Limits local/scan_timeout to 1000-3600000 i.e. 1s to 1h - Files with zero duration are now excluded. - Mtime handling moved out of scanner so we can use it for streams etc. --- mopidy/backends/local/__init__.py | 3 +- mopidy/scanner.py | 189 ++++++++---------------------- tests/scanner_test.py | 21 ++-- 3 files changed, 63 insertions(+), 150 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5c6fec47b3..9d1e655f5b 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -21,7 +21,8 @@ def get_config_schema(self): schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() - schema['scan_timeout'] = config.Integer(minimum=0) + schema['scan_timeout'] = config.Integer( + minimum=1000, maximum=1000*60*60) return schema def validate_environment(self): diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 81ac5c535d..5a3772af6a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -25,12 +25,17 @@ import pygst pygst.require('0.10') import gst +import gst.pbutils -from mopidy import config as config_lib, ext +from mopidy import config as config_lib, ext, exceptions from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning +class ScannerError(exceptions.ExtensionError): + pass + + def main(): args = parse_args() # TODO: support config files and overrides (shared from main?) @@ -101,26 +106,18 @@ def main(): uris_update.add(uri) logging.info('Found %d new or modified tracks.', len(uris_update)) - - def store(data): - track = translator(data) - local_updater.add(track) - logging.debug('Added %s', track.uri) - - def debug(uri, error, debug): - logging.warning('Failed %s: %s', uri, error) - logging.debug('Debug info for %s: %s', uri, debug) - - scan_timeout = config['local']['scan_timeout'] - logging.info('Scanning new and modified tracks.') - # TODO: just pass the library in instead? - scanner = Scanner(uris_update, store, debug, scan_timeout) - try: - scanner.start() - except KeyboardInterrupt: - scanner.stop() - raise + + scanner = Scanner(config['local']['scan_timeout']) + for uri in uris_update: + try: + data = scanner.scan(uri) + data[b'mtime'] = os.path.getmtime(path.uri_to_path(uri)) + track = translator(data) + local_updater.add(track) + logging.debug('Added %s', track.uri) + except ScannerError as error: + logging.warning('Failed %s: %s', uri, error) logging.info('Done scanning; commiting changes.') local_updater.commit() @@ -192,125 +189,43 @@ def _retrieve(source_key, target_key, target): class Scanner(object): - def __init__( - self, uris, data_callback, error_callback=None, scan_timeout=1000): - self.data = {} - self.uris = iter(uris) - self.data_callback = data_callback - self.error_callback = error_callback - self.scan_timeout = scan_timeout - self.loop = gobject.MainLoop() - self.timeout_id = None - - self.fakesink = gst.element_factory_make('fakesink') - self.fakesink.set_property('signal-handoffs', True) - self.fakesink.connect('handoff', self.process_handoff) - - self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property( - 'caps', gst.Caps(b'audio/x-raw-int; audio/x-raw-float')) - self.uribin.connect('pad-added', self.process_new_pad) - - self.pipe = gst.element_factory_make('pipeline') - self.pipe.add(self.uribin) - self.pipe.add(self.fakesink) - - bus = self.pipe.get_bus() - bus.add_signal_watch() - bus.connect('message::application', self.process_application) - bus.connect('message::tag', self.process_tags) - bus.connect('message::error', self.process_error) - - def process_handoff(self, fakesink, buffer_, pad): - # When this function is called the first buffer has reached the end of - # the pipeline, and we can continue with the next track. Since we're - # in another thread, we send a message back to the main thread using - # the bus. - structure = gst.Structure('handoff') - message = gst.message_new_application(fakesink, structure) - bus = self.pipe.get_bus() - bus.post(message) - - def process_new_pad(self, source, pad): - pad.link(self.fakesink.get_pad('sink')) - - def process_application(self, bus, message): - if message.src != self.fakesink: - return - - if message.structure.get_name() != 'handoff': - return - - uri = unicode(self.uribin.get_property('uri')) - self.data['uri'] = uri - self.data['mtime'] = os.path.getmtime(path.uri_to_path(uri)) - self.data[gst.TAG_DURATION] = self.get_duration() + def __init__(self, timeout=1000): + self.discoverer = gst.pbutils.Discoverer(timeout * 1000000) + def scan(self, uri): try: - self.data_callback(self.data) - self.next_uri() - except KeyboardInterrupt: - self.stop() - - def process_tags(self, bus, message): - taglist = message.parse_tag() - - for key in taglist.keys(): - # XXX: For some crazy reason some wma files spit out lists here, - # not sure if this is due to better data in headers or wma being - # stupid. So ugly hack for now :/ - if type(taglist[key]) is list: - self.data[key] = taglist[key][0] - else: - self.data[key] = taglist[key] - - def process_error(self, bus, message): - if self.error_callback: - uri = self.uribin.get_property('uri') - error, debug = message.parse_error() - self.error_callback(uri, error, debug) - self.next_uri() - - def process_timeout(self): - if self.error_callback: - uri = self.uribin.get_property('uri') - self.error_callback( - uri, 'Scan timed out after %d ms' % self.scan_timeout, None) - self.next_uri() - return False - - def get_duration(self): - self.pipe.get_state() # Block until state change is done. - try: - return self.pipe.query_duration( - gst.FORMAT_TIME, None)[0] // gst.MSECOND - except gst.QueryError: - return None - - def next_uri(self): - self.data = {} - if self.timeout_id: - gobject.source_remove(self.timeout_id) - self.timeout_id = None - try: - uri = next(self.uris) - except StopIteration: - self.stop() - return False - self.pipe.set_state(gst.STATE_NULL) - self.uribin.set_property('uri', uri) - self.timeout_id = gobject.timeout_add( - self.scan_timeout, self.process_timeout) - self.pipe.set_state(gst.STATE_PLAYING) - return True - - def start(self): - if self.next_uri(): - self.loop.run() - - def stop(self): - self.pipe.set_state(gst.STATE_NULL) - self.loop.quit() + info = self.discoverer.discover_uri(uri) + except gobject.GError as e: + # Loosing traceback is non-issue since this is from C code. + raise ScannerError(e) + + data = {} + audio_streams = info.get_audio_streams() + + if not audio_streams: + raise ScannerError('Did not find any audio streams.') + + for stream in audio_streams: + taglist = stream.get_tags() + if not taglist: + continue + for key in taglist.keys(): + # XXX: For some crazy reason some wma files spit out lists + # here, not sure if this is due to better data in headers or + # wma being stupid. So ugly hack for now :/ + if type(taglist[key]) is list: + data[key] = taglist[key][0] + else: + data[key] = taglist[key] + + # Never trust metadata for these fields: + data[b'uri'] = uri + data[b'duration'] = info.get_duration() // gst.MSECOND + + if data[b'duration'] == 0: + raise ScannerError('Zero length audio streams are not supported.') + + return data if __name__ == '__main__': diff --git a/tests/scanner_test.py b/tests/scanner_test.py index ca00753319..be8cbd400f 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -2,7 +2,7 @@ import unittest -from mopidy.scanner import Scanner, translator +from mopidy.scanner import Scanner, ScannerError, translator from mopidy.models import Track, Artist, Album from mopidy.utils import path as path_lib @@ -150,21 +150,18 @@ def setUp(self): def scan(self, path): paths = path_lib.find_files(path_to_data_dir(path)) uris = (path_lib.path_to_uri(p) for p in paths) - scanner = Scanner(uris, self.data_callback, self.error_callback) - scanner.start() + scanner = Scanner() + for uri in uris: + key = uri[len('file://'):] + try: + self.data[key] = scanner.scan(uri) + except ScannerError as error: + self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) self.assertEqual(self.data[name][key], value) - def data_callback(self, data): - uri = data['uri'][len('file://'):] - self.data[uri] = data - - def error_callback(self, uri, error, debug): - uri = uri[len('file://'):] - self.errors[uri] = (error, debug) - def test_data_is_set(self): self.scan('scanner/simple') self.assert_(self.data) @@ -210,7 +207,7 @@ def test_other_media_is_ignored(self): self.scan('scanner/image') self.assert_(self.errors) - def test_log_file_is_ignored(self): + def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan('scanner/example.log') self.assert_(self.errors) From 0f6176a3b471d931fa2d2449ffb179c591cd065c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 13:26:38 +0200 Subject: [PATCH 119/175] local: Temporary workaround for issue #527 Adds a fallback to `Track(uri=uri` when track lookup fails for playlists. This means we can at least load metadata less tracks giving users functioning playlists, instead of only supporting `local:track:...` style URIs. Issue is not fixed, but this is sufficient to reduce priority until we get to the larger planed refactor for this and other core API issues. --- mopidy/backends/local/playlists.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index af3814aeb2..5206718e02 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -6,7 +6,7 @@ import shutil from mopidy.backends import base, listener -from mopidy.models import Playlist +from mopidy.models import Playlist, Track from mopidy.utils import formatting, path from .translator import parse_m3u @@ -52,11 +52,12 @@ def refresh(self): tracks = [] for track_uri in parse_m3u(m3u, self._media_dir): try: - # TODO We must use core.library.lookup() to support tracks + # TODO: We must use core.library.lookup() to support tracks # from other backends tracks += self.backend.library.lookup(track_uri) except LookupError as ex: - logger.warning('Playlist item could not be added: %s', ex) + # TODO: this is just a quick workaround for issue #527. + tracks.append(Track(uri=track_uri)) playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) From d8b0952f663a93a23e48d26e6d62240262aa5fbd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 15:46:35 +0200 Subject: [PATCH 120/175] scanner: Remove support for running Python on the mopidy/scanner.py file See commit 1a02b4d17f072557cf7833970b77bc473e6b5494 for details --- mopidy/scanner.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 81ac5c535d..4acf49baf7 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -16,12 +16,6 @@ sys.argv[1:] = [] -# Add ../ to the path so we can run Mopidy from a Git checkout without -# installing it on the system. -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) - - import pygst pygst.require('0.10') import gst From 2f01fc4e4679d20f6e3b1787446498cf68ed2818 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 15:51:39 +0200 Subject: [PATCH 121/175] scanner: Review comments and flake fixes - Move ScannerError to exceptions module. - Subclass ScannerError from MopidyException. - Fix import sorting. --- mopidy/exceptions.py | 4 ++++ mopidy/scanner.py | 14 +++++--------- tests/scanner_test.py | 5 +++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 2c53e3e48d..025d8fad04 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -18,3 +18,7 @@ def message(self, message): class ExtensionError(MopidyException): pass + + +class ScannerError(MopidyException): + pass diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 5a3772af6a..908958f811 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -27,15 +27,11 @@ import gst import gst.pbutils -from mopidy import config as config_lib, ext, exceptions +from mopidy import config as config_lib, exceptions, ext from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning -class ScannerError(exceptions.ExtensionError): - pass - - def main(): args = parse_args() # TODO: support config files and overrides (shared from main?) @@ -116,7 +112,7 @@ def main(): track = translator(data) local_updater.add(track) logging.debug('Added %s', track.uri) - except ScannerError as error: + except exceptions.ScannerError as error: logging.warning('Failed %s: %s', uri, error) logging.info('Done scanning; commiting changes.') @@ -197,13 +193,13 @@ def scan(self, uri): info = self.discoverer.discover_uri(uri) except gobject.GError as e: # Loosing traceback is non-issue since this is from C code. - raise ScannerError(e) + raise exceptions.ScannerError(e) data = {} audio_streams = info.get_audio_streams() if not audio_streams: - raise ScannerError('Did not find any audio streams.') + raise exceptions.ScannerError('Did not find any audio streams.') for stream in audio_streams: taglist = stream.get_tags() @@ -223,7 +219,7 @@ def scan(self, uri): data[b'duration'] = info.get_duration() // gst.MSECOND if data[b'duration'] == 0: - raise ScannerError('Zero length audio streams are not supported.') + raise exceptions.ScannerError('Rejecting zero length audio.') return data diff --git a/tests/scanner_test.py b/tests/scanner_test.py index be8cbd400f..deae4835d1 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -2,7 +2,8 @@ import unittest -from mopidy.scanner import Scanner, ScannerError, translator +from mopidy import exceptions +from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album from mopidy.utils import path as path_lib @@ -155,7 +156,7 @@ def scan(self, path): key = uri[len('file://'):] try: self.data[key] = scanner.scan(uri) - except ScannerError as error: + except exceptions.ScannerError as error: self.errors[key] = error def check(self, name, key, value): From c8886dec49d6c6da669dc6715495b1e49fa70c5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 16:04:09 +0200 Subject: [PATCH 122/175] tests: Sort imports --- tests/scanner_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index deae4835d1..1102c525aa 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -3,8 +3,8 @@ import unittest from mopidy import exceptions -from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album +from mopidy.scanner import Scanner, translator from mopidy.utils import path as path_lib from tests import path_to_data_dir From 53333f7121bb1f3be6e643f59f74d9561b45156c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 16:06:08 +0200 Subject: [PATCH 123/175] docs: Update changelog --- docs/changelog.rst | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1151f422c3..592bbff3d9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,21 +22,6 @@ of the following extensions as well: - The MPRIS frontend has been moved to `Mopidy-MPRIS `_. -**Audio** - -- 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`) - -- Added support for muting audio without setting the volume to 0. This works - both for the software and hardware mixers. (Fixes: :issue:`186`) - **Core** - Parts of the functionality in :class:`mopidy.core.PlaybackController` have @@ -84,6 +69,27 @@ of the following extensions as well: - Added :meth:`mopidy.core.CoreListener.mute_changed` event that is triggered when the mute state changes. +**Audio** + +- 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`) + +- Added support for muting audio without setting the volume to 0. This works + both for the software and hardware mixers. (Fixes: :issue:`186`) + +**Local backend** + +- Replaced our custom media library scanner with GStreamer's builtin scanner. + +- Media files with zero duration are now excluded from the library. + **MPD frontend** - Made the formerly unused commands ``outputs``, ``enableoutput``, and From fcd3e5d5cf32d78d4297ac246c0000cde62bcb7b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 16:18:03 +0200 Subject: [PATCH 124/175] local: Add test for fallback handling of unknown track uris in playlists - Checks that adding a non local:track:... uri to a playlist and reading it back works. - Also fixes mistake in API usage where we assumed lookup still raised LookupError. --- mopidy/backends/local/playlists.py | 8 +++----- tests/backends/local/playlists_test.py | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 5206718e02..081bc335e1 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -51,12 +51,10 @@ def refresh(self): tracks = [] for track_uri in parse_m3u(m3u, self._media_dir): - try: - # TODO: We must use core.library.lookup() to support tracks - # from other backends + result = self.backend.library.lookup(track_uri) + if result: tracks += self.backend.library.lookup(track_uri) - except LookupError as ex: - # TODO: this is just a quick workaround for issue #527. + else: tracks.append(Track(uri=track_uri)) playlist = Playlist(uri=uri, name=name, tracks=tracks) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index a9f13974b9..c8fedd6213 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -201,6 +201,18 @@ def test_save_replaces_existing_playlist_with_updated_playlist(self): self.assertNotIn(playlist1, self.core.playlists.playlists) self.assertIn(playlist2, self.core.playlists.playlists) - @unittest.SkipTest def test_playlist_with_unknown_track(self): - pass + track = Track(uri='file:///dev/null') + playlist = self.core.playlists.create('test') + playlist = playlist.copy(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + backend = self.backend_class(config=self.config, audio=self.audio) + + self.assert_(backend.playlists.playlists) + self.assertEqual( + 'local:playlist:test', backend.playlists.playlists[0].uri) + self.assertEqual( + playlist.name, backend.playlists.playlists[0].name) + self.assertEqual( + track.uri, backend.playlists.playlists[0].tracks[0].uri) From cfe93c244b636ec3af9b3d9a0f3b850af2ab8917 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 16:43:04 +0200 Subject: [PATCH 125/175] scanner: Reject files with <100ms audio data --- mopidy/scanner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 3d2e6ddcf6..2e8876a271 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -212,8 +212,9 @@ def scan(self, uri): data[b'uri'] = uri data[b'duration'] = info.get_duration() // gst.MSECOND - if data[b'duration'] == 0: - raise exceptions.ScannerError('Rejecting zero length audio.') + if data[b'duration'] < 100: + raise exceptions.ScannerError( + 'Rejecting file with less than 100ms audio data.') return data From d96824471278ade2b01d23674fe49fdc8acf64c5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 17:19:03 +0200 Subject: [PATCH 126/175] docs: Update changelog with scanner info. --- docs/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 592bbff3d9..9863b6abb3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -87,8 +87,10 @@ of the following extensions as well: **Local backend** - Replaced our custom media library scanner with GStreamer's builtin scanner. + This should make scanning less error prone and faster as timeouts should be + infrequent. -- Media files with zero duration are now excluded from the library. +- Media files with less than 100ms duration are now excluded from the library. **MPD frontend** From 025bafc114484ba49816d54cfcb9fa3728f72922 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 15:41:12 +0200 Subject: [PATCH 127/175] local/core: Update random tests to be more predictable - Replace setting the random seed with mocking out shuffle with reverse. This makes tests easier to reason about while still being able to test correct behaviour. - Increases number of tracks to four to avoid test passing due to symetry in plain and reversed lists. - Made test_eot_track_with_random_after_append_playlist actually test eot and not next. - Found a test failure in handling of test_random_until_end_of_playlist, fix in next commit. --- tests/backends/local/playback_test.py | 120 +++++++++++++++++--------- tests/data/song4.wav | Bin 0 -> 35292 bytes 2 files changed, 81 insertions(+), 39 deletions(-) create mode 100644 tests/data/song4.wav diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index cd67a0e6fe..63d9b95815 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -27,8 +27,12 @@ class LocalPlaybackProviderTest(unittest.TestCase): 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } + + # We need four tracks so that our shuffled track tests behave nicely with + # reversed as a fake shuffle. Ensuring that shuffled order is [4,3,2,1] and + # normal order [1,2,3,4] which means next_track != next_track_with_random tracks = [ - Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + Track(uri=generate_song(i), length=4464) for i in (1, 2, 3, 4)] def add_track(self, uri): track = Track(uri=uri, length=4464) @@ -320,12 +324,14 @@ def test_next_track_at_end_of_playlist_with_repeat(self): self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist - def test_next_track_with_random(self): - random.seed(1) + @mock.patch('random.shuffle') + def test_next_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[2]) + current_tl_track = self.playback.current_tl_track + next_tl_track = self.tracklist.next_track(current_tl_track) + self.assertEqual(next_tl_track, self.tl_tracks[-1]) @populate_tracklist def test_next_with_consume(self): @@ -343,25 +349,38 @@ def test_next_with_single_and_repeat(self): self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_tracklist - def test_next_with_random(self): - # FIXME feels very fragile - random.seed(1) + @mock.patch('random.shuffle') + def test_next_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True self.playback.play() self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[-2]) @populate_tracklist - def test_next_track_with_random_after_append_playlist(self): - random.seed(1) + @mock.patch('random.shuffle') + def test_next_track_with_random_after_append_playlist(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), - self.tl_tracks[2]) + current_tl_track = self.playback.current_tl_track + + excpected_tl_track = self.tracklist.tl_tracks[-1] + next_tl_track = self.tracklist.next_track(current_tl_track) + + # Baseline checking that first next_track is last tl track per our fake shuffle. + self.assertEqual(next_tl_track, excpected_tl_track) + self.tracklist.add(self.tracks[:1]) - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + old_next_tl_track = next_tl_track + excpected_tl_track = self.tracklist.tl_tracks[-1] + next_tl_track = self.tracklist.next_track(current_tl_track) + + # Verify that first next track has changed since we added to the playlist. + self.assertEqual(next_tl_track, excpected_tl_track) + self.assertNotEqual(next_tl_track, old_next_tl_track) @populate_tracklist def test_end_of_track(self): @@ -475,12 +494,14 @@ def test_end_of_track_track_at_end_of_playlist_with_repeat(self): self.tracklist.next_track(tl_track), self.tl_tracks[0]) @populate_tracklist - def test_end_of_track_track_with_random(self): - random.seed(1) + @mock.patch('random.shuffle') + def test_end_of_track_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True tl_track = self.playback.current_tl_track self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[2]) + self.tracklist.next_track(tl_track), self.tl_tracks[-1]) @populate_tracklist def test_end_of_track_with_consume(self): @@ -490,25 +511,39 @@ def test_end_of_track_with_consume(self): self.assertNotIn(self.tracks[0], self.tracklist.tracks) @populate_tracklist - def test_end_of_track_with_random(self): - # FIXME feels very fragile - random.seed(1) + @mock.patch('random.shuffle') + def test_end_of_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[-2]) @populate_tracklist - def test_end_of_track_track_with_random_after_append_playlist(self): - random.seed(1) + @mock.patch('random.shuffle') + def test_end_of_track_track_with_random_after_append_playlist(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[2]) + current_tl_track = self.playback.current_tl_track + + excpected_tl_track = self.tracklist.tl_tracks[-1] + eot_tl_track = self.tracklist.eot_track(current_tl_track) + + # Baseline checking that first eot_track is last tl track per our fake shuffle. + self.assertEqual(eot_tl_track, excpected_tl_track) + self.tracklist.add(self.tracks[:1]) - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + old_eot_tl_track = eot_tl_track + excpected_tl_track = self.tracklist.tl_tracks[-1] + eot_tl_track = self.tracklist.eot_track(current_tl_track) + + # Verify that first next track has changed since we added to the playlist. + self.assertEqual(eot_tl_track, excpected_tl_track) + self.assertNotEqual(eot_tl_track, old_eot_tl_track) @populate_tracklist def test_previous_track_before_play(self): @@ -872,15 +907,19 @@ def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.assertEqual(len(self.tracklist.tracks), 0) @populate_tracklist - def test_play_with_random(self): - random.seed(1) + @mock.patch('random.shuffle') + def test_play_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[2]) + self.assertEqual(self.playback.current_track, self.tracks[-1]) @populate_tracklist - def test_previous_with_random(self): - random.seed(1) + @mock.patch('random.shuffle') + def test_previous_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True self.playback.play() self.playback.next() @@ -918,7 +957,10 @@ def test_consume_off_by_default(self): self.assertEqual(self.tracklist.consume, False) @populate_tracklist - def test_random_until_end_of_playlist(self): + @mock.patch('random.shuffle') + def test_random_until_end_of_playlist(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + self.tracklist.random = True self.playback.play() for _ in self.tracks[1:]: diff --git a/tests/data/song4.wav b/tests/data/song4.wav new file mode 100644 index 0000000000000000000000000000000000000000..0041c7ba42b9ae5499f8a73092f66c2a1e024fe1 GIT binary patch literal 35292 zcmZvgJ+id9QiSJvnCt+S0tPn%7Xxe}2t2&&&mEK9uoxCZKKUc*Js%r*Y}5!S|=6pKmKq$m?3{Z zfO${?uIJ%mOVciDi*+Y)Tsk!9mQb;km?B|UY5+mB4y;}`hMk)2K?`pWxFs0!u%1sf z#Jr;{lpMf|u3qH?&Qg!V#%5^|nbc|CT>`Q0U@IE~rR*`P>VsW8)9uR4^HvqC0;wCib-Gj&s%dLrFjRU2N8v&rO%%A3RZJ8b{m(_|>-+A^Kg^|fv*_lU4t z2h(95JOZcecB3Sp2ex=XDv(u0A4ZUYKcmcHH&X|-QiLou#=U8{&L}T0Ko}l1`M^Bj z=CNRMVZI0H_%r1DtZJM99e8$Y6^ZC2FZFqWY!XnZ-YM9vbSOh$H=fcQa}OQ&kQ-s0 zKmH)UUWd(Ei;dSnQT8~d0B zI{rW?wgFPF_7x|eD>BFpP~;;-yb!b%bPtXy&OVpt4;9O9sS;X6$|2h=#NWee#K}~g zru2U~K6#T6sH{`A);i1q!GKs7FnRn17y&Qs?BK7%0ZfiuJ~U2dFNs943fEMLTy2A> zE#7?!Q8Y2Lug8&aZ1&b84B3m9HChq&zc%Xrq1pp{0H0I|sYx z(N@@tcVv%-08S~>S-`wAlq7TnJ&l_?2v(jsP3tK+O;$$6_{>T z6JLXt7h!YZp6r8SL7(smD=U(`ny&o{HiB@s=`sU$0P?wT6**c8Ie}<9EQ^^Ly4RIG zS< zD!CpJ1Zsj7rxotxXe#HEN9Cydz3%=(63O&zDCMT+bR!LqWOsNHW=C40khgD_)3d`o z-?loN1M>4PkLeyH<>Ej6EKYzYyKhpYZno-8cKWLozrh5a%DgP;g|sg>|HYVpwAc%E zCt-VQusm^Uh2i&;8i@liBZGYIK?bh~80xge`m^j!xmXR4_*eMQvMmc7#Ei{kI)o22 zd2kWuIx*utT!MM73lNWIyBY2+-v_}UU6P#mJg53m!ru{K2M(IdJgbM5V6o=9m`hu3vNk-qRo-DJftIp9H7{5%lRghz#fNDhId~F^eaFK?o?awdL@8DH9cJbbZWvlLxRopNw@kB;aKTbfE7;D8!hLZ7d^;&}TqtI*1mt~z{ z_KQHiDYgAb;7VcPs5Up!HPXmnQzQD;Scg%VY{mT(N9-)S)%#sk)Wf4@RZv|fttrN( z+)v_XPv)~(8r+C0O+rkRB5UcO3@!djzn8&>VR_RusF<_g*6BDhyd%#zZaj%C+2-QC z!^;+xO#CH2x$H~(Eyz}Hj@Bd{xeu!wce2$AO%7+2X%($#?+Ug^#et^va`Z`8Q&3%G zA`S06E#f-nXKwA71Ei3}fG9$hKL1}(E!=*J2c1RK(;gBSsokFharQFETW;A-=XT;K zDIGE52x%3E^%x3fidl!(D$-l>?30xnjprm>V+!4G1B1lz(@+*?+{r4dJJGu8J~j4u zIjJCIP*JoGteZ;bgS^{F5G=m#!riy;iYWSvFg_ko+mTGG`$t{qSH%4)VL;7!Qj|K9 zuG*Q+C=$&8r8l7j!X!;BRpXr6G7Xe7u|*$T6we4kw}?j*IFmiadbRwj55%3eL+*CL~?O7IqP+pp!jt+^@=k;ug$& zYbH|o;aGbmZw8+oJxudGNMmVT+s$EkD!WC;S;@0-`8e@N!T0AxZKUM@-@;tFd=MrQ z9$lgAG!2^l8DR+E!m$ZOmiWQoR*diwcMxH#gD~Ni7uT0m-;R7@)c8uXOg64ry%YDrIjI1{W&KPw2kIfOg8@zH#kf?;yflxKX`nQ)2?5+@Pxo$ad3TW z%0-Z)ZR^E&5MZUy5)>fjF`(SX`4;y28CuoxSdNvq>_1$gKAEYNmN@w#q?9ZH6VKVG z%xRzS^ z|IjkCsG?_vm$|*gdV&>?%uUFj43nDMLH%Cd4;BwgIybP3VY}Ai!Uy{>- z?V~c8VDD5KP#*qW%^jO_A?n}GO#|{ib!uu88~T8}U684&#>qF~%}3qaJl-FYMn%++ zgUeQo-l^Z%swbj03Y8d@Y2l4NM~2(wdJ8QqpX2(kv@HhlkM|hBIYsn(Fiq_Y4l8n* zc*c%uF`B5*v=ohJTP=$eW5eUA%EXqcFirl;B;6nw9ZRm2sdKK^qGs9ACDr*EL5qIH zxNerhJG*(po&7-6y1QcmG%uNzlmW<_hoQ$x$JA<{5#_1IiPlhqU>)WR=fCctU*Pei zJfIZD8%5?k*GkX`4e_(-<@CHo$aQ&ognnofPBi;mnEAx$ftL5W?{>KFg1Jejn*S$T zWF_Eotjy=#4`*>71BeBwfQ-<=3FHElSJ&BrSju#`snXc{;aJ9THCK&@n$iG^M&_&V!^jcj z-k5%FrKm@CigxrD$$ZN~;s_@~?ZtG;63|s9r;ysT-N`9aD2h!5t)tV(5O{wcs~mS2 z1`WBntRK>ARHI&=qZjsJr?(*DWJax!ruM#MpCf&55_r+nQ2yqcn8`hI4Nt--rn1=R zdM!TL11`?h)JA)zQl`sxOrP0&6}(zfQ4+ps%`y1?)Qk?2iMAH1;+^D-`owEe*o(?O zw&+~}Bkm!(nCK^)gSh-QjDU>;VRhSi!U2Ig6xGzuRO4nrGf<7$%yXa$ISUZn&xOgg zR@VEtGeNCMh$k7N)VzpHJhlZIDl=JK7?T&NfwO0F^F28R%DmO5y*Oc5+SKZ*pi|VC;AAI5aMTpq z0kL$cgl>#B91P^MWmSFy5L_W}D!3s$)rf2t2T^c?#|`Twvza6mp(i+x^ssDAwJ>BR zLRUsxT(iw3>`e&l$Sc~70?xNdgGNq`g|Vqjr_QZ&ULXybqCu|HyeJ4zDM+==0&8AI z%yew)Z&C5aXGI1&be`s6ea?yY1ZWtw%i-Tvj%Yw3Kidu~SnI?@!q1~sj}D?y38fl? zVhWU#Iwu*w#(Yv3b3)Z?i?TX-QF#;vB6M9NGaF|>&SI&Xb28TMX~{0imR1qb)@0-* z)0D0a@UYxk2yp%kigv>%4}a!bJ#!JBke7$}=}Iz|twig!EI6&GDhp>>>Kd{tKl35h ztF>O*!Y3dlUwc5b$#LX)zfgduL`&EMFF<^d&O_bcm2MbUBji=0l#Zge@owf(hn8sZD!7X<>QNMMIZ zjy+V^hn+nex`6>QBeVTqPf}$~KB00r%4{WPPZ?pq|D_7gy%Ll?F`jb0^dH}8SpII*wwsZz;yc3aMYG90>NW_{(MqMX@RwZ(Lx$U@ z`gQKRo4-txB%Nh7%Da^FDnzrQ`eJ=Ta{sK=TKufwiL%XTxPMx^l3)h=V3Rdz8^~GS zN~-$OY-`YKXXNh^puI~c=Pz_3YzmQI%13mPFa`oY z`78bb>pYB+t4G69Z>HdSn!>g0)Zy?@Zr;K={nnba)|TA!h{tyI&pD9s(;l>|$xp!e zKsqWW=OGrflD!+>l4zuM>>jgKUN)tWy}oLvJkvyd@vdAuj$R{9wl?JZ(-D6tBbj=f z{*XhH)F*)wrr^6N)4Ifd@on9d$wpWjNvSc05siAk-DF|?0`bZbSCJs1&|BD7Px_0u zFiQ<&qPVPYjva@+NXvNst5`r@@69uJVrH<2V{Xmput|jTX28b-SLvmN3-RxwlzH-?&WP8dyU97Ck=Zm;d= zwqkl^G6{TC_S?m%p1P7q>wFkU8T>lXq$Ka-`jRmwL3sFQ{w9+p!_J&Alwj=^qN5Ysr!-s^^ZEcr6g& zVgxHrK0Ai_zMV+Wu=FV5iZzpkjH%ejf6B*|Q5@r zwerH!RopzqX=OwcM1eS#SA5&qVVrzatT!K_M7l1)V7LU&-HPyg(%cd9nMrF;n8jl1u^U%6t}0sD zWz2E<_mL2BT!|?}$yRR4PSWFtjvj_NVaLmd0X7IdG7Ub9o=-cs)*5f%LD!fNk}c4( z`AWahz_}mG9kj2JeP4O%Iw>$DrO8L?Vn4$^V>jOk-=7FH0k?+~LbH%oUAYuP?{=pO zVGNt-O;ZcH*YIB@8 zW^HBFK_obTVDM_qB{H|W7&X3(7WmX4utMb3lc{%6eyoNX^_KX`)@zl6u>7|Gye1QC zWvQZ)TAa^G1eOhD7|Q_72?MAYrRl}B^%aY_py#P4ID4(MxNpK1&+HqY+Lr2&F4im9~M5EK+V) zsiEj_5+7eUnXKx`L^gkzv^0S{u{cG8bb;jEA6y548czBOV&?fe4(=GEJPs3m^v4+k zt-8LSRr%ehp13fB0>57nD5s;9(d3Q&s8Y!VlhajLtrl`oM~hR4((;j@+L(YU6~Xx$ z1j;+r%a~rx$VP?=B3%|eAX&njggHQe_;Q3{7K+d@Nwq~#c!pmD-+ib>5kSc2Hfohz z!G+1lwX&XvqhgjB0`al{;inx&N6G=#(~7%6oWr>A zAgY=_d$)x3i*2Er?4{*;%g(E*{GCH}iP_uReG(LU83^iKKocJUq5;InBHqSsz?O^n z5wb7Tpw-P}(tdKF{=J3L2ns61wk1EXB2vZ0;R<{!&|Y5hr0io567ZWNTK7~Y zR_HCJN${ygPGU zQt33s(JPl_7}klb0sG29XW3%9&>~47)2mey{j5}!0cR%xpWvy*9tW+}o33y-7_U+4 zJ+nQUvGo>?Oh(Aj3#eL+^G>mRI0C^qB5A7D$4d;U3HB6mJ&#Z>558Z8n@Q$KTFLp4 z<*8p5RSWLG8Q|w^IMBGQX@Z82n3q-r>CfNo#8%G^9{sx(l2&$7hj=5;E#+F$LYt-Ez{(!)#}r`NAc}2iT?c0y-%U+z%3FmPeD) zS*)6VJ%l{}t>`Mq>ZSG$wX&v$lh6*W^r8t`rd$1xqlNR}-9~$-#v)R1 zEjOf2#A4d*&>3#T5*2R;#B#NolOW;cjW1HMO^_pJaDJ5|r$&LX^-I!I-UOV&xy2!B zE`yetY+EiknQLDAeEe4xp(L|NPW*gj;oJKr+O=@P3?@s+v-C`w(~u-l!2c9llzYje z<2&BmJRhi&@wq9iyMJzTm~7RO9tgJ@dy~w0&{5Dgvmb;}S5&HD3~y|6BC=6wmB%}v zQX_Hk;_(zaZL2@g{KA<$zSsV_eyB1(r{Ik^Z@0A4lgHzvGjA_f6itBk(7ZNqeLPax zu3;jWll&fi5D%y6a!PO3aV5lemGwM+8WlS1t0`ZL{JAkAge0dK?qt2foo%>Rm%S~q zgCI^`Eo%c8z`JY#3g(w+WhXw4u#QwgQ(n;ZvRjpDc74&3oqxmwjPCh7TeIajbR6H+ zHWR4E=KR3ZCm?m_7exEmi-3n0WNnpB3$l#*YEYJ&nkB-ZC5yH75CwVv%n zMo4GjZY7<>dJZ7EZNu2pK@p7RSGhH(UO;`Qv$344wo13Iw`M@eq>S)xWf6<>G$Tac zWuqZzh2uVxl?Q)IaRC86=2#VYbz*<8ZBeI43&v4`e%T1GgP+hqE=vQg7Tx>boiyQ5 z{1kz*9#hTD`a7B-IiZr!=~(J{Jz7S|kqyCZWr!9l{aiX=rx`@U^Ehz!r*o{Pit2H3 zm)ZByTtIW-I8mdH3MDi{t_Wf4E-%`FYrIa&7%XkOJNP5%Xi`ewFQpM80*ah^YMkvm zas*qb4^*`lr7*NeTk|Y&Cg^-MokV3O)UI0&;yCHn} z5Ars_$a2%F?8198CNf9>a}7xWurO!5{m-&&GbpQ%OOX9$c)uneX!ap&v&xX=Hv$%v z1LLYKfO{pL?1I%!nzKYp5#;+~6N0aUa__Ne@SN@$3&gG5$aiwM`6t#n-goZ^X`>RluiMEj~8bNb}c<1P3G^Hl^_laBNyiUjo%kf`j zI~<bu=%6ndWzRzC2Qoq2@R!x4ckJg`9 z_D7$0RkN|Q{G|5AoQ1&<|Hadau7?x!bb0`{V--V6S$i)I3fUfP3uFRm9t-=0X}lgt z#u%?5)zwLLemZYiwP_fVOxEYRG|yo?T~t_ZVVv=;#>#0D%Y$z9E?<+G6nclu5aR4wQFNtQlyX@)r#c2}Mh{ zpcd{^C;wp&D#arpd~4G})QYw0swMMVBxJ3HK^_I_Pdk#?>Lrwlf>Q0F7>NN3qMb3B_x+_)U4@+p`?euDpS3YR26X+dPV&%P{fqCOqo9SkcS@#Z%BXB^ za2EdboS^66$@FDc*BOjzU0_2v9*egr!2gduS7`cbcB@ zIfo44ytHQ|;ygds@vb}MI#SmONyS99v+p#1s$9O6T2&@OPAVQpcni2zt9lN&vFOtI zI>tGTOeSziw5|ylvI_SX_?RFMTTTf28%y|PyI8ptVvCyghk%^H_RAvWAr|*8A3AnL zAJ)1EC1`^H-cOwKw^KR~FO!l&NYCS%ZzTvMV9e~E6U(_|((3ZJZF8m11@JZwoC@qA z#k`prpCh$~2D$G>sZ{BrVC`OLxTd2L;RiG=gOGH_02l`?&QXEBp|IS-Tf()b;Vo>F z7POo#Obam^tccqx1Bhp#mdxl v1#?P9{9O0AACa{yG&aY>+>8s{@2XD*GwRWllC06fVk^yuChyiL$Y%OKkwfK8 literal 0 HcmV?d00001 From 66ae1b8eeed5ef5c521e69bc252670230215155f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 18:35:03 +0200 Subject: [PATCH 128/175] local/core: Add and fix more random related tests. - Adds tests for eot_track vs next_track as we were only testing the one code path. - Makes test_random_until_end_of_playlist_and_play_from_start actually test random and not repeat. - Adds test_play_track_then_enable_random which is the underlying bug covered by the regression test for issue #17. --- tests/backends/local/playback_test.py | 48 +++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 63d9b95815..eb34826e86 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -957,10 +957,7 @@ def test_consume_off_by_default(self): self.assertEqual(self.tracklist.consume, False) @populate_tracklist - @mock.patch('random.shuffle') - def test_random_until_end_of_playlist(self, shuffle_mock): - shuffle_mock.side_effect = lambda tracks: tracks.reverse() - + def test_random_until_end_of_playlist(self): self.tracklist.random = True self.playback.play() for _ in self.tracks[1:]: @@ -968,9 +965,19 @@ def test_random_until_end_of_playlist(self, shuffle_mock): tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.next_track(tl_track), None) + @populate_tracklist + def test_random_with_eot_until_end_of_playlist(self): + self.tracklist.random = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.eot_track(tl_track), None) + @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): - self.tracklist.repeat = True + self.tracklist.random = True + self.playback.play() for _ in self.tracks: self.playback.next() tl_track = self.playback.current_tl_track @@ -979,12 +986,24 @@ def test_random_until_end_of_playlist_and_play_from_start(self): self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) + @populate_tracklist + def test_random_with_eot_until_end_of_playlist_and_play_from_start(self): + self.tracklist.random = True + self.playback.play() + for _ in self.tracks: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertNotEqual(self.tracklist.eot_track(tl_track), None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + @populate_tracklist def test_random_until_end_of_playlist_with_repeat(self): self.tracklist.repeat = True self.tracklist.random = True self.playback.play() - for _ in self.tracks: + for _ in self.tracks[1:]: self.playback.next() tl_track = self.playback.current_tl_track self.assertNotEqual(self.tracklist.next_track(tl_track), None) @@ -999,6 +1018,23 @@ def test_played_track_during_random_not_played_again(self): played.append(self.playback.current_track) self.playback.next() + @populate_tracklist + @mock.patch('random.shuffle') + def test_play_track_then_enable_random(self, shuffle_mock): + # Covers underlying issue IssueGH17RegressionTest tests for. + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + expected = self.tl_tracks[1::-1] + actual = [] + + self.playback.play() + self.tracklist.random = True + for _ in self.tracks[1:]: + self.playback.next() + actual.append(self.playback.current_tl_track) + + self.assertEqual(actual, expected) + @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): test = lambda: self.playback.play((17, Track())) From 40754bb2e05d8e05a28b050010efe6affd727d67 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 19:28:13 +0200 Subject: [PATCH 129/175] core: Fix handling of shuffle (fixes #453) - Shuffle internal list when random is enabled - Use presence of tl_track to determine if this is first run to trigger subsequent shuffles. --- mopidy/core/tracklist.py | 31 +++++++++++++++++---------- tests/backends/local/playback_test.py | 5 ++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 5d85e190f1..a2fbb61afc 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -21,7 +21,6 @@ def __init__(self, core): self._version = 0 self._shuffled = [] - self._first_shuffle = True ### Properties @@ -89,6 +88,9 @@ def get_random(self): def set_random(self, value): if self.get_random() != value: self._trigger_options_changed() + if value: + self._shuffled = self.tl_tracks + random.shuffle(self._shuffled) return setattr(self, '_random', value) random = property(get_random, set_random) @@ -162,14 +164,16 @@ def eot_track(self, tl_track): return None if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: + if self.repeat or not tl_track: logger.debug('Shuffling tracks') self._shuffled = self.tl_tracks random.shuffle(self._shuffled) - self._first_shuffle = False - if self.random and self._shuffled: - return self._shuffled[0] + if self.random: + try: + return self._shuffled[0] + except IndexError: + return None if tl_track is None: return self.tl_tracks[0] @@ -206,14 +210,16 @@ def next_track(self, tl_track): return None if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: + if self.repeat or not tl_track: logger.debug('Shuffling tracks') self._shuffled = self.tl_tracks random.shuffle(self._shuffled) - self._first_shuffle = False - if self.random and self._shuffled: - return self._shuffled[0] + if self.random: + try: + return self._shuffled[0] + except IndexError: + return None if tl_track is None: return self.tl_tracks[0] @@ -455,8 +461,11 @@ def mark_played(self, tl_track): return True def _trigger_tracklist_changed(self): - self._first_shuffle = True - self._shuffled = [] + if self.random: + self._shuffled = self.tl_tracks + random.shuffle(self._shuffled) + else: + self._shuffled = [] logger.debug('Triggering event: tracklist_changed()') listener.CoreListener.send('tracklist_changed') diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index eb34826e86..af7962f07d 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1024,15 +1024,14 @@ def test_play_track_then_enable_random(self, shuffle_mock): # Covers underlying issue IssueGH17RegressionTest tests for. shuffle_mock.side_effect = lambda tracks: tracks.reverse() - expected = self.tl_tracks[1::-1] + expected = self.tl_tracks[::-1] + [None] actual = [] self.playback.play() self.tracklist.random = True - for _ in self.tracks[1:]: + while self.playback.state != PlaybackState.STOPPED: self.playback.next() actual.append(self.playback.current_tl_track) - self.assertEqual(actual, expected) @populate_tracklist From d9780050082e1263e0dbc546d2edc7a7077903a7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 20:33:48 +0200 Subject: [PATCH 130/175] core: flake8 fixes --- tests/backends/local/playback_test.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index af7962f07d..593a979b66 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import mock -import random import time import unittest @@ -369,7 +368,8 @@ def test_next_track_with_random_after_append_playlist(self, shuffle_mock): excpected_tl_track = self.tracklist.tl_tracks[-1] next_tl_track = self.tracklist.next_track(current_tl_track) - # Baseline checking that first next_track is last tl track per our fake shuffle. + # Baseline checking that first next_track is last tl track per our fake + # shuffle. self.assertEqual(next_tl_track, excpected_tl_track) self.tracklist.add(self.tracks[:1]) @@ -378,7 +378,8 @@ def test_next_track_with_random_after_append_playlist(self, shuffle_mock): excpected_tl_track = self.tracklist.tl_tracks[-1] next_tl_track = self.tracklist.next_track(current_tl_track) - # Verify that first next track has changed since we added to the playlist. + # Verify that first next track has changed since we added to the + # playlist. self.assertEqual(next_tl_track, excpected_tl_track) self.assertNotEqual(next_tl_track, old_next_tl_track) @@ -523,7 +524,8 @@ def test_end_of_track_with_random(self, shuffle_mock): @populate_tracklist @mock.patch('random.shuffle') - def test_end_of_track_track_with_random_after_append_playlist(self, shuffle_mock): + def test_end_of_track_track_with_random_after_append_playlist( + self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True @@ -532,7 +534,8 @@ def test_end_of_track_track_with_random_after_append_playlist(self, shuffle_mock excpected_tl_track = self.tracklist.tl_tracks[-1] eot_tl_track = self.tracklist.eot_track(current_tl_track) - # Baseline checking that first eot_track is last tl track per our fake shuffle. + # Baseline checking that first eot_track is last tl track per our fake + # shuffle. self.assertEqual(eot_tl_track, excpected_tl_track) self.tracklist.add(self.tracks[:1]) @@ -541,7 +544,8 @@ def test_end_of_track_track_with_random_after_append_playlist(self, shuffle_mock excpected_tl_track = self.tracklist.tl_tracks[-1] eot_tl_track = self.tracklist.eot_track(current_tl_track) - # Verify that first next track has changed since we added to the playlist. + # Verify that first next track has changed since we added to the + # playlist. self.assertEqual(eot_tl_track, excpected_tl_track) self.assertNotEqual(eot_tl_track, old_eot_tl_track) From 7fc753d16759dff411e4942b55167d7755988035 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 21:15:00 +0200 Subject: [PATCH 131/175] core/local: Update playback test with review comments. - Fixes typo. - Adds assert to better test intermediate state. --- tests/backends/local/playback_test.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 593a979b66..da353a5856 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -354,6 +354,7 @@ def test_next_with_random(self, shuffle_mock): self.tracklist.random = True self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[-1]) self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[-2]) @@ -365,22 +366,22 @@ def test_next_track_with_random_after_append_playlist(self, shuffle_mock): self.tracklist.random = True current_tl_track = self.playback.current_tl_track - excpected_tl_track = self.tracklist.tl_tracks[-1] + expected_tl_track = self.tracklist.tl_tracks[-1] next_tl_track = self.tracklist.next_track(current_tl_track) # Baseline checking that first next_track is last tl track per our fake # shuffle. - self.assertEqual(next_tl_track, excpected_tl_track) + self.assertEqual(next_tl_track, expected_tl_track) self.tracklist.add(self.tracks[:1]) old_next_tl_track = next_tl_track - excpected_tl_track = self.tracklist.tl_tracks[-1] + expected_tl_track = self.tracklist.tl_tracks[-1] next_tl_track = self.tracklist.next_track(current_tl_track) # Verify that first next track has changed since we added to the # playlist. - self.assertEqual(next_tl_track, excpected_tl_track) + self.assertEqual(next_tl_track, expected_tl_track) self.assertNotEqual(next_tl_track, old_next_tl_track) @populate_tracklist @@ -531,22 +532,22 @@ def test_end_of_track_track_with_random_after_append_playlist( self.tracklist.random = True current_tl_track = self.playback.current_tl_track - excpected_tl_track = self.tracklist.tl_tracks[-1] + expected_tl_track = self.tracklist.tl_tracks[-1] eot_tl_track = self.tracklist.eot_track(current_tl_track) # Baseline checking that first eot_track is last tl track per our fake # shuffle. - self.assertEqual(eot_tl_track, excpected_tl_track) + self.assertEqual(eot_tl_track, expected_tl_track) self.tracklist.add(self.tracks[:1]) old_eot_tl_track = eot_tl_track - excpected_tl_track = self.tracklist.tl_tracks[-1] + expected_tl_track = self.tracklist.tl_tracks[-1] eot_tl_track = self.tracklist.eot_track(current_tl_track) # Verify that first next track has changed since we added to the # playlist. - self.assertEqual(eot_tl_track, excpected_tl_track) + self.assertEqual(eot_tl_track, expected_tl_track) self.assertNotEqual(eot_tl_track, old_eot_tl_track) @populate_tracklist From f1f223bba85d43ee485fb3813f4cbe314739c744 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 21:43:19 +0200 Subject: [PATCH 132/175] local: Fix handling of single in eot_track (fixes #496) - Adds test cases for code paths that caused bug - Short circuits EOT next track handling when in single mode. --- mopidy/core/tracklist.py | 10 +++++---- tests/backends/local/playback_test.py | 30 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 5d85e190f1..8e2789c44e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -161,6 +161,11 @@ def eot_track(self, tl_track): if not self.tl_tracks: return None + if self.single and self.repeat: + return tl_track + elif self.single: + return None + if self.random and not self._shuffled: if self.repeat or self._first_shuffle: logger.debug('Shuffling tracks') @@ -176,10 +181,7 @@ def eot_track(self, tl_track): position = self.index(tl_track) - if self.repeat and self.single: - return self.tl_tracks[position] - - if self.repeat and not self.single: + if self.repeat: return self.tl_tracks[(position + 1) % len(self.tl_tracks)] try: diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index cd67a0e6fe..5543b3abac 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -339,6 +339,7 @@ def test_next_with_single_and_repeat(self): self.tracklist.single = True self.tracklist.repeat = True self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -899,9 +900,38 @@ def test_end_of_song_with_single_and_repeat_starts_same(self): self.tracklist.single = True self.tracklist.repeat = True self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[0]) + @populate_tracklist + def test_end_of_song_with_single_random_and_repeat_starts_same(self): + self.tracklist.single = True + self.tracklist.repeat = True + self.tracklist.random = True + self.playback.play() + current_track = self.playback.current_track + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_end_of_song_with_single_stops(self): + self.tracklist.single = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_song_with_single_and_random_stops(self): + self.tracklist.single = True + self.tracklist.random = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + @populate_tracklist def test_end_of_playlist_stops(self): self.playback.play(self.tracklist.tl_tracks[-1]) From ba55181bc1ddab4760fd0a2a27b08dc2d5f66bf0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 21:48:42 +0200 Subject: [PATCH 133/175] core: Reduce duplication between next and eot track handling. --- mopidy/core/tracklist.py | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 8e2789c44e..d995029c57 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -158,36 +158,15 @@ def eot_track(self, tl_track): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if not self.tl_tracks: - return None - if self.single and self.repeat: return tl_track elif self.single: return None - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = self.tl_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if tl_track is None: - return self.tl_tracks[0] - - position = self.index(tl_track) - - if self.repeat: - return self.tl_tracks[(position + 1) % len(self.tl_tracks)] - - try: - return self.tl_tracks[position + 1] - except IndexError: - return None + # Current differnce between next and eot handling is that eot needs to + # handle single, with that out of the way the rest of the logic is + # shared. + return self.next_track(tl_track) def next_track(self, tl_track): """ @@ -220,13 +199,12 @@ def next_track(self, tl_track): if tl_track is None: return self.tl_tracks[0] - position = self.index(tl_track) - + next_index = self.index(tl_track) + 1 if self.repeat: - return self.tl_tracks[(position + 1) % len(self.tl_tracks)] + next_index %= len(self.tl_tracks) try: - return self.tl_tracks[position + 1] + return self.tl_tracks[next_index] except IndexError: return None From bfddfab15a3fd57d59e9d9a6f26c15c021b3dbc1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 22:47:54 +0200 Subject: [PATCH 134/175] core: Fix typos pointed out in PR#542 --- mopidy/core/tracklist.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 57da790117..dbc819456f 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -165,8 +165,8 @@ def eot_track(self, tl_track): elif self.single: return None - # Current differnce between next and eot handling is that eot needs to - # handle single, with that out of the way the rest of the logic is + # Current difference between next and EOT handling is that EOT needs to + # handle "single", with that out of the way the rest of the logic is # shared. return self.next_track(tl_track) From 35a31d3d2758c8a06dcf22a1585f4398a086c83a Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 13 Oct 2013 07:39:34 +0200 Subject: [PATCH 135/175] Add support for 'count ..' and 'find .. track X' --- mopidy/backends/local/library.py | 2 +- mopidy/frontends/mpd/protocol/music_db.py | 13 +++++++++++-- mopidy/frontends/mpd/translator.py | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 0de63fafc6..24145e9a26 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -58,7 +58,7 @@ def find_exact(self, query=None, uris=None): q = value.strip() uri_filter = lambda t: q == t.uri - track_filter = lambda t: q == t.name + track_filter = lambda t: int(q) == t.track_no album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index f81d57ee56..20d53a9479 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -11,7 +11,7 @@ QUERY_RE = ( r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') + r'[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$') def _get_field(field, search_results): @@ -54,7 +54,16 @@ def count(context, mpd_query): - does not add quotes around the tag argument. - use multiple tag-needle pairs to make more specific searches. """ - return [('songs', 0), ('playtime', 0)] # TODO + try: + query = translator.query_from_mpd_search_format(mpd_query) + except ValueError: + return + results = context.core.library.find_exact(**query).get() + result_tracks = [] + + result_tracks = _get_tracks(results) + return [('songs', len(result_tracks)), + ('playtime', sum(track.length for track in result_tracks) / 1000)] @handle_request(r'^find ' + QUERY_RE) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index df3338ba81..8f70320db5 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -195,7 +195,7 @@ def query_from_mpd_search_format(mpd_query): query_parts = re.findall(query_pattern, mpd_query) query_part_pattern = ( r'"?(?P([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny))"? "(?P[^"]+)"') + r'[Tt]itle|[Tt]rack|[Aa]ny))"? "(?P[^"]+)"') query = {} for query_part in query_parts: m = re.match(query_part_pattern, query_part) From df32e7fcd0c2560f6508d0d7760642c52b88f6c1 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 13 Oct 2013 10:24:19 +0200 Subject: [PATCH 136/175] Error checking before type-cast --- mopidy/backends/local/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 24145e9a26..73b4bf50ba 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -58,7 +58,8 @@ def find_exact(self, query=None, uris=None): q = value.strip() uri_filter = lambda t: q == t.uri - track_filter = lambda t: int(q) == t.track_no + track_filter = lambda t: q == int(t.track_no) \ + if t.track_no.isdigit() else None album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) From 1975694d08f9367bc82e70ee37b859dc738b8370 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 13 Oct 2013 13:28:13 +0200 Subject: [PATCH 137/175] Changed track to track_no, fix code, add tests Added testcases for track and count --- mopidy/backends/local/library.py | 23 ++++-- mopidy/frontends/mpd/protocol/music_db.py | 2 - mopidy/frontends/mpd/translator.py | 2 + tests/backends/local/library_test.py | 72 +++++++++++++++++-- tests/data/library_tag_cache | 3 + tests/frontends/mpd/protocol/music_db_test.py | 37 ++++++++++ 6 files changed, 127 insertions(+), 12 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 73b4bf50ba..bec5b0d074 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -55,23 +55,28 @@ def find_exact(self, query=None, uris=None): values = [values] # FIXME this is bound to be slow for large libraries for value in values: - q = value.strip() + if field != 'track_no': + q = value.strip() + else: + q = value uri_filter = lambda t: q == t.uri - track_filter = lambda t: q == int(t.track_no) \ - if t.track_no.isdigit() else None + track_filter = lambda t: q == t.name album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) albumartist_filter = lambda t: any([ q == a.name for a in getattr(t.album, 'artists', [])]) + track_no_filter = lambda t: q == t.track_no date_filter = lambda t: q == t.date any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or + track_no_filter(t) or + date_filter(t)) or uri_filter(t)) if field == 'uri': @@ -86,6 +91,8 @@ def find_exact(self, query=None, uris=None): result_tracks = filter(albumartist_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) + elif field == 'track_no': + result_tracks = filter(track_no_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -106,7 +113,10 @@ def search(self, query=None, uris=None): values = [values] # FIXME this is bound to be slow for large libraries for value in values: - q = value.strip().lower() + if field != 'track_no': + q = value.strip().lower() + else: + q = value uri_filter = lambda t: q in t.uri.lower() track_filter = lambda t: q in t.name.lower() @@ -117,12 +127,15 @@ def search(self, query=None, uris=None): albumartist_filter = lambda t: any([ q in a.name.lower() for a in getattr(t.album, 'artists', [])]) + track_no_filter = lambda t: q == t.track_no date_filter = lambda t: t.date and t.date.startswith(q) any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or + track_no_filter(t) or + date_filter(t)) or uri_filter(t)) if field == 'uri': @@ -137,6 +150,8 @@ def search(self, query=None, uris=None): result_tracks = filter(albumartist_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) + elif field == 'track_no': + result_tracks = filter(track_no_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 20d53a9479..3f4d460fac 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -59,8 +59,6 @@ def count(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - result_tracks = [] - result_tracks = _get_tracks(results) return [('songs', len(result_tracks)), ('playtime', sum(track.length for track in result_tracks) / 1000)] diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 8f70320db5..ec6dec4bb9 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -202,6 +202,8 @@ def query_from_mpd_search_format(mpd_query): field = m.groupdict()['field'].lower() if field == 'title': field = 'track' + if field == 'track': + field = 'track_no' elif field in ('file', 'filename'): field = 'uri' field = str(field) # Needed for kwargs keys on OS X and Windows diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 6b0cd6f6e7..7077e65cea 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -29,15 +29,15 @@ class LocalLibraryProviderTest(unittest.TestCase): Track( uri='local:track:path1', name='track1', artists=[artists[0]], album=albums[0], - date='2001-02-03', length=4000), + date='2001-02-03', length=4000, track_no=1), Track( uri='local:track:path2', name='track2', artists=[artists[1]], album=albums[1], - date='2002', length=4000), + date='2002', length=4000, track_no=2), Track( uri='local:track:path3', name='track3', artists=[artists[3]], album=albums[2], - date='2003', length=4000), + date='2003', length=4000, track_no=3), ] config = { @@ -87,6 +87,18 @@ def test_find_exact_no_hits(self): result = self.library.find_exact(album=['unknown artist']) self.assertEqual(list(result[0].tracks), []) + result = self.library.find_exact(date=['1990']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(track_no=[9]) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(uri=['fake uri']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(any=['unknown any']) + self.assertEqual(list(result[0].tracks), []) + def test_find_exact_uri(self): track_1_uri = 'local:track:path1' result = self.library.find_exact(uri=track_1_uri) @@ -96,6 +108,22 @@ def test_find_exact_uri(self): result = self.library.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_find_exact_any(self): + result = self.library.find_exact(any=['track1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(any=['track2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + result = self.library.find_exact(any=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + result = self.library.find_exact(any=['album1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(any=['artist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_find_exact_track(self): result = self.library.find_exact(track=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) @@ -130,6 +158,13 @@ def test_find_exact_albumartist(self): result = self.library.find_exact(albumartist=['artist3']) self.assertEqual(list(result[0].tracks), [self.tracks[2]]) + def test_find_exact_track_no(self): + result = self.library.find_exact(track_no=[1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(track_no=[2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_find_exact_date(self): result = self.library.find_exact(date=['2001']) self.assertEqual(list(result[0].tracks), []) @@ -175,16 +210,28 @@ def test_find_exact_with_empty_query(self): test = lambda: self.library.find_exact(album=['']) self.assertRaises(LookupError, test) - def test_search_no_hits(self): - result = self.library.search(track=['unknown track']) - self.assertEqual(list(result[0].tracks), []) + test = lambda: self.library.find_exact(track_no=[]) + self.assertRaises(LookupError, test) + test = lambda: self.library.find_exact(date=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(any=['']) + self.assertRaises(LookupError, test) + + def test_search_no_hits(self): result = self.library.search(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) result = self.library.search(album=['unknown artist']) self.assertEqual(list(result[0].tracks), []) + result = self.library.search(track_no=[9]) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(date=['unknown']) + self.assertEqual(list(result[0].tracks), []) + result = self.library.search(uri=['unknown']) self.assertEqual(list(result[0].tracks), []) @@ -245,6 +292,13 @@ def test_search_date(self): result = self.library.search(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_search_track_no(self): + result = self.library.search(track_no=[1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(track_no=[2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + def test_search_any(self): # Matches on track artist result = self.library.search(any=['Tist1']) @@ -254,6 +308,9 @@ def test_search_any(self): result = self.library.search(any=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.search(any=['Rack2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + # Matches on track album result = self.library.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) @@ -280,6 +337,9 @@ def test_search_with_empty_query(self): test = lambda: self.library.search(album=['']) self.assertRaises(LookupError, test) + test = lambda: self.library.search(date=['']) + self.assertRaises(LookupError, test) + test = lambda: self.library.search(uri=['']) self.assertRaises(LookupError, test) diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index 2272a114f3..e9e87c1b2c 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -9,6 +9,7 @@ Artist: artist1 Title: track1 Album: album1 Date: 2001-02-03 +Track: 1 Time: 4 key: key2 file: /path2 @@ -16,6 +17,7 @@ Artist: artist2 Title: track2 Album: album2 Date: 2002 +Track: 2 Time: 4 key: key3 file: /path3 @@ -24,5 +26,6 @@ AlbumArtist: artist3 Title: track3 Album: album3 Date: 2003 +Track: 3 Time: 4 songList end diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index eaa5da06a1..203fc0205f 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -24,6 +24,23 @@ def test_count_with_multiple_pairs(self): self.assertInResponse('playtime: 0') self.assertInResponse('OK') + def test_count_correct_length(self): + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(name='A', date="2001", length=4000), + Track(name='B', date="2001", length=50000), + Track(name='C', date="2001", length=600000), + Track(name='D', date="2009", length=60000) + ]) + self.sendRequest('count "date" "2009"') + self.assertInResponse('songs: 1') + self.assertInResponse('playtime: 4000') + self.assertInResponse('OK') + + self.sendRequest('count "date" "2001"') + self.assertInResponse('songs: 3') + self.assertInResponse('playtime: 654000') + self.assertInResponse('OK') + def test_findadd(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) @@ -235,6 +252,14 @@ def test_find_title_without_quotes(self): self.sendRequest('find title "what"') self.assertInResponse('OK') + def test_find_track_no(self): + self.sendRequest('find "track" "what"') + self.assertInResponse('OK') + + def test_find_track_no_without_quotes(self): + self.sendRequest('find track "what"') + self.assertInResponse('OK') + def test_find_date(self): self.sendRequest('find "date" "2002-01-01"') self.assertInResponse('OK') @@ -593,6 +618,18 @@ def test_search_any_without_filter_value(self): self.sendRequest('search "any" ""') self.assertInResponse('OK') + def test_search_track_no(self): + self.sendRequest('search "track" "anything"') + self.assertInResponse('OK') + + def test_search_track_no_without_quotes(self): + self.sendRequest('search track "anything"') + self.assertInResponse('OK') + + def test_search_track_no_without_filter_value(self): + self.sendRequest('search "track" ""') + self.assertInResponse('OK') + def test_search_date(self): self.sendRequest('search "date" "2002-01-01"') self.assertInResponse('OK') From 6f761a03f0d538c08751b084dbf753f31410250d Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 13 Oct 2013 13:36:52 +0200 Subject: [PATCH 138/175] Return MpdArgError if count parsing fails --- mopidy/frontends/mpd/protocol/music_db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 3f4d460fac..1bcbf84c2b 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,7 +5,7 @@ from mopidy.models import Track from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.frontends.mpd.exceptions import MpdNotImplemented, MpdArgError from mopidy.frontends.mpd.protocol import handle_request, stored_playlists @@ -57,7 +57,7 @@ def count(context, mpd_query): try: query = translator.query_from_mpd_search_format(mpd_query) except ValueError: - return + raise MpdArgError('incorrect arguments', command='count') results = context.core.library.find_exact(**query).get() result_tracks = _get_tracks(results) return [('songs', len(result_tracks)), From c31815b0e65b2cac0fc357e9c37ae75090876e5e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 23:21:45 +0200 Subject: [PATCH 139/175] docs: Update changelog --- docs/changelog.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9863b6abb3..374dc2cdc4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,15 @@ of the following extensions as well: - Added :meth:`mopidy.core.CoreListener.mute_changed` event that is triggered when the mute state changes. +- In "random" mode, after a full playthrough of the tracklist, playback + continued from the last track played to the end of the playlist in non-random + order. It now stops when all tracks have been played once, unless "repeat" + mode is enabled. (Fixes: :issue:`453`) + +- In "single" mode, after a track ended, playback continued with the next track + in the tracklis. It now stops after playing a single track, unless "repeat" + mode is enabled. (Fixes: :issue:`496`) + **Audio** - Added support for parsing and playback of playlists in GStreamer. For end @@ -92,6 +101,11 @@ of the following extensions as well: - Media files with less than 100ms duration are now excluded from the library. +- Unknown URIs found in playlists are now made into track objects with the URI + set instead of being ignored. This makes it possible to have playlists with + e.g. HTTP radio streams and not just ``local:track:...`` URIs. This used to + work, but was broken in Mopidy 0.15.0. (Fixes: :issue:`527`) + **MPD frontend** - Made the formerly unused commands ``outputs``, ``enableoutput``, and From 6028d6766d92ee13bbf6edae135655995863581a Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 20 Oct 2013 23:35:31 +0200 Subject: [PATCH 140/175] Fix tabs->spaces and extra ')' --- mopidy/backends/local/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index bec5b0d074..1cb8534e51 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -76,7 +76,7 @@ def find_exact(self, query=None, uris=None): artist_filter(t) or albumartist_filter(t) or track_no_filter(t) or - date_filter(t)) or + date_filter(t) or uri_filter(t)) if field == 'uri': @@ -135,7 +135,7 @@ def search(self, query=None, uris=None): artist_filter(t) or albumartist_filter(t) or track_no_filter(t) or - date_filter(t)) or + date_filter(t) or uri_filter(t)) if field == 'uri': From 010cb62756efd785724b297d85466684f9d86844 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 20 Oct 2013 23:45:33 +0200 Subject: [PATCH 141/175] docs: Add note about fixing #198 to changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 374dc2cdc4..e05a6c9a08 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -97,7 +97,7 @@ of the following extensions as well: - Replaced our custom media library scanner with GStreamer's builtin scanner. This should make scanning less error prone and faster as timeouts should be - infrequent. + infrequent. (Fixes: :issue:`198`) - Media files with less than 100ms duration are now excluded from the library. From 2117add55f94151452942de7399d6973338207f8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 20 Oct 2013 23:48:17 +0200 Subject: [PATCH 142/175] docs: Include minimum GStreamer version --- docs/installation/index.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 238184f4fd..65c014b25a 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -175,10 +175,10 @@ can install Mopidy from PyPI using Pip. #. Then you'll need to install all of Mopidy's hard non-Python dependencies: - - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most - popular Linux distributions. Search for GStreamer in your package manager, - and make sure to install the Python bindings, and the "good" and "ugly" - plugin sets. + - GStreamer 0.10.31 or later, with Python bindings. GStreamer is packaged + for most popular Linux distributions. Search for GStreamer in your package + manager, and make sure to install the Python bindings, and the "good" and + "ugly" plugin sets. If you use Debian/Ubuntu you can install GStreamer like this:: From 95046b4852f6630df6a98f415fc0a3f15171ebb3 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Tue, 22 Oct 2013 00:12:32 +0200 Subject: [PATCH 143/175] Fix small bug and fix test --- mopidy/frontends/mpd/translator.py | 2 +- tests/frontends/mpd/protocol/music_db_test.py | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index ec6dec4bb9..80bfb60a6c 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -202,7 +202,7 @@ def query_from_mpd_search_format(mpd_query): field = m.groupdict()['field'].lower() if field == 'title': field = 'track' - if field == 'track': + elif field == 'track': field = 'track_no' elif field in ('file', 'filename'): field = 'uri' diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 203fc0205f..0d4dcaacc3 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -25,20 +25,24 @@ def test_count_with_multiple_pairs(self): self.assertInResponse('OK') def test_count_correct_length(self): - self.backend.library.dummy_search_result = SearchResult( - tracks=[Track(name='A', date="2001", length=4000), - Track(name='B', date="2001", length=50000), - Track(name='C', date="2001", length=600000), - Track(name='D', date="2009", length=60000) - ]) - self.sendRequest('count "date" "2009"') + # Count the lone track + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(uri='dummy:a', name="foo", date="2001", length=4000)] + ) + self.sendRequest('count "title" "foo"') self.assertInResponse('songs: 1') - self.assertInResponse('playtime: 4000') + self.assertInResponse('playtime: 4') self.assertInResponse('OK') + # Count multiple tracks + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(uri='dummy:b', date="2001", length=50000), + Track(uri='dummy:c', date="2001", length=600000) + ] + ) self.sendRequest('count "date" "2001"') - self.assertInResponse('songs: 3') - self.assertInResponse('playtime: 654000') + self.assertInResponse('songs: 2') + self.assertInResponse('playtime: 650') self.assertInResponse('OK') def test_findadd(self): From 457c39fc78de912e7cdbd8a15f5c8d5b9fdc28c0 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Tue, 22 Oct 2013 23:40:38 +0200 Subject: [PATCH 144/175] Remove duplicate test group, merge it into existing --- tests/backends/local/library_test.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 7077e65cea..9c3bc13cfa 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -108,22 +108,6 @@ def test_find_exact_uri(self): result = self.library.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - def test_find_exact_any(self): - result = self.library.find_exact(any=['track1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(any=['track2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - result = self.library.find_exact(any=['2002']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - result = self.library.find_exact(any=['album1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(any=['artist2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - def test_find_exact_track(self): result = self.library.find_exact(track=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) @@ -180,10 +164,16 @@ def test_find_exact_any(self): result = self.library.find_exact(any=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.find_exact(any=['artist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + # Matches on track result = self.library.find_exact(any=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + result = self.library.find_exact(any=['track2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + # Matches on track album result = self.library.find_exact(any=['album1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) @@ -192,6 +182,10 @@ def test_find_exact_any(self): result = self.library.find_exact(any=['artist3']) self.assertEqual(list(result[0].tracks), self.tracks[2:3]) + # Matches on track year + result = self.library.find_exact(any=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + # Matches on URI result = self.library.find_exact(any=['local:track:path1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) From ef15c4f6fc66fefde84c30ec19129475738ba399 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Oct 2013 20:27:19 +0200 Subject: [PATCH 145/175] docs: Enable new RTD theme --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 56ddbf92ce..5a75b7d4d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,6 +78,9 @@ def __getattr__(self, name): # the string True. on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +# Enable Read the Docs' new theme +RTD_NEW_THEME = True + # -- General configuration ---------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be From 8097add7ed6aabbeca42682011930dbb794a6921 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Oct 2013 20:30:25 +0200 Subject: [PATCH 146/175] docs: More exact GStreamer version range --- docs/installation/index.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 65c014b25a..cd4ad983cb 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -88,7 +88,7 @@ Mopidy Git repo, which always corresponds to the latest release. #. Optional: If you want to scrobble your played tracks to Last.fm, you need to install `python2-pylast`:: - + sudo pacman -S python2-pylast #. Finally, you need to set a couple of :doc:`config values `, and @@ -175,10 +175,10 @@ can install Mopidy from PyPI using Pip. #. Then you'll need to install all of Mopidy's hard non-Python dependencies: - - GStreamer 0.10.31 or later, with Python bindings. GStreamer is packaged - for most popular Linux distributions. Search for GStreamer in your package - manager, and make sure to install the Python bindings, and the "good" and - "ugly" plugin sets. + - GStreamer 0.10 (>= 0.10.31, < 0.11), with Python bindings. GStreamer is + packaged for most popular Linux distributions. Search for GStreamer in + your package manager, and make sure to install the Python bindings, and + the "good" and "ugly" plugin sets. If you use Debian/Ubuntu you can install GStreamer like this:: From 80b9329dcb7e5c49753b6e7e34745a436fc8e93d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Oct 2013 20:57:30 +0200 Subject: [PATCH 147/175] docs: Update authors --- .mailmap | 1 + AUTHORS | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.mailmap b/.mailmap index 2ff779fc4d..242d4d91dc 100644 --- a/.mailmap +++ b/.mailmap @@ -8,3 +8,4 @@ John Bäckstrand Alli Witheford Alexandre Petitjean Alexandre Petitjean +Javier Domingo Cansino diff --git a/AUTHORS b/AUTHORS index fdfb82fbf8..e59b92e214 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,7 +24,6 @@ - Alli Witheford - Alexandre Petitjean - Terje Larsen -- Pavol Babincak -- Javier Domingo - Javier Domingo Cansino +- Pavol Babincak - Lasse Bigum From 9d0b04e96f7dadacb33bc7d0e37864b7afedacac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Oct 2013 21:20:00 +0200 Subject: [PATCH 148/175] docs: Fix link to Mopidy-Arcam --- docs/ext/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 940dd37ac1..27fe3b457a 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -33,7 +33,7 @@ developers. Mopidy-Arcam ------------ -https://github.com/mopidy/mopidy-arcam +https://github.com/TooDizzy/mopidy-arcam Extension for controlling volume using an external Arcam amplifier. Developed and tested with an Arcam AVR-300. From 84612ca1ac9afb46150a9fd980a3ee52fd2e3fe4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Oct 2013 22:27:04 +0200 Subject: [PATCH 149/175] docs: Fix typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e05a6c9a08..57e0a3c814 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,7 +75,7 @@ of the following extensions as well: mode is enabled. (Fixes: :issue:`453`) - In "single" mode, after a track ended, playback continued with the next track - in the tracklis. It now stops after playing a single track, unless "repeat" + in the tracklist. It now stops after playing a single track, unless "repeat" mode is enabled. (Fixes: :issue:`496`) **Audio** From f4d0d5648f0a726ea5011ef7c3c6bed3d9f63dc4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Oct 2013 22:39:33 +0200 Subject: [PATCH 150/175] docs: Fix typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 57e0a3c814..df235d1d5f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -111,7 +111,7 @@ of the following extensions as well: - Made the formerly unused commands ``outputs``, ``enableoutput``, and ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) -- The MPD command ``list`` now works with ``"albumartist"`` as it's second +- The MPD command ``list`` now works with ``"albumartist"`` as its second argument, e.g. ``"album" "albumartist" "anartist"``. (Fixes: :issue:`468`) **Extension support** From 1e3191a0f9a45a7d640a5881ba26a1f9af918975 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 24 Oct 2013 09:51:09 +0200 Subject: [PATCH 151/175] docs: Search is on every page, don't need a search page --- docs/index.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 17a40c326e..732c9f321a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,4 +81,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` -* :ref:`search` From e448d77eb76a55ad08991fa05096cd926e69bfba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 24 Oct 2013 22:19:59 +0200 Subject: [PATCH 152/175] mpd: Fix flipped mute logic --- mopidy/frontends/mpd/protocol/audio_output.py | 4 ++-- tests/frontends/mpd/protocol/audio_output_test.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 65e693ecc4..17cf4ac435 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -14,7 +14,7 @@ def disableoutput(context, outputid): Turns an output off. """ if int(outputid) == 0: - context.core.playback.set_mute(True) + context.core.playback.set_mute(False) else: raise MpdNoExistError('No such audio output', command='disableoutput') @@ -29,7 +29,7 @@ def enableoutput(context, outputid): Turns an output on. """ if int(outputid) == 0: - context.core.playback.set_mute(False) + context.core.playback.set_mute(True) else: raise MpdNoExistError('No such audio output', command='enableoutput') diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index cbfb504316..4871f16981 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -5,12 +5,12 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): - self.core.playback.mute = True + self.core.playback.mute = False self.sendRequest('enableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), False) + self.assertEqual(self.core.playback.mute.get(), True) def test_enableoutput_unknown_outputid(self): self.sendRequest('enableoutput "7"') @@ -18,12 +18,12 @@ def test_enableoutput_unknown_outputid(self): self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): - self.core.playback.mute = False + self.core.playback.mute = True self.sendRequest('disableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), True) + self.assertEqual(self.core.playback.mute.get(), False) def test_disableoutput_unknown_outputid(self): self.sendRequest('disableoutput "7"') From b0ae7d3c6fbd002cfd202e6fd0ffa54dce7e57af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 24 Oct 2013 23:22:01 +0200 Subject: [PATCH 153/175] local: Fix crash on non-ASCII chars in URIs --- docs/changelog.rst | 3 +++ mopidy/backends/local/playback.py | 2 +- tests/backends/local/playback_test.py | 8 ++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index df235d1d5f..e77cd9487e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -106,6 +106,9 @@ of the following extensions as well: e.g. HTTP radio streams and not just ``local:track:...`` URIs. This used to work, but was broken in Mopidy 0.15.0. (Fixes: :issue:`527`) +- Fixed crash when playing ``local:track:...`` URIs which contained non-ASCII + chars after uridecode. + **MPD frontend** - Made the formerly unused commands ``outputs``, ``enableoutput``, and diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py index eda06ff799..98c92a85b9 100644 --- a/mopidy/backends/local/playback.py +++ b/mopidy/backends/local/playback.py @@ -13,7 +13,7 @@ class LocalPlaybackProvider(base.BasePlaybackProvider): def change_track(self, track): media_dir = self.backend.config['local']['media_dir'] # TODO: check that type is correct. - file_path = path.uri_to_path(track.uri).split(':', 1)[1] + file_path = path.uri_to_path(track.uri).split(b':', 1)[1] file_path = os.path.join(media_dir, file_path) track = track.copy(uri=path.path_to_uri(file_path)) return super(LocalPlaybackProvider, self).change_track(track) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index ab1357666a..8fbc441568 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -72,6 +72,14 @@ def test_play_flac(self): self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) + def test_play_uri_with_non_ascii_bytes(self): + # Regression test: If trying to do .split(u':') on a bytestring, the + # string will be decoded from ASCII to Unicode, which will crash on + # non-ASCII strings, like the bytestring the following URI decodes to. + self.add_track('local:track:12%20Doin%E2%80%99%20It%20Right.flac') + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + def test_initial_state_is_stopped(self): self.assertEqual(self.playback.state, PlaybackState.STOPPED) From add79d90dd4052837215d066624ebe676144ed58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 25 Oct 2013 18:16:08 +0200 Subject: [PATCH 154/175] docs: Warn about EOT issue with Shoutcast (fixes #545) --- docs/config.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/config.rst b/docs/config.rst index 5b8f5de104..c381ef7089 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -209,6 +209,13 @@ this work first:: Streaming through SHOUTcast/Icecast ----------------------------------- +.. warning:: Known issue + + Currently, Mopidy does not handle end-of-track vs end-of-stream signalling + in GStreamer correctly. This causes the SHOUTcast stream to be disconnected + at the end of each track, rendering it quite useless. For further details, + see :issue:`492`. + If you want to play the audio on another computer than the one running Mopidy, you can stream the audio from Mopidy through an SHOUTcast or Icecast audio streaming server. Multiple media players can then be connected to the streaming From d5cb4282d97068845fda65940bf09a84c7b100f4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 27 Oct 2013 11:38:01 +0100 Subject: [PATCH 155/175] config: Add preprocessor for preserving comments when editing configs. Adds markers to configs files that ensures configparser won't mangle comments in the files. Will be combined with a postprocessor that undoes these changes. --- mopidy/config/__init__.py | 37 ++++++++++++++ tests/config/config_test.py | 96 +++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 0767b50c7a..8d68c7f3e2 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -2,8 +2,10 @@ import ConfigParser as configparser import io +import itertools import logging import os.path +import re from mopidy.config import keyring from mopidy.config.schemas import * # noqa @@ -145,6 +147,41 @@ def _format(config, comments, schemas, display): return b'\n'.join(output) +def _preprocess(string): + """Convert a raw config into a form that preserves comments etc.""" + results = ['[__COMMENTS__]'] + counter = itertools.count(0) + + section_re = re.compile(r'^(\[[^\]]+\])\s*(.+)$') + blank_line_re = re.compile(r'^\s*$') + comment_re = re.compile(r'^(#|;)') + inline_comment_re = re.compile(r' ;') + + def newlines(match): + return '__BLANK%d__ =' % next(counter) + + def comments(match): + if match.group(1) == '#': + return '__HASH%d__ =' % next(counter) + elif match.group(1) == ';': + return '__SEMICOLON%d__ =' % next(counter) + + def inlinecomments(match): + return '\n__INLINE%d__ =' % next(counter) + + def sections(match): + return '%s\n__SECTION%d__ = %s' % ( + match.group(1), next(counter), match.group(2)) + + for line in string.splitlines(): + line = blank_line_re.sub(newlines, line) + line = section_re.sub(sections, line) + line = comment_re.sub(comments, line) + line = inline_comment_re.sub(inlinecomments, line) + results.append(line) + return '\n'.join(results) + + class Proxy(collections.Mapping): def __init__(self, data): self._data = data diff --git a/tests/config/config_test.py b/tests/config/config_test.py index c40baa87b1..1676951332 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -106,3 +106,99 @@ def test_config_single_schema_config_error(self): self.assertEqual({'foo': {'bar': 'bad'}}, errors) # TODO: add more tests + + +INPUT_CONFIG = """# comments before first section should work + +[section] anything goes ; after the [] block it seems. +; this is a valid comment +this-should-equal-baz = baz ; as this is a comment +this-should-equal-everything = baz # as this is not a comment + +# this is also a comment ; and the next line should be a blank comment. +; +# foo # = should all be treated as a comment. +""" + +PROCESSED_CONFIG = """[__COMMENTS__] +__HASH0__ = comments before first section should work +__BLANK1__ = +[section] +__SECTION2__ = anything goes +__INLINE3__ = after the [] block it seems. +__SEMICOLON4__ = this is a valid comment +this-should-equal-baz = baz +__INLINE5__ = as this is a comment +this-should-equal-everything = baz # as this is not a comment +__BLANK6__ = +__HASH7__ = this is also a comment +__INLINE8__ = and the next line should be a blank comment. +__SEMICOLON9__ = +__HASH10__ = foo # = should all be treated as a comment.""" + + +class ProcessorTest(unittest.TestCase): + maxDiff = None # Show entire diff. + + def test_preprocessor_empty_config(self): + result = config._preprocess('') + self.assertEqual(result, '[__COMMENTS__]') + + def test_preprocessor_plain_section(self): + result = config._preprocess('[section]\nfoo = bar') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar') + + def test_preprocessor_initial_comments(self): + result = config._preprocess('; foobar') + self.assertEqual(result, '[__COMMENTS__]\n' + '__SEMICOLON0__ = foobar') + + result = config._preprocess('# foobar') + self.assertEqual(result, '[__COMMENTS__]\n' + '__HASH0__ = foobar') + + result = config._preprocess('; foo\n# bar') + self.assertEqual(result, '[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__HASH1__ = bar') + + def test_preprocessor_initial_comment_inline_handling(self): + result = config._preprocess('; foo ; bar ; baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__INLINE1__ = bar\n' + '__INLINE2__ = baz') + + def test_preprocessor_inline_semicolon_comment(self): + result = config._preprocess('[section]\nfoo = bar ; baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar\n' + '__INLINE0__ = baz') + + def test_preprocessor_no_inline_hash_comment(self): + result = config._preprocess('[section]\nfoo = bar # baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar # baz') + + def test_preprocessor_section_extra_text(self): + result = config._preprocess('[section] foobar') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar') + + def test_preprocessor_section_extra_text_inline_semicolon(self): + result = config._preprocess('[section] foobar ; baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar\n' + '__INLINE1__ = baz') + + def test_preprocessor_conversion(self): + """Tests all of the above cases at once.""" + result = config._preprocess(INPUT_CONFIG) + self.assertEqual(result, PROCESSED_CONFIG) + From 73f91710e14bc72c7de2f5ffc2b21042c43dc422 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 27 Oct 2013 12:30:02 +0100 Subject: [PATCH 156/175] config: Add postprocessor for converting config back. Idea forward from here is that once we have a config sub command that we expose a setting config values which will: 1. Run the preprocessor on the file to edit. 2. Load it into config parser. 3. Modify the value. 4. Write it to a io.ByteString 5. Run the postprocessor 6. Save the file with comments etc intact. --- mopidy/config/__init__.py | 16 ++++++- tests/config/config_test.py | 89 +++++++++++++++++++++++++++++++------ 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 8d68c7f3e2..6d66e25306 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -147,7 +147,7 @@ def _format(config, comments, schemas, display): return b'\n'.join(output) -def _preprocess(string): +def _preprocess(config_string): """Convert a raw config into a form that preserves comments etc.""" results = ['[__COMMENTS__]'] counter = itertools.count(0) @@ -173,7 +173,7 @@ def sections(match): return '%s\n__SECTION%d__ = %s' % ( match.group(1), next(counter), match.group(2)) - for line in string.splitlines(): + for line in config_string.splitlines(): line = blank_line_re.sub(newlines, line) line = section_re.sub(sections, line) line = comment_re.sub(comments, line) @@ -182,6 +182,18 @@ def sections(match): return '\n'.join(results) +def _postprocess(config_string): + """Converts a preprocessed config back to original form.""" + flags = re.IGNORECASE | re.MULTILINE + result = re.sub(r'^\[__COMMENTS__\](\n|$)', '', config_string, flags=flags) + result = re.sub(r'\n__INLINE\d+__ =(.*)$', ' ;\g<1>', result, flags=flags) + result = re.sub(r'^__HASH\d+__ =(.*)$', '#\g<1>', result, flags=flags) + result = re.sub(r'^__SEMICOLON\d+__ =(.*)$', ';\g<1>', result, flags=flags) + result = re.sub(r'\n__SECTION\d+__ =(.*)$', '\g<1>', result, flags=flags) + result = re.sub(r'^__BLANK\d+__ =$', '', result, flags=flags) + return result + + class Proxy(collections.Mapping): def __init__(self, data): self._data = data diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 1676951332..fceb293d62 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -117,8 +117,7 @@ def test_config_single_schema_config_error(self): # this is also a comment ; and the next line should be a blank comment. ; -# foo # = should all be treated as a comment. -""" +# foo # = should all be treated as a comment.""" PROCESSED_CONFIG = """[__COMMENTS__] __HASH0__ = comments before first section should work @@ -137,20 +136,20 @@ def test_config_single_schema_config_error(self): __HASH10__ = foo # = should all be treated as a comment.""" -class ProcessorTest(unittest.TestCase): - maxDiff = None # Show entire diff. +class PreProcessorTest(unittest.TestCase): + maxDiff = None # Show entire diff. - def test_preprocessor_empty_config(self): + def test_empty_config(self): result = config._preprocess('') self.assertEqual(result, '[__COMMENTS__]') - def test_preprocessor_plain_section(self): + def test_plain_section(self): result = config._preprocess('[section]\nfoo = bar') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar') - def test_preprocessor_initial_comments(self): + def test_initial_comments(self): result = config._preprocess('; foobar') self.assertEqual(result, '[__COMMENTS__]\n' '__SEMICOLON0__ = foobar') @@ -164,41 +163,105 @@ def test_preprocessor_initial_comments(self): '__SEMICOLON0__ = foo\n' '__HASH1__ = bar') - def test_preprocessor_initial_comment_inline_handling(self): + def test_initial_comment_inline_handling(self): result = config._preprocess('; foo ; bar ; baz') self.assertEqual(result, '[__COMMENTS__]\n' '__SEMICOLON0__ = foo\n' '__INLINE1__ = bar\n' '__INLINE2__ = baz') - def test_preprocessor_inline_semicolon_comment(self): + def test_inline_semicolon_comment(self): result = config._preprocess('[section]\nfoo = bar ; baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar\n' '__INLINE0__ = baz') - def test_preprocessor_no_inline_hash_comment(self): + def test_no_inline_hash_comment(self): result = config._preprocess('[section]\nfoo = bar # baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar # baz') - def test_preprocessor_section_extra_text(self): + def test_section_extra_text(self): result = config._preprocess('[section] foobar') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' '__SECTION0__ = foobar') - def test_preprocessor_section_extra_text_inline_semicolon(self): + def test_section_extra_text_inline_semicolon(self): result = config._preprocess('[section] foobar ; baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' '__SECTION0__ = foobar\n' '__INLINE1__ = baz') - def test_preprocessor_conversion(self): + def test_conversion(self): """Tests all of the above cases at once.""" result = config._preprocess(INPUT_CONFIG) self.assertEqual(result, PROCESSED_CONFIG) + +class PostProcessorTest(unittest.TestCase): + maxDiff = None # Show entire diff. + + def test_empty_config(self): + result = config._postprocess('[__COMMENTS__]') + self.assertEqual(result, '') + + def test_plain_section(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + 'foo = bar') + self.assertEqual(result, '[section]\nfoo = bar') + + def test_initial_comments(self): + result = config._postprocess('[__COMMENTS__]\n' + '__SEMICOLON0__ = foobar') + self.assertEqual(result, '; foobar') + + result = config._postprocess('[__COMMENTS__]\n' + '__HASH0__ = foobar') + self.assertEqual(result, '# foobar') + + result = config._postprocess('[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__HASH1__ = bar') + self.assertEqual(result, '; foo\n# bar') + + def test_initial_comment_inline_handling(self): + result = config._postprocess('[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__INLINE1__ = bar\n' + '__INLINE2__ = baz') + self.assertEqual(result, '; foo ; bar ; baz') + + def test_inline_semicolon_comment(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + 'foo = bar\n' + '__INLINE0__ = baz') + self.assertEqual(result, '[section]\nfoo = bar ; baz') + + def test_no_inline_hash_comment(self): + result = config._preprocess('[section]\nfoo = bar # baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar # baz') + + def test_section_extra_text(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar') + self.assertEqual(result, '[section] foobar') + + def test_section_extra_text_inline_semicolon(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar\n' + '__INLINE1__ = baz') + self.assertEqual(result, '[section] foobar ; baz') + + def test_conversion(self): + result = config._postprocess(PROCESSED_CONFIG) + self.assertEqual(result, INPUT_CONFIG) From ecc0bae3447aefb635533bcb9a3b861fbd39bb54 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 27 Oct 2013 14:10:56 +0100 Subject: [PATCH 157/175] local: Delete uris in library refresh (fixes #500) Makes sure we remove uri's that can no longer be found in the tag cache. --- mopidy/backends/local/library.py | 5 +++++ tests/backends/local/library_test.py | 30 +++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 0de63fafc6..f21ac81a27 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -27,9 +27,14 @@ def refresh(self, uri=None): self._media_dir, self._tag_cache_file) tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) + uris_to_remove = set(self._uri_mapping) for track in tracks: self._uri_mapping[track.uri] = track + uris_to_remove.discard(track.uri) + + for uri in uris_to_remove: + del self._uri_mapping[uri] logger.info( 'Loaded %d local tracks from %s using %s', diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 6b0cd6f6e7..09b3febb12 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import tempfile import unittest import pykka @@ -11,6 +12,8 @@ from tests import path_to_data_dir +# TODO: update tests to only use backend, not core. we need a seperate +# core test that does this integration test. class LocalLibraryProviderTest(unittest.TestCase): artists = [ Artist(name='artist1'), @@ -49,7 +52,6 @@ class LocalLibraryProviderTest(unittest.TestCase): } def setUp(self): - self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) @@ -65,9 +67,31 @@ def test_refresh(self): def test_refresh_uri(self): pass - @unittest.SkipTest def test_refresh_missing_uri(self): - pass + # Verifies that https://github.com/mopidy/mopidy/issues/500 + # has been fixed. + + tag_cache = tempfile.NamedTemporaryFile() + with open(self.config['local']['tag_cache_file']) as fh: + tag_cache.write(fh.read()) + tag_cache.flush() + + config = {'local': self.config['local'].copy()} + config['local']['tag_cache_file'] = tag_cache.name + backend = actor.LocalBackend(config=config, audio=None) + + # Sanity check that value is in tag cache + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, self.tracks[0:1]) + + # Clear tag cache and refresh + tag_cache.seek(0) + tag_cache.truncate() + backend.library.refresh() + + # Now it should be gone. + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, []) def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) From 3a1d4db227b72cb598b04a0300ee90da6a5f6f81 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 27 Oct 2013 14:26:06 +0100 Subject: [PATCH 158/175] local: Add file ext blacklist (fixes #516). Adds a new local/scan_blacklist_extensions config value for controlling file extensions to ignore. --- docs/ext/local.rst | 4 ++++ mopidy/backends/local/__init__.py | 1 + mopidy/backends/local/ext.conf | 6 ++++++ mopidy/scanner.py | 5 +++++ 4 files changed, 16 insertions(+) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 1abebb1d5a..1fbb3d51ae 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -52,6 +52,10 @@ Configuration values Number of milliseconds before giving up scanning a file and moving on to the next file. +.. confval:: local/scan_blacklist_extensions + + File extensions to ignore when scanning the media directory. + Usage ===== diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 9d1e655f5b..4efe4456bc 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -23,6 +23,7 @@ def get_config_schema(self): schema['tag_cache_file'] = config.Path() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000*60*60) + schema['scan_blacklist_extensions'] = config.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index 7e0f0f2b0f..ef5fdd78a9 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -4,3 +4,9 @@ media_dir = $XDG_MUSIC_DIR playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache scan_timeout = 1000 +scan_blacklist_extensions = + .jpeg + .jpg + .png + .txt + .log diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 2e8876a271..f8e12f981e 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -71,6 +71,7 @@ def main(): local_updater = updaters.values()[0](config) # TODO: switch to actor? media_dir = config['local']['media_dir'] + blacklist_extensions = config['local']['scan_blacklist_extensions'] uris_library = set() uris_update = set() @@ -92,6 +93,10 @@ def main(): logging.info('Checking %s for new or modified tracks.', media_dir) for uri in path.find_uris(config['local']['media_dir']): + if os.path.splitext(path.uri_to_path(uri))[1] in blacklist_extensions: + logging.debug('Skipped %s: File extension blacklisted.', uri) + continue + if uri not in uris_library: uris_update.add(uri) From 7d23302737cff32eb482dc2801ea9a08f175b029 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 12:12:45 +0100 Subject: [PATCH 159/175] mpd: Remove Python 2.6-ism --- mopidy/frontends/mpd/translator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index df3338ba81..d0198f1388 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -204,7 +204,6 @@ def query_from_mpd_search_format(mpd_query): field = 'track' elif field in ('file', 'filename'): field = 'uri' - field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'] if not what: raise ValueError From d8227244261a4b20a621d5e9ab87cd49dc959610 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 20:06:42 +0100 Subject: [PATCH 160/175] mpd: Format search query regexps better --- mopidy/frontends/mpd/translator.py | 48 +++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index d0198f1388..d4686597ad 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -179,6 +179,42 @@ def query_from_mpd_list_format(field, mpd_query): raise MpdArgError('not able to parse args', command='list') +# XXX The regexps below should be refactored to reuse common patterns here +# and in mopidy.frontends.mpd.protocol.music_db.QUERY_RE. + +MPD_SEARCH_QUERY_RE = re.compile(r""" + "? # Optional quote around the field type + (?: # A non-capturing group for the field type + [Aa]lbum + | [Aa]rtist + | [Dd]ate + | [Ff]ile + | [Ff]ilename + | [Tt]itle + | [Aa]ny + ) + "? # End of optional quote around the field type + \s # A single space + "[^"]+" # Matching a quoted search string +""", re.VERBOSE) + +MPD_SEARCH_QUERY_PART_RE = re.compile(r""" + "? # Optional quote around the field type + (?P( # A capturing group for the field type + [Aa]lbum + | [Aa]rtist + | [Dd]ate + | [Ff]ile + | [Ff]ilename + | [Tt]itle + | [Aa]ny + )) + "? # End of optional quote around the field type + \s # A single space + "(?P[^"]+)" # Capturing a quoted search string +""", re.VERBOSE) + + def query_from_mpd_search_format(mpd_query): """ Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy @@ -187,18 +223,10 @@ def query_from_mpd_search_format(mpd_query): :param mpd_query: the MPD search query :type mpd_query: string """ - # XXX The regexps below should be refactored to reuse common patterns here - # and in mopidy.frontends.mpd.protocol.music_db. - query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"') - query_parts = re.findall(query_pattern, mpd_query) - query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny))"? "(?P[^"]+)"') + query_parts = re.findall(MPD_SEARCH_QUERY_RE, mpd_query) query = {} for query_part in query_parts: - m = re.match(query_part_pattern, query_part) + m = re.match(MPD_SEARCH_QUERY_PART_RE, query_part) field = m.groupdict()['field'].lower() if field == 'title': field = 'track' From dabddd81fda70b5a6ab7e95cdbc03967b0517ed7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 20:22:45 +0100 Subject: [PATCH 161/175] mpd: Accept 'albumartist' type to 'find' and 'search' commands --- mopidy/frontends/mpd/protocol/music_db.py | 4 ++-- tests/frontends/mpd/protocol/music_db_test.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index f81d57ee56..49384cb0c4 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -10,8 +10,8 @@ QUERY_RE = ( - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') + r'(?P("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Dd]ate|[Ff]ile|' + r'[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def _get_field(field, search_results): diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index eaa5da06a1..47d5bb877a 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -211,6 +211,14 @@ def test_find_artist_without_quotes(self): self.sendRequest('find artist "what"') self.assertInResponse('OK') + def test_find_albumartist(self): + self.sendRequest('find "albumartist" "what"') + self.assertInResponse('OK') + + def test_find_albumartist_without_quotes(self): + self.sendRequest('find albumartist "what"') + self.assertInResponse('OK') + def test_find_filename(self): self.sendRequest('find "filename" "afilename"') self.assertInResponse('OK') @@ -545,6 +553,18 @@ def test_search_artist_without_filter_value(self): self.sendRequest('search "artist" ""') self.assertInResponse('OK') + def test_search_albumartist(self): + self.sendRequest('search "albumartist" "analbumartist"') + self.assertInResponse('OK') + + def test_search_albumartist_without_quotes(self): + self.sendRequest('search albumartist "analbumartist"') + self.assertInResponse('OK') + + def test_search_albumartist_without_filter_value(self): + self.sendRequest('search "albumartist" ""') + self.assertInResponse('OK') + def test_search_filename(self): self.sendRequest('search "filename" "afilename"') self.assertInResponse('OK') From 6af0ace8aca9263054a95d133ea17b64c5959e28 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 20:23:22 +0100 Subject: [PATCH 162/175] mpd: Map 'albumartist' field from MPD to Mopidy query --- mopidy/frontends/mpd/translator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index d4686597ad..266d0d8806 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -183,10 +183,12 @@ def query_from_mpd_list_format(field, mpd_query): # and in mopidy.frontends.mpd.protocol.music_db.QUERY_RE. MPD_SEARCH_QUERY_RE = re.compile(r""" + \b # Only begin matching at word bundaries "? # Optional quote around the field type (?: # A non-capturing group for the field type [Aa]lbum | [Aa]rtist + | [Aa]lbumartist | [Dd]ate | [Ff]ile | [Ff]ilename @@ -199,10 +201,12 @@ def query_from_mpd_list_format(field, mpd_query): """, re.VERBOSE) MPD_SEARCH_QUERY_PART_RE = re.compile(r""" + \b # Only begin matching at word bundaries "? # Optional quote around the field type (?P( # A capturing group for the field type [Aa]lbum | [Aa]rtist + | [Aa]lbumartist | [Dd]ate | [Ff]ile | [Ff]ilename From 24a2b08fc53e0fab7c8c7ec3155055b5e6a26301 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 20:24:07 +0100 Subject: [PATCH 163/175] mpd: Don't return fake artists when searching by albumartist --- mopidy/frontends/mpd/protocol/music_db.py | 2 +- tests/frontends/mpd/protocol/music_db_test.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 49384cb0c4..7c99c538e5 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -91,7 +91,7 @@ def find(context, mpd_query): return results = context.core.library.find_exact(**query).get() result_tracks = [] - if 'artist' not in query: + if 'artist' not in query and 'albumartist' not in query: result_tracks += [_artist_as_track(a) for a in _get_artists(results)] if 'album' not in query: result_tracks += [_album_as_track(a) for a in _get_albums(results)] diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 47d5bb877a..65f76d3786 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -175,6 +175,26 @@ def test_find_artist_does_not_include_fake_artist_tracks(self): self.assertInResponse('OK') + def test_find_albumartist_does_not_include_fake_artist_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "albumartist" "foo"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_find_artist_and_album_does_not_include_fake_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], From 38bbaeee18bf556b495b6d6b1e85e8fbf94f5cfb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 20:59:46 +0100 Subject: [PATCH 164/175] mpd: Review fixes --- mopidy/frontends/mpd/translator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 266d0d8806..d2b0f2d447 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -227,10 +227,10 @@ def query_from_mpd_search_format(mpd_query): :param mpd_query: the MPD search query :type mpd_query: string """ - query_parts = re.findall(MPD_SEARCH_QUERY_RE, mpd_query) + query_parts = MPD_SEARCH_QUERY_RE.findall(mpd_query) query = {} for query_part in query_parts: - m = re.match(MPD_SEARCH_QUERY_PART_RE, query_part) + m = MPD_SEARCH_QUERY_PART_RE.match(query_part) field = m.groupdict()['field'].lower() if field == 'title': field = 'track' From 70801c2481680acc10ff603cd6a023b66f5db169 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:22:34 +0100 Subject: [PATCH 165/175] mpd: Fix import order --- mopidy/frontends/mpd/protocol/music_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 1bcbf84c2b..f0d8b11eef 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,7 +5,7 @@ from mopidy.models import Track from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import MpdNotImplemented, MpdArgError +from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists From d13d9958b05f897035c98160b72a2fa832cc913b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:22:46 +0100 Subject: [PATCH 166/175] mpd: Formatting --- mopidy/frontends/mpd/protocol/music_db.py | 6 ++++-- tests/frontends/mpd/protocol/music_db_test.py | 13 +++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index f0d8b11eef..e56c123e3e 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -60,8 +60,10 @@ def count(context, mpd_query): raise MpdArgError('incorrect arguments', command='count') results = context.core.library.find_exact(**query).get() result_tracks = _get_tracks(results) - return [('songs', len(result_tracks)), - ('playtime', sum(track.length for track in result_tracks) / 1000)] + return [ + ('songs', len(result_tracks)), + ('playtime', sum(track.length for track in result_tracks) / 1000), + ] @handle_request(r'^find ' + QUERY_RE) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 0d4dcaacc3..ea1bd5b122 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -27,8 +27,9 @@ def test_count_with_multiple_pairs(self): def test_count_correct_length(self): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[Track(uri='dummy:a', name="foo", date="2001", length=4000)] - ) + tracks=[ + Track(uri='dummy:a', name="foo", date="2001", length=4000), + ]) self.sendRequest('count "title" "foo"') self.assertInResponse('songs: 1') self.assertInResponse('playtime: 4') @@ -36,10 +37,10 @@ def test_count_correct_length(self): # Count multiple tracks self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[Track(uri='dummy:b', date="2001", length=50000), - Track(uri='dummy:c', date="2001", length=600000) - ] - ) + tracks=[ + Track(uri='dummy:b', date="2001", length=50000), + Track(uri='dummy:c', date="2001", length=600000), + ]) self.sendRequest('count "date" "2001"') self.assertInResponse('songs: 2') self.assertInResponse('playtime: 650') From c42dd1bf1d78abbeffaa15b5d4190855cdc05f3e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:23:45 +0100 Subject: [PATCH 167/175] mpd: Add 'find track ""' test to match search tests --- tests/frontends/mpd/protocol/music_db_test.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index ea1bd5b122..1b115bd60a 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -258,11 +258,15 @@ def test_find_title_without_quotes(self): self.assertInResponse('OK') def test_find_track_no(self): - self.sendRequest('find "track" "what"') + self.sendRequest('find "track" "10"') self.assertInResponse('OK') def test_find_track_no_without_quotes(self): - self.sendRequest('find track "what"') + self.sendRequest('find track "10"') + self.assertInResponse('OK') + + def test_find_track_no_without_filter_value(self): + self.sendRequest('find "track" ""') self.assertInResponse('OK') def test_find_date(self): @@ -624,11 +628,11 @@ def test_search_any_without_filter_value(self): self.assertInResponse('OK') def test_search_track_no(self): - self.sendRequest('search "track" "anything"') + self.sendRequest('search "track" "10"') self.assertInResponse('OK') def test_search_track_no_without_quotes(self): - self.sendRequest('search track "anything"') + self.sendRequest('search track "10"') self.assertInResponse('OK') def test_search_track_no_without_filter_value(self): From 4a9552fc15da55b1912bf8c86923ac604ad78aed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:25:36 +0100 Subject: [PATCH 168/175] local: Bring the corner case close to the condition --- mopidy/backends/local/library.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 0e4a29a295..8a0aac790c 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -60,10 +60,10 @@ def find_exact(self, query=None, uris=None): values = [values] # FIXME this is bound to be slow for large libraries for value in values: - if field != 'track_no': - q = value.strip() - else: + if field == 'track_no': q = value + else: + q = value.strip() uri_filter = lambda t: q == t.uri track_filter = lambda t: q == t.name @@ -118,10 +118,10 @@ def search(self, query=None, uris=None): values = [values] # FIXME this is bound to be slow for large libraries for value in values: - if field != 'track_no': - q = value.strip().lower() - else: + if field == 'track_no': q = value + else: + q = value.strip().lower() uri_filter = lambda t: q in t.uri.lower() track_filter = lambda t: q in t.name.lower() From f0aff669c6992d2870a383c239f93191af47d8ca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:26:03 +0100 Subject: [PATCH 169/175] local: Order search filters consistently --- mopidy/backends/local/library.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 8a0aac790c..2ff0e6d10b 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -76,13 +76,13 @@ def find_exact(self, query=None, uris=None): track_no_filter = lambda t: q == t.track_no date_filter = lambda t: q == t.date any_filter = lambda t: ( + uri_filter(t) or track_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or track_no_filter(t) or - date_filter(t) or - uri_filter(t)) + date_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -94,10 +94,10 @@ def find_exact(self, query=None, uris=None): result_tracks = filter(artist_filter, result_tracks) elif field == 'albumartist': result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) elif field == 'track_no': result_tracks = filter(track_no_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -135,13 +135,13 @@ def search(self, query=None, uris=None): track_no_filter = lambda t: q == t.track_no date_filter = lambda t: t.date and t.date.startswith(q) any_filter = lambda t: ( + uri_filter(t) or track_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or track_no_filter(t) or - date_filter(t) or - uri_filter(t)) + date_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -153,10 +153,10 @@ def search(self, query=None, uris=None): result_tracks = filter(artist_filter, result_tracks) elif field == 'albumartist': result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) elif field == 'track_no': result_tracks = filter(track_no_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: From be6e6c9a936ca4c1523d231311d1009d497a644c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:26:26 +0100 Subject: [PATCH 170/175] local: Readd lost test assertion --- tests/backends/local/library_test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index f58ca76903..1cb0745103 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -238,6 +238,9 @@ def test_find_exact_with_empty_query(self): self.assertRaises(LookupError, test) def test_search_no_hits(self): + result = self.library.search(track=['unknown track']) + self.assertEqual(list(result[0].tracks), []) + result = self.library.search(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) @@ -247,13 +250,13 @@ def test_search_no_hits(self): result = self.library.search(track_no=[9]) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(date=['unknown']) + result = self.library.search(date=['unknown date']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(uri=['unknown']) + result = self.library.search(uri=['unknown uri']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(any=['unknown']) + result = self.library.search(any=['unknown anything']) self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): From 3884da2d7eaf5cc251aa6b94e8e36bcf3883fbd0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 27 Oct 2013 21:27:11 +0100 Subject: [PATCH 171/175] local: Rename scan_blacklist_extensions to excluded_file_extensions --- docs/ext/local.rst | 4 ++-- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/local/ext.conf | 2 +- mopidy/scanner.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 1fbb3d51ae..f6b281bdb9 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -52,9 +52,9 @@ Configuration values Number of milliseconds before giving up scanning a file and moving on to the next file. -.. confval:: local/scan_blacklist_extensions +.. confval:: local/excluded_file_extensions - File extensions to ignore when scanning the media directory. + File extensions to exclude when scanning the media directory. Usage diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 4efe4456bc..6c66c70d2a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -23,7 +23,7 @@ def get_config_schema(self): schema['tag_cache_file'] = config.Path() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000*60*60) - schema['scan_blacklist_extensions'] = config.List(optional=True) + schema['excluded_file_extensions'] = config.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index ef5fdd78a9..f05a09c050 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -4,7 +4,7 @@ media_dir = $XDG_MUSIC_DIR playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache scan_timeout = 1000 -scan_blacklist_extensions = +excluded_file_extensions = .jpeg .jpg .png diff --git a/mopidy/scanner.py b/mopidy/scanner.py index f8e12f981e..dd21fdb472 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -71,7 +71,7 @@ def main(): local_updater = updaters.values()[0](config) # TODO: switch to actor? media_dir = config['local']['media_dir'] - blacklist_extensions = config['local']['scan_blacklist_extensions'] + excluded_extensions = config['local']['excluded_file_extensions'] uris_library = set() uris_update = set() @@ -93,8 +93,8 @@ def main(): logging.info('Checking %s for new or modified tracks.', media_dir) for uri in path.find_uris(config['local']['media_dir']): - if os.path.splitext(path.uri_to_path(uri))[1] in blacklist_extensions: - logging.debug('Skipped %s: File extension blacklisted.', uri) + if os.path.splitext(path.uri_to_path(uri))[1] in excluded_extensions: + logging.debug('Skipped %s: File extension excluded.', uri) continue if uri not in uris_library: From 20c727d4bf9056760a90276f9ed3c01df5cd08ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 21:57:48 +0100 Subject: [PATCH 172/175] git: Update mailmap --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 242d4d91dc..260770b86a 100644 --- a/.mailmap +++ b/.mailmap @@ -9,3 +9,4 @@ Alli Witheford Alexandre Petitjean Alexandre Petitjean Javier Domingo Cansino +Lasse Bigum From 191e809841a7941a02b7fd9742b1cd7cc664f9cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 22:03:20 +0100 Subject: [PATCH 173/175] Update changelog --- docs/changelog.rst | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e77cd9487e..51db0312be 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,22 @@ This changelog is used to track all major changes to Mopidy. v0.16.0 (UNRELEASED) ==================== +The goals for 0.16 were to add support for queuing playlists of e.g. radio +streams directly to Mopidy, without manually extracting the stream URLs from +the playlist first, and to move the Spotify, Last.fm, and MPRIS support out to +independent Mopidy extensions, living outside the main Mopidy repo. In +addition, we've seen some cleanup to the playback vs tracklist part of the core +API, which will require some changes for user's of the HTTP/JavaScript APIs, as +well as the addition of audio muting to the core API. To speed up the +:ref:`development of new extensions `, we've added a cookiecutter +project to get the skeleton of a Mopidy extension up and running in a matter of +minutes. Read below for all the details and for links to issues with even more +details. + +Since the release of 0.15, we've closed or merged 31 issues and pull requests +through about 200 commits by :ref:`five people `, including three new +contributors. + **Dependencies** Parts of Mopidy have been moved to their own external extensions. If you want @@ -101,6 +117,11 @@ of the following extensions as well: - Media files with less than 100ms duration are now excluded from the library. +- Media files with the file extensions ``.jpeg``, ``.jpg``, ``.png``, ``.txt``, + and ``.log`` are now skipped by the media library scanner. You can change the + list of excluded file extensions by setting the + :confval:`local/excluded_file_extensions` config value. (Fixes: :issue:`516`) + - Unknown URIs found in playlists are now made into track objects with the URI set instead of being ignored. This makes it possible to have playlists with e.g. HTTP radio streams and not just ``local:track:...`` URIs. This used to @@ -109,13 +130,25 @@ of the following extensions as well: - Fixed crash when playing ``local:track:...`` URIs which contained non-ASCII chars after uridecode. +- Removed media files are now also removed from the in-memory media library + when the media library is reloaded from disk. (Fixes: :issue:`500`) + **MPD frontend** - Made the formerly unused commands ``outputs``, ``enableoutput``, and ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) - The MPD command ``list`` now works with ``"albumartist"`` as its second - argument, e.g. ``"album" "albumartist" "anartist"``. (Fixes: :issue:`468`) + argument, e.g. ``list "album" "albumartist" "anartist"``. (Fixes: + :issue:`468`) + +- The MPD commands ``find`` and ``search`` now accepts ``albumartist`` and + ``track`` (this is the track number, not the track name) as field types to + limit the search result with. + +- The MPD command ``count`` is now implemented. It accepts the same type of + arguments as ``find`` and ``search``, but returns the number of tracks and + their total playtime instead. **Extension support** From 10102f0c8632838189d99683483ec5d11c9b801b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 22:34:24 +0100 Subject: [PATCH 174/175] docs: Update changelog for v0.16.0 --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 51db0312be..e45381a780 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.16.0 (UNRELEASED) +v0.16.0 (2013-10-27) ==================== The goals for 0.16 were to add support for queuing playlists of e.g. radio @@ -12,7 +12,7 @@ streams directly to Mopidy, without manually extracting the stream URLs from the playlist first, and to move the Spotify, Last.fm, and MPRIS support out to independent Mopidy extensions, living outside the main Mopidy repo. In addition, we've seen some cleanup to the playback vs tracklist part of the core -API, which will require some changes for user's of the HTTP/JavaScript APIs, as +API, which will require some changes for users of the HTTP/JavaScript APIs, as well as the addition of audio muting to the core API. To speed up the :ref:`development of new extensions `, we've added a cookiecutter project to get the skeleton of a Mopidy extension up and running in a matter of From 40674ae0527c117e3f64ad3a3dc46b307fd178b7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 27 Oct 2013 22:34:54 +0100 Subject: [PATCH 175/175] Bump version to 0.16.0 --- mopidy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 8ba54f4ead..10ecd71881 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.16.0a1' +__version__ = '0.16.0'